diff --git a/docs/adr/20260814-base-ui-combobox-for-virtualized-search.md b/docs/adr/20260814-base-ui-combobox-for-virtualized-search.md new file mode 100644 index 000000000..239e1d2ab --- /dev/null +++ b/docs/adr/20260814-base-ui-combobox-for-virtualized-search.md @@ -0,0 +1,35 @@ +# ADR — Base UI + TanStack Virtual for the virtualized Combobox + +- **Status:** Accepted +- **Date:** 2026-08-14 +- **Related:** Issues #2086, #2087, #2089, #2090. Affects `components/Combobox.tsx` only. + +## Context + +Several pickers (node-type and attribute in the Search Sidebar, node-type in Data Explorer) render schema-derived option lists that scale with the number of vertex/edge types — into the thousands on large schemas. The existing `Select`/`SelectField` components wrap Radix's `Select` primitive, which renders every item into its own internal item-registration registry regardless of what's actually visible in the DOM. That registry, not just the visible DOM node count, is what scales with option count, so it can't be virtualized without abandoning Radix's `Select` entirely: at schema sizes in the thousands this locked up the UI. + +Two alternatives were tried before landing on the current approach: + +- **A fully hand-rolled combobox** was built first (see git history: the commit preceding this one shipped a hand-rolled implementation). It re-implemented accessible combobox semantics from scratch — ARIA roles, keyboard navigation, focus management, positioning — which is exactly the kind of well-tested, easy-to-get-subtly-wrong surface a primitives library exists to own. It was replaced rather than kept. +- **cmdk**, a virtualization-friendly command-palette library, was considered but doesn't provide the same breadth of accessible combobox wiring (positioning, focus management, ARIA) that Base UI ships, which would have meant hand-building some of the same surface the hand-rolled attempt already showed is easy to get wrong. + +## Decision + +`components/Combobox.tsx` wraps `@base-ui/react`'s `Combobox` primitives (accessible listbox/combobox semantics, ARIA wiring, positioning) with `@tanstack/react-virtual` for windowed rendering. This is deliberately scoped to **one file**: `Select`/`SelectField` and their Radix-based implementation are untouched, and remain the right choice for bounded, non-schema-sized option lists. + +Both dependencies are added only to `packages/graph-explorer/package.json`, not the workspace root, since only this file imports them. + +## Consequences + +- **A second UI primitive stack now exists, scoped to one file.** An agent choosing between `Combobox` and `Select`/`SelectField` for a new picker should pick based on scale: `Combobox` for schema-sized/unbounded lists that need type-to-filter and virtualization, `Select`/`SelectField` for small bounded enums. Do not introduce a third primitive stack for the same class of problem — extend `Combobox` instead. +- **Base UI's attribute convention differs from Radix's.** Base UI emits bare boolean data attributes (`data-open`, `data-closed`, `data-starting-style`, `data-ending-style`), not Radix's `data-state="open"`/`"closed"`. The project's `data-open:`/`data-closed:` Tailwind shorthand is scoped to the Radix convention and will not match Base UI elements — see `docs/agents/design.md`. +- **`useVirtualizer` needs a React Compiler suppression.** The call in `Combobox.tsx` carries a `// eslint-disable-next-line react-compiler/incompatible-library` comment, since the compiler can't verify the hook's internal mutation patterns are safe to auto-memoize. See `docs/agents/react.md`. +- **The trigger/input interaction pattern is VoiceOver-validated, not just ARIA-linted.** The decorative arrow button is `aria-hidden` and click-only (not keyboard-focusable); the input itself gets an explicit `onClick` handler to open the list, because a text input has no native "click" default action the way a ` + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + const nextField = getByTestId("next-field"); + + input.focus(); + fireEvent.change(input, { target: { value: "00005" } }); + expect(input.value).toBe("00005"); + + nextField.focus(); + await waitFor(() => { + expect(input.getAttribute("aria-expanded")).toBe("false"); + expect(input.value).toBe(""); + }); + + // Reopening starts clean + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-expanded")).toBe("true"); + }); + }); + + describe("Keyboard Navigation", () => { + it("should open on the VoiceOver Control-Option-Space hint without inserting a space", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + fireEvent.keyDown(input, { + key: " ", + code: "Space", + ctrlKey: true, + altKey: true, + }); + + expect(input.getAttribute("aria-expanded")).toBe("true"); + expect(input.value).toBe(""); + }); + + it("should open on arrow key and respond to input", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // Initially closed + expect(input.getAttribute("aria-expanded")).toBe("false"); + + // Arrow Down opens combobox + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-expanded")).toBe("true"); + + // Typing filters options + fireEvent.change(input, { target: { value: "type" } }); + expect(input.value).toBe("type"); + }); + + it("should track the focused option via aria-activedescendant", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // Not tracking anything while closed + expect(input.getAttribute("aria-activedescendant")).toBeNull(); + + // The listbox renders through a portal into document.body + fireEvent.keyDown(input, { key: "ArrowDown" }); + const firstOptionId = input.getAttribute("aria-activedescendant"); + expect(firstOptionId).toBeTruthy(); + expect(document.querySelector(`#${firstOptionId}`)).toBeTruthy(); + expect(document.querySelector(`#${firstOptionId}`)?.textContent).toBe( + options[0].label, + ); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + const secondOptionId = input.getAttribute("aria-activedescendant"); + expect(secondOptionId).not.toBe(firstOptionId); + expect(document.querySelector(`#${secondOptionId}`)?.textContent).toBe( + options[1].label, + ); + }); + + it("should keep listbox options out of the tab sequence", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // The listbox renders through a portal into document.body + fireEvent.keyDown(input, { key: "ArrowDown" }); + const optionButtons = document.querySelectorAll('[role="option"]'); + expect(optionButtons.length).toBeGreaterThan(0); + optionButtons.forEach(option => { + expect((option as HTMLElement).tabIndex).toBe(-1); + }); + }); + + it("should close on Escape", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-expanded")).toBe("true"); + + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.getAttribute("aria-expanded")).toBe("false"); + }); + + it("should select the highlighted option with ArrowDown then Enter", async () => { + const options = createTestOptions(10); + const handleChange = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + await user.click(input); + await user.keyboard("{ArrowDown}{Enter}"); + + expect(handleChange).toHaveBeenCalledWith("type_0"); + }); + + it("should navigate well past the virtualized render window with the keyboard", () => { + // Regression test: without Base UI's `virtualized` prop, CompositeList + // truncates its internal ref list to only the ~13 currently-mounted + // rows on every scroll remount, and useListNavigation's max index is + // bounded by that truncated length — so arrowing far past the window + // silently wraps back inside it instead of tracking the real option. + // jsdom doesn't implement real scrolling, so the highlighted item's + // DOM node isn't reliably present here; the ID Base UI assigns it + // (`-`) is the part that's actually under test. + const largeOptions = createTestOptions(10000); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + fireEvent.keyDown(input, { key: "ArrowDown" }); + + const stepsPastTheWindow = 60; + for (let i = 0; i < stepsPastTheWindow; i++) { + fireEvent.keyDown(input, { key: "ArrowDown" }); + } + + // The first ArrowDown highlights index 0, so `stepsPastTheWindow` more + // presses lands on that same index. + expect(input.getAttribute("aria-activedescendant")).toBe( + `${input.id}-${stepsPastTheWindow}`, + ); + }); + + it("should keep the toggle button in the accessibility tree, though not the tab order", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + // Exposed to screen readers (unlike an aria-hidden version): when the + // input already has a value, VoiceOver's Read-All treats a filled + // text input as content to read and skips announcing its combobox + // role — this button is what re-announces "combo box" in that case. + // Still not a Tab stop, matching Base UI's own Trigger default + // (ArrowDown on the input already opens the list). + const toggleButton = container.querySelector( + "button", + ) as HTMLButtonElement; + expect(toggleButton.getAttribute("aria-hidden")).toBeNull(); + expect(toggleButton.tabIndex).toBe(-1); + + const input = container.querySelector("input") as HTMLInputElement; + expect(input.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(toggleButton); + expect(input.getAttribute("aria-expanded")).toBe("true"); + }); + + it("should close the list when the toggle button is clicked again", async () => { + // Base UI's click-to-open/close handling keys off the real + // pointerdown-before-click sequence (it tracks which element was + // pressed to distinguish "open the newly active trigger" from "toggle + // this same trigger closed"). A bare fireEvent.click skips that + // sequence and can't exercise the toggle-closed path. userEvent.click + // doesn't work either here — it never registers the initial open on + // this decorative button — so this dispatches the raw + // pointer/mouse sequence directly instead. + const options = createTestOptions(10); + const { container } = render( + , + ); + + const toggleButton = container.querySelector( + "button", + ) as HTMLButtonElement; + const input = container.querySelector("input") as HTMLInputElement; + + // Base UI's mousedown-based click handling defers the actual open/ + // close state update to a requestAnimationFrame tick (it waits for + // focus to land before flipping state, to avoid a focus-visible + // outline flash). A real user's click resolves that within one + // frame; a test has to wait for it explicitly. + function realClick(el: HTMLElement) { + fireEvent.pointerDown(el, { pointerType: "mouse", button: 0 }); + fireEvent.mouseDown(el, { button: 0 }); + fireEvent.pointerUp(el, { pointerType: "mouse", button: 0 }); + fireEvent.mouseUp(el, { button: 0 }); + fireEvent.click(el, { button: 0 }); + } + + realClick(toggleButton); + await waitFor(() => + expect(input.getAttribute("aria-expanded")).toBe("true"), + ); + + realClick(toggleButton); + await waitFor(() => + expect(input.getAttribute("aria-expanded")).toBe("false"), + ); + }); + }); + + describe("No Results State", () => { + it("should show no results message when filter matches nothing", () => { + const options = createTestOptions(10); + const { container, getByText } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "zzzzz_nonexistent" } }); + + const noResultsMsg = getByText("No results found"); + expect(noResultsMsg).toBeTruthy(); + }); + }); + + describe("Disabled State", () => { + it("should not open when disabled", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + expect(input.disabled).toBe(true); + + fireEvent.focus(input); + + // List should not appear — options render through a portal into + // document.body, not into the render container. + expect(document.querySelector('[role="option"]')).toBeFalsy(); + }); + }); + + describe("Performance at Scale (10,000 options)", () => { + it("should select a specific late item after filtering", async () => { + const largeOptions = createTestOptions(10000); + const handleChange = vi.fn(); + const { container, findByText } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "08500" } }); + + const option = await findByText("Vertex Type 08500"); + fireEvent.click(option); + + expect(handleChange).toHaveBeenCalledWith("type_8500"); + }); + + it("should filter then select via ArrowDown and Enter", async () => { + const largeOptions = createTestOptions(10000); + const handleChange = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + await user.click(input); + await user.type(input, "08500"); + await waitFor(() => { + expect(document.querySelectorAll('[role="option"]').length).toBe(1); + }); + await user.keyboard("{ArrowDown}{Enter}"); + + expect(handleChange).toHaveBeenCalledWith("type_8500"); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/Combobox.tsx b/packages/graph-explorer/src/components/Combobox.tsx new file mode 100644 index 000000000..8ad57e8c3 --- /dev/null +++ b/packages/graph-explorer/src/components/Combobox.tsx @@ -0,0 +1,406 @@ +import type { ReactNode } from "react"; + +import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; +import { type ReactVirtualizer, useVirtualizer } from "@tanstack/react-virtual"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { + useCallback, + useDeferredValue, + useId, + useImperativeHandle, + useMemo, + useReducer, + useRef, +} from "react"; + +import { cn } from "@/utils"; + +export interface ComboboxOption { + label: string; + value: string; +} + +export interface ComboboxProps { + /** Array of options to render */ + options: ComboboxOption[]; + /** Current selected value */ + value?: string; + /** Called when an option is selected */ + onValueChange?: (value: string) => void; + /** Placeholder text when no value selected */ + placeholder?: string; + /** Small caption rendered above the value, matching SelectField's "inner" label placement */ + label?: ReactNode; + /** Disable interaction */ + disabled?: boolean; + /** Additional CSS classes for the outer control */ + className?: string; + /** Forwarded to the input, for external `