Files
biggus-dickus/docs/superpowers/specs/2026-06-08-token-select-design.md
T

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)

  1. All four selects → ui/Select (uniform token styling + focus ring; lowest risk).
  2. New ui/select.tsx wraps Base UI Select; validated by running (novel primitive).
  3. 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):

  • SelectSelectPrimitive.Root (generic value; value/onValueChange/defaultValue/disabled/name).
  • SelectTriggerSelectPrimitive.Trigger styled 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.Icon with a lucide ChevronDown, aria-hidden) and a SelectValue (SelectPrimitive.Value) showing the chosen item (with placeholder).
  • SelectContentSelectPrimitive.Portal + SelectPrimitive.Positioner (sideOffset, z-50) + SelectPrimitive.Popup styled as a card (min-w-[var(--anchor-width)] if supported, else min-w-32; rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none + open/close animation data-attrs).
  • SelectItemSelectPrimitive.Item row (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) + a SelectPrimitive.ItemIndicator (lucide Check) and SelectPrimitive.ItemText.
  • Token classes only (no raw palette). The exact part tree + props (SelectValue placeholder, positioner anchoring, item value) 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 from TYPES (labels fields.types.${type}). id="field-type".
  • vocabulary_id (when dataType==="term"): <Select value={vocabularyId} onValueChange={setVocabularyId} disabled={isEdit}> with a placeholder (form.selectPlaceholder) and items from vocabularies (value vocab.id, label vocab.key). id="field-vocab". The empty/placeholder state: Base UI Select shows the SelectValue placeholder when value is ""; keep vocabularyId="" as the unselected state (the existing required-check !vocabularyId still works).
  • authority_kind (when dataType==="authority"): <Select value={authorityKind} onValueChange={setAuthorityKind} disabled={isEdit}> with an "Any" item (value "", label fields.anyKind) + KINDS items (labels authorities.${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 includes disabled: classes).
  • The visibility Controller default 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.tsx validates the primitive by running (open + select).
  • object-form.test.tsx rewrite the visibility assertions:
    • "shows visibility (draft/internal only, not public)": open the Select trigger, assert items Draft
      • Internal are present and Public is absent (query the portal list); select Internal and assert it's reflected. (Replaces HTMLSelectElement.options inspection.)
    • "edit mode: no visibility control": keep — query by the visibility Label name returns null.
  • fields.test.tsx rewrite the select flows to click interaction:
    • data_type → open, click Authority → the kind picker appears → open, click Person → assert authority_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.
  • 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

  1. A ui/Select (Base UI Select) exists, styled to match Input (h-8, rounded-lg, border-input, focus-visible ring, disabled/aria-invalid states), with a Storybook story validated by running.
  2. All four raw <select> elements (object-form visibility; field-form data_type / vocabulary / authority_kind) are replaced by ui/Select; they share Input's tokens and have a visible focus ring.
  3. 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.
  4. Tests rewritten to Base UI Select interaction (no native selectOptions); all payload assertions unchanged; suite green.
  5. typecheck/lint/test/build/check:colors green; check:size reported (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 OptionsCombobox to {id,label}) — deferred; revisit if vocabulary lists grow large.
  • Replacing any other native form controls; a lint guard banning raw <select> outside components/ui/.
  • The visibility select gaining a public option (intentionally absent — publishing is via publish-control).