# 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)

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:
```ts
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 `required` — `required` 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`/`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`/`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).
