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.

single (default)
Pick one value. Open and start typing to filter; arrows move; Enter selects; the chosen row carries a gold tick. onChange returns a string.
30-Year Fixed15-Year Fixed5/1 ARMFHA LoanVA LoanJumbo
multiple
Pick as many as you need. Selections show up as removable chips; menu rows carry a checkbox that fills gold when picked. onChange returns a string[]. Keep typing to filter while you pick.
plain (searchable={false})
A non-searchable picker for a short enum — the input is read-only (no typing, no soft keyboard on mobile); click or arrow to open and choose. The right call when a typeahead over three options is just friction.

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.

state: idle
The default. Solid fill, visible border, a soft resting shadow.
state: invalid
Red border + red focus ring; the trigger is marked invalid. Pair it with an error message via the surrounding field.
state: disabled
Muted fill, dimmed text. The control clearly reads as switched off — no guessing.

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-xs shadow; --gold-aa-500 border 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-surface surface, solid border, --shadow-lg shadow. 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
boolean

Switches to the multi-value chip picker.

searchable
boolean

Typeahead 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
boolean

Renders the red error border (matches Input). Pair it with a message — never color alone (ADR-0017).

disabled
boolean

The muted, dimmed, clearly-off state.

ariaLabel / ariaLabelledBy
string

Accessible 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 · boolean

The 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

Singletsx
import { Selector } from '@halo-compliance/ui'

<Selector
  ariaLabel="Primary loan product"
  options={loanProducts}
  value={product}
  onChange={setProduct}
/>
Multitsx
<Selector
  multiple
  ariaLabel="Loan products offered"
  options={loanProducts}
  value={products}          // string[]
  onChange={setProducts}    // (next: string[]) => void
/>
Server-side searchtsx
<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.
  • /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.