9.1 KiB
Token-Styled Select — Design
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #51.
Context
Four raw <select> elements (field-form.tsx:120 data_type, :138 vocabulary, :158
authority_kind; object-form.tsx:148 visibility) are styled w-full rounded border px-2 py-1 text-sm
— different radius/border/padding/height from the sibling ui/Input (h-8 rounded-lg border-input … focus-visible:ring-3 ring-ring/50), and crucially they have no focus ring (keyboard users get no
focus affordance). This milestone adds a token-styled ui/Select (Base UI Select) matching Input
and replaces all four. (Decision: all four → ui/Select; making the vocabulary picker a searchable
combobox is a deferred follow-up — keeps the working object-editing combobox untouched.)
Facts: Base UI Select is import { Select as SelectPrimitive } from "@base-ui/react/select"
(namespace; parts Root/Trigger/Value/Icon/Portal/Positioner/Popup/List/Item/ItemIndicator/ItemText) —
no new dependency. object-form is react-hook-form (visibility is register'd); field-form is
useState-controlled (all three selects), with data_type/vocabulary_id/authority_kind disabled
on edit. ui/menu.tsx + ui/combobox.tsx are the established Base UI wrapper patterns. The Input
className to match is in ui/input.tsx. Base UI Select is NOT a native <select>, so existing tests
using userEvent.selectOptions / HTMLSelectElement must be rewritten to click interaction.
Decisions (from brainstorming)
- All four selects →
ui/Select(uniform token styling + focus ring; lowest risk). - New
ui/select.tsxwraps Base UI Select; validated by running (novel primitive). - Tests rewritten to Base UI Select interaction (open trigger → click item).
Components
web/src/components/ui/select.tsx (new)
Wrap Base UI Select in the ui/* style (data-slot, cn, no semicolons), mirroring
ui/combobox.tsx/ui/menu.tsx. Exports (names final after validate-by-running):
Select—SelectPrimitive.Root(generic value;value/onValueChange/defaultValue/disabled/name).SelectTrigger—SelectPrimitive.Triggerstyled to match Input:h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm inline-flex items-center justify-between transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive dark:bg-input/30+ a trailing chevron (SelectPrimitive.Iconwith a lucideChevronDown,aria-hidden) and aSelectValue(SelectPrimitive.Value) showing the chosen item (withplaceholder).SelectContent—SelectPrimitive.Portal+SelectPrimitive.Positioner(sideOffset,z-50) +SelectPrimitive.Popupstyled as a card (min-w-[var(--anchor-width)]if supported, elsemin-w-32;rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none+ open/close animation data-attrs).SelectItem—SelectPrimitive.Itemrow (flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground) + aSelectPrimitive.ItemIndicator(lucideCheck) andSelectPrimitive.ItemText.- Token classes only (no raw palette). The exact part tree + props (
SelectValueplaceholder, positioner anchoring, itemvalue) must be confirmed by running the story (Base UI Select is novel here), as combobox/menu/toast were.
Accessibility: SelectTrigger accepts id so the existing <Label htmlFor={id}> associates →
getByLabelText/getByRole("combobox", { name }) keeps working in tests. (Base UI Select trigger has
role combobox; confirm the accessible-name wiring when validating.)
web/src/components/ui/select.stories.tsx (new)
A Select with a few SelectItems; a play test that opens the trigger and selects an item, asserting
the value/label updates (portal content queried via within(document.body), like the menu story). This
is the validation.
Replacements
object-form.tsx visibility (create-mode only): Base UI Select isn't a native input, so replace
the register("visibility") <select> with a Controller (control={form.control} name="visibility") rendering <Select value={field.value} onValueChange={field.onChange}> with items
draft/internal (labels form.draft/form.internal). Keep <Label htmlFor="visibility"> → trigger
id="visibility". Default stays "draft" (the form's defaultValues already set it).
field-form.tsx (useState — pass value/onValueChange directly, no Controller):
- data_type:
<Select value={dataType} onValueChange={setDataType} disabled={isEdit}>, items fromTYPES(labelsfields.types.${type}).id="field-type". - vocabulary_id (when
dataType==="term"):<Select value={vocabularyId} onValueChange={setVocabularyId} disabled={isEdit}>with a placeholder (form.selectPlaceholder) and items fromvocabularies(valuevocab.id, labelvocab.key).id="field-vocab". The empty/placeholder state: Base UI Select shows theSelectValueplaceholder when value is""; keepvocabularyId=""as the unselected state (the existing required-check!vocabularyIdstill works). - authority_kind (when
dataType==="authority"):<Select value={authorityKind} onValueChange={setAuthorityKind} disabled={isEdit}>with an "Any" item (value"", labelfields.anyKind) +KINDSitems (labelsauthorities.${kind}).id="field-kind".
No change to submit logic, validation, or the disabled-on-edit behavior — only the control swaps.
Data flow
Unchanged: the same state/register/Controller value drives the same submit payloads. Only the
rendered control (native <select> → Base UI Select) and its styling change.
Error handling / edges
- Base UI Select
value=""must render the placeholder (vocabulary) or the "Any" item (authority_kind) — verify when running. The authority_kind "Any" is a real selectable item with value"". disabled={isEdit}must visually + functionally disable the trigger (the styling includesdisabled:classes).- The visibility
Controllerdefault must remain"draft"(no regression to the create payload). - Keyboard: Base UI Select is fully keyboard-operable (the focus ring is the headline fix).
Testing
select.stories.tsxvalidates the primitive by running (open + select).object-form.test.tsxrewrite the visibility assertions:- "shows visibility (draft/internal only, not public)": open the Select trigger, assert items
DraftInternalare present andPublicis absent (query the portal list); selectInternaland assert it's reflected. (ReplacesHTMLSelectElement.optionsinspection.)
- "edit mode: no visibility control": keep — query by the visibility Label name returns null.
- "shows visibility (draft/internal only, not public)": open the Select trigger, assert items
fields.test.tsxrewrite the select flows to click interaction:- data_type → open, click
Authority→ the kind picker appears → open, clickPerson→ assertauthority_kind: "person"in the create payload. - data_type → open, click
Term→ the vocabulary picker appears → submit blocked until a vocab is chosen → open, click the vocab → assert the create payload. Keep the same payload assertions. - "creates a text field" (default type) — unaffected or minimal change.
- data_type → open, click
- Keep all payload/mutation assertions identical (don't weaken); only the interaction changes.
- Gate:
typecheck/lint/test/build/check:size/check:colors; en/sv parity (no new keys expected); no codename.check:size: Base UI Select adds to the always-loaded form chunk — report the value (budget 250 KB gz; flag if it exceeds rather than silently raising).
Acceptance criteria
- A
ui/Select(Base UI Select) exists, styled to matchInput(h-8, rounded-lg, border-input, focus-visible ring, disabled/aria-invalid states), with a Storybook story validated by running. - All four raw
<select>elements (object-form visibility; field-form data_type / vocabulary / authority_kind) are replaced byui/Select; they share Input's tokens and have a visible focus ring. - Behavior preserved: same values/payloads, disabled-on-edit, create-mode-only visibility, term/authority conditional pickers, the required-vocabulary check, and the create-form reset.
- Tests rewritten to Base UI Select interaction (no native
selectOptions); all payload assertions unchanged; suite green. typecheck/lint/test/build/check:colorsgreen;check:sizereported (within budget or flagged); en/sv parity; no codename; no new npm dependency.
Out of scope → follow-ups
- Making the vocabulary picker a searchable combobox (generalizing
OptionsComboboxto{id,label}) — deferred; revisit if vocabulary lists grow large. - Replacing any other native form controls; a lint guard banning raw
<select>outsidecomponents/ui/. - The
visibilityselect gaining apublicoption (intentionally absent — publishing is viapublish-control).