Files
biggus-dickus/docs/superpowers/plans/2026-06-06-searchable-term-authority-picker.md
T
2026-06-06 10:24:39 +02:00

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 UI Combobox.* parts (mirror ui/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 as OptionsSelect), 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/AuthorityField render OptionsCombobox; delete OptionsSelect.
  • 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> used w-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 OptionsCombobox web/src/objects/options-combobox.tsx — the drop-in with the exact contract of the old OptionsSelect. It converts between the rhf value (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:

  • labelIn is duplicated here from field-input.tsx. In Task 2 you will export labelIn from 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's Root.Props<Value> signature; if Base UI's value/onValueChange/itemToStringLabel/isItemEqualToValue prop 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 via within(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 (mirror web/src/objects/visibility-badge.stories.tsx format: @storybook/react-vite, storybook/test, tags: ['ai-generated'], single quotes, no semicolons). Stories: Default (placeholder visible), Selected (value set → label shown), and FiltersOnType (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 OptionsCombobox in TermField/AuthorityField (field-input.tsx). Replace the <OptionsSelect … /> rendered inside each Controller with <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.tsx and options-combobox.tsx both define labelIn. Export it from options-combobox.tsx (add export to its labelIn) and import it in field-input.tsx, removing field-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.tsx for the combobox. Find the existing term and/or authority test cases (they currently interact with a native <select> — e.g. selectOptions or 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. Use within(document.body) for the portaled popup. Leave the text/integer/date/boolean/localized_text cases unchanged.

    • Read the current field-input.test.tsx to see exactly how the term/authority cases are set up (MSW handlers for useTerms/useAuthorities, the form wrapper) and adapt those specific cases; do not rewrite the whole file.
  • 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 a term/authority field (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 combobox primitive, no new dep → Task 1 ui/combobox.tsx. ✓
  • useTerms/useAuthorities unchanged (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/react already present) → no pnpm-lock.yaml churn expected.
  • The popup is portaled — every test/story that interacts with options must query within(document.body), not canvas alone.