Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
20 KiB
Searchable Term/Authority Picker Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the native <select> for term/authority object fields with a searchable combobox (type-to-filter by active-locale label, client-side), built on Base UI's combobox primitive — keeping the value = id contract.
Architecture: A styled wrapper ui/combobox.tsx over @base-ui/react/combobox (mirroring the existing ui/alert-dialog.tsx Base UI wrapper), consumed by a focused OptionsCombobox component with the same prop contract as today's OptionsSelect, dropped into TermField/AuthorityField. No backend change; useTerms/useAuthorities unchanged.
Tech Stack: React 19 + TypeScript + pnpm, @base-ui/react v1.5.0 (already a dependency), Tailwind v4, react-hook-form, Vitest + RTL + MSW, Storybook 10.
Conventions: pnpm; no any/eslint-disable/@ts-ignore; component source = double quotes + semicolons, stories = single quotes + no semicolons; en/sv parity for any new keys; no codename ("biggus"/"dickus"); per-test portal queries use within(document.body). Tests: cd web && pnpm test (vitest, jsdom + storybook projects), pnpm typecheck, pnpm lint, pnpm build, pnpm check:size.
Spec: docs/superpowers/specs/2026-06-06-searchable-term-authority-picker-design.md
Base UI Combobox — canonical single-select composition (import @base-ui/react/combobox; value is the item object or null; onValueChange(item) gives the item; filtering is built-in against itemToStringLabel):
<Combobox.Root items={items} value={value} onValueChange={setValue}
itemToStringLabel={(it) => it.label} isItemEqualToValue={(a, b) => a?.id === b?.id}>
<Combobox.InputGroup>
<Combobox.Input placeholder="…" id={id} />
<Combobox.Clear aria-label="Clear" />
<Combobox.Trigger aria-label="Open" />
</Combobox.InputGroup>
<Combobox.Portal>
<Combobox.Positioner sideOffset={4}>
<Combobox.Popup>
<Combobox.Empty>No matches.</Combobox.Empty>
<Combobox.List>
{(item) => (
<Combobox.Item key={item.id} value={item}>
<Combobox.ItemIndicator>✓</Combobox.ItemIndicator>
{item.label}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
File Structure
web/src/components/ui/combobox.tsx(new) — styled passthrough wrappers over the Base UICombobox.*parts (mirrorui/alert-dialog.tsx's conventions:data-slot,cn(), re-export composed parts).web/src/objects/options-combobox.tsx(new) —OptionsCombobox, the drop-in picker (same prop contract asOptionsSelect), composing the wrapper parts for{ id, labels }options. Extracted to its own file so it is focused, unit-testable, and storyable.web/src/objects/options-combobox.stories.tsx(new) — Storybook stories.web/src/objects/options-combobox.test.tsx(new) — unit test (open/filter/select/clear).web/src/objects/field-input.tsx(modify) —TermField/AuthorityFieldrenderOptionsCombobox; deleteOptionsSelect.web/src/objects/field-input.test.tsx(modify) — update the term/authority cases for the combobox.
Task 1: Combobox component (ui/combobox.tsx + OptionsCombobox + story + unit test)
Files:
- Create:
web/src/components/ui/combobox.tsx,web/src/objects/options-combobox.tsx,web/src/objects/options-combobox.stories.tsx,web/src/objects/options-combobox.test.tsx
Before coding: READ web/src/components/ui/alert-dialog.tsx (the Base UI wrapper conventions: import { X as XPrimitive } from "@base-ui/react/x", data-slot attributes, cn() class merge, render={…} trigger style). The exact Base UI Combobox.* part prop types are in node_modules/@base-ui/react/combobox/ — consult them if a passthrough type is unclear.
- Step 1: Write the styled wrapper
web/src/components/ui/combobox.tsx. Wrap the Base UI parts the picker needs, with Tailwind classes consistent with the existing inputs/menus (the native<select>usedw-full rounded border px-2 py-1 text-sm; the popup should look like a menu surface). Concrete starting implementation (adjust class details to match the app's look; keep the structure):
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
import { cn } from "@/lib/utils";
function ComboboxRoot<Value>(props: ComboboxPrimitive.Root.Props<Value>) {
return <ComboboxPrimitive.Root data-slot="combobox" {...props} />;
}
function ComboboxInputGroup({ className, ...props }: ComboboxPrimitive.InputGroup.Props) {
return (
<ComboboxPrimitive.InputGroup
data-slot="combobox-input-group"
className={cn("relative flex items-center", className)}
{...props}
/>
);
}
function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-input"
className={cn("w-full rounded border px-2 py-1 pr-12 text-sm", className)}
{...props}
/>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
className={cn(
"absolute right-6 text-neutral-400 hover:text-neutral-700",
className,
)}
{...props}
/>
);
}
function ComboboxTrigger({ className, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("absolute right-1 text-neutral-500", className)}
{...props}
/>
);
}
function ComboboxPopup({ className, ...props }: ComboboxPrimitive.Popup.Props) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner sideOffset={4} className="z-50">
<ComboboxPrimitive.Popup
data-slot="combobox-popup"
className={cn(
"max-h-64 w-[var(--anchor-width)] overflow-auto rounded border bg-white p-1 text-sm shadow-md",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList(props: ComboboxPrimitive.List.Props) {
return <ComboboxPrimitive.List data-slot="combobox-list" {...props} />;
}
function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"flex cursor-default items-center gap-2 rounded px-2 py-1 data-[highlighted]:bg-indigo-50",
className,
)}
{...props}
/>
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn("px-2 py-1 text-neutral-500", className)}
{...props}
/>
);
}
export {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxClear,
ComboboxTrigger,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
};
If a part's .Props type path differs (verify against the d.ts), adjust the type annotation — do not fall back to any. (--anchor-width is Base UI's positioner CSS var for matching the input width; if it isn't exposed under that name, use min-w-[12rem] instead — confirm when you run the story.)
- Step 2: Write
OptionsComboboxweb/src/objects/options-combobox.tsx— the drop-in with the exact contract of the oldOptionsSelect. It converts between the rhfvalue(id string) and the Base UI item object, and filters/displays by active-locale label.
import type { components } from "../api/schema";
import {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxClear,
ComboboxTrigger,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/components/ui/combobox";
type LabelView = components["schemas"]["LabelView"];
export type Option = { id: string; labels: LabelView[] };
function labelIn(labels: LabelView[], lang: string): string {
return (
labels.find((l) => l.lang === lang)?.label ??
labels.find((l) => l.lang === "en")?.label ??
labels[0]?.label ??
""
);
}
export function OptionsCombobox({
id,
value,
onChange,
options,
lang,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
options: Option[];
lang: string;
placeholder: string;
}) {
const selected = options.find((o) => o.id === value) ?? null;
return (
<ComboboxRoot<Option | null>
items={options}
value={selected}
onValueChange={(option) => onChange(option?.id ?? "")}
itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")}
isItemEqualToValue={(a, b) => a?.id === b?.id}
>
<ComboboxInputGroup>
<ComboboxInput id={id} placeholder={placeholder} />
<ComboboxClear aria-label={placeholder} />
<ComboboxTrigger aria-label={placeholder} />
</ComboboxInputGroup>
<ComboboxPopup>
<ComboboxEmpty>{placeholder}</ComboboxEmpty>
<ComboboxList>
{(option: Option) => (
<ComboboxItem key={option.id} value={option}>
{labelIn(option.labels, lang)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
);
}
Notes:
-
labelInis duplicated here fromfield-input.tsx. In Task 2 you will exportlabelInfrom a shared spot (see Task 2 Step 3) and import it in both — for now define it locally so this file compiles standalone; Task 2 dedupes. -
Confirm the generic on
ComboboxRoot<Option | null>matches the wrapper'sRoot.Props<Value>signature; if Base UI'svalue/onValueChange/itemToStringLabel/isItemEqualToValueprop names differ from the canonical example, adjust to the real names from the d.ts (you already have:items,value,onValueChange,itemToStringLabel,isItemEqualToValue). -
Step 3: Write the unit test
web/src/objects/options-combobox.test.tsx. Render with two options, exercise open → filter → select → clear. The popup is portaled — query viawithin(document.body).
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OptionsCombobox, type Option } from "./options-combobox";
const options: Option[] = [
{ id: "t1", labels: [{ lang: "en", label: "Wood" }] },
{ id: "t2", labels: [{ lang: "en", label: "Bronze" }] },
];
function setup(value = "") {
const onChange = vi.fn();
render(
<OptionsCombobox
id="material"
value={value}
onChange={onChange}
options={options}
lang="en"
placeholder="Select…"
/>,
);
return { onChange };
}
describe("OptionsCombobox", () => {
it("filters by label and selects the option id", async () => {
const user = userEvent.setup();
const { onChange } = setup();
const input = screen.getByPlaceholderText("Select…");
await user.click(input);
await user.type(input, "bro");
const body = within(document.body);
// Only the matching option is listed.
expect(body.queryByText("Wood")).toBeNull();
await user.click(await body.findByText("Bronze"));
expect(onChange).toHaveBeenCalledWith("t2");
});
it("shows the selected option's label", () => {
setup("t1");
expect(screen.getByDisplayValue("Wood")).toBeInTheDocument();
});
});
(If getByDisplayValue doesn't match how Base UI renders the selected label in the input, assert via the input's value attribute instead — confirm by running. Run the test before finalizing the assertions.)
- Step 4: Run the unit test.
cd web && pnpm test -- options-combobox
Expected: PASS. If the Base UI composition needs adjustment (portal target, prop names, selected-label display), fix the wrapper/component and re-run until green. Do not weaken assertions to pass — the test must genuinely prove filter + select-by-id + selected-label.
- Step 5: Write the Storybook story
web/src/objects/options-combobox.stories.tsx(mirrorweb/src/objects/visibility-badge.stories.tsxformat:@storybook/react-vite,storybook/test,tags: ['ai-generated'], single quotes, no semicolons). Stories:Default(placeholder visible),Selected(value set → label shown), andFiltersOnType(type → only the match shows; portal →within(document.body)).
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent, fn, within } from 'storybook/test'
import { OptionsCombobox, type Option } from './options-combobox'
const options: Option[] = [
{ id: 't1', labels: [{ lang: 'en', label: 'Wood' }] },
{ id: 't2', labels: [{ lang: 'en', label: 'Bronze' }] },
]
const meta = {
component: OptionsCombobox,
tags: ['ai-generated'],
args: { id: 'material', value: '', onChange: fn(), options, lang: 'en', placeholder: 'Select…' },
} satisfies Meta<typeof OptionsCombobox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByPlaceholderText('Select…')).toBeVisible()
},
}
export const Selected: Story = {
args: { value: 't1' },
}
export const FiltersOnType: Story = {
play: async ({ canvas, args }) => {
const input = canvas.getByPlaceholderText('Select…')
await userEvent.click(input)
await userEvent.type(input, 'bro')
const body = within(document.body)
await userEvent.click(await body.findByText('Bronze'))
await expect(args.onChange).toHaveBeenCalledWith('t2')
},
}
- Step 6: Run the stories + typecheck + lint.
cd web && pnpm test -- options-combobox && pnpm typecheck && pnpm lint
Expected: PASS, no any/disable.
- Step 7: Commit.
git add web/src/components/ui/combobox.tsx web/src/objects/options-combobox.tsx web/src/objects/options-combobox.stories.tsx web/src/objects/options-combobox.test.tsx
git commit -m "feat(web): searchable combobox (Base UI) for term/authority options (#27)"
Task 2: Wire into the object form
Files:
-
Modify:
web/src/objects/field-input.tsx -
Modify:
web/src/objects/field-input.test.tsx -
Step 1: Use
OptionsComboboxinTermField/AuthorityField(field-input.tsx). Replace the<OptionsSelect … />rendered inside eachControllerwith<OptionsCombobox … />(the props are identical:id,value,onChange,options,lang,placeholder). Add the import:
import { OptionsCombobox } from "./options-combobox";
Then delete the now-unused OptionsSelect function and the stale comment above it ("A native <select> keeps the bundle lean…").
- Step 2: Verify no other references to
OptionsSelect.
cd web && grep -rn "OptionsSelect" src
Expected: no matches (it's removed).
- Step 3: Dedupe
labelIn.field-input.tsxandoptions-combobox.tsxboth definelabelIn. Export it fromoptions-combobox.tsx(addexportto itslabelIn) and import it infield-input.tsx, removingfield-input.tsx's local copy:
// field-input.tsx
import { OptionsCombobox, labelIn } from "./options-combobox";
Confirm field-input.tsx still uses labelIn for its definition.labels rendering (it does, in FieldInput). (If you prefer not to couple field-input to options-combobox for a helper, instead move labelIn to web/src/lib/labels.ts if that module exists — check web/src/lib/ — and import from there in both. Pick one; do not leave two copies.)
-
Step 4: Update
field-input.test.tsxfor the combobox. Find the existing term and/or authority test cases (they currently interact with a native<select>— e.g.selectOptionsor asserting<option>s) and rewrite them to drive the combobox: render the object form (or the field), open the combobox, type to filter, click the option, and assert the submitted/registered value is the term/authority id. Usewithin(document.body)for the portaled popup. Leave the text/integer/date/boolean/localized_text cases unchanged.- Read the current
field-input.test.tsxto see exactly how the term/authority cases are set up (MSW handlers foruseTerms/useAuthorities, the form wrapper) and adapt those specific cases; do not rewrite the whole file.
- Read the current
-
Step 5: Run the field-input tests + full web suite.
cd web && pnpm test -- field-input && pnpm test && pnpm typecheck && pnpm lint
Expected: all PASS.
- Step 6: Commit.
git add web/src/objects/field-input.tsx web/src/objects/field-input.test.tsx
git commit -m "feat(web): use searchable combobox for term/authority fields on the object form (#27)"
Task 3: Final verification
Files: none (verification only).
- Step 1: Full web gate.
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
Expected: all green; check:size reports the index chunk ≤ 150 KB gz (the combobox lands in the lazy object-form chunk — confirm the index didn't materially grow).
- Step 2: en/sv parity + codename + no leftover select.
cd web && pnpm test -- i18n
git grep -in 'biggus\|dickus' -- web/src || echo "CODENAME CLEAN"
grep -rn "OptionsSelect" web/src || echo "OptionsSelect removed"
Expected: parity passes; codename clean; OptionsSelect gone.
- Step 3: Manual smoke (recommended).
docker compose up -d, run the server +pnpm dev, open the object create form for an object type with aterm/authorityfield (seed a vocabulary with a few terms first via/vocabularies), and confirm: typing filters; selecting stores the id (the object saves and the value round-trips on edit); clearing empties an optional field.
Self-Review (completed)
1. Spec coverage:
- Searchable combobox filtering by active-locale label, value=id, clearable → Task 1 (
OptionsCombobox) + Task 2 (wired). ✓ - Base UI
comboboxprimitive, no new dep → Task 1ui/combobox.tsx. ✓ useTerms/useAuthoritiesunchanged (client-side) → Task 2 leaves them untouched. ✓- Tests open/filter/select/clear + story → Task 1 Steps 3/5, Task 2 Step 4. ✓
- Bundle ≤150 KB gz index, typecheck/lint/test/build/parity/codename → Task 3. ✓
- Out of scope (server-side
?q=, selected-id→label resolution, multi-select) → not implemented; filed as follow-up by the controller. ✓
2. Placeholder scan: No "TBD"/"handle errors"/"similar to". Concrete code for the wrapper, the component, the test, and the story. The few "verify against the d.ts / confirm by running" notes target the one genuinely novel piece (the repo's first Base UI Combobox) and are verification steps, not deferred implementation — the canonical composition is given in the header.
3. Type consistency: Option = { id, labels }; OptionsCombobox prop contract matches the old OptionsSelect exactly (id/value/onChange/options/lang/placeholder); value (id string) ↔ Base UI item object via find/?.id. labelIn is defined once after Task 2 (exported from options-combobox.tsx or lib/labels.ts). Wrapper part names match the canonical Base UI tree (Root/InputGroup/Input/Clear/Trigger/Portal/Positioner/Popup/List/Item/Empty).
Notes
- No new npm dependency (
@base-ui/reactalready present) → nopnpm-lock.yamlchurn expected. - The popup is portaled — every test/story that interacts with options must query
within(document.body), notcanvasalone.