From 714d1bd787ffeacca8bc4d32b062feb1b6f9abc7 Mon Sep 17 00:00:00 2001 From: Mario Juarros Date: Thu, 13 Aug 2026 13:28:52 -0600 Subject: [PATCH 1/7] Add virtualized Combobox component with tests Backs large option lists (5,000-10,000+ items) with @tanstack/react-virtual so rendering stays smooth instead of freezing the UI, matching the look and keyboard/animation behavior of the existing Select component. Also supports an inner label above the value, matching SelectField's labelPlacement="inner" style, so sites migrating off SelectField don't lose that caption. --- package.json | 2 + packages/graph-explorer/package.json | 2 + .../src/components/Combobox.test.tsx | 501 ++++++++++++++++++ .../src/components/Combobox.tsx | 455 ++++++++++++++++ .../graph-explorer/src/components/index.ts | 3 + pnpm-lock.yaml | 117 ++++ 6 files changed, 1080 insertions(+) create mode 100644 packages/graph-explorer/src/components/Combobox.test.tsx create mode 100644 packages/graph-explorer/src/components/Combobox.tsx diff --git a/package.json b/package.json index 832733da1..808e502c6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "dev": "pnpm --stream -r run dev" }, "dependencies": { + "@base-ui/react": "^1.7.0", + "@tanstack/react-virtual": "^3.14.9", "dompurify": "^3.4.13" }, "devDependencies": { diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 5df2e91fd..6602af5e9 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -13,11 +13,13 @@ "build": "pnpm vite-build -- --mode production" }, "dependencies": { + "@base-ui/react": "^1.7.0", "@graph-explorer/shared": "workspace:*", "@hookform/resolvers": "^5.4.0", "@monaco-editor/react": "^4.7.0", "@react-aria/textfield": "3.19.1", "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.14.9", "babel-plugin-react-compiler": "^1.0.0", "clsx": "^2.1.1", "color": "^5.0.3", diff --git a/packages/graph-explorer/src/components/Combobox.test.tsx b/packages/graph-explorer/src/components/Combobox.test.tsx new file mode 100644 index 000000000..dc6d23c84 --- /dev/null +++ b/packages/graph-explorer/src/components/Combobox.test.tsx @@ -0,0 +1,501 @@ +/** + * @vitest-environment jsdom + * + * Combobox Component Tests + * + * Focus: Behavior-based tests at realistic scales (500, 5,000, 10,000 items) + * No DOM node count assertions (virtualization is an implementation detail) + * + * Run: pnpm test Combobox.test.tsx + */ + +import { render, fireEvent } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { Combobox, type ComboboxOption } from "./Combobox"; + +// jsdom never lays out elements, so offsetWidth/offsetHeight are always 0. +// TanStack Virtual measures the scroll container via offsetHeight to decide +// which rows are visible, so a real 0 means it renders zero rows. Give +// elements a realistic size so tests can assert against actually-rendered +// options. +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(300); + vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(300); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// Helper: Create test options +function createTestOptions(count: number): ComboboxOption[] { + const options: ComboboxOption[] = []; + for (let i = 0; i < count; i++) { + options.push({ + label: `Vertex Type ${i.toString().padStart(5, "0")}`, + value: `type_${i}`, + }); + } + return options; +} + +describe("Combobox", () => { + describe("Basic Rendering", () => { + it("should render input with placeholder", () => { + const options = createTestOptions(10); + const { getByPlaceholderText } = render( + , + ); + + const input = getByPlaceholderText("Select type"); + expect(input).toBeTruthy(); + }); + + it("should show selected value in closed state", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input"); + expect(input?.value).toBe("Vertex Type 00005"); + }); + + it("should render accessible attributes", () => { + const options = createTestOptions(10); + const { getByPlaceholderText } = render( + , + ); + + const input = getByPlaceholderText("Select type"); + expect(input).toBeTruthy(); + }); + + it("should render an inner label above the value, matching SelectField", () => { + const options = createTestOptions(10); + const { getByText, container } = render( + , + ); + + expect(getByText("Node type")).toBeTruthy(); + const input = container.querySelector("input"); + expect(input?.value).toBe("Vertex Type 00005"); + }); + }); + + describe("Accessible Labeling", () => { + it("should use a caller-supplied aria-label instead of a hardcoded one", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + expect(input.getAttribute("aria-label")).toBe("Node type"); + }); + + it("should associate with an external label via id/htmlFor", () => { + const options = createTestOptions(10); + const { container, getByLabelText } = render( +
+ + +
, + ); + + const input = container.querySelector("input") as HTMLInputElement; + expect(getByLabelText("Node type")).toBe(input); + }); + }); + + describe("Interaction & Selection", () => { + it("should open on arrow key, not on focus", () => { + const options = createTestOptions(10); + const { container } = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // Initially closed + expect(input.getAttribute("aria-expanded")).toBe("false"); + + // Focus alone doesn't open it + fireEvent.focus(input); + expect(input.getAttribute("aria-expanded")).toBe("false"); + + // Arrow Down opens it + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-expanded")).toBe("true"); + }); + + it("should open on click", () => { + // A