From 0188e730e842a8b45e31b939577a0eed71d45916 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 10:31:58 +0200 Subject: [PATCH] feat(web): searchable combobox (Base UI) for term/authority options (#27) --- web/src/components/ui/combobox.tsx | 123 +++++++++++++++++++ web/src/objects/options-combobox.stories.tsx | 39 ++++++ web/src/objects/options-combobox.test.tsx | 63 ++++++++++ web/src/objects/options-combobox.tsx | 72 +++++++++++ 4 files changed, 297 insertions(+) create mode 100644 web/src/components/ui/combobox.tsx create mode 100644 web/src/objects/options-combobox.stories.tsx create mode 100644 web/src/objects/options-combobox.test.tsx create mode 100644 web/src/objects/options-combobox.tsx diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx new file mode 100644 index 0000000..722fe33 --- /dev/null +++ b/web/src/components/ui/combobox.tsx @@ -0,0 +1,123 @@ +import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; + +import { cn } from "@/lib/utils"; + +function ComboboxRoot(props: ComboboxPrimitive.Root.Props) { + return ; +} + +function ComboboxInputGroup({ className, ...props }: ComboboxPrimitive.InputGroup.Props) { + return ( + + ); +} + +function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) { + return ( + + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + + ); +} + +function ComboboxTrigger({ className, ...props }: ComboboxPrimitive.Trigger.Props) { + return ( + + ); +} + +function ComboboxPopup({ className, ...props }: ComboboxPrimitive.Popup.Props) { + return ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) { + return ( + + ); +} + +function ComboboxItemIndicator({ className, ...props }: ComboboxPrimitive.ItemIndicator.Props) { + return ( + + ); +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ); +} + +export { + ComboboxRoot, + ComboboxInputGroup, + ComboboxInput, + ComboboxClear, + ComboboxTrigger, + ComboboxPopup, + ComboboxList, + ComboboxItem, + ComboboxItemIndicator, + ComboboxEmpty, +}; diff --git a/web/src/objects/options-combobox.stories.tsx b/web/src/objects/options-combobox.stories.tsx new file mode 100644 index 0000000..85aec64 --- /dev/null +++ b/web/src/objects/options-combobox.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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') + }, +} diff --git a/web/src/objects/options-combobox.test.tsx b/web/src/objects/options-combobox.test.tsx new file mode 100644 index 0000000..315b03f --- /dev/null +++ b/web/src/objects/options-combobox.test.tsx @@ -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( + , + ); + + 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(""); + }); +}); diff --git a/web/src/objects/options-combobox.tsx b/web/src/objects/options-combobox.tsx new file mode 100644 index 0000000..09ec994 --- /dev/null +++ b/web/src/objects/options-combobox.tsx @@ -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 ( + + items={options} + value={selected} + onValueChange={(option) => onChange(option?.id ?? "")} + itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")} + isItemEqualToValue={(a, b) => a?.id === b?.id} + > + + + + + + + + No matches. + + {(option: Option) => ( + + + {labelIn(option.labels, lang)} + + )} + + + + ); +}