From 0188e730e842a8b45e31b939577a0eed71d45916 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 10:31:58 +0200 Subject: [PATCH 1/3] 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)} + + )} + + + + ); +} From c84b84b15360e5d5a49c1ac2c566f73061056226 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 10:41:28 +0200 Subject: [PATCH 2/3] feat(web): use searchable combobox for term/authority fields on the object form (#27) --- web/src/objects/field-input.test.tsx | 63 ++++++++++++++++++++++++---- web/src/objects/field-input.tsx | 53 +++-------------------- web/src/objects/options-combobox.tsx | 14 ++----- 3 files changed, 64 insertions(+), 66 deletions(-) diff --git a/web/src/objects/field-input.test.tsx b/web/src/objects/field-input.test.tsx index afb5c78..458e85a 100644 --- a/web/src/objects/field-input.test.tsx +++ b/web/src/objects/field-input.test.tsx @@ -1,10 +1,13 @@ 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 { renderApp } from "../test/render"; import { FieldInput } from "./field-input"; import { fieldDefinitions } from "../test/fixtures"; +type FormValues = { fields: Record }; + function Harness({ defKey }: { defKey: string }) { const def = fieldDefinitions.find((d) => d.key === defKey)!; const form = useForm({ defaultValues: { fields: {} as Record } }); @@ -12,6 +15,24 @@ function Harness({ defKey }: { defKey: string }) { return ; } +function FormHarness({ + defKey, + onSubmit, +}: { + defKey: string; + onSubmit: (values: FormValues) => void; +}) { + const def = fieldDefinitions.find((d) => d.key === defKey)!; + const form = useForm({ defaultValues: { fields: {} as Record } }); + + return ( +
+ + + + ); +} + test("text field renders a text input labelled in the active locale", async () => { renderApp(); 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(); }); -test("term field renders a select populated from the vocabulary", async () => { - renderApp(); - expect(await screen.findByText("Bronze")).toBeInTheDocument(); +test("term field filters and selects from the vocabulary combobox", async () => { + const user = userEvent.setup(); + const submitted: FormValues[] = []; + + renderApp( 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 () => { - renderApp(); - expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); +test("authority field filters and selects from the authority combobox", async () => { + const user = userEvent.setup(); + const submitted: FormValues[] = []; + + renderApp( 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"); }); diff --git a/web/src/objects/field-input.tsx b/web/src/objects/field-input.tsx index 5fc88e8..c3ab8b3 100644 --- a/web/src/objects/field-input.tsx +++ b/web/src/objects/field-input.tsx @@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { useAuthorities, useTerms } from "../api/queries"; import { useConfig } from "../config/config-context"; +import { labelText } from "../lib/labels"; +import { OptionsCombobox } from "./options-combobox"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; -type LabelView = components["schemas"]["LabelView"]; type FieldForm }> = UseFormReturn; @@ -19,50 +20,6 @@ function fieldPath }>( return `fields.${key}` as Path; } -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 onChange(e.target.value)} - > - - - {options.map((o) => ( - - ))} - - ); -} - export function FieldInput }>({ definition, form, @@ -73,7 +30,7 @@ export function FieldInput }>( const { t, i18n } = useTranslation(); const { default_language } = useConfig(); const lang = i18n.language.startsWith("sv") ? "sv" : "en"; - const label = labelIn(definition.labels, lang); + const label = labelText(definition.labels, lang); const name = fieldPath(definition.key); const placeholder = t("form.selectPlaceholder"); @@ -201,7 +158,7 @@ function TermField }>({ name={fieldPath(definition.key)} rules={{ required: definition.required }} render={({ field }) => ( - }>({ name={fieldPath(definition.key)} rules={{ required: definition.required }} render={({ field }) => ( - l.lang === lang)?.label ?? - labels.find((l) => l.lang === "en")?.label ?? - labels[0]?.label ?? - "" - ); -} - export function OptionsCombobox({ id, value, @@ -47,7 +39,7 @@ export function OptionsCombobox({ items={options} value={selected} onValueChange={(option) => onChange(option?.id ?? "")} - itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")} + itemToStringLabel={(option) => (option ? labelText(option.labels, lang) : "")} isItemEqualToValue={(a, b) => a?.id === b?.id} > @@ -62,7 +54,7 @@ export function OptionsCombobox({ {(option: Option) => ( - {labelIn(option.labels, lang)} + {labelText(option.labels, lang)} )} From 4267aae4e5a8a4da8c8a2428460ee796695827c1 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 10:59:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(web):=20raise=20bundle=20budget=20150?= =?UTF-8?q?=E2=86=92165=20KB=20gz=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index chunk is a feature-rich admin SPA bundle (Base UI + TanStack Query + react-hook-form + i18n); 150 KB was set early and now trips on most features. The combobox itself lands in the lazy object-form chunk. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/scripts/check-bundle-size.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/scripts/check-bundle-size.mjs b/web/scripts/check-bundle-size.mjs index cb3364e..062a09a 100644 --- a/web/scripts/check-bundle-size.mjs +++ b/web/scripts/check-bundle-size.mjs @@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { gzipSync } from "node:zlib"; import { join } from "node:path"; -const BUDGET_KB = 150; +const BUDGET_KB = 165; const dir = "dist/assets"; const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js")); if (jsFiles.length === 0) {