docs(specs): searchable term/authority combobox picker (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user