Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/adr/20260814-base-ui-combobox-for-virtualized-search.md
Original file line number Diff line number Diff line change
@@ -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 `<button>` does, and VoiceOver's Control-Option-Space gesture needs one to trigger reliably. A grouped, AX-visible trigger button reads to VoiceOver as "stop interacting with this group" rather than "open the list" — this was found and fixed via live VoiceOver testing on macOS across several rounds, not derived from an accessibility guideline. Changing this interaction pattern needs to be re-validated with a screen reader, not just re-derived from ARIA best practices.
- **Only visible options mount.** `VirtualizedOptions` renders ~20 DOM nodes regardless of total option count; the virtualizer must measure `Combobox.List` (the bounded, scrollable viewport), not the inner spacer div that's deliberately as tall as the full list — measuring the wrong element defeats virtualization silently (it "works" but renders nearly everything).

## Considered Options

- **Base UI + TanStack Virtual (chosen).** Accessible combobox semantics come from a maintained library; virtualization is a separate, composable concern. Cost: a second primitive stack, plus the attribute-convention and compiler-suppression divergences above.
- **Hand-rolled combobox.** Tried first, replaced. Re-implementing ARIA wiring, keyboard navigation, and positioning from scratch duplicates what a primitives library already gets right, for no benefit over adopting one.
- **cmdk.** Virtualization-friendly, but would still require hand-building positioning and some ARIA wiring that Base UI provides directly.
- **Extend Radix `Select`.** Not viable: its item-registration model scales with option count independently of the DOM, which is the exact problem being solved.
1 change: 1 addition & 0 deletions docs/agents/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Dark mode is a planned future feature. The semantic token structure is designed
- Use **Tailwind v4 CSS syntax** — `@theme`, `@utility`, `@custom-variant` blocks in CSS.
- Prefer **data attributes** for conditional styles. Tailwind v4 provides two forms:
- `data-open:` / `data-closed:` — shorthand variants (defined via `@custom-variant` in `index.css`) that match `[data-state="open"]` / `[data-state="closed"]`, the Radix convention. Note: this **redefines** Tailwind's native behavior, where a bare `data-open:` would match the presence of a `data-open` attribute. Also: `aria-invalid:` for form validation.
- **Base UI components don't emit `data-state`.** They set bare boolean attributes instead (`data-open`, `data-closed`, `data-starting-style`, `data-ending-style`), so the project's `data-open:`/`data-closed:` shorthand above will silently never match a Base UI element. For Base UI components (currently only `components/Combobox.tsx`), use Tailwind's native bare-attribute variants directly — `data-open:`, `data-ending-style:`, etc. — not the Radix-scoped shorthand.
- `data-[attr=value]:` — arbitrary-value form for one-off attributes.
- Prefer **Tailwind responsive directives and container queries** over `ResizeObserver` for responsive layout changes.
- Reference: https://tailwindcss.com/docs
Expand Down
2 changes: 2 additions & 0 deletions docs/agents/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

- Server state goes in TanStack Query
- **Exception: vertex icons.** They resolve through the `core/icons/` registry, read via `useSyncExternalStore`, because a per-hook subscription scaled with vertex-type count and locked up the schema view at 10k types. Don't move icon resolution back into TanStack Query — see `docs/adr/20260813-icon-registry-not-react-query.md`
- **Exception: `useSearchableAttributes`'s internal `useMemo`** (`core/StateProvider/displayTypeConfigs.ts`). The compiler doesn't run under Vitest, so this memo is load-bearing for the test suite even though it would be redundant in the compiled production bundle. Don't remove it without also proving the referential-stability test in `useKeywordSearch.test.ts` still passes. Elsewhere, prefer stabilizing the _input_ to a memo (e.g. `useTranslations()`'s returned function is wrapped in `useCallback`) over adding more manual memos downstream of an unstable dependency
- **Exception: `useVirtualizer` (`@tanstack/react-virtual`)**, used in `components/Combobox.tsx`. It needs a `// eslint-disable-next-line react-compiler/incompatible-library` comment on the call — the compiler can't verify the hook's internal mutation patterns are safe to auto-memoize

## Feature Modules

Expand Down
1 change: 1 addition & 0 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Commands are in AGENTS.md.

- **`vi.doMock` + dynamic `import()`**: call `vi.resetModules()` in the test's own `beforeEach` (not global — it's expensive). See any test that swaps a module impl between cases.
- **Production behavior**: tests run `DEV=true`/`PROD=false`; override per-test with `vi.stubEnv("PROD", true)`.
- **jsdom layout**: jsdom never lays out elements, so `offsetHeight`/`offsetWidth` are always `0`. A component that measures its own size (e.g. a virtualizer deciding which rows are visible) will render as empty, and the failure looks like a component bug rather than an environment limitation. See `Combobox.test.tsx`'s `beforeEach` for the canonical fix: mock `offsetHeight`/`offsetWidth` to read the element's own inline style, falling back to a fixed size, so real measurements are distinguishable from unmeasured ones.
- **Errors**: assert the full error, not just that one was thrown. `expect(() => fn()).toThrow(new FooError(a, b))` — or `await expect(fn()).rejects.toThrow(new FooError(a, b))` for a rejected promise — deep-compares every property, so a wrong field fails the test. Prefer this over `toThrow(FooError)` (type only) or `toThrow("message")` (message only), which pass even when the code built the error with the wrong data. No need to catch the error and assert fields separately — the instance form already covers them.

## Backward compatibility for persisted data
Expand Down
2 changes: 1 addition & 1 deletion docs/features/data-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

You can use the Data Table view to view the data for the selected node type. You can open the Data Table view by clicking "Data Table" in the navigation bar or by clicking the node type row in the [connection details](./connections.md#connection-details) pane.

- Select a node type from the dropdown to view its data
- Select a node type from the picker to view its data, or start typing to filter the list
- View tabular data for the selected node type
- Set the node type display name and description attributes
- Export the current table data to a CSV or JSON file
Expand Down
1 change: 1 addition & 0 deletions docs/features/graph-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The Search UI provides two powerful ways to search and interact with your graph
- Enables faceted filtering of nodes based on:
- Node labels (or rdf:type for RDF databases)
- Node attribute values
- The node label and attribute pickers support type-to-filter, so you can find a type or property by typing part of its name instead of scrolling
- Supports partial text matching
- Search results can be added to the graph individually or all at once
- Supports cancellation of long-running queries
Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ On the right side of the graph view, you will see a vertical strip of sidebar ic
## Search for a Node

1. Click the **Search** icon (magnifying glass) in the right sidebar to open the Search panel.
2. In the **Node Label** dropdown, select **airport**.
2. In the **Node Label** dropdown, select **airport** (you can type to filter the list).
3. In the **Property** dropdown, select **code**.
4. In the search text field, type `AUS`.
5. Click the result for Austin to expand it, then click the **⊕** button to add it to the graph canvas.
Expand Down Expand Up @@ -99,7 +99,7 @@ All airport nodes on the canvas update with the new labels and color. You can al
The Data Table page lets you browse all nodes in the database without adding them to the graph first.

1. Click **Data Table** in the navigation bar.
2. The **Node Label** dropdown at the top left is pre-selected to **airport**. Use it to switch to other types like **country** or **continent**.
2. The **Node Label** dropdown at the top left is pre-selected to **airport**. Use it to switch to other types like **country** or **continent** (you can type to filter the list).
3. Browse the paginated table of all airports in the dataset.
4. To send a specific airport to the graph view, click the **Send to Explorer** button on its row.

Expand Down
2 changes: 2 additions & 0 deletions packages/graph-explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading