docs(specs): token-styled ui/Select replacing raw selects (#51)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# 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):
|
||||
- `Select` — `SelectPrimitive.Root` (generic value; `value`/`onValueChange`/`defaultValue`/`disabled`/`name`).
|
||||
- `SelectTrigger` — `SelectPrimitive.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`).
|
||||
- `SelectContent` — `SelectPrimitive.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).
|
||||
- `SelectItem` — `SelectPrimitive.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 `SelectItem`s; 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`).
|
||||
Reference in New Issue
Block a user