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

129 lines
7.2 KiB
Markdown

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