Selector
Your go-to option picker. One component, two modes — single and multiple — over one shared discriminated-union API. Search comes built in — typeahead works locally, or server-side via serverFiltered + onSearchChange — and you can switch it off with searchable={false} when search would just be noise. It dresses like Input (solid fill, visible border, gold on focus, error red when something's wrong), never like a Card. downshift handles selection state and accessibility; Floating UI handles positioning — the menu flips and shifts to stay on screen; react-virtual keeps long lists fast.
Selector is a bare control, like Input — it renders the field and the menu, not its own label or helper text. Give it an ariaLabel and compose labels externally (a Selector-field wrapper, parallel to /input, is deferred until a consumer needs it).
Modes
Single or multiple, chosen by props — and searchable by default, with typeahead built in. Pass searchable={false} for a plain picker (read-only input, click or arrows to choose) where typing to filter is noise. All three share the same options, field, and menu.
States
The field matches Input exactly. Idle is a solid field with a visible border; focus turns the border gold with a soft gold glow; invalid switches to error red (always pair it with a message — color alone never carries the signal, ADR-0017); disabled goes muted and dimmed. Focus is live — click any field above and see for yourself.
Anatomy
Flat and friendly, end to end. The field is styled as Input's sibling — solid fill, visible border — and the menu is an opaque panel on a soft shadow, so it reads as the field's own surface popping up, not a separate piece of chrome. (Glass and ink belong to the proposed system; here everything is solid.)
- Field
- Mirror of Input’s wrap.
Visible border, solid fill,
--shadow-xsshadow;--gold-aa-500border with a soft gold glow on hover/focus, error red on invalid, muted fill on disabled. The single trigger is a button; searchable/multi wrap an input (focus driven by :focus-within). - Menu
- A solid panel on a soft shadow.
An opaque
--bg-surfacesurface, solid border,--shadow-lgshadow. No blur, no glass — that trick belongs to the proposed system. Portaled to <body> so it never clips inside scroll containers or popovers; an id-scoped portal root lifts it above chrome but below the command palette. - Options
Flat, friendly rows; the highlighted one takes a gold wash (
--gold-aa-500), matching the command palette. Single-select marks the chosen row with a gold tick; multi-select with a gold check-box. Disabled options dim and are unselectable.- Chips (multi)
- Solid, rounded pills — flat color, no metal, no glass. When they overflow they collapse into an overlapping deck that fans apart on hover or focus, with a +N counter for the rest; each carries an X that turns red on hover.
- Positioning
- Floating UI.
flip + shift keep the menu on-screen, size matches it to the field width and caps its height to the available space. placement sets the preferred side; it flips when there’s no room.
- Virtualization
- react-virtual.
Only the visible rows render, so a thousand-option list stays cheap. Keyboard navigation drives the virtualizer so the highlighted row scrolls into view even when it isn’t mounted.
Props
- options
SelectorOption[]The list. Each is
{ value, label, disabled? }— flat, no groups (yet).- value / onChange
string | string[]Controlled. string + (v) => void for single; string[] + (v[]) => void for multi. The pair is discriminated by multiple, so TypeScript enforces the matching shape.
- multiple
booleanSwitches to the multi-value chip picker.
- searchable
booleanTypeahead filtering. Defaults to true; pass false for a plain picker — the input goes read-only (click / arrows to choose, no soft keyboard on mobile). Turn it off for short enums where search is noise. Works with single and multi; ignored under serverFiltered.
- invalid
booleanRenders the red error border (matches Input). Pair it with a message — never color alone (ADR-0017).
- disabled
booleanThe muted, dimmed, clearly-off state.
- ariaLabel / ariaLabelledBy
stringAccessible name for the control — there is no built-in label. Pass one, or point at an external label element.
- placement
'bottom' | 'top'Preferred open side. Floating UI flips it when there’s no room. hug makes the field hug its content instead of filling the container width.
- onSearchChange / serverFiltered / loading
(q) => void · boolean · booleanThe server-search escape hatch: report the query, tell the component its options are already filtered, and show a “Searching…” affordance while results are in flight.
Usage
import { Selector } from '@halo-compliance/ui'
<Selector
ariaLabel="Primary loan product"
options={loanProducts}
value={product}
onChange={setProduct}
/><Selector
multiple
ariaLabel="Loan products offered"
options={loanProducts}
value={products} // string[]
onChange={setProducts} // (next: string[]) => void
/><Selector
serverFiltered
loading={isFetching}
ariaLabel="Vendor"
options={results} // already filtered by the server
value={vendorId}
onChange={setVendorId}
onSearchChange={(q) => runQuery(q)}
/>Accessibility
downshift carries the combobox/listbox semantics and keyboard model; the rest is wiring. What you get for free:
- Roles & state — the menu is a listbox of options with aria-activedescendant tracking the highlight; the trigger is a combobox with aria-expanded.
- Keyboard — open with Space/Enter (or type, when searchable); Up/Down/Home/End move; Enter selects; Escape closes (and restores the selection on a searchable field); Tab leaves.
- Name — provide ariaLabel or ariaLabelledBy. There’s no implicit label, so a nameless Selector is an a11y bug — the rule of thumb that drove keeping the control bare.
- Disabled options — skipped by keyboard navigation and unselectable, not just dimmed.
When to reach for which
- single — a short, known list (a status, a loan product, a mode). If it’s two or three mutually exclusive options, consider a radio group instead.
- multiple — many values at once (loan products, recipients, tags).
- Not the command palette — Selector picks a value into a field; the palette navigates or runs commands. Different jobs, different patterns.
Anti-patterns
- A nameless Selector. No ariaLabel and no external label means screen readers announce an unlabeled combobox. Always name it.
- Severity by color alone. invalid is just a border color; put the actual error in a message right beside the field (ADR-0017).
- A two-option Selector. For a binary or three-way mutually-exclusive choice, a radio group is clearer and needs no open/close.
- Restyling the field at the consumer. It matches Input on purpose. If the system is missing something you need, bring it to the design system — don't patch around it locally.
Related
- /input — the sibling primitive the Selector field is styled to match.
- /surfaces — the overlay plane the floating menu lives on (ADR-0025). The dropdown is its own solid panel, floating above the page on a soft shadow.
- /tokens — the border, focus-ring, shadow, and gold-highlight tokens the field and menu resolve to.