/** * `Select` — a design-system wrapper over Radix Select (an accessible listbox with * a `combobox` trigger). Options are passed declaratively — either a flat * `options` run or labelled `groups`, never both — and the trigger auto-wires to * an enclosing `Field`. * * Every surface is a class — `tai-select-trigger` for the control, and * `tai-select-content` / `tai-select-item` for the popup, which is where the * shared keyboard-highlight rule lives. The disclosure mark is the design * system's `ChevronDownIcon`, decorative behind the trigger's own name. */ import * as RadixSelect from '@radix-ui/react-select'; import { SELECT_TRIGGER_CLASS } from './control-styles'; import { useFieldControl } from './field'; import { CheckIcon, ChevronDownIcon } from './icons'; export interface SelectOption { readonly value: string; readonly label: string; readonly disabled?: boolean; } /** A labelled cluster of options (e.g. tools grouped by tag). */ export interface SelectGroup { readonly label: string; readonly options: readonly SelectOption[]; } /** Everything the two listbox shapes have in common. */ interface SelectSharedProps { readonly value?: string; readonly defaultValue?: string; readonly onValueChange?: (value: string) => void; readonly placeholder?: string; readonly disabled?: boolean; readonly name?: string; /** * Accessible name for the trigger when it is not wired to an enclosing * `Field`. A `Field` wins where both are given — it names the trigger from * visible text, and `aria-label` would override that native label and leave * the visible text out of the accessible name (WCAG 2.5.3). */ readonly 'aria-label'?: string; } /** The FLAT listbox: one ungrouped run of options. */ export interface SelectProps extends SelectSharedProps { readonly options: readonly SelectOption[]; } /** The GROUPED listbox: labelled clusters, each carrying its own options. */ export interface SelectGroupsProps extends SelectSharedProps { readonly groups: readonly SelectGroup[]; } /** * What `Select` ACCEPTS: exactly one of the two shapes, never both. Only one * list can be rendered, so accepting `options` alongside `groups` means * discarding every entry of the other without a word. * * The exclusion lives HERE and not on the two interfaces because both are * published plugin API and must stay extendable: `groups?: undefined` written * onto `SelectProps` makes `interface MyPicker extends SelectProps { groups: * readonly SelectGroup[] }` a TS2430, and a union cannot be `extends`-ed at all * (TS2312). */ type SelectArgs = | (SelectProps & { readonly groups?: undefined }) | (SelectGroupsProps & { readonly options?: undefined }); function OptionItem({ option }: { option: SelectOption }) { return ( {option.label} {/* The highlight follows the reader through the list, so it cannot also stand for the chosen value. The check is what marks that, and Radix renders it for the selected option only. It is decorative: Radix already puts `aria-selected` on the item. */} ); } export function Select(props: SelectArgs) { const { value, defaultValue, onValueChange, placeholder = 'Select…', disabled, name, 'aria-label': ariaLabel, } = props; const field = useFieldControl(); // Untyped JavaScript is the only caller that reaches this. Rendering one list // and dropping the other would lose every entry in the discarded one silently, // and an empty listbox reads as "there is nothing to choose". if ((props.options === undefined) === (props.groups === undefined)) { throw new Error( 'Select renders exactly one of `options` or `groups`; it was given both, or neither.', ); } return ( `, and `aria-label` outranks a native label — emitting both would // discard the visible text the field renders. aria-label={field.id === undefined ? ariaLabel : undefined} aria-describedby={field['aria-describedby']} aria-invalid={field['aria-invalid']} > {props.groups !== undefined ? props.groups.map((group) => ( {group.label} {group.options.map((option) => ( ))} )) : props.options.map((option) => )} ); }