Frontend UX: replace raw <select> elements with a token-styled Select / combobox #51

Closed
opened 2026-06-06 18:52:20 +00:00 by logaritmisk · 1 comment
Owner

Severity: High. From a frontend UX audit.

Problem

Four raw <select> elements (web/src/fields/field-form.tsx:120,138,158, web/src/objects/object-form.tsx:148) are styled w-full rounded border px-2 py-1 text-sm, while the sibling ui/Input (components/ui/input.tsx:12) is h-8 rounded-lg border-input … focus-visible:ring-3 focus-visible:ring-ring/50. So in the same form a select and an Input differ in radius, border color, padding, height — and the select has no focus ring (keyboard users get no focus affordance). Separately, the field-form's vocabulary/authority pickers use a bare native <select> while the object form's equivalent reference pickers use the searchable OptionsCombobox (objects/field-input.tsx:8) — the same "pick a vocabulary/authority" action looks and behaves differently in two places.

Suggested fix

  • Add a ui/select.tsx (Base UI Select) that matches Input's tokens/height/radius/focus ring, and replace all four raw selects.
  • For the field-form vocabulary + authority-kind pickers, reuse the existing combobox so reference selection is uniform with object editing.

Source: frontend UX/design audit, 2026-06-06.

**Severity: High.** _From a frontend UX audit._ ## Problem Four raw `<select>` elements (`web/src/fields/field-form.tsx:120,138,158`, `web/src/objects/object-form.tsx:148`) are styled `w-full rounded border px-2 py-1 text-sm`, while the sibling `ui/Input` (`components/ui/input.tsx:12`) is `h-8 rounded-lg border-input … focus-visible:ring-3 focus-visible:ring-ring/50`. So in the **same form** a select and an Input differ in radius, border color, padding, height — and the select has **no focus ring** (keyboard users get no focus affordance). Separately, the field-form's vocabulary/authority pickers use a bare native `<select>` while the object form's equivalent reference pickers use the searchable `OptionsCombobox` (`objects/field-input.tsx:8`) — the same "pick a vocabulary/authority" action looks and behaves differently in two places. ## Suggested fix - Add a `ui/select.tsx` (Base UI Select) that matches `Input`'s tokens/height/radius/focus ring, and replace all four raw selects. - For the field-form vocabulary + authority-kind pickers, reuse the existing combobox so reference selection is uniform with object editing. _Source: frontend UX/design audit, 2026-06-06._
Author
Owner

Done — merged to main (f45f1d8).

Added web/src/components/ui/select.tsx (Base UI Select) styled to match ui/Input — same h-8/rounded-lg/border-input/padding and a real focus-visible:ring-3 ring-ring/50 focus ring, plus disabled + aria-invalid states. Story-validated by running.

Replaced all four raw <select> elements with it:

  • object-form visibility (draft/internal) — via a react-hook-form Controller (Base UI Select isn't a native input); default stays draft.
  • field-form data_type, vocabulary, authority-kind — value/onValueChange; disabled-on-edit preserved; the vocabulary picker shows a placeholder until chosen (the required check still blocks submit); the authority "Any" state shows "Any" (placeholder), with an "Any" item to switch back.

So in a given form, selects and Inputs now share radius/border/height/padding, and keyboard users get a focus ring on every control.

Implementation notes:

  • The wrapper derives item labels from its <SelectItem> children (incl. .map() arrays), so the trigger shows the chosen label with the plain <SelectItem value="x">Label</SelectItem> API.
  • Tests rewritten from native userEvent.selectOptions to open-trigger + click-option (Base UI Select isn't a <select>); all payload assertions unchanged.
  • Base UI Select code-split into its own ~12.6 KB gz chunk — the entry bundle stayed 214.4 KB gz (< 250 budget).

No new dependency (Base UI already present); no new i18n keys; en/sv parity; 204 tests green; typecheck/lint/build/check:size/check:colors clean; no codename.

Follow-ups (out of scope): a searchable vocabulary picker (generalize OptionsCombobox to {id,label}) if vocab lists grow; the remaining non-target <select> (objects-table page-size picker); a lint guard banning raw <select> outside components/ui/.

Done — merged to `main` (`f45f1d8`). Added **`web/src/components/ui/select.tsx`** (Base UI Select) styled to match `ui/Input` — same `h-8`/`rounded-lg`/`border-input`/padding and a real `focus-visible:ring-3 ring-ring/50` focus ring, plus disabled + `aria-invalid` states. Story-validated by running. Replaced all four raw `<select>` elements with it: - **object-form** visibility (draft/internal) — via a react-hook-form `Controller` (Base UI Select isn't a native input); default stays draft. - **field-form** data_type, vocabulary, authority-kind — `value`/`onValueChange`; disabled-on-edit preserved; the vocabulary picker shows a placeholder until chosen (the required check still blocks submit); the authority "Any" state shows "Any" (placeholder), with an "Any" item to switch back. So in a given form, selects and Inputs now share radius/border/height/padding, and keyboard users get a focus ring on every control. Implementation notes: - The wrapper derives item labels from its `<SelectItem>` children (incl. `.map()` arrays), so the trigger shows the chosen label with the plain `<SelectItem value="x">Label</SelectItem>` API. - Tests rewritten from native `userEvent.selectOptions` to open-trigger + click-option (Base UI Select isn't a `<select>`); all payload assertions unchanged. - Base UI Select code-split into its own ~12.6 KB gz chunk — the entry bundle stayed 214.4 KB gz (< 250 budget). No new dependency (Base UI already present); no new i18n keys; en/sv parity; 204 tests green; typecheck/lint/build/check:size/check:colors clean; no codename. Follow-ups (out of scope): a **searchable** vocabulary picker (generalize `OptionsCombobox` to `{id,label}`) if vocab lists grow; the remaining non-target `<select>` (objects-table page-size picker); a lint guard banning raw `<select>` outside `components/ui/`.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logaritmisk/biggus-dickus#51