Files
biggus-dickus/docs/superpowers/specs/2026-06-06-searchable-term-authority-picker-design.md
T
2026-06-06 10:20:26 +02:00

7.2 KiB

Searchable Term/Authority Picker — Design

Date: 2026-06-06 Status: Approved (brainstorming) — ready for implementation planning. Issue: #27.

Context

The object authoring form renders term and authority flexible fields as native <select>s (web/src/objects/field-input.tsxOptionsSelect, used by TermField / AuthorityField). Each field fetches the full option set for its vocabulary / authority-kind (useTerms(vocabulary_id) / useAuthorities(kind), cached 5 min) and lists it client-side. The value contract is option value = term/authority id, and labels render in the active locale via labelIn(labels, lang) (sv → en → first). This is lean but has no type-to-filter, which gets unwieldy as a vocabulary grows.

Decisions (from brainstorming)

  1. Search strategy: client-side filtering now. The combobox filters the already-loaded option list as you type. No backend change. Server-side ?q= search (for genuinely large vocabularies, plus resolving a selected id→label that isn't in the filtered set) is deferred to a follow-up issue — current vocabularies are small, and YAGNI.
  2. Primitive: Base UI Combobox. @base-ui/react (v1.5.0) is already a dependency and ships a native combobox primitive. Using it is consistent with the existing Base UI wrappers (e.g. ui/alert-dialog.tsx wraps @base-ui/react/alert-dialog) and adds zero new packages (no Radix, no cmdk). Combobox (not Autocomplete) is correct: the committed value is the id, distinct from the typed filter text.
  3. Bundle: non-issue. The object form is already lazy-loaded (app.tsx: ObjectNewPage/ObjectEditForm are lazy(() => import(...))), so the combobox weight lands in the object-form route chunk, not the ~147 KB gz index bundle the 150 KB budget guards.

Components

1. web/src/components/ui/combobox.tsx (new)

A thin wrapper over @base-ui/react/combobox, in the same style as the other ui/* Base UI wrappers (data-slot attributes, cn() class composition, exporting the composed parts). The plan must read @base-ui/react/combobox (its index.d.ts / Base UI docs) to pin the exact part names and value props (the namespace exposes Root / Input / Trigger / Positioner / Popup / List / Item-style parts and a selectionMode; single-select value is controlled). The wrapper exposes whatever composed parts the OptionsCombobox below needs (root, input, popup/list, item) with the project's Tailwind classes matching the existing inputs/menus.

2. OptionsCombobox in web/src/objects/field-input.tsx

Replaces OptionsSelect with the same prop contract so it is a drop-in:

function OptionsCombobox({
  id: string,
  value: string,                              // selected id ("" = none)
  onChange: (v: string) => void,              // emits the selected id (or "")
  options: { id: string; labels: LabelView[] }[],
  lang: string,
  placeholder: string,
}): JSX.Element

Behavior:

  • Renders a text input that filters options client-side by each option's active-locale label (labelIn(o.labels, lang)), case-insensitively, as the user types. Base UI Combobox's built-in filtering drives this (the items carry an id value + a label string for matching/display).
  • Selecting an item calls onChange(option.id); the closed trigger/input shows the selected item's label (resolved from options by id).
  • Clearable: when nothing matches / the user clears the input, the value becomes "" (valid only when the field is not requiredrequired is enforced by the existing react-hook-form Controller rules, unchanged).
  • Accessible (label association via the existing <Label htmlFor>, keyboard nav from the primitive).

TermField and AuthorityField keep their Controller (value = id, onChange = field.onChange, rules={{ required }}); only the rendered child changes from OptionsSelect to OptionsCombobox. useTerms / useAuthorities are unchanged.

Data flow

TermField/AuthorityFielduseTerms/useAuthorities (full list, cached) → OptionsCombobox filters by active-locale label as the user types → on select, emits the chosen id to the rhf Controller → stored in fields.<key> exactly as before. Editing an object: the stored id is matched against the loaded options to show its label (same as the current <select>).

Error handling / edges

  • Options still loading (useTerms/useAuthorities pending): the combobox shows the placeholder and is effectively empty/disabled until options arrive — same as the current <select> rendering options ?? [].
  • Unknown stored id (e.g. the referenced term was later deleted): no matching option, so the combobox shows empty/placeholder (or the raw id) — acceptable and no worse than the current <select>, which also can't render a missing option.
  • Empty vocabulary: the combobox shows the placeholder and an empty list.

Testing

  • web/src/objects/field-input.test.tsx (update): drive the combobox instead of the native select — open it, type to filter, assert only matching options show, select one and assert the rhf value is the option id; clear and assert "". The popup renders in a portal, so query it via within(document.body) (the established pattern from the dialog work). Keep the existing non-term/authority field-type tests (text/integer/date/ boolean/localized_text) untouched.
  • Storybook (web/src/objects/field-input.stories.tsx or a focused combobox.stories.tsx): stories for the combobox — default/placeholder, filter-as-you-type, a selected value. Mirror the established story format (@storybook/react-vite, storybook/test, tags: ['ai-generated'], single quotes). (Per the standing rule: stories for meaningful interactive components.)
  • Bundle: pnpm check:size — the index chunk stays ≤ 150 KB gz (combobox weight is in the lazy object-form chunk; confirm the index didn't grow materially).

Acceptance criteria

  1. Term and authority fields render a searchable combobox that filters the loaded options by active-locale label as you type; selecting commits the term/authority id (value contract unchanged); the field is clearable when not required.
  2. Built on Base UI's combobox primitive via a ui/combobox.tsx wrapper — no new npm dependency.
  3. useTerms/useAuthorities unchanged (client-side filtering; no backend change).
  4. field-input.test.tsx covers open/filter/select/clear; a Storybook story exists; other field types unchanged.
  5. pnpm typecheck + pnpm lint (no any/eslint-disable/@ts-ignore) + pnpm test + pnpm build green; index bundle ≤ 150 KB gz; en/sv parity for any new i18n keys; no codename.

Out of scope → follow-up issue

  • Server-side term/authority search (GET /api/admin/vocabularies/{id}/terms?q=, authorities?q=, debounced, top-N) for genuinely large vocabularies, and resolving a selected id→label when the item isn't in the filtered/loaded set (needs a by-id lookup). File this as a new issue when a vocabulary actually grows large.
  • Multi-select term/authority fields (the schema is single-value today).