videorc/ui

Device Picker

The one picker for a screen, window, camera or microphone. A popover list with words for every state, so it's never blank while discovery runs or a device is gone.

Installation

$ pnpm dlx shadcn@latest add @videorc/device-picker

It installs lib/source-select-state.ts, the picker's words, beside the component.

Usage

import { DevicePicker } from "@/components/device-picker";
import type { PickerDevice } from "@/components/device-picker";
<DevicePicker
  devices={cameras}
  discoveryPending={!camerasListed}
  kind="camera"
  label="Camera"
  noneLabel="Off"
  onChange={setCameraId}
  selectedName={savedCameraName}
  value={cameraId}
/>

A device is { id, name, kind, status, detail? }. status is "available", "permission-required" or "unavailable"; only an available device can be chosen, and the others say why in a muted word after their name. onChange gets the device id, or undefined for the "no device" item.

Use it everywhere a screen, window, camera or microphone is chosen, so they all say the same words. Don't use a Select for devices: it has no words for discovery, a missing device or a search.

Words for every state

The picker never shows a blank surface. With nothing chosen, discovery and an empty list win over the "no device" words, so a missing camera never hides behind "Off".

StateThe trigger saysThe list says
Discovery hasn't reported"Finding cameras…""Finding cameras…"
Nothing found"No cameras found""No cameras found"
A device that can't runIts name, then "Needs permission" or "Unavailable"The same, disabled
A saved device that's goneselectedName (or "Saved camera"), then "Not found"The same, disabled
A search with no match"No matching cameras"

kind decides the nouns: capture for screens and windows, camera, or microphone. A saved device is only called "Not found" once discovery has reported.

Examples

Search, and screens and windows

searchable adds a search field that matches every word against a device's name, kind and detail (the app that owns a window), never its id. groupByKind puts screens and windows under their own headings when both are present.

Inline

trigger="inline" is a row's right-aligned value for a row whose label sits beside it. A long name truncates, and its title shows it whole.

Microphone

The picker's words

lib/source-select-state.ts is pure, so every picker says the same thing in the same state, and the words are tested without a DOM.

ExportWhat it does
devicePickerTriggerView(input)What the closed picker says: label, status and whether it's a placeholder
devicePickerGroups(input)The list: the "no device" item, a missing selection, then the matches
devicePickerListStatus(input)The one status line, or null
deviceStatusLabel(status)"Needs permission", "Unavailable", "Not found", or null
devicePickerCopy(kind)Every sentence for a kind
sourceMatchesQuery(device, query)The search rule
DEVICE_PICKER_NONE_VALUEThe "no device" item's value

Theming

Token or attributeMeaning
selectTriggerClassNameThe field trigger is exactly a Select's
data-popup-openOn the trigger while the list is open; an inline trigger takes the hover block
data-placeholderA prompt or a state, in the secondary tone
min-h-7List rows at the menu item height
--anchor-widthThe field list is as wide as its trigger

Keyboard

KeyAction
Enter SpaceOn the trigger, opens the list
↑ ↓Moves the highlight, which starts on the current choice
EnterChooses the highlighted device
EscCloses without changing the choice, and clears the search

Accessibility

The trigger is a combobox named by label, with aria-haspopup="listbox", or "dialog" with a search field. A field trigger is labelled by its visible label (labelHidden keeps it for screen readers only) and described by description, a polite live line for a switch in progress. Without a search field the list takes focus on open and names the highlighted row; with one, the field does. The list's status line is a status.

API reference

Prop

Type