Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.tsx → OptionsSelect, 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)
- 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. - Primitive: Base UI Combobox.
@base-ui/react(v1.5.0) is already a dependency and ships a nativecomboboxprimitive. Using it is consistent with the existing Base UI wrappers (e.g.ui/alert-dialog.tsxwraps@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. - Bundle: non-issue. The object form is already lazy-loaded (
app.tsx:ObjectNewPage/ObjectEditFormarelazy(() => 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
optionsclient-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 fromoptionsby id). - Clearable: when nothing matches / the user clears the input, the value becomes
""(valid only when the field is notrequired—requiredis enforced by the existing react-hook-formControllerrules, 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/AuthorityField → useTerms/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/useAuthoritiespending): the combobox shows the placeholder and is effectively empty/disabled until options arrive — same as the current<select>renderingoptions ?? []. - 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 viawithin(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.tsxor a focusedcombobox.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
- 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.
- Built on Base UI's
comboboxprimitive via aui/combobox.tsxwrapper — no new npm dependency. useTerms/useAuthoritiesunchanged (client-side filtering; no backend change).field-input.test.tsxcovers open/filter/select/clear; a Storybook story exists; other field types unchanged.pnpm typecheck+pnpm lint(noany/eslint-disable/@ts-ignore) +pnpm test+pnpm buildgreen; 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).