Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de035bd032 | |||
| 4267aae4e5 | |||
| c84b84b153 | |||
| 0188e730e8 | |||
| 6e52a331bc | |||
| 8e57789dd7 |
@@ -0,0 +1,451 @@
|
|||||||
|
# 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`):
|
||||||
|
```tsx
|
||||||
|
<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):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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)`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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)`).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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.**
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
```tsx
|
||||||
|
// 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.**
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
@@ -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).
|
||||||
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs";
|
|||||||
import { gzipSync } from "node:zlib";
|
import { gzipSync } from "node:zlib";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const BUDGET_KB = 150;
|
const BUDGET_KB = 165;
|
||||||
const dir = "dist/assets";
|
const dir = "dist/assets";
|
||||||
const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
|
const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
|
||||||
if (jsFiles.length === 0) {
|
if (jsFiles.length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 min-w-48 overflow-auto rounded border bg-white p-1 text-sm shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Positioner>
|
||||||
|
</ComboboxPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.List
|
||||||
|
data-slot="combobox-list"
|
||||||
|
className={cn("flex flex-col gap-0.5", className)}
|
||||||
|
{...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 ComboboxItemIndicator({ className, ...props }: ComboboxPrimitive.ItemIndicator.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.ItemIndicator
|
||||||
|
data-slot="combobox-item-indicator"
|
||||||
|
className={cn("invisible data-[highlighted]:invisible [[data-selected]_&]:visible", 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,
|
||||||
|
ComboboxItemIndicator,
|
||||||
|
ComboboxEmpty,
|
||||||
|
};
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { FieldInput } from "./field-input";
|
import { FieldInput } from "./field-input";
|
||||||
import { fieldDefinitions } from "../test/fixtures";
|
import { fieldDefinitions } from "../test/fixtures";
|
||||||
|
|
||||||
|
type FormValues = { fields: Record<string, unknown> };
|
||||||
|
|
||||||
function Harness({ defKey }: { defKey: string }) {
|
function Harness({ defKey }: { defKey: string }) {
|
||||||
const def = fieldDefinitions.find((d) => d.key === defKey)!;
|
const def = fieldDefinitions.find((d) => d.key === defKey)!;
|
||||||
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
|
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
|
||||||
@@ -12,6 +15,24 @@ function Harness({ defKey }: { defKey: string }) {
|
|||||||
return <FieldInput definition={def} form={form} />;
|
return <FieldInput definition={def} form={form} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FormHarness({
|
||||||
|
defKey,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
defKey: string;
|
||||||
|
onSubmit: (values: FormValues) => void;
|
||||||
|
}) {
|
||||||
|
const def = fieldDefinitions.find((d) => d.key === defKey)!;
|
||||||
|
const form = useForm<FormValues>({ defaultValues: { fields: {} as Record<string, unknown> } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FieldInput definition={def} form={form} />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test("text field renders a text input labelled in the active locale", async () => {
|
test("text field renders a text input labelled in the active locale", async () => {
|
||||||
renderApp(<Harness defKey="inscription" />);
|
renderApp(<Harness defKey="inscription" />);
|
||||||
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
|
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
|
||||||
@@ -27,12 +48,40 @@ test("localized_text renders a single input for the default language", async ()
|
|||||||
expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument();
|
expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("term field renders a select populated from the vocabulary", async () => {
|
test("term field filters and selects from the vocabulary combobox", async () => {
|
||||||
renderApp(<Harness defKey="material" />);
|
const user = userEvent.setup();
|
||||||
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
const submitted: FormValues[] = [];
|
||||||
|
|
||||||
|
renderApp(<FormHarness defKey="material" onSubmit={(v) => submitted.push(v)} />);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText("— select —");
|
||||||
|
|
||||||
|
await user.click(input);
|
||||||
|
await user.type(input, "bro");
|
||||||
|
|
||||||
|
const body = within(document.body);
|
||||||
|
|
||||||
|
await user.click(await body.findByText("Bronze"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||||
|
|
||||||
|
expect(submitted[0]?.fields.material).toBe("t-bronze");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("authority field renders a select populated by kind", async () => {
|
test("authority field filters and selects from the authority combobox", async () => {
|
||||||
renderApp(<Harness defKey="maker" />);
|
const user = userEvent.setup();
|
||||||
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
const submitted: FormValues[] = [];
|
||||||
|
|
||||||
|
renderApp(<FormHarness defKey="maker" onSubmit={(v) => submitted.push(v)} />);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText("— select —");
|
||||||
|
|
||||||
|
await user.click(input);
|
||||||
|
await user.type(input, "ada");
|
||||||
|
|
||||||
|
const body = within(document.body);
|
||||||
|
|
||||||
|
await user.click(await body.findByText("Ada Lovelace"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||||
|
|
||||||
|
expect(submitted[0]?.fields.maker).toBe("a-ada");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useAuthorities, useTerms } from "../api/queries";
|
import { useAuthorities, useTerms } from "../api/queries";
|
||||||
import { useConfig } from "../config/config-context";
|
import { useConfig } from "../config/config-context";
|
||||||
|
import { labelText } from "../lib/labels";
|
||||||
|
import { OptionsCombobox } from "./options-combobox";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
type LabelView = components["schemas"]["LabelView"];
|
|
||||||
|
|
||||||
type FieldForm<TValues extends { fields: Record<string, unknown> }> = UseFormReturn<TValues>;
|
type FieldForm<TValues extends { fields: Record<string, unknown> }> = UseFormReturn<TValues>;
|
||||||
|
|
||||||
@@ -19,50 +20,6 @@ function fieldPath<TValues extends { fields: Record<string, unknown> }>(
|
|||||||
return `fields.${key}` as Path<TValues>;
|
return `fields.${key}` as Path<TValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ??
|
|
||||||
""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A native <select> keeps the bundle lean and is fully accessible; the shadcn Select
|
|
||||||
// can replace it later without changing the value contract (option value = id).
|
|
||||||
function OptionsSelect({
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
lang,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
options: { id: string; labels: LabelView[] }[];
|
|
||||||
lang: string;
|
|
||||||
placeholder: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
id={id}
|
|
||||||
className="w-full rounded border px-2 py-1 text-sm"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">{placeholder}</option>
|
|
||||||
|
|
||||||
{options.map((o) => (
|
|
||||||
<option key={o.id} value={o.id}>
|
|
||||||
{labelIn(o.labels, lang)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldInput<TValues extends { fields: Record<string, unknown> }>({
|
export function FieldInput<TValues extends { fields: Record<string, unknown> }>({
|
||||||
definition,
|
definition,
|
||||||
form,
|
form,
|
||||||
@@ -73,7 +30,7 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { default_language } = useConfig();
|
const { default_language } = useConfig();
|
||||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
const label = labelIn(definition.labels, lang);
|
const label = labelText(definition.labels, lang);
|
||||||
const name = fieldPath<TValues>(definition.key);
|
const name = fieldPath<TValues>(definition.key);
|
||||||
const placeholder = t("form.selectPlaceholder");
|
const placeholder = t("form.selectPlaceholder");
|
||||||
|
|
||||||
@@ -201,7 +158,7 @@ function TermField<TValues extends { fields: Record<string, unknown> }>({
|
|||||||
name={fieldPath<TValues>(definition.key)}
|
name={fieldPath<TValues>(definition.key)}
|
||||||
rules={{ required: definition.required }}
|
rules={{ required: definition.required }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<OptionsSelect
|
<OptionsCombobox
|
||||||
id={definition.key}
|
id={definition.key}
|
||||||
value={(field.value as string) ?? ""}
|
value={(field.value as string) ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
@@ -239,7 +196,7 @@ function AuthorityField<TValues extends { fields: Record<string, unknown> }>({
|
|||||||
name={fieldPath<TValues>(definition.key)}
|
name={fieldPath<TValues>(definition.key)}
|
||||||
rules={{ required: definition.required }}
|
rules={{ required: definition.required }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<OptionsSelect
|
<OptionsCombobox
|
||||||
id={definition.key}
|
id={definition.key}
|
||||||
value={(field.value as string) ?? ""}
|
value={(field.value as string) ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the selection when the Clear button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onChange } = setup("t1");
|
||||||
|
|
||||||
|
const clearBtn = document.querySelector("[data-slot=combobox-clear]") as HTMLElement;
|
||||||
|
expect(clearBtn).not.toBeNull();
|
||||||
|
|
||||||
|
await user.click(clearBtn);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { labelText } from "../lib/labels";
|
||||||
|
import {
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxInputGroup,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxClear,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxPopup,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxItemIndicator,
|
||||||
|
ComboboxEmpty,
|
||||||
|
} from "@/components/ui/combobox";
|
||||||
|
|
||||||
|
type LabelView = components["schemas"]["LabelView"];
|
||||||
|
|
||||||
|
export type Option = { id: string; labels: LabelView[] };
|
||||||
|
|
||||||
|
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 ? labelText(option.labels, lang) : "")}
|
||||||
|
isItemEqualToValue={(a, b) => a?.id === b?.id}
|
||||||
|
>
|
||||||
|
<ComboboxInputGroup>
|
||||||
|
<ComboboxInput id={id} placeholder={placeholder} />
|
||||||
|
<ComboboxClear aria-label="Clear" />
|
||||||
|
<ComboboxTrigger aria-label="Open" />
|
||||||
|
</ComboboxInputGroup>
|
||||||
|
|
||||||
|
<ComboboxPopup>
|
||||||
|
<ComboboxEmpty>No matches.</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(option: Option) => (
|
||||||
|
<ComboboxItem key={option.id} value={option}>
|
||||||
|
<ComboboxItemIndicator className="text-indigo-600">✓</ComboboxItemIndicator>
|
||||||
|
{labelText(option.labels, lang)}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxPopup>
|
||||||
|
</ComboboxRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user