Agent docs

Select

The picker: a control button that opens a keyboard-navigable option list.

The kit's picker: a control button that opens a floating, keyboard-navigable option list with an optional filter field. It is composed on Dropdown for positioning, Select itself owns selection, search, and ARIA, never the overlay mechanics.

One component, two modes. By default it picks one value and closes. Pass multiple and it picks several, keeps the panel open while you toggle, and can restate what is applied as removable chips under the control.

The canon behind it. docs/design-language/07-toolbars-and-filters.md §A2 (07-toolbars-and-filters.md:18, every facet option carries a live count and an icon), §A4 (07-toolbars-and-filters.md:24, a count-only trigger owes the reader removable chips), and docs/design-language/10-overlays.md §A7 (10-overlays.md:39, the positioning primitive Select composes on).

When to reach for it#

Any choice from a list of options, one value or several, especially when the list needs search (auto-on above 8 options), grouping, or per-option icons. This is the one Select; there is deliberately no wrapper component for defaults and no second component for multi-select, reach for multiple, a preset, and explicit props instead.

The multi mode landed on 2026-08-16 (owner ruling, doc docs/plan/52-select-gap.md). Before it, both product apps had to hand-roll one: walmart-mvp built MultiFacetControl at walmart-mvp/frontend/src/components/FacetToolbar.tsx:65-135, whose own comment at line 76 says it "wears the kit Select's exact control skin (sm scale), so single and multi facets read as one family in a toolbar", and the kit's own FacetBar shipped a private copy of the same control. Both are answered by multiple now. Do not build a third: if multiple cannot express what a surface needs, the fix is a prop on Select.

Contract#

SelectProps is a discriminated union on multiple (select.types.ts:150-153). A single Select rejects an array value. A multiple one rejects a bare value. chips and label are typed never in single mode (select.types.ts:119-120), so passing them is a compile error rather than a prop that does nothing.

Options and selection:

  • options: SelectOptions<T, D>, bare strings or SelectOption objects, auto-normalized (normalizeOptions, select.tsx:73-80). A bare string becomes { value, label, labelText }.
  • value / onChange(value, option): value-first, single T | undefined. onChange's second argument is the resolved option (including its data payload) when one exists. Multiple mode's shapes are in the section below.
  • SelectOption.group: grouping is implemented, not deferred. Options sharing a group string render under a small uppercase sticky header (select.tsx:419-427), and adjacent same-group options are required to stay adjacent for the header to render correctly. There is no confirmed shipped call site using group in either app as of this pass, but the feature itself is live in both the type and the renderer, not a week-2 promise.
  • SelectOption.description / SelectOption.count (select.types.ts:32-34): an optional second line under the label (smaller, lighter ink) and a trailing count. Both render in every option row (select.tsx:448-460), and description joins the search haystack (select.tsx:179), so a searchable Select matches description words too (owner ruling 2026-08-14, the surface task #34 shipped).
  • preset: SelectPreset, "simple" or "facet", applies a bundle of defaults before explicit props (select.tsx:44-54). A preset is a shorthand and never a gate: everything it sets can be set by hand, and an explicit prop always wins.

Appearance and behavior:

  • placeholder, disabled, loading (renders a skeleton in the control's place), size (sm | md | lg), fullWidth, clearable (inline clear affordance once something is selected; in multiple mode it empties the whole array), error (border-only styling, no message slot of its own), warning (the warning edge for a soft issue; error wins when both are set), noAnimate.
  • searchable: forces the in-panel filter field on or off. Default is auto, on above 8 options (SEARCH_AUTO_THRESHOLD, select.tsx:58). searchPlaceholder, emptyLabel (shown when nothing matches).
  • name: native-form mode, mirrors the selection into hidden inputs under this field name, for a plain HTML form submit. Multiple mode writes one input per selected value (select.tsx:499-504).
  • placement (floating-ui placement, default "bottom-start"), noPortal (render the panel inline instead of through a portal).
  • "aria-label": names the closed control for assistive tech; without it the trigger announces only its visible text (the placeholder or the picked value's label).
  • SelectOption.trailing / SelectOption.swatch: a right-side helper before the affordance (a unit, a short status, a small node), and a CSS colour rendered as a dot on the row's right and beside the control's value while that row is selected; a swatched row's hover wash is its own colour, so scanning the list previews the tone (SchemaForm's x-option-meta maps onto both).
  • Per-option icon, and IconImportProps (Icon/iconSize/iconProps/iconClassName) for the control's own icon, shown when nothing is selected or when the selected option has no icon of its own.
  • Slot classnames per SelectClassNames: outer wrapper (_), control box, control text, panel, option, search, chips (the applied-chips row, multiple mode only).

Multiple mode#

multiple turns the same component into a multi-value picker. The panel stays open while you toggle, so picking three values is three clicks and not three re-opens (select.tsx:156).

  • value?: readonly T[], onChange?: (value: T[], options: SelectOption<T, D>[]) => void. The handler gets the whole next selection, never a delta. Toggling appends to the end and removing filters in place, so the array order is the order the user picked.
  • The panel is role="listbox" aria-multiselectable and every row carries aria-selected (select.tsx:408-409). The row's trailing affordance is a checkbox instead of the single mode's check glyph. That box is decoration: the row owns the click and the ARIA, and the box never takes focus.
  • label?: ReactNode: the property word the closed control leads with, rendered "Owner · 2". Without it the control shows the first pick and "+N" for the rest (select.tsx:303-318). Reach for label in a toolbar, where a narrow control cannot fit a list of names.
  • chips?: boolean: restates each applied value as a removable Chip presets="applied" in a row under the control (select.tsx:516-534). Removing a chip removes that value.
  • clearable empties the array. name writes one hidden input per value.
  • searchable, emptyLabel, size, options, disabled, error and the rest of the surface behave exactly as they do in single mode.

Keyboard#

KeyMultiple modeSingle mode
Arrow down / upMove the active row, skipping disabledSame
Home / EndJump to the first or last enabled rowSame
EnterToggle the active row, panel stays openSelect and close
SpaceToggle the active row, panel stays openOpens the panel when closed
EscapeClose, focus returns to the controlSame
TypingFilters the list when the search field is shownSame

Space commits only where it cannot be a typed character, so it toggles on the list and on the control but never inside the search box (select.tsx:264-276). Focus returns to the control whenever the panel that held it closes (select.tsx:205-211), in both modes.

preset="facet"#

The toolbar shorthand: multiple, chips, and size="sm" (select.tsx:50-54). Presets are shorthands, so any of the three can be overridden by an explicit prop. preset="facet" implies the mode, so callers do not repeat multiple.

When to reach for chips#

Turn chips on when the closed control can only summarize. A count ("Owner · 2") tells the reader how many filters are on but not which, and a filter the reader cannot name is a filter they will forget is there. Leave chips off when the control already names the selection, or when a parent gathers every facet's chips in one row after the controls, which is what FacetBar does (canon doc 07-toolbars-and-filters.md §A4).

Keyboard and accessibility#

The control is aria-haspopup="listbox", the panel is a role="listbox" of role="option" rows, and multiple mode adds aria-multiselectable. When the search field is shown, it is the role="combobox" driver and keyboard focus lands there on open; otherwise the list itself takes focus. Arrow keys move the active row (skipping disabled options), Home/End jump to the ends, Enter selects or toggles, Escape closes and hands focus back to the control. The active row is tracked with aria-activedescendant and scrolled into view by scrolling the listbox only, never the page. This closed the accessibility gap of the component's predecessor (SearchSelectV2), which had no listbox role and no arrow navigation. The per-key table above covers where the two modes differ.

Sanctioned combinations#

CombinationProducesWhere usedWhy
preset="simple" size="sm" noPortal inside a kit ModalA select whose panel renders correctly on top of a native <dialog>speedway/app/components/FilePreview.tsx:224-233 (worksheet picker), walmart-mvp/frontend/src/pages/ImportParts.tsx:519-524 (duplicate-handling table, inside Modal elementId={DUPLICATE_CHECK_MODAL} at line 463)The code comment at FilePreview.tsx:230-232 states it directly: "The modal is a native <dialog> (top layer); a body-portaled panel would paint invisibly underneath it." Any Select inside a Modal needs noPortal for the same reason
size="sm" value={...} onChange={...} with no preset, no searchable overrideThe default toolbar/table-cell selectspeedway/app/routes/workspaces/schemas/attributes.tsx, walmart-mvp/frontend/src/features/mapping/MappingEditor.tsx (5 call sites)The plain single-facet or single-field-mapping shape, letting the auto search threshold decide
multiple size="sm" label={...} with the parent owning the chipsA toolbar facet that summarizes as "Owner · 2" and leaves the applied chips to a row gathered after every controlpackages/ui/src/table/facet-bar.tsx:253-264 (every multi facet in the kit's own toolbar)FacetBar collects chips for all facets in one place, so each control keeps chips off and only reports its count
preset="facet" on a standalone facetThe same control with its own chips row underneath, no parent neededapps/playground/src/app/components/select/examples/facet-preset.tsxA facet that lives outside a FacetBar has nowhere else to put its applied values, so the preset turns chips on
clearable inside FilterBarA facet whose value can be reset from the control itself, not just by re-picking the "all" optionspeedway/app/routes/workspaces/review/log.tsx:685, speedway/app/components/FilterBar.tsx:126-148Canon doc 7 covers single-select facets as the preferred shape precisely because they carry their selection visibly and clear cleanly

Banned combinations#

  • A second multi-select control built beside this one. The private copies are retired: FacetBar now composes Select multiple for its multi facets, and walmart's MultiFacetControl is the shape this mode replaces. If a surface needs something multiple cannot express, add the prop here rather than starting a third control.
  • A facet whose values can contain the URL list delimiter, built as a multi-select. Canon doc 7 §A4 states this as a hard constraint independent of the kit: "a facet over values that can contain the URL list delimiter (part types with commas) must be single-select." This is why walmart's part-type facet stays single-select. The multiple mode does not lift it, because the constraint is about the URL, not the control.
  • A Select panel inside a Modal without noPortal. It will paint underneath the native <dialog>'s top layer and be invisible or unreachable; see the sanctioned-combinations row above for the fix.

Before you adopt this#

Five questions to answer before reaching for Select.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, Select is a form control; no shell already renders it.
  2. Does this app already ship a local implementation of the same thing? MultiFacetControl and FacetBar's private copy are retired; multiple replaces both.
  3. Does this app's kit pin reach the version this component or prop landed in? multiple mode landed 2026-08-16 (doc 52); confirm the app's kit pin reaches it.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? aria-label names the closed control; without it only the visible text announces.
  5. Which canon §A rules bind this surface, and which does the composition break? §A4 owes removable chips once a trigger can only summarize as a count.

Travels with#

  • Dropdown, always, Select composes on it for positioning and never reimplements overlay mechanics itself.
  • Chip, in multiple mode: chips renders the applied values as removable presets="applied" chips in a row under the control. A parent that gathers several facets' chips into one row keeps chips off and renders them itself (canon doc 7 §A4, and FacetBar).
  • Tooltip, in the toolbar context, wrapping a facet's Select to carry helper text and a "Selected: ..." line (FacetToolbar.tsx facetTooltip).

Snippet#

<Select
preset="simple"
size="sm"
aria-label="Worksheet"
options={full.sheets}
value={sheet ?? full.sheet ?? undefined}
onChange={(v) => setSheet(v ?? null)}
className="w-56"
noPortal
/>

speedway/app/components/FilePreview.tsx:223-234

And the multi-value shape, a facet that carries its own chips:

<Select
multiple
chips
clearable
size="sm"
label="Owner"
placeholder="Any owner"
aria-label="Filter by owner"
options={OWNERS}
value={owners}
onChange={setOwners}
/>

apps/playground/src/app/components/select/examples/multi-select-chips.tsx

@versable-git/ui · reference, canon, and method, read in place