feat(web): searchable combobox (Base UI) for term/authority options (#27)

This commit is contained in:
2026-06-06 10:31:58 +02:00
parent 6e52a331bc
commit 0188e730e8
4 changed files with 297 additions and 0 deletions
@@ -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')
},
}
+63
View File
@@ -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("");
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { components } from "../api/schema";
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 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="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>
{labelIn(option.labels, lang)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
);
}