Replace the native <select> for term/authority object fields with a searchable Base UI combobox (client-side filter by active-locale label; value=id contract preserved). Server-side ?q= search deferred to a follow-up. Closes #27. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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