feat(web): searchable combobox (Base UI) for term/authority options (#27)
This commit is contained in:
@@ -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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user