From fece4439d64a31277420cdc00090edc9c803e429 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 12 Aug 2026 17:50:06 +0300 Subject: [PATCH 1/3] feat(ui): add accessible tree suite Tree and TreeItem components with ARIA tree semantics, roving tabindex, hierarchy navigation, buffered type-ahead, multi-select, sticky scroll, and VS Code-parity styling across the shared, Modern, and stable styles. Collapsed branches unmount and row re-renders are scoped to the rows that changed. Review hardening is folded in: range-anchor seeding, selection-echo tab stop handling, modifier clicks that never toggle expansion, sticky backgrounds only while pinned, React 19 ref cleanups, Shift+Home/End ranges, Escape to clear, and Ctrl+Shift+A passthrough. Closes #1037 --- .storybook/main.ts | 6 +- package.json | 1 + packages/ui/README.md | 131 +- .../ui/src/components/Tree/RovingTabStop.ts | 91 ++ packages/ui/src/components/Tree/Tree.css | 187 +++ .../ui/src/components/Tree/Tree.modern.css | 19 + .../ui/src/components/Tree/Tree.stable.css | 14 + .../ui/src/components/Tree/Tree.stories.tsx | 242 ++++ packages/ui/src/components/Tree/Tree.tsx | 147 +++ .../src/components/Tree/TreeItem.stories.tsx | 51 + packages/ui/src/components/Tree/TreeItem.tsx | 217 ++++ .../src/components/Tree/TreeItemRegistry.ts | 120 ++ .../ui/src/components/Tree/TreeSelection.ts | 79 ++ packages/ui/src/components/Tree/TreeStore.ts | 435 +++++++ packages/ui/src/components/Tree/TypeAhead.ts | 49 + packages/ui/src/components/Tree/context.ts | 26 + packages/ui/src/components/Tree/mergeRefs.ts | 30 + packages/ui/src/components/Tree/rowDom.ts | 84 ++ packages/ui/src/index.ts | 2 + packages/ui/src/tokens.css | 57 + packages/ui/storybook/Tree.demo.tsx | 106 ++ packages/ui/tsconfig.json | 2 +- pnpm-lock.yaml | 15 + pnpm-workspace.yaml | 1 + test/webview/ui/tree.test.tsx | 1087 +++++++++++++++++ test/webview/ui/treeStore.test.ts | 99 ++ 26 files changed, 3288 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/components/Tree/RovingTabStop.ts create mode 100644 packages/ui/src/components/Tree/Tree.css create mode 100644 packages/ui/src/components/Tree/Tree.modern.css create mode 100644 packages/ui/src/components/Tree/Tree.stable.css create mode 100644 packages/ui/src/components/Tree/Tree.stories.tsx create mode 100644 packages/ui/src/components/Tree/Tree.tsx create mode 100644 packages/ui/src/components/Tree/TreeItem.stories.tsx create mode 100644 packages/ui/src/components/Tree/TreeItem.tsx create mode 100644 packages/ui/src/components/Tree/TreeItemRegistry.ts create mode 100644 packages/ui/src/components/Tree/TreeSelection.ts create mode 100644 packages/ui/src/components/Tree/TreeStore.ts create mode 100644 packages/ui/src/components/Tree/TypeAhead.ts create mode 100644 packages/ui/src/components/Tree/context.ts create mode 100644 packages/ui/src/components/Tree/mergeRefs.ts create mode 100644 packages/ui/src/components/Tree/rowDom.ts create mode 100644 packages/ui/storybook/Tree.demo.tsx create mode 100644 test/webview/ui/tree.test.tsx create mode 100644 test/webview/ui/treeStore.test.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 5b262100d4..54f71a684c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -6,7 +6,11 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: ["../packages/*/src/**/*.stories.@(ts|tsx)"], - addons: ["@storybook/addon-a11y", "@storybook/addon-docs"], + addons: [ + "@storybook/addon-a11y", + "@storybook/addon-docs", + "storybook-addon-pseudo-states", + ], framework: { name: "@storybook/react-vite", options: {}, diff --git a/package.json b/package.json index 660003403e..2d8c339e43 100644 --- a/package.json +++ b/package.json @@ -844,6 +844,7 @@ "react": "catalog:", "react-dom": "catalog:", "storybook": "catalog:", + "storybook-addon-pseudo-states": "catalog:", "typescript": "catalog:", "typescript-eslint": "^8.66.0", "utf-8-validate": "^6.0.6", diff --git a/packages/ui/README.md b/packages/ui/README.md index ec79369344..89ed2c1027 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,12 +38,126 @@ Every component forwards `className` and `style` to its root element, and default rules use single-class specificity, so a consumer class imported after the library overrides any default (width, height, spacing). -Where VS Code's stable rendering and its Modern UI preview -(`workbench.experimental.modernUI`) diverge, components follow Modern UI, -and new components should too. Webviews get no signal for the setting, so -the default cannot follow the host. Until the design settles, -`data-ui-style="stable"` on the document root restores the stable-parity -menu motion; Storybook's "UI style" toolbar switch toggles it live. +VS Code currently uses its stable UI by default; Modern UI remains behind the +experimental `workbench.experimental.modernUI` setting. `@repo/ui` +intentionally uses Modern UI as its package default because webviews receive no +host signal for that setting. The divergence is isolated: set +`data-ui-style="stable"` on the document root to restore stable row geometry, +focus behavior, and menu motion. Storybook's "UI style" toolbar switch toggles +that override live. + +## Tree + +`Tree` and `TreeItem` form a declarative hierarchy; a row's children are its +child rows: + +```tsx +const [selectedItemId, setSelectedItemId] = useState("src"); +const [expanded, setExpanded] = useState(true); + + + + + +; +``` + +`itemId` carries selection and registry identity. `label` is the row content: +a string also supplies the accessible name and the case-insensitive, buffered +type-ahead key, so matching never depends on rendered DOM text; a `ReactNode` +label must pass `textValue` for those, which the types enforce. `icon` renders +a codicon ahead of the label. `aria-label` or `aria-labelledby` override the +accessible name. + +`expanded` is what makes a row a branch: it adds the twistie and lets the row +nest child rows, including a branch whose children have not loaded yet. Only a +branch may have children, and passing them without `expanded` throws. Because +children are always child rows and never row content, wrapper components, +fragments, and arrays all work. `Tree` controls selection, each `TreeItem` +controls its own expansion, and neither defaults. + +Arrow Up/Down, Home, End, and type-ahead move focus through visible enabled +rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or +returns to the parent. Enter and Space select the focused row and toggle a +branch. Clicking a row does both; clicking the twistie only toggles, leaving +selection in place like the native tree. Interactive content in the trailing +`action` slot is isolated from selection and expansion. + +The tree claims only unmodified keys, plus Ctrl/Cmd+A and Escape in +multi-select, and the root `onKeyDown` runs before any of them, so host +shortcuts like Ctrl+C or Ctrl+X need no dedicated API: handle them there, +read the focused or selected rows, and call `preventDefault()` to also stop +the browser default. Keybinding hints stay where VS Code shows them, in +menus and action-button tooltips, never on tree rows. + +`multiSelect` swaps the singular selection props for `selectedItemIds` and +`onSelectedItemsChange` and marks the tree `aria-multiselectable`. Ctrl/Cmd +click toggles a row, Shift click, Shift arrows, and Shift+Home/End extend +from the anchor (the last row selected without Shift), Ctrl/Cmd+A takes every +visible enabled row, and Escape clears the selection. Modifier clicks never +toggle a branch. Ranges follow tree order and skip disabled and collapsed +rows. + +`stickyScroll` pins the ancestors of the topmost visible row against the +nearest scrolling ancestor, like VS Code's tree sticky scroll; a number caps +the depth that can pin, so branches deeper than that many levels scroll +normally (default 7). VS Code's `stickyScrollMaxItemCount` instead caps how +many of the nearest ancestors pin at once; count semantics would need a +scroll listener, which this design avoids. Pinning is `position: sticky` on +the branch rows themselves, so the browser does the push-out, and pinned +rows carry the native shadow through a `scroll-state` container query +(Chromium 133+, so every supported host). + +Webviews receive no `workbench.tree.*` settings, so honoring the user's own +configuration is the host's job: read the settings, send them over, and keep +them live with `watchConfigurationChanges` from `src/configWatcher.ts`, which +debounces and only fires on a real change. + +```ts +const read = () => { + const tree = vscode.workspace.getConfiguration("workbench.tree"); + return ( + tree.get("enableStickyScroll") && + (tree.get("stickyScrollMaxItemCount") ?? 7) + ); +}; +watchConfigurationChanges( + [ + { setting: "workbench.tree.enableStickyScroll", getValue: read }, + { setting: "workbench.tree.stickyScrollMaxItemCount", getValue: read }, + ], + () => postToWebview({ stickyScroll: read() }), +); +``` + +Navigation order, visibility, and hierarchy are read back from the rendered +rows, so reordering or reparenting needs no extra wiring. Collapsing a branch +unmounts its children, so cost tracks what is open rather than the size of the +tree: a 100k-node tree browsed a folder at a time mounts in ~350ms and keeps +keystrokes under a millisecond. The suite is not virtualized, so the limit is +rows open _at once_ — around 10k is comfortable, 50k degrades, and 100k +expanded at once needs a virtualized tree instead. + +Rows are 22px tall and keep the VS Code twistie gutter, matching trees whose +branch rows render icons. For file trees whose folders render without icons — +the native Explorer default — `variant="explorer"` collapses that gutter on +leaf rows so file icons align with branch twisties; don't combine it with +branch icons, which pulls leaf icons out of alignment with branch content. +Indent guides appear on hover, with the focused and selected ancestor paths +always lit. The package's intentional Modern default insets rows 4px with 4px +corners and keyboard-only focus outlines; `data-ui-style="stable"` on the +document root makes them edge-to-edge and square, restoring VS Code's current +stable focus behavior. ## Overlays @@ -79,7 +193,6 @@ until the exit animation ends. High contrast, `forced-colors`, and - Keybinding hints show the contributed defaults the consumer passes, not user remaps: VS Code exposes no API for extensions to resolve a command's effective keybinding. -- List/selection-row tokens are deferred to the Tree suite (#1037). ## Codicons @@ -97,4 +210,6 @@ declared CSS exports. Shared internals are reached through `package.json` subpath imports (`#cx`, `#codicons`, `#storybook`). These resolve only inside this package and ship -with it, so they survive a standalone NPM split. +with it, so they survive a standalone NPM split. Component families keep +their own internals (contexts, stores) inside their folder and import them +relatively, so a family can lift out wholesale. diff --git a/packages/ui/src/components/Tree/RovingTabStop.ts b/packages/ui/src/components/Tree/RovingTabStop.ts new file mode 100644 index 0000000000..b821b10a8c --- /dev/null +++ b/packages/ui/src/components/Tree/RovingTabStop.ts @@ -0,0 +1,91 @@ +import { isDisabled } from "./rowDom"; + +import type { TreeItemRegistry } from "./TreeItemRegistry"; + +/** + * The single tabbable row. Falls back in order: a controlled selection + * waiting to be revealed, the current row if still reachable, its nearest + * reachable ancestor, then the first row. + */ +export class RovingTabStop { + private tabStopId: string | undefined; + private pendingSelectedId: string | undefined; + private removedAncestorIds: readonly string[] = []; + + constructor(private readonly registry: TreeItemRegistry) {} + + get id(): string | undefined { + return this.tabStopId; + } + + is(id: string): boolean { + return this.tabStopId === id; + } + + tabIndexFor(id: string): 0 | -1 { + return this.tabStopId === id ? 0 : -1; + } + + /** A controlled selection claims the tab stop once its branch is open. */ + claim(selectedId: string | undefined): void { + this.pendingSelectedId = selectedId; + } + + /** The user took over, so the claim no longer applies. */ + release(): void { + this.pendingSelectedId = undefined; + } + + set(id: string | undefined): boolean { + if (this.tabStopId === id) { + return false; + } + this.tabStopId = id; + return true; + } + + /** Ancestry captured before a row unmounts, while its links still exist. */ + noteRemoval(ancestorIds: readonly string[]): void { + this.removedAncestorIds = ancestorIds; + } + + reconcile(): boolean { + if (this.reconcilePendingSelection()) { + return true; + } + + const currentItem = this.registry.get(this.tabStopId); + if (currentItem && this.registry.isVisible(currentItem)) { + return false; + } + + // An unregistered tab stop is gone from the map; use the captured path. + const ancestor = this.registry.findReachableAncestor( + currentItem + ? this.registry.ancestorIds(currentItem.id) + : this.removedAncestorIds, + ); + this.removedAncestorIds = []; + return this.set(ancestor?.id ?? this.registry.visible()[0]?.id); + } + + private reconcilePendingSelection(): boolean { + const pendingSelectedId = this.pendingSelectedId; + if (pendingSelectedId === undefined) { + return false; + } + const selectedItem = this.registry.get(pendingSelectedId); + if (!selectedItem) { + return false; + } + if (!this.registry.isVisible(selectedItem)) { + // Hidden keeps the claim until the branch reveals it; disabled drops it. + if (isDisabled(selectedItem.element)) { + this.pendingSelectedId = undefined; + } + return false; + } + this.pendingSelectedId = undefined; + return this.set(pendingSelectedId); + } +} diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css new file mode 100644 index 0000000000..c9f39cbe98 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -0,0 +1,187 @@ +.ui-tree { + --ui-tree-indent-size: 8px; + --ui-tree-row-height: 22px; + width: 100%; + min-width: 0; +} + +.ui-tree-item { + outline: 0; +} + +.ui-tree-item__row { + position: relative; + display: flex; + align-items: center; + height: var(--ui-tree-row-height); + padding-inline-end: var(--ui-spacing-120); + background: var(--ui-tree-row-background, transparent); + cursor: pointer; + user-select: none; +} + +/* Pinned below its pinned ancestors; the subtree's end pushes it out. */ +.ui-tree-item--sticky > .ui-tree-item__row { + position: sticky; + top: calc((var(--ui-tree-level) - 1) * var(--ui-tree-row-height)); + z-index: calc(100 + var(--ui-tree-level)); + container-type: scroll-state; +} + +/* Painted only while actually pinned. A scroll-state container cannot style + itself, so the ::after child carries the pinned background and shadow, + behind the row content. Deeper rows paint above their ancestors, so the + stack shows one shadow, over the content it covers. */ +.ui-tree-item--sticky > .ui-tree-item__row::after { + position: absolute; + inset: 0; + z-index: -1; + content: ""; + pointer-events: none; +} + +@container scroll-state(stuck: top) { + /* Pinned rows paint over their hover and selection backgrounds. */ + .ui-tree-item__row::after { + background: var(--ui-tree-sticky-background); + box-shadow: var(--ui-tree-sticky-shadow) 0 6px 6px -6px; + } +} + +.ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) + > .ui-tree-item__row:hover { + color: var(--ui-list-hover-foreground); + background: var(--ui-list-hover-background); + outline: 1px dashed var(--ui-list-hover-outline); + outline-offset: -1px; +} + +.ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: var(--ui-list-inactive-selection-foreground); + background: var(--ui-list-inactive-selection-background); + outline: 1px dotted var(--ui-list-selection-outline); + outline-offset: -1px; +} + +.ui-tree--focused .ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: var(--ui-list-active-selection-foreground); + background: var(--ui-list-active-selection-background); +} + +.ui-tree-item[aria-disabled="true"] > .ui-tree-item__row { + color: var(--ui-disabled-foreground, currentColor); + cursor: default; +} + +.ui-tree-item__indent { + position: absolute; + inset-block: 0; + inset-inline-start: calc(2 * var(--ui-tree-indent-size)); + display: flex; + pointer-events: none; +} + +/* The native list's inactive focus outline: kept while the tree is blurred. */ +.ui-tree:not(.ui-tree--focused) .ui-tree-item--focused > .ui-tree-item__row { + outline: 1px dotted var(--ui-list-inactive-focus-outline); + outline-offset: -1px; +} + +/* One guide per ancestor, like the native tree's .indent-guide. */ +.ui-tree-item__indent-slot { + box-sizing: border-box; + width: var(--ui-tree-indent-size); + flex: none; + border-inline-start: 1px solid transparent; +} + +/* Never overlapping selectors, so neither can override the other. */ +.ui-tree-item__indent-slot--active { + border-inline-start-color: var(--ui-tree-indent-guide-active); +} + +.ui-tree:hover + .ui-tree-item__indent-slot:not(.ui-tree-item__indent-slot--active) { + border-inline-start-color: var(--ui-tree-indent-guide-inactive); +} + +.ui-tree-item__chevron { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: var(--ui-tree-row-height); + padding-inline-start: calc(var(--ui-tree-level) * var(--ui-tree-indent-size)); + padding-inline-end: 6px; + flex: none; + transform: translateX(3px); +} + +.ui-tree-item__chevron:dir(rtl) { + transform: translateX(-3px); +} + +/* Keep 3px so leaf icons clear the innermost guide and line up with twisties. */ +.ui-tree--explorer + .ui-tree-item:not([aria-expanded]) + > .ui-tree-item__row + > .ui-tree-item__chevron { + width: 3px; + padding-inline-end: 0; + visibility: hidden; +} + +.ui-tree-item__chevron > .ui-icon { + width: 10px; + font-size: 10px; +} + +.ui-tree-item__content { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + line-height: var(--ui-tree-row-height); + overflow: hidden; + white-space: nowrap; +} + +.ui-tree-item__content > .ui-icon { + margin-inline-end: var(--ui-spacing-60); + flex: none; +} + +.ui-tree-item__action { + display: none; + align-items: center; + align-self: stretch; + flex: none; + gap: 2px; +} + +.ui-tree-item[aria-selected="true"] > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item__row:hover .ui-tree-item__action, +.ui-tree-item:focus > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item__row:focus-within .ui-tree-item__action { + display: inline-flex; +} + +@media (prefers-reduced-motion: no-preference) { + .ui-tree-item__indent-slot { + transition: border-color 100ms linear; + } +} + +@media (forced-colors: active) { + .ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) + > .ui-tree-item__row:hover, + .ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: HighlightText; + background: Highlight; + } + + .ui-tree:hover .ui-tree-item__indent-slot, + .ui-tree-item__indent-slot--active { + border-color: CanvasText; + } +} diff --git a/packages/ui/src/components/Tree/Tree.modern.css b/packages/ui/src/components/Tree/Tree.modern.css new file mode 100644 index 0000000000..b0d07e292c --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.modern.css @@ -0,0 +1,19 @@ +:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { + margin-inline: var(--ui-spacing-40); + border-radius: var(--ui-radius-small); +} + +:where(:root:not([data-ui-style="stable"])) + .ui-tree--focused + .ui-tree-item:focus-visible + > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +:where(:root:not([data-ui-style="stable"])) + .ui-tree--focused + .ui-tree-item[aria-selected="true"]:focus-visible + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.stable.css b/packages/ui/src/components/Tree/Tree.stable.css new file mode 100644 index 0000000000..d5f4de92c4 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stable.css @@ -0,0 +1,14 @@ +:where(:root[data-ui-style="stable"]) + .ui-tree--focused + .ui-tree-item:focus + > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +:where(:root[data-ui-style="stable"]) + .ui-tree--focused + .ui-tree-item[aria-selected="true"]:focus + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx new file mode 100644 index 0000000000..be6353472f --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,242 @@ +import { expect, fireEvent, userEvent, within } from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +// The native default Explorer: branch rows render without icons, so the +// explorer variant aligns leaf file icons with the branch twisties. +const FILES: readonly TreeDemoNode[] = [ + { + id: "source", + label: "src", + children: [ + { + id: "components", + label: "components", + children: [ + { + id: "tree", + label: "Tree.tsx", + icon: "symbol-class", + action: { icon: "close", label: "Close Tree.tsx" }, + }, + { id: "styles", label: "Tree.css", icon: "symbol-color" }, + ], + }, + { id: "tests", label: "tests", icon: "beaker", disabled: true }, + ], + }, + { id: "readme", label: "README.md", icon: "markdown" }, +]; + +const TreeStates = (): React.JSX.Element => ( + +); + +const meta: Meta = { + title: "UI/Tree", + component: TreeStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +const exerciseTree = async ({ + canvasElement, +}: { + canvasElement: HTMLElement; +}): Promise => { + const canvas = within(canvasElement); + const selectedItem = (): HTMLElement => + canvas.getByRole("treeitem", { name: "components" }); + await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + + // Click the trailing action while another row owns selection to prove + // action clicks never select their host row. The button is display:none + // until its row is hovered, selected, or focused, so query it hidden; + // the synthetic click still dispatches and bubbles. + const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await userEvent.click( + canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), + ); + await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + await expect(treeItem).toHaveAttribute("aria-selected", "false"); + + await userEvent.click(treeItem); + await expect(treeItem).toHaveAttribute("aria-selected", "true"); + + const readme = canvas.getByRole("treeitem", { name: "README.md" }); + await userEvent.click(readme); + await expect(readme).toHaveAttribute("aria-selected", "true"); +}; + +export const States: Story = { play: exerciseTree }; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, + play: exerciseTree, +}; + +const STORE_FILES: readonly TreeDemoNode[] = [ + { id: "TreeStore.ts", label: "TreeStore.ts", icon: "symbol-class" }, + { id: "context.ts", label: "context.ts", icon: "symbol-interface" }, + { + id: "Tree.tsx", + label: "Tree.tsx", + icon: "symbol-class", + className: "story-hover", + action: { icon: "close", label: "Close Tree.tsx" }, + }, +]; + +const NESTED_FILES: readonly TreeDemoNode[] = [ + ...["src", "components", "Tree", "store"].reduceRight< + readonly TreeDemoNode[] + >((children, label) => [{ id: label, label, children }], STORE_FILES), + { id: "README.md", label: "README.md", icon: "markdown" }, +]; + +const DEEP_FILES: readonly TreeDemoNode[] = ["alpha", "beta"].map((branch) => ({ + id: branch, + label: branch, + children: [ + { + id: `${branch}/src`, + label: "src", + children: Array.from({ length: 12 }, (_, index) => ({ + id: `${branch}/src/file-${index}`, + label: `file-${index}.ts`, + icon: "symbol-class" as const, + })), + }, + ], +})); + +export const StickyScroll: Story = { + render: () => ( +
{ + if (scroller) { + scroller.scrollTop = 143; + } + }} + > + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("treeitem", { name: "alpha" })).toHaveClass( + "ui-tree-item--sticky", + ); + // The scroll, not the pinned offsets: headless Chrome paints no frame + // during play, so sticky positions never settle here. The snapshot is + // what proves they pin. + await expect(canvas.getByTestId("scroller").scrollTop).toBeGreaterThan(0); + }, +}; + +export const MultiSelect: Story = { + // Synthetic events do not move DOM focus, so force the outline. + parameters: { + pseudo: { + focus: ['[aria-label="README.md"]'], + focusVisible: ['[aria-label="README.md"]'], + }, + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A held modifier does not carry across separate userEvent calls. + await fireEvent.click(canvas.getByRole("treeitem", { name: "README.md" }), { + ctrlKey: true, + }); + await expect( + canvas.getByRole("treeitem", { name: "README.md" }), + ).toHaveAttribute("aria-selected", "true"); + await expect( + canvas.getByRole("treeitem", { name: "Tree.tsx" }), + ).toHaveAttribute("aria-selected", "true"); + // A real focus call, since React listens for focusin, which the + // synthetic focus event does not bubble. + canvas.getByRole("treeitem", { name: "README.md" }).focus(); + }, +}; + +/** Focus is its own state: the outline moves without changing selection. */ +export const Focused: Story = { + parameters: { + pseudo: { + focus: ['[aria-label="Tree.css"]'], + focusVisible: ['[aria-label="Tree.css"]'], + }, + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + canvas.getByRole("treeitem", { name: "Tree.css" }).focus(); + await expect( + canvas.getByRole("treeitem", { name: "Tree.css" }), + ).toHaveAttribute("aria-selected", "false"); + }, +}; + +export const Nested: Story = { + render: () => ( + + ), + // Real Tree.css hover, forced by the pseudo-states addon: the hovered tree + // reveals the faint indent guides next to the active guide of the selection. + parameters: { + pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const deepLeaf = canvas.getByRole("treeitem", { name: "TreeStore.ts" }); + await expect(deepLeaf).toHaveAttribute("aria-level", "5"); + + // Focus makes the selection render active. + await userEvent.click(deepLeaf); + await expect(deepLeaf).toHaveAttribute("aria-selected", "true"); + }, +}; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx new file mode 100644 index 0000000000..d1e830842d --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,147 @@ +import { + type ComponentPropsWithRef, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { cx } from "#cx"; + +import { + TreeContext, + TreeHierarchyContext, + type TreeHierarchyContextValue, +} from "./context"; +import { mergeRefs } from "./mergeRefs"; +import "./Tree.css"; +import "./Tree.modern.css"; +import "./Tree.stable.css"; +import { TreeStore } from "./TreeStore"; + +/** VS Code's workbench.tree.stickyScrollMaxItemCount default. */ +const DEFAULT_STICKY_LEVELS = 7; + +function focusBelongsToTree( + tree: HTMLElement, + target: EventTarget | null, +): boolean { + return target instanceof Element && target.closest(".ui-tree") === tree; +} + +export interface TreeProps extends Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" +> { + /** + * "explorer" collapses the twistie gutter on leaf rows so file icons align + * with branch twisties, like the native Explorer whose folders render + * without icons. Combining it with branch icons misaligns leaf icons. + */ + variant?: "default" | "explorer"; + selectedItemId?: string; + onSelectedItemChange?: (itemId: string) => void; + /** Ctrl/Cmd click toggles a row and Shift click extends from the anchor. */ + multiSelect?: boolean; + selectedItemIds?: readonly string[]; + onSelectedItemsChange?: (itemIds: readonly string[]) => void; + /** + * Pins ancestors of the topmost visible row against the nearest scrolling + * ancestor, like VS Code. A number caps the depth that can pin: branches + * deeper than that many levels scroll normally (default 7). + */ + stickyScroll?: boolean | number; +} + +/** A controlled, single-selection tree with native VS Code keyboard behavior. */ +export function Tree({ + variant = "default", + selectedItemId, + onSelectedItemChange, + multiSelect = false, + selectedItemIds, + onSelectedItemsChange, + stickyScroll = false, + className, + children, + onBlur, + onFocus, + onKeyDown, + ref, + ...props +}: TreeProps): React.JSX.Element { + const selection = multiSelect + ? (selectedItemIds ?? []) + : selectedItemId === undefined + ? [] + : [selectedItemId]; + const [store] = useState(() => new TreeStore(selection)); + const [hasDomFocus, setHasDomFocus] = useState(false); + const rootHierarchy: TreeHierarchyContextValue = { + level: 1, + pathItemIds: [], + stickyLevels: + stickyScroll === true ? DEFAULT_STICKY_LEVELS : Number(stickyScroll), + }; + + // Any commit can add, remove, reorder, or reveal rows. + useLayoutEffect(() => { + store.setConfiguration( + selection, + multiSelect + ? onSelectedItemsChange + : ([itemId]) => { + if (itemId !== undefined) { + onSelectedItemChange?.(itemId); + } + }, + multiSelect, + ); + store.reconcile(); + }); + useEffect(() => () => store.dispose(), [store]); + + return ( + + +
{ + onFocus?.(event); + if ( + !event.defaultPrevented && + focusBelongsToTree(event.currentTarget, event.target) + ) { + setHasDomFocus(true); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !focusBelongsToTree(event.currentTarget, event.relatedTarget) + ) { + setHasDomFocus(false); + } + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented) { + store.onKeyDown(event); + } + }} + > + {children} +
+
+
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeItem.stories.tsx b/packages/ui/src/components/Tree/TreeItem.stories.tsx new file mode 100644 index 0000000000..d1f4860ec2 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.stories.tsx @@ -0,0 +1,51 @@ +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const ITEM_STATES: readonly TreeDemoNode[] = [ + { id: "plain", label: "Plain item", icon: "file" }, + { + id: "selected", + label: "Selected branch", + icon: "folder-opened", + children: [{ id: "child", label: "Child item" }], + }, + { + id: "collapsed", + label: "Collapsed branch", + icon: "folder", + collapsed: true, + children: [{ id: "hidden", label: "Hidden item" }], + }, + { + id: "action", + label: "Item with action", + action: { icon: "trash", label: "Delete item" }, + }, + { id: "disabled", label: "Disabled item", disabled: true }, +]; + +const TreeItemStates = (): React.JSX.Element => ( + +); + +const meta: Meta = { + title: "UI/TreeItem", + component: TreeItemStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, +}; diff --git a/packages/ui/src/components/Tree/TreeItem.tsx b/packages/ui/src/components/Tree/TreeItem.tsx new file mode 100644 index 0000000000..7b8a9a5d2f --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.tsx @@ -0,0 +1,217 @@ +import { + type ComponentPropsWithRef, + type CSSProperties, + type ReactNode, + use, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import { TreeHierarchyContext, useTreeContext } from "./context"; +import { mergeRefs } from "./mergeRefs"; +import { nestedInteractiveTarget } from "./rowDom"; + +import type { CodiconName } from "#codicons"; + +/** A string label doubles as the text value; a rich label must supply one. */ +type TreeItemLabel = + | { label: string; textValue?: string } + | { label: ReactNode; textValue: string }; + +export type TreeItemProps = Omit< + ComponentPropsWithRef<"div">, + "children" | "id" | "role" | "onSelect" +> & + TreeItemLabel & { + itemId: string; + icon?: CodiconName; + disabled?: boolean; + /** Controls expansion, and makes the row a branch. */ + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + /** Child rows; only a branch can have them. */ + children?: ReactNode; + action?: ReactNode; + }; + +function eventBelongsToRow( + event: { currentTarget: HTMLElement; target: EventTarget | null }, + row: HTMLElement | null, +): boolean { + return ( + event.target === event.currentTarget || + (event.target instanceof Node && row?.contains(event.target) === true) + ); +} + +/** A controlled tree row. Passing `expanded` makes it a branch that nests rows. */ +export function TreeItem({ + itemId, + label, + textValue, + icon, + expanded, + onExpandedChange, + children, + action, + disabled = false, + className, + style, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + onClick, + onFocus, + ref, + ...props +}: TreeItemProps): React.JSX.Element { + const store = useTreeContext(); + const hierarchy = use(TreeHierarchyContext); + const internalRef = useRef(null); + const rowRef = useRef(null); + const chevronRef = useRef(null); + const isBranch = expanded !== undefined; + const isSticky = isBranch && hierarchy.level <= hierarchy.stickyLevels; + // A childless node mapped to [] is a leaf, so [] must not count. + const hasChildRows = Array.isArray(children) + ? children.length > 0 + : Boolean(children); + if (!isBranch && hasChildRows) { + throw new Error( + `TreeItem "${itemId}" has child rows, so it is a branch: pass expanded and onExpandedChange to control it.`, + ); + } + + const rowTextValue = textValue ?? (typeof label === "string" ? label : ""); + const setExpanded = isBranch ? onExpandedChange : undefined; + const { + activeGuideIds, + focused: isFocusedRow, + selected: isSelected, + tabIndex, + } = useSyncExternalStore( + store.subscribe, + () => store.getItemSnapshot(itemId), + () => store.getItemSnapshot(itemId), + ); + + useLayoutEffect(() => { + const element = internalRef.current; + return element ? store.registerItem(itemId, element) : undefined; + }, [itemId, store]); + + useLayoutEffect(() => { + store.updateItem(itemId, { + textValue: rowTextValue, + parentId: hierarchy.parentItemId, + setExpanded, + }); + }, [hierarchy.parentItemId, itemId, rowTextValue, setExpanded, store]); + + const groupHierarchy = { + level: hierarchy.level + 1, + parentItemId: itemId, + pathItemIds: [...hierarchy.pathItemIds, itemId], + stickyLevels: hierarchy.stickyLevels, + }; + + return ( +
{ + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onFocus?.(event); + if (!event.defaultPrevented && event.target === event.currentTarget) { + store.onItemFocus(itemId); + } + }} + onClick={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onClick?.(event); + if ( + event.defaultPrevented || + disabled || + nestedInteractiveTarget(event.target, event.currentTarget) !== null + ) { + return; + } + // Twistie clicks toggle without moving selection, like the native tree. + const onTwistie = + isBranch && + event.target instanceof Node && + chevronRef.current?.contains(event.target) === true; + if (!onTwistie) { + store.requestSelection(itemId, { + toggle: event.ctrlKey || event.metaKey, + range: event.shiftKey, + }); + } + // A selection-modifier click never toggles expansion, like the + // native tree; the twistie toggles regardless of modifiers. + if (onTwistie || !(event.ctrlKey || event.metaKey || event.shiftKey)) { + setExpanded?.(!expanded); + } + }} + > +
+
+ {expanded && hasChildRows ? ( + +
+ {children} +
+
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeItemRegistry.ts b/packages/ui/src/components/Tree/TreeItemRegistry.ts new file mode 100644 index 0000000000..c90fb15fe3 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItemRegistry.ts @@ -0,0 +1,120 @@ +import { comesAfter, isVisibleRow, queryVisibleRows } from "./rowDom"; + +/** Only what the DOM cannot answer; parentId outlives the row's removal. */ +export interface TreeStoreItem { + readonly id: string; + readonly element: HTMLElement; + textValue: string; + parentId?: string; + setExpanded?: (expanded: boolean) => void; +} + +/** A row's whole next state: every key is required, so absent values clear. */ +export interface TreeStoreItemUpdate { + textValue: string; + parentId: string | undefined; + setExpanded: ((expanded: boolean) => void) | undefined; +} + +/** Which rows exist and in what order. Knows nothing about interaction. */ +export class TreeItemRegistry { + private readonly items = new Map(); + private readonly itemsByElement = new Map(); + private root: HTMLElement | undefined; + private visibleItems: readonly TreeStoreItem[] = []; + private visibleItemsDirty = true; + + setRoot(root: HTMLElement | null): void { + this.root = root ?? undefined; + } + + /** The DOM moved, so the cached order is stale. */ + invalidate(): void { + this.visibleItemsDirty = true; + } + + register(id: string, element: HTMLElement): void { + if (this.items.has(id)) { + throw new Error( + `Tree keyboard navigation item id "${id}" is already registered by another row. Item ids must be unique.`, + ); + } + const item: TreeStoreItem = { id, element, textValue: "" }; + this.items.set(id, item); + this.itemsByElement.set(element, item); + } + + unregister(id: string): void { + const item = this.items.get(id); + if (item) { + this.items.delete(id); + this.itemsByElement.delete(item.element); + } + } + + update(id: string, update: TreeStoreItemUpdate): void { + const item = this.items.get(id); + if (item) { + Object.assign(item, update); + } + } + + get(id: string | undefined): TreeStoreItem | undefined { + return id === undefined ? undefined : this.items.get(id); + } + + fromElement(element: HTMLElement | null): TreeStoreItem | undefined { + return element === null ? undefined : this.itemsByElement.get(element); + } + + owns(element: HTMLElement): boolean { + return this.itemsByElement.has(element); + } + + /** Ancestor ids nearest first. The React tree makes parent links acyclic. */ + ancestorIds(id: string): readonly string[] { + const ancestorIds: string[] = []; + let parentId = this.items.get(id)?.parentId; + while (parentId !== undefined) { + ancestorIds.push(parentId); + parentId = this.items.get(parentId)?.parentId; + } + return ancestorIds; + } + + visible(): readonly TreeStoreItem[] { + if (this.visibleItemsDirty) { + this.visibleItems = queryVisibleRows(this.root) + .map((row) => this.itemsByElement.get(row)) + .filter((item): item is TreeStoreItem => item !== undefined); + this.visibleItemsDirty = false; + } + return this.visibleItems; + } + + isVisible(item: TreeStoreItem): boolean { + return isVisibleRow(item.element); + } + + /** Where a disabled or hidden row would sit among the visible ones. */ + insertionIndex(item: TreeStoreItem): number { + const visibleItems = this.visible(); + const index = visibleItems.findIndex((visible) => + comesAfter(item.element, visible.element), + ); + return index === -1 ? visibleItems.length : index; + } + + /** The first ancestor a tab stop or focus can land on. */ + findReachableAncestor( + ancestorIds: readonly string[], + ): TreeStoreItem | undefined { + for (const ancestorId of ancestorIds) { + const ancestor = this.items.get(ancestorId); + if (ancestor && this.isVisible(ancestor)) { + return ancestor; + } + } + return undefined; + } +} diff --git a/packages/ui/src/components/Tree/TreeSelection.ts b/packages/ui/src/components/Tree/TreeSelection.ts new file mode 100644 index 0000000000..44d919ec40 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeSelection.ts @@ -0,0 +1,79 @@ +/** Ctrl/Cmd toggles a row, Shift extends from the anchor, like VS Code. */ +export interface SelectionModifiers { + toggle?: boolean; + range?: boolean; +} + +/** Resolves a range in tree order; the store owns order, this owns policy. */ +type ResolveRange = (fromId: string, toId: string) => readonly string[]; + +/** What is selected and what a click or key means for it. */ +export class TreeSelection { + private selectedIds: ReadonlySet; + private anchorId: string | undefined; + private multiSelect = false; + private onChange: ((itemIds: readonly string[]) => void) | undefined; + + constructor(selectedIds: readonly string[] = []) { + this.selectedIds = new Set(selectedIds); + } + + get ids(): ReadonlySet { + return this.selectedIds; + } + + get isMultiSelect(): boolean { + return this.multiSelect; + } + + has(id: string): boolean { + return this.selectedIds.has(id); + } + + /** True when the selected set changed, so the caller can republish. */ + configure( + selectedIds: readonly string[], + onChange: ((itemIds: readonly string[]) => void) | undefined, + multiSelect: boolean, + ): boolean { + this.onChange = onChange; + this.multiSelect = multiSelect; + // The controlled selection anchors the first Shift range until a click + // or Shift-less navigation moves the anchor. + this.anchorId ??= selectedIds[0]; + const nextSelectedIds = new Set(selectedIds); + const unchanged = + nextSelectedIds.size === this.selectedIds.size && + [...nextSelectedIds].every((id) => this.selectedIds.has(id)); + if (unchanged) { + return false; + } + this.selectedIds = nextSelectedIds; + return true; + } + + request( + itemId: string, + { toggle, range }: SelectionModifiers, + resolveRange: ResolveRange, + ): void { + if (this.multiSelect && range && this.anchorId !== undefined) { + this.onChange?.(resolveRange(this.anchorId, itemId)); + return; + } + this.anchorId = itemId; + if (this.multiSelect && toggle) { + const next = new Set(this.selectedIds); + if (!next.delete(itemId)) { + next.add(itemId); + } + this.onChange?.([...next]); + return; + } + this.onChange?.([itemId]); + } + + replaceWith(itemIds: readonly string[]): void { + this.onChange?.(itemIds); + } +} diff --git a/packages/ui/src/components/Tree/TreeStore.ts b/packages/ui/src/components/Tree/TreeStore.ts new file mode 100644 index 0000000000..6b3d752c89 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeStore.ts @@ -0,0 +1,435 @@ +import { RovingTabStop } from "./RovingTabStop"; +import { + closestRow, + isDisabled, + nestedInteractiveTarget, + readExpanded, +} from "./rowDom"; +import { + TreeItemRegistry, + type TreeStoreItem, + type TreeStoreItemUpdate, +} from "./TreeItemRegistry"; +import { TreeSelection, type SelectionModifiers } from "./TreeSelection"; +import { TypeAhead } from "./TypeAhead"; + +import type { KeyboardEvent } from "react"; + +export interface TreeItemSnapshot { + tabIndex: 0 | -1; + selected: boolean; + /** Outlives losing DOM focus, like the native list's focused row. */ + focused: boolean; + /** This row's own ancestors that own an active indent guide. */ + activeGuideIds: readonly string[]; +} + +const NO_GUIDES: readonly string[] = []; + +function sameIds(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((id, index) => id === right[index]) + ); +} + +function sameMembers( + left: readonly string[], + right: readonly string[], +): boolean { + const rightIds = new Set(right); + return ( + new Set(left).size === rightIds.size && left.every((id) => rightIds.has(id)) + ); +} + +/** + * Interaction engine for one tree. Owns the subscription, model focus and + * indent guides, and coordinates the registry, tab stop, selection and + * type-ahead collaborators; each of those owns its own state. + */ +export class TreeStore { + private readonly registry = new TreeItemRegistry(); + private readonly tabStop = new RovingTabStop(this.registry); + private readonly selection: TreeSelection; + private readonly typeAhead = new TypeAhead(); + private readonly listeners = new Set<() => void>(); + private readonly itemSnapshots = new Map(); + private focusedElement: HTMLElement | undefined; + private guideOwnerIds: ReadonlySet = new Set(); + /** Lets focusItem tell whether focus() already published, avoiding a double notify. */ + private revision = 0; + private reconcileQueued = false; + /** What the tree itself last emitted, to tell echoes from external changes. */ + private emittedSelectionIds: readonly string[] | undefined; + + constructor(selectedIds: readonly string[] = []) { + this.selection = new TreeSelection(selectedIds); + this.tabStop.claim(selectedIds[0]); + } + + readonly subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange); + return () => this.listeners.delete(onChange); + }; + + readonly setRoot = (root: HTMLElement | null): void => { + this.registry.setRoot(root); + }; + + /** The DOM moved, so re-derive what depends on it. */ + readonly reconcile = (): void => { + this.registry.invalidate(); + const tabStopChanged = this.tabStop.reconcile(); + const focusChanged = this.reconcileFocusedItem(); + if (this.refreshGuideOwners() || tabStopChanged || focusChanged) { + this.publishChange(); + } + }; + + readonly setConfiguration = ( + selectedIds: readonly string[], + onSelectionChange?: (itemIds: readonly string[]) => void, + multiSelect = false, + ): void => { + const emittedIds = this.emittedSelectionIds; + this.emittedSelectionIds = undefined; + const changed = this.selection.configure( + selectedIds, + onSelectionChange && + ((itemIds): void => { + this.emittedSelectionIds = itemIds; + onSelectionChange(itemIds); + }), + multiSelect, + ); + if (!changed) { + return; + } + // Only an external selection change claims the tab stop. The echo of + // the user's own click or keystroke must not move it off their row. + if (emittedIds === undefined || !sameMembers(selectedIds, emittedIds)) { + this.tabStop.claim(selectedIds[0]); + } + this.tabStop.reconcile(); + this.refreshGuideOwners(); + this.publishChange(); + }; + + readonly dispose = (): void => { + this.typeAhead.reset(); + }; + + readonly requestSelection = ( + itemId: string, + modifiers: SelectionModifiers = {}, + ): void => { + this.selection.request(itemId, modifiers, (fromId, toId) => + this.rangeIds(fromId, toId), + ); + }; + + readonly registerItem = (id: string, element: HTMLElement): (() => void) => { + this.registry.register(id, element); + this.queueReconcile(); + + return (): void => { + if (this.registry.get(id)?.element !== element) { + return; + } + if (this.tabStop.is(id)) { + // Capture ancestry before deletion; reconciliation needs it. + this.tabStop.noteRemoval(this.registry.ancestorIds(id)); + } + this.registry.unregister(id); + this.itemSnapshots.delete(id); + this.queueReconcile(); + }; + }; + + /** + * Rows can mount or unmount in a commit that never re-renders Tree itself + * (expansion state held in a component below it, or memoized children), + * where Tree's own layout effect never runs. Registration queues a + * reconcile for when the commit settles. + */ + private queueReconcile(): void { + if (this.reconcileQueued) { + return; + } + this.reconcileQueued = true; + queueMicrotask(() => { + this.reconcileQueued = false; + this.reconcile(); + }); + } + + readonly updateItem = (id: string, update: TreeStoreItemUpdate): void => { + this.registry.update(id, update); + }; + + /** + * Every row subscribes, so a snapshot is only replaced when something + * this row renders differs. A guide owner elsewhere in the tree must not + * hand every row a new snapshot. + */ + readonly getItemSnapshot = (id: string): TreeItemSnapshot => { + const tabIndex = this.tabStop.tabIndexFor(id); + const selected = this.selection.has(id); + const focused = this.focusedItem?.id === id; + const activeGuideIds = this.activeGuideIdsFor(id); + const previous = this.itemSnapshots.get(id); + if ( + previous?.tabIndex === tabIndex && + previous.selected === selected && + previous.focused === focused && + sameIds(previous.activeGuideIds, activeGuideIds) + ) { + return previous; + } + + const snapshot = { tabIndex, selected, focused, activeGuideIds }; + this.itemSnapshots.set(id, snapshot); + return snapshot; + }; + + private activeGuideIdsFor(id: string): readonly string[] { + if (this.guideOwnerIds.size === 0) { + return NO_GUIDES; + } + const activeGuideIds = this.registry + .ancestorIds(id) + .filter((ancestorId) => this.guideOwnerIds.has(ancestorId)); + return activeGuideIds.length === 0 ? NO_GUIDES : activeGuideIds; + } + + readonly onItemFocus = (id: string): void => { + const item = this.registry.get(id); + const canReceiveFocus = item !== undefined && this.registry.isVisible(item); + if (canReceiveFocus) { + // The user took over; the initial selection no longer claims the + // tab stop when its branch is revealed later. + this.tabStop.release(); + } + const focusChanged = this.setFocusedElement( + canReceiveFocus ? item.element : undefined, + ); + const tabStopChanged = canReceiveFocus ? this.tabStop.set(item.id) : false; + if (this.refreshGuideOwners() || focusChanged || tabStopChanged) { + this.publishChange(); + } + }; + + readonly onKeyDown = (event: KeyboardEvent): void => { + if (this.isInteractiveTarget(event)) { + return; + } + const visibleItems = this.registry.visible(); + if ( + this.selection.isMultiSelect && + (event.ctrlKey || event.metaKey) && + !event.shiftKey && + !event.altKey && + event.key.toLowerCase() === "a" + ) { + this.selection.replaceWith(visibleItems.map((item) => item.id)); + event.preventDefault(); + return; + } + const currentItem = + this.registry.fromElement(closestRow(event.target)) ?? + this.focusedItem ?? + this.registry.get(this.tabStop.id) ?? + visibleItems[0]; + if (!currentItem) { + return; + } + + // A disabled or hidden row is absent from visibleItems, so take its + // neighbors from where it would sit in tree order. + const currentIndex = visibleItems.indexOf(currentItem); + const nextIndex = + currentIndex === -1 + ? this.registry.insertionIndex(currentItem) + : currentIndex + 1; + const previousIndex = + currentIndex === -1 ? nextIndex - 1 : currentIndex - 1; + const disabled = isDisabled(currentItem.element); + const expanded = readExpanded(currentItem.element); + let handled = true; + + switch (event.key) { + case "ArrowDown": + this.focusItem(visibleItems[nextIndex], event.shiftKey); + break; + case "ArrowUp": + this.focusItem(visibleItems[previousIndex], event.shiftKey); + break; + case "Home": + this.focusItem(visibleItems[0], event.shiftKey); + break; + case "End": + this.focusItem(visibleItems.at(-1), event.shiftKey); + break; + case "Escape": + // The native list clears a multi-selection on Escape (list.clear). + handled = this.selection.isMultiSelect && this.selection.ids.size > 0; + if (handled) { + this.selection.replaceWith([]); + } + break; + case "ArrowRight": + if (disabled) { + break; + } + if (expanded === false && currentItem.setExpanded) { + currentItem.setExpanded(true); + } else if (expanded === true) { + // Descendants directly follow their branch in tree order. + const firstChild = visibleItems[nextIndex]; + if (firstChild && currentItem.element.contains(firstChild.element)) { + this.focusItem(firstChild); + } + } + break; + case "ArrowLeft": + if (disabled) { + break; + } + if (expanded === true && currentItem.setExpanded) { + currentItem.setExpanded(false); + } else { + this.focusItem( + this.registry.findReachableAncestor( + this.registry.ancestorIds(currentItem.id), + ), + ); + } + break; + case "Enter": + case " ": + if (disabled) { + break; + } + this.requestSelection(currentItem.id, { + toggle: event.ctrlKey || event.metaKey, + }); + if (expanded !== null) { + currentItem.setExpanded?.(!expanded); + } + break; + default: + handled = false; + } + + if (handled) { + event.preventDefault(); + return; + } + + if ( + event.key.length !== 1 || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + this.focusItem( + this.typeAhead.match(event.key, visibleItems, nextIndex, currentIndex), + ); + event.preventDefault(); + }; + + private get focusedItem(): TreeStoreItem | undefined { + return this.registry.fromElement(this.focusedElement ?? null); + } + + /** Every visible enabled row between two ids, in tree order. */ + private rangeIds(fromId: string, toId: string): readonly string[] { + const visibleItems = this.registry.visible(); + const from = visibleItems.findIndex((item) => item.id === fromId); + const to = visibleItems.findIndex((item) => item.id === toId); + if (from === -1 || to === -1) { + return [toId]; + } + return visibleItems + .slice(Math.min(from, to), Math.max(from, to) + 1) + .map((item) => item.id); + } + + private isInteractiveTarget(event: KeyboardEvent): boolean { + const interactiveTarget = nestedInteractiveTarget( + event.target, + event.currentTarget, + ); + // Row elements are the navigation surface, not embedded controls. + return ( + interactiveTarget !== null && + !( + interactiveTarget instanceof HTMLElement && + this.registry.owns(interactiveTarget) + ) + ); + } + + private guideOwnerId(item: TreeStoreItem): string | undefined { + return readExpanded(item.element) === true ? item.id : item.parentId; + } + + private refreshGuideOwners(): boolean { + const ownerIds = new Set(); + for (const id of [this.focusedItem?.id, ...this.selection.ids]) { + const item = this.registry.get(id); + const ownerId = item && this.guideOwnerId(item); + if (ownerId !== undefined) { + ownerIds.add(ownerId); + } + } + const unchanged = + ownerIds.size === this.guideOwnerIds.size && + [...ownerIds].every((id) => this.guideOwnerIds.has(id)); + if (unchanged) { + return false; + } + this.guideOwnerIds = ownerIds; + return true; + } + + private reconcileFocusedItem(): boolean { + const focusedItem = this.focusedItem; + if (focusedItem && this.registry.isVisible(focusedItem)) { + return false; + } + return this.setFocusedElement(undefined); + } + + private setFocusedElement(element: HTMLElement | undefined): boolean { + if (this.focusedElement === element) { + return false; + } + this.focusedElement = element; + return true; + } + + private focusItem(item: TreeStoreItem | undefined, extend = false): void { + if (!item) { + return; + } + if (extend && this.selection.isMultiSelect) { + this.requestSelection(item.id, { range: true }); + } + const tabStopChanged = this.tabStop.set(item.id); + const revisionBeforeFocus = this.revision; + item.element.focus(); + if (tabStopChanged && this.revision === revisionBeforeFocus) { + this.publishChange(); + } + } + + private publishChange(): void { + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } +} diff --git a/packages/ui/src/components/Tree/TypeAhead.ts b/packages/ui/src/components/Tree/TypeAhead.ts new file mode 100644 index 0000000000..4c68f4f731 --- /dev/null +++ b/packages/ui/src/components/Tree/TypeAhead.ts @@ -0,0 +1,49 @@ +const TIMEOUT_MS = 500; + +/** Repeating one character cycles matches instead of growing the query. */ +function nextQuery(buffer: string, character: string): string { + const isRepeat = + buffer.length > 0 && [...buffer].every((value) => value === character); + return isRepeat ? character : `${buffer}${character}`; +} + +/** The buffered prefix search that jumps focus, as the native list does. */ +export class TypeAhead { + private buffer = ""; + private timer: ReturnType | undefined; + + /** + * Extends the query and returns the row to focus. Searches from + * `fromIndex`, except that a query past its first character can match + * the row already focused, so that one passes `currentIndex`. + */ + match( + key: string, + items: readonly T[], + fromIndex: number, + currentIndex: number, + ): T | undefined { + this.buffer = nextQuery(this.buffer, key.toLocaleLowerCase()); + clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.buffer = ""; + this.timer = undefined; + }, TIMEOUT_MS); + + const startIndex = + this.buffer.length > 1 && currentIndex !== -1 ? currentIndex : fromIndex; + for (let offset = 0; offset < items.length; offset++) { + const item = items[(startIndex + offset) % items.length]; + if (item?.textValue.toLocaleLowerCase().startsWith(this.buffer)) { + return item; + } + } + return undefined; + } + + reset(): void { + clearTimeout(this.timer); + this.timer = undefined; + this.buffer = ""; + } +} diff --git a/packages/ui/src/components/Tree/context.ts b/packages/ui/src/components/Tree/context.ts new file mode 100644 index 0000000000..3453927174 --- /dev/null +++ b/packages/ui/src/components/Tree/context.ts @@ -0,0 +1,26 @@ +import { createContext, use } from "react"; + +import type { TreeStore } from "./TreeStore"; + +export interface TreeHierarchyContextValue { + level: number; + parentItemId?: string; + pathItemIds: readonly string[]; + /** Levels that pin on scroll; 0 disables sticky scroll. */ + stickyLevels: number; +} + +export const TreeContext = createContext(undefined); +export const TreeHierarchyContext = createContext({ + level: 1, + pathItemIds: [], + stickyLevels: 0, +}); + +export function useTreeContext(): TreeStore { + const context = use(TreeContext); + if (!context) { + throw new Error("Tree components must be rendered inside Tree."); + } + return context; +} diff --git a/packages/ui/src/components/Tree/mergeRefs.ts b/packages/ui/src/components/Tree/mergeRefs.ts new file mode 100644 index 0000000000..0a1d7d5652 --- /dev/null +++ b/packages/ui/src/components/Tree/mergeRefs.ts @@ -0,0 +1,30 @@ +import type { Ref, RefCallback } from "react"; + +/** + * Composes refs into one callback ref. A React 19 cleanup-style callback + * ref gets its cleanup run instead of a null call it never expects. + */ +export function mergeRefs( + ...refs: ReadonlyArray | undefined> +): RefCallback { + return (value) => { + const cleanups = refs.map((ref) => { + if (typeof ref === "function") { + const cleanup = ref(value); + return typeof cleanup === "function" ? cleanup : () => ref(null); + } + if (ref) { + ref.current = value; + return () => { + ref.current = null; + }; + } + return undefined; + }); + return () => { + for (const cleanup of cleanups) { + cleanup?.(); + } + }; + }; +} diff --git a/packages/ui/src/components/Tree/rowDom.ts b/packages/ui/src/components/Tree/rowDom.ts new file mode 100644 index 0000000000..b52c570d6c --- /dev/null +++ b/packages/ui/src/components/Tree/rowDom.ts @@ -0,0 +1,84 @@ +/** + * Every read of the rendered tree. The DOM is the model: order, visibility, + * expansion and disabled state are asked of it rather than mirrored, so this + * is the only file that needs to change if that contract does. + */ + +export const INTERACTIVE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[contenteditable]:not([contenteditable='false'])", + "[role='button']", + "[role='checkbox']", + "[role='combobox']", + "[role='link']", + "[role='menuitem']", + "[role='option']", + "[role='radio']", + "[role='slider']", + "[role='spinbutton']", + "[role='switch']", + "[role='textbox']", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +const ROW_SELECTOR = '[role="treeitem"]'; + +/** Rows a user can reach: rendered, outside a hidden container, enabled. */ +const VISIBLE_ROW_SELECTOR = `${ROW_SELECTOR}:not([hidden]):not([hidden] *):not([aria-disabled="true"])`; + +export function isDisabled(element: HTMLElement): boolean { + return element.getAttribute("aria-disabled") === "true"; +} + +/** null on leaves. */ +export function readExpanded(element: HTMLElement): boolean | null { + const expanded = element.getAttribute("aria-expanded"); + return expanded === null ? null : expanded === "true"; +} + +export function isVisibleRow(element: HTMLElement): boolean { + return element.matches(VISIBLE_ROW_SELECTOR); +} + +export function queryVisibleRows( + root: HTMLElement | undefined, +): readonly HTMLElement[] { + return [...(root?.querySelectorAll(VISIBLE_ROW_SELECTOR) ?? [])]; +} + +/** The interactive element the event targets, unless it is `container`. */ +export function nestedInteractiveTarget( + target: EventTarget | null, + container: HTMLElement, +): Element | null { + if (!(target instanceof Element) || target === container) { + return null; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + return interactiveTarget !== null && + interactiveTarget !== container && + container.contains(interactiveTarget) + ? interactiveTarget + : null; +} + +export function closestRow(target: EventTarget | null): HTMLElement | null { + return target instanceof Element + ? target.closest(ROW_SELECTOR) + : null; +} + +export function comesAfter( + reference: HTMLElement, + other: HTMLElement, +): boolean { + return ( + (reference.compareDocumentPosition(other) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0 + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0a..cd64e1ade9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,4 +72,6 @@ export { TooltipProvider, type TooltipProviderProps, } from "./components/Tooltip/Tooltip"; +export { Tree, type TreeProps } from "./components/Tree/Tree"; +export { TreeItem, type TreeItemProps } from "./components/Tree/TreeItem"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index e4345fa09f..63f854d078 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -147,11 +147,68 @@ --ui-radius-circle: var(--vscode-cornerRadius-circle, 9999px); /* Spacing, VS Code's scale (baseSizes.ts); names are px times ten */ + --ui-spacing-40: var(--vscode-spacing-size40, 4px); --ui-spacing-60: var(--vscode-spacing-size60, 6px); --ui-spacing-120: var(--vscode-spacing-size120, 12px); --ui-spacing-160: var(--vscode-spacing-size160, 16px); --ui-spacing-240: var(--vscode-spacing-size240, 24px); + /* Lists and trees */ + --ui-list-hover-background: var(--vscode-list-hoverBackground, transparent); + --ui-list-hover-foreground: var( + --vscode-list-hoverForeground, + var(--ui-foreground) + ); + --ui-list-active-selection-background: var( + --vscode-list-activeSelectionBackground, + var(--ui-list-hover-background) + ); + --ui-list-active-selection-foreground: var( + --vscode-list-activeSelectionForeground, + var(--ui-foreground) + ); + --ui-list-inactive-selection-background: var( + --vscode-list-inactiveSelectionBackground, + var(--ui-list-active-selection-background) + ); + --ui-list-inactive-selection-foreground: var( + --vscode-list-inactiveSelectionForeground, + var(--ui-foreground) + ); + --ui-list-focus-outline: var( + --vscode-list-focusOutline, + var(--ui-focus-border) + ); + --ui-list-selection-outline: var(--vscode-list-selectionOutline, transparent); + --ui-list-inactive-focus-outline: var( + --vscode-list-inactiveFocusOutline, + transparent + ); + --ui-list-hover-outline: var(--vscode-list-hoverOutline, transparent); + --ui-list-focus-and-selection-outline: var( + --vscode-list-focusAndSelectionOutline, + var(--vscode-list-selectionOutline, var(--ui-list-focus-outline)) + ); + /* Outside a webview, approximate the native guides (inactive is the + active stroke at 40%) instead of disappearing. */ + --ui-tree-indent-guide-inactive: var( + --vscode-tree-inactiveIndentGuidesStroke, + color-mix(in srgb, currentColor 16%, transparent) + ); + --ui-tree-indent-guide-active: var( + --vscode-tree-indentGuidesStroke, + color-mix(in srgb, currentColor 40%, transparent) + ); + /* Pinned rows paint over what scrolls beneath them. */ + --ui-tree-sticky-background: var( + --vscode-sideBarStickyScroll-background, + var(--ui-background) + ); + --ui-tree-sticky-shadow: var( + --vscode-sideBarStickyScroll-shadow, + transparent + ); + /* Menus */ --ui-menu-background: var(--vscode-menu-background); --ui-menu-foreground: var(--vscode-menu-foreground); diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx new file mode 100644 index 0000000000..cf34552dc8 --- /dev/null +++ b/packages/ui/storybook/Tree.demo.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; + +import { IconButton } from "../src/components/IconButton/IconButton"; +import { Tree, type TreeProps } from "../src/components/Tree/Tree"; +import { TreeItem } from "../src/components/Tree/TreeItem"; + +import type { CodiconName } from "#codicons"; + +export interface TreeDemoNode { + id: string; + label: string; + icon?: CodiconName; + action?: { icon: CodiconName; label: string }; + disabled?: boolean; + className?: string; + /** Branches start expanded unless this says otherwise. */ + collapsed?: boolean; + children?: readonly TreeDemoNode[]; +} + +export interface TreeDemoProps extends Omit< + TreeProps, + "children" | "onSelectedItemChange" | "onSelectedItemsChange" +> { + nodes: readonly TreeDemoNode[]; +} + +function initialCollapsedIds(nodes: readonly TreeDemoNode[]): Set { + const collapsedIds = new Set(); + const visit = (node: TreeDemoNode): void => { + if (node.collapsed) { + collapsedIds.add(node.id); + } + node.children?.forEach(visit); + }; + nodes.forEach(visit); + return collapsedIds; +} + +/** Wraps a node tree in the selection and expansion state a Tree expects. */ +export function TreeDemo({ + nodes, + multiSelect, + selectedItemId: initialSelectedItemId, + selectedItemIds: initialSelectedItemIds, + ...treeProps +}: TreeDemoProps): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState(initialSelectedItemId); + const [selectedItemIds, setSelectedItemIds] = useState( + initialSelectedItemIds ?? [], + ); + const [collapsedIds, setCollapsedIds] = useState(() => + initialCollapsedIds(nodes), + ); + + const setExpanded = (itemId: string, expanded: boolean): void => { + setCollapsedIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.delete(itemId); + } else { + next.add(itemId); + } + return next; + }); + }; + + const renderNodes = ( + siblings: readonly TreeDemoNode[], + ): React.JSX.Element[] => + siblings.map((node) => ( + setExpanded(node.id, expanded)) + } + action={ + node.action && ( + + ) + } + > + {node.children && renderNodes(node.children)} + + )); + + const selection = multiSelect + ? { + multiSelect: true, + selectedItemIds, + onSelectedItemsChange: setSelectedItemIds, + } + : { selectedItemId, onSelectedItemChange: setSelectedItemId }; + + return ( + + {renderNodes(nodes)} + + ); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index de3f039b95..d8416421b9 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "resolveJsonModule": true }, - "include": ["src", "storybook.preview.ts"] + "include": ["src", "storybook", "storybook.preview.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b6827e3b..721bf5543f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ catalogs: storybook: specifier: ^10.5.7 version: 10.5.7 + storybook-addon-pseudo-states: + specifier: ^10.5.7 + version: 10.5.7 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -299,6 +302,9 @@ importers: storybook: specifier: 'catalog:' version: 10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) + storybook-addon-pseudo-states: + specifier: 'catalog:' + version: 10.5.7(storybook@10.5.7) typescript: specifier: 'catalog:' version: 6.0.3 @@ -4978,6 +4984,11 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + storybook-addon-pseudo-states@10.5.7: + resolution: {integrity: sha512-ZX8duQTWmIzI/z6T/evmcQN5QTeCTJS+OKgoKDNFzuc97C3PPORan5RaNSMXJiZJo5uUTIAELjr+BNV0VX7cmw==} + peerDependencies: + storybook: ^10.5.7 + storybook@10.5.7: resolution: {integrity: sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==} hasBin: true @@ -10554,6 +10565,10 @@ snapshots: stdin-discarder@0.2.2: {} + storybook-addon-pseudo-states@10.5.7(storybook@10.5.7): + dependencies: + storybook: 10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) + storybook@10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6): dependencies: '@storybook/global': 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1346e857f..12273baaa3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,7 @@ catalog: react: ^19.2.8 react-dom: ^19.2.8 storybook: ^10.5.7 + storybook-addon-pseudo-states: ^10.5.7 typescript: ^6.0.3 vite: ^8.2.1 diff --git a/test/webview/ui/tree.test.tsx b/test/webview/ui/tree.test.tsx new file mode 100644 index 0000000000..4a08ec78f6 --- /dev/null +++ b/test/webview/ui/tree.test.tsx @@ -0,0 +1,1087 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createRef, Fragment, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tree, TreeItem } from "@repo/ui"; + +const ACTIVE_GUIDE = "ui-tree-item__indent-slot--active"; + +const treeItem = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + +const guideSlots = (name: string): Element[] => [ + ...treeItem(name).querySelectorAll(".ui-tree-item__indent-slot"), +]; + +/** A branch with an enabled and a disabled child, plus a root-level sibling. */ +function ControlledTree({ + onSelectedItemChange = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelectedItemChange?: (itemId: string) => void; + onExpandedChange?: (expanded: boolean) => void; +}): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState("child"); + const [expanded, setExpanded] = useState(true); + + return ( + { + onSelectedItemChange(itemId); + setSelectedItemId(itemId); + }} + > + { + onExpandedChange(nextExpanded); + setExpanded(nextExpanded); + }} + > + + + + + + ); +} + +/** Two branches whose labels share prefixes, for arrow keys and type-ahead. */ +function NavTree({ + onSelect = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelect?: (itemId: string) => void; + onExpandedChange?: (itemId: string, expanded: boolean) => void; +}): React.JSX.Element { + const [expandedIds, setExpandedIds] = useState(() => new Set(["alpha"])); + const branch = ( + itemId: string, + ): Pick< + React.ComponentProps, + "expanded" | "onExpandedChange" + > => ({ + expanded: expandedIds.has(itemId), + onExpandedChange: (nextExpanded: boolean) => { + onExpandedChange(itemId, nextExpanded); + setExpandedIds((current) => { + const next = new Set(current); + if (nextExpanded) { + next.add(itemId); + } else { + next.delete(itemId); + } + return next; + }); + }, + }); + + return ( + + + + + + + + + + + Bravo + + + } + textValue="Bravo" + /> + + ); +} + +const revealTree = (expanded: boolean): React.JSX.Element => ( + + + + + + +); + +describe("Tree", () => { + it("forwards tree semantics, className, style, and ref", () => { + const ref = createRef(); + render( + + + , + ); + + const tree = screen.getByRole("tree", { name: "Explorer" }); + expect(tree).toHaveClass("ui-tree", "ui-tree--explorer", "custom-tree"); + expect(tree).toHaveStyle({ width: "240px" }); + expect(ref.current).toBe(tree); + }); + + it("runs cleanup-style callback refs instead of calling them with null", () => { + const cleanup = vi.fn(); + const treeRef = vi.fn(() => cleanup); + const itemRef = vi.fn(() => cleanup); + const { unmount } = render( + + + , + ); + + unmount(); + expect(cleanup).toHaveBeenCalledTimes( + treeRef.mock.calls.length + itemRef.mock.calls.length, + ); + expect(treeRef).not.toHaveBeenCalledWith(null); + expect(itemRef).not.toHaveBeenCalledWith(null); + }); + + it("exposes levels, selection, disabled state, groups, and branch expansion", () => { + render(); + + const parent = treeItem("Parent"); + const child = treeItem("Child"); + expect(parent).toHaveAttribute("aria-level", "1"); + expect(parent).toHaveAttribute("aria-expanded", "true"); + expect(parent).toHaveAttribute("aria-selected", "false"); + expect(child).toHaveAttribute("aria-level", "2"); + expect(child).toHaveAttribute("aria-selected", "true"); + expect(child).not.toHaveAttribute("aria-expanded"); + expect(treeItem("Disabled")).toHaveAttribute("aria-disabled", "true"); + + const group = screen.getByRole("group"); + expect(group).not.toHaveAttribute("hidden"); + expect(group.closest('[role="treeitem"]')).toBe(parent); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("keeps exactly one visible enabled item in the tab order", () => { + render(); + + const tabStops = screen + .getAllByRole("treeitem") + .filter((item) => item.tabIndex === 0); + expect(tabStops).toEqual([treeItem("Child")]); + expect(treeItem("Disabled")).toHaveAttribute("tabindex", "-1"); + }); + + it("hands the tab stop to a selection revealed by expansion", () => { + const { rerender } = render(revealTree(false)); + expect(treeItem("Top")).toHaveAttribute("tabindex", "0"); + + rerender(revealTree(true)); + expect(treeItem("Child")).toHaveAttribute("tabindex", "0"); + }); + + it("keeps the tab stop with the user once they focus another row", () => { + const { rerender } = render(revealTree(false)); + act(() => treeItem("Parent").focus()); + + rerender(revealTree(true)); + expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); + expect(treeItem("Child")).toHaveAttribute("tabindex", "-1"); + }); + + it("moves the tab stop to an ancestor when its row unmounts", async () => { + const renderTree = (showLeaf: boolean): React.JSX.Element => ( + + + + {showLeaf && } + + + ); + const { rerender } = render(renderTree(true)); + act(() => treeItem("Leaf").focus()); + + rerender(renderTree(false)); + await act(() => Promise.resolve()); + expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); + }); + + it("leaves keys to interactive elements rendered outside rows", () => { + render( + + + + , + ); + + const input = screen.getByRole("textbox", { name: "New file" }); + act(() => input.focus()); + const arrowNotPrevented = fireEvent.keyDown(input, { key: "ArrowDown" }); + const typeAheadNotPrevented = fireEvent.keyDown(input, { key: "a" }); + + expect(document.activeElement).toBe(input); + expect(arrowNotPrevented).toBe(true); + expect(typeAheadNotPrevented).toBe(true); + }); + + it("derives indent guide owners from focus and controlled selection", () => { + const renderTree = (selectedItemId?: string): React.JSX.Element => ( + + {["Alpha", "Beta"].map((branch) => ( + + + + ))} + + ); + const { rerender } = render(renderTree()); + + expect(treeItem("Alpha")).toHaveAttribute("tabindex", "0"); + expect(guideSlots("Alpha leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Beta leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Beta leaf").focus()); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + + rerender(renderTree("Alpha leaf")); + expect(treeItem("Alpha leaf")).toHaveAttribute("tabindex", "0"); + expect(treeItem("Beta leaf")).toHaveAttribute("tabindex", "-1"); + expect(guideSlots("Alpha leaf")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("uses only the expanded focused branch as its indent guide owner", () => { + render( + + + + + + + , + ); + + act(() => treeItem("Branch").focus()); + const slots = guideSlots("Leaf"); + expect(slots).toHaveLength(2); + expect(slots[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(slots[1]).toHaveClass(ACTIVE_GUIDE); + }); + + it("clears hidden, disabled, and unmounted focused guide owners", async () => { + const renderTree = ({ + expanded = true, + disabled = false, + showChild = true, + }: { + expanded?: boolean; + disabled?: boolean; + showChild?: boolean; + }): React.JSX.Element => ( + + + {showChild && ( + + )} + + + ); + const { rerender } = render(renderTree({})); + + act(() => treeItem("Child").focus()); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + rerender(renderTree({ expanded: false })); + rerender(renderTree({ expanded: true })); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Child").focus()); + rerender(renderTree({ disabled: true })); + rerender(renderTree({})); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Child").focus()); + rerender(renderTree({ showChild: false })); + await act(() => Promise.resolve()); + rerender(renderTree({})); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + }); + + it("updates controlled selection before the rerender is observable", () => { + const renderTree = (selectedItemId: string): React.JSX.Element => ( + + + + + ); + const { rerender } = render(renderTree("first")); + + rerender(renderTree("second")); + expect(treeItem("First")).toHaveAttribute("aria-selected", "false"); + expect(treeItem("Second")).toHaveAttribute("aria-selected", "true"); + }); + + it("keeps marking the focused row after the tree loses focus", () => { + render(); + const child = treeItem("Child"); + + act(() => child.focus()); + expect(child).toHaveClass("ui-tree-item--focused"); + expect(treeItem("Parent")).not.toHaveClass("ui-tree-item--focused"); + + // Blur drops the tree's class, not the row's; that gap is the + // inactive focus outline. + fireEvent.blur(child, { relatedTarget: document.body }); + expect(child).toHaveClass("ui-tree-item--focused"); + expect(screen.getByRole("tree")).not.toHaveClass("ui-tree--focused"); + }); + + it("uses focus from this tree only for active selection colors", () => { + render( + <> + + + + + + + , + ); + + const firstTree = screen.getByRole("tree", { name: "First" }); + const secondTree = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(treeItem("First item")); + expect(firstTree).toHaveClass("ui-tree--focused"); + expect(secondTree).not.toHaveClass("ui-tree--focused"); + + fireEvent.blur(treeItem("First item"), { + relatedTarget: treeItem("Second item"), + }); + fireEvent.focus(treeItem("Second item")); + expect(firstTree).not.toHaveClass("ui-tree--focused"); + expect(secondTree).toHaveClass("ui-tree--focused"); + }); +}); + +describe("TreeItem", () => { + it("names rows from the label unless the consumer overrides it", () => { + render( + + Custom labelled item + + Rich item} + textValue="Rich item" + /> + + + , + ); + + expect(treeItem("Plain item")).toBeInTheDocument(); + expect(treeItem("Rich item")).toBeInTheDocument(); + expect(treeItem("Custom labelled item")).toBeInTheDocument(); + expect(treeItem("Custom label")).toBeInTheDocument(); + expect( + treeItem("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), + ).toHaveClass("codicon-file"); + }); + + it("accepts child rows from fragments, arrays, and wrapper components", () => { + const WrappedRows = (): React.JSX.Element => ( + <> + + + ); + render( + + + + + {[]} + + + , + ); + + expect(treeItem("Branch")).toHaveAttribute("aria-expanded", "true"); + expect(treeItem("Wrapped")).toHaveAttribute("aria-level", "2"); + expect(treeItem("Listed")).toHaveAttribute("aria-level", "2"); + }); + + it("rejects child rows on a row that is not a branch", () => { + expect(() => + render( + + + + + , + ), + ).toThrow(/has child rows, so it is a branch/); + }); + + it("treats an empty children array as a leaf", () => { + render( + + + {[].map(() => null)} + + , + ); + + expect(treeItem("Leaf")).not.toHaveAttribute("aria-expanded"); + }); + + it("shows a twistie for a branch whose children are not loaded yet", () => { + const onExpandedChange = vi.fn(); + render( + + + , + ); + + const lazy = treeItem("Lazy"); + expect(lazy).toHaveAttribute("aria-expanded", "false"); + expect(lazy.querySelector(".ui-tree-item__chevron > .ui-icon")).toHaveClass( + "codicon-chevron-right", + ); + fireEvent.keyDown(lazy, { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + + it("pins branch rows down to the sticky scroll limit", () => { + const renderTree = (stickyScroll: boolean): React.JSX.Element => ( + + + + + + + + + + ); + const { rerender } = render(renderTree(false)); + expect(treeItem("One")).not.toHaveClass("ui-tree-item--sticky"); + + rerender(renderTree(true)); + expect(treeItem("One")).toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Two")).toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Three")).not.toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Leaf")).not.toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Three").style.getPropertyValue("--ui-tree-level")).toBe( + "3", + ); + }); + + it("reports controlled selection and expansion from a row click", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + , + ); + + fireEvent.click(treeItem("Parent")); + expect(onSelectedItemChange).toHaveBeenCalledWith("parent"); + expect(onExpandedChange).toHaveBeenCalledWith(false); + // Collapsing unmounts the subtree, so a mostly-closed tree only + // renders what is open. + expect(screen.queryByRole("group", { hidden: true })).toBeNull(); + expect( + screen.queryByRole("treeitem", { name: "Child", hidden: true }), + ).toBeNull(); + }); + + it("toggles a branch from its twistie without changing selection", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + const onClick = vi.fn(); + render( + + + + + , + ); + + const chevron = treeItem("Branch").querySelector(".ui-tree-item__chevron"); + if (!chevron) { + throw new Error("Expected a branch twistie."); + } + fireEvent.click(chevron); + + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(onClick).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("isolates a trailing action from tree selection and expansion", () => { + const onAction = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + Delete + + } + > + + + , + ); + + const action = screen.getByRole("button", { name: "Delete" }); + expect(treeItem("Branch")).toHaveAccessibleName("Branch"); + expect(action.parentElement).toHaveClass("ui-tree-item__action"); + + fireEvent.click(action); + expect(onAction).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("keeps parent row handlers isolated from descendant treeitems", () => { + const onParentClick = vi.fn(); + const onParentFocus = vi.fn(); + const onChildClick = vi.fn(); + const onChildFocus = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + + + , + ); + + const child = treeItem("Child"); + const childContent = child.querySelector(".ui-tree-item__content"); + if (!childContent) { + throw new Error("Expected child row content."); + } + fireEvent.click(childContent); + expect(onChildClick).toHaveBeenCalledOnce(); + expect(onParentClick).not.toHaveBeenCalled(); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + + fireEvent.focus(child); + expect(onChildFocus).toHaveBeenCalledOnce(); + expect(onParentFocus).not.toHaveBeenCalled(); + + onSelectedItemChange.mockClear(); + fireEvent.keyDown(child, { key: "Enter" }); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("does not activate a disabled row that receives programmatic focus", () => { + const onSelectedItemChange = vi.fn(); + render(); + const disabled = treeItem("Disabled"); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "Enter" }); + fireEvent.keyDown(disabled, { key: " " }); + + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("forwards className, style, and ref, and marks the selected row", () => { + const ref = createRef(); + render( + + Selected action} + /> + + , + ); + + const selected = treeItem("Selected"); + expect(selected).toHaveClass("ui-tree-item", "custom-item"); + // Selection is what keeps the action slot visible, per Tree.css. + expect(selected).toHaveAttribute("aria-selected", "true"); + expect( + screen.getByRole("button", { name: "Selected action" }).parentElement, + ).toHaveClass("ui-tree-item__action"); + expect(selected.style.color).toBe("red"); + expect(selected.firstElementChild).toHaveClass("ui-tree-item__row"); + expect(ref.current).toBe(selected); + expect(treeItem("Plain")).toHaveAttribute("aria-selected", "false"); + }); +}); + +describe("Tree multi-select", () => { + const MultiTree = ({ + onSelectedItemsChange, + }: { + onSelectedItemsChange: (itemIds: readonly string[]) => void; + }): React.JSX.Element => { + const [selectedItemIds, setSelectedItemIds] = useState([ + "one", + ]); + return ( + { + onSelectedItemsChange(itemIds); + setSelectedItemIds(itemIds); + }} + > + {["One", "Two", "Three", "Four"].map((label) => ( + + ))} + + ); + }; + + const selectedNames = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-selected") === "true") + .map((item) => item.getAttribute("aria-label") ?? ""); + + it("marks the tree multi-selectable and reflects every selected row", () => { + render(); + + expect(screen.getByRole("tree")).toHaveAttribute( + "aria-multiselectable", + "true", + ); + expect(selectedNames()).toEqual(["One"]); + }); + + it("toggles with Ctrl and replaces without it", () => { + const onSelectedItemsChange = vi.fn(); + render(); + + fireEvent.click(treeItem("Three"), { ctrlKey: true }); + expect(selectedNames()).toEqual(["One", "Three"]); + fireEvent.click(treeItem("One"), { metaKey: true }); + expect(selectedNames()).toEqual(["Three"]); + + fireEvent.click(treeItem("Four")); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["four"]); + expect(selectedNames()).toEqual(["Four"]); + }); + + it("extends from the anchor with Shift click and Shift arrows", () => { + render(); + + fireEvent.click(treeItem("Two")); + fireEvent.click(treeItem("Four"), { shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + + // The anchor stays put, so shrinking the range works too. + fireEvent.click(treeItem("Three"), { shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three"]); + + fireEvent.keyDown(treeItem("Three"), { key: "ArrowDown", shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + expect(document.activeElement).toBe(treeItem("Four")); + }); + + it("extends the first Shift range from the controlled selection", () => { + render(); + + fireEvent.click(treeItem("Three"), { shiftKey: true }); + expect(selectedNames()).toEqual(["One", "Two", "Three"]); + }); + + it("extends to either end with Shift+Home and Shift+End", () => { + render(); + + fireEvent.click(treeItem("Two")); + fireEvent.keyDown(treeItem("Two"), { key: "End", shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + expect(document.activeElement).toBe(treeItem("Four")); + + fireEvent.keyDown(treeItem("Four"), { key: "Home", shiftKey: true }); + expect(selectedNames()).toEqual(["One", "Two"]); + }); + + it("keeps the tab stop on the row the user focused last", () => { + render(); + + act(() => treeItem("Two").focus()); + fireEvent.click(treeItem("Two"), { ctrlKey: true }); + expect(selectedNames()).toEqual(["One", "Two"]); + expect(treeItem("Two")).toHaveAttribute("tabindex", "0"); + expect(treeItem("One")).toHaveAttribute("tabindex", "-1"); + }); + + it("never toggles a branch from a modifier click", () => { + const onExpandedChange = vi.fn(); + render( + + + + + , + ); + + fireEvent.click(treeItem("Branch"), { ctrlKey: true }); + fireEvent.click(treeItem("Branch"), { shiftKey: true }); + expect(onExpandedChange).not.toHaveBeenCalled(); + + // The twistie keeps toggling whatever the modifiers. + const chevron = treeItem("Branch").querySelector(".ui-tree-item__chevron"); + if (!chevron) { + throw new Error("Expected a branch twistie."); + } + fireEvent.click(chevron, { ctrlKey: true }); + expect(onExpandedChange).toHaveBeenCalledWith(false); + }); + + it("selects every visible row with Ctrl+A", () => { + render(); + + fireEvent.keyDown(treeItem("One"), { key: "a", ctrlKey: true }); + expect(selectedNames()).toEqual(["One", "Two", "Three", "Four"]); + }); + + it("leaves modified select-all combos to the host", () => { + render(); + + const notPrevented = fireEvent.keyDown(treeItem("One"), { + key: "A", + ctrlKey: true, + shiftKey: true, + }); + expect(notPrevented).toBe(true); + expect(selectedNames()).toEqual(["One"]); + }); + + it("clears the selection with Escape", () => { + render(); + + expect(fireEvent.keyDown(treeItem("One"), { key: "Escape" })).toBe(false); + expect(selectedNames()).toEqual([]); + // Nothing left to clear, so the key falls through to the host. + expect(fireEvent.keyDown(treeItem("One"), { key: "Escape" })).toBe(true); + }); + + it("lights the ancestor guide for every selected row", () => { + render( + + + + + + , + ); + + expect(guideSlots("A")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("B")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("ignores the modifiers when multi-select is off", () => { + const onSelectedItemChange = vi.fn(); + render( + + + + , + ); + + expect(screen.getByRole("tree")).not.toHaveAttribute( + "aria-multiselectable", + ); + fireEvent.click(treeItem("Two"), { ctrlKey: true }); + expect(onSelectedItemChange).toHaveBeenCalledWith("two"); + }); +}); + +describe("Tree keyboard navigation", () => { + it("moves through visible enabled items with arrows, Home, and End", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Apricot")); + fireEvent.keyDown(treeItem("Apricot"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Amber")); + fireEvent.keyDown(treeItem("Amber"), { key: "End" }); + expect(document.activeElement).toBe(treeItem("Bravo")); + fireEvent.keyDown(treeItem("Bravo"), { key: "Home" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + fireEvent.keyDown(treeItem("Alpha"), { key: "ArrowUp" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + + it("expands a branch, enters it, collapses, and returns to the parent", () => { + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(document.activeElement).toBe(treeItem("Blue")); + fireEvent.keyDown(treeItem("Blue"), { key: "ArrowLeft" }); + expect(document.activeElement).toBe(treeItem("Beta")); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowLeft" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("selects and toggles a branch with Enter and Space", () => { + const onSelect = vi.fn(); + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "Enter" }); + expect(onSelect).toHaveBeenLastCalledWith("beta"); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(treeItem("Beta"), { key: " " }); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("keeps navigation fresh across subtree-only commits", async () => { + // Expansion state lives below Tree, so collapsing commits only the + // branch subtree and Tree's own layout effect never runs. + function IsolatedBranch(): React.JSX.Element { + const [expanded, setExpanded] = useState(true); + return ( + + + + ); + } + render( + + + + , + ); + + act(() => treeItem("Branch").focus()); + fireEvent.keyDown(treeItem("Branch"), { key: "ArrowLeft" }); + await act(() => Promise.resolve()); + + fireEvent.keyDown(treeItem("Branch"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Tail")); + }); + + it("lets the host capture clipboard shortcuts through onKeyDown", () => { + const captured: string[] = []; + render( + { + if (event.ctrlKey && (event.key === "c" || event.key === "x")) { + captured.push(event.key); + event.preventDefault(); + } + }} + > + + + , + ); + + act(() => treeItem("Copy me").focus()); + fireEvent.keyDown(treeItem("Copy me"), { key: "c", ctrlKey: true }); + fireEvent.keyDown(treeItem("Copy me"), { key: "x", ctrlKey: true }); + + expect(captured).toEqual(["c", "x"]); + // The tree neither navigated nor type-ahead-jumped on the combos. + expect(document.activeElement).toBe(treeItem("Copy me")); + }); + + it("steps from a focused disabled row to its enabled neighbors", () => { + render(); + const disabled = treeItem("Disabled"); + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Apricot")); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowUp" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + + it("ignores keys from interactive content nested in a row", () => { + const onSelect = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Action" }), { + key: "Enter", + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("follows DOM order after rows reorder without item updates", () => { + const renderPair = (reversed: boolean): React.JSX.Element => { + const rows = ["One", "Two"].map((label) => ( + + )); + return ( + {reversed ? rows.reverse() : rows} + ); + }; + const { rerender } = render(renderPair(false)); + fireEvent.keyDown(treeItem("One"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Two")); + + rerender(renderPair(true)); + fireEvent.keyDown(treeItem("Two"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("One")); + }); + + it("rejects duplicate item ids across rows", () => { + expect(() => + render( + + + + , + ), + ).toThrow(/already registered by another row/i); + }); + + it("finds renamed items by type-ahead without re-registering", () => { + const renderNames = (label: string): React.JSX.Element => ( + + + + + ); + const { rerender } = render(renderNames("Amber")); + rerender(renderNames("Cedar")); + + fireEvent.keyDown(treeItem("Alpha"), { key: "c" }); + expect(document.activeElement).toBe(treeItem("Cedar")); + }); + + describe("type-ahead", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("matches case-insensitively, wraps, and cycles repeated characters", () => { + render(); + fireEvent.keyDown(treeItem("Amber"), { key: "B" }); + expect(document.activeElement).toBe(treeItem("Beta")); + fireEvent.keyDown(treeItem("Beta"), { key: "b" }); + expect(document.activeElement).toBe(treeItem("Bravo")); + fireEvent.keyDown(treeItem("Bravo"), { key: "b" }); + expect(document.activeElement).toBe(treeItem("Beta")); + }); + + it("keeps focus on a row the longer query still matches", () => { + render( + + + + , + ); + + // The first character moves on, as the native list does. + fireEvent.keyDown(treeItem("Amber"), { key: "a" }); + expect(document.activeElement).toBe(treeItem("Amethyst")); + // "am" still matches the focused row, so focus stays instead of + // wrapping back round to Amber. + fireEvent.keyDown(treeItem("Amethyst"), { key: "m" }); + expect(document.activeElement).toBe(treeItem("Amethyst")); + }); + + it("buffers characters and clears the buffer after the timeout", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "a" }); + fireEvent.keyDown(treeItem("Apricot"), { key: "m" }); + expect(document.activeElement).toBe(treeItem("Amber")); + + act(() => { + vi.advanceTimersByTime(500); + }); + fireEvent.keyDown(treeItem("Amber"), { key: "a" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + }); +}); diff --git a/test/webview/ui/treeStore.test.ts b/test/webview/ui/treeStore.test.ts new file mode 100644 index 0000000000..fe60de1e66 --- /dev/null +++ b/test/webview/ui/treeStore.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { TreeStore } from "@repo/ui/components/Tree/TreeStore"; + +/** + * Every row subscribes to the store, so a snapshot that changes identity + * re-renders that row. These assert the blast radius of a change. + */ + +interface Row { + id: string; + parentId?: string; + branch?: boolean; +} + +function setup(rows: readonly Row[]): { + store: TreeStore; + element: (id: string) => HTMLElement; +} { + const root = document.createElement("div"); + root.setAttribute("role", "tree"); + document.body.append(root); + + const store = new TreeStore(); + store.setRoot(root); + const elements = new Map(); + + for (const row of rows) { + const element = document.createElement("div"); + element.setAttribute("role", "treeitem"); + element.tabIndex = -1; + if (row.branch) { + element.setAttribute("aria-expanded", "true"); + } + // Nest under the parent so document order matches tree order. + (row.parentId ? elements.get(row.parentId) : root)?.append(element); + elements.set(row.id, element); + store.registerItem(row.id, element); + store.updateItem(row.id, { + textValue: row.id, + parentId: row.parentId, + setExpanded: row.branch ? () => undefined : undefined, + }); + } + store.reconcile(); + return { store, element: (id) => elements.get(id)! }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +const TWO_BRANCHES: readonly Row[] = [ + { id: "alpha", branch: true }, + { id: "alpha-1", parentId: "alpha" }, + { id: "alpha-2", parentId: "alpha" }, + { id: "beta", branch: true }, + { id: "beta-1", parentId: "beta" }, + { id: "beta-2", parentId: "beta" }, +]; + +describe("TreeStore snapshots", () => { + it("leaves unrelated rows untouched when focus crosses branches", () => { + const { store, element } = setup(TWO_BRANCHES); + store.onItemFocus("alpha-1"); + const before = new Map( + TWO_BRANCHES.map(({ id }) => [id, store.getItemSnapshot(id)]), + ); + + // Focus moves into the other branch: the two focused rows change, and + // so do the rows whose indent guide gains or loses its owner. + element("beta-1").focus(); + store.onItemFocus("beta-1"); + const changed = TWO_BRANCHES.filter( + ({ id }) => store.getItemSnapshot(id) !== before.get(id), + ).map(({ id }) => id); + + expect(changed.sort()).toEqual( + ["alpha-1", "alpha-2", "beta-1", "beta-2"].sort(), + ); + }); + + it("leaves every other row untouched when selection changes", () => { + const { store } = setup(TWO_BRANCHES); + store.setConfiguration(["alpha-1"]); + const before = new Map( + TWO_BRANCHES.map(({ id }) => [id, store.getItemSnapshot(id)]), + ); + + store.setConfiguration(["alpha-2"]); + const changed = TWO_BRANCHES.filter( + ({ id }) => store.getItemSnapshot(id) !== before.get(id), + ).map(({ id }) => id); + + // Both rows change selection; their shared guide owner does not move, + // so their siblings and the other branch keep their snapshots. + expect(changed.sort()).toEqual(["alpha-1", "alpha-2"].sort()); + }); +}); From d794977c810e23793a5b12216da1bc3a05a3cf3b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 12 Aug 2026 18:27:43 +0300 Subject: [PATCH 2/3] refactor(ui): flatten the tree to a data-driven row projection Tree now takes nodes plus expandedIds instead of composed TreeItem children, and renders every visible node as a flat sibling row with declared aria-level, posinset, and setsize, the projection VS Code, react-arborist, and headless-tree converged on. treeModel.ts owns the projection; order, visibility, and hierarchy derive from data, so the store's DOM queries, the item registry, the hierarchy context, and the subtree-commit reconcile all disappear, and refs forward natively. The folder splits by concern: store/ for interaction state, sticky/ for pinning, with per-part unit tests. Sticky scroll becomes a widget with VS Code semantics: a pinned-count cap (stickyScrollMaxItemCount), a 40% viewport cap, per-level indentation with twisties, the deepest row sliding out as its subtree ends, scroll and resize tracking, click to reveal and focus the real row, and the twistie collapsing the pinned branch in place. The pure math lives in stickyState.ts. PageUp/PageDown move focus by the scroller's viewport in rows, with Shift extending the selection like the other navigation keys. Benchmarks, 5,020 expanded rows in jsdom with the React Compiler, median of three: mount 965ms to 695ms, 20 arrow keydowns 249ms to 89ms. Virtualization, if ever needed, is a windowed slice over the same projection. --- packages/ui/README.md | 116 ++- packages/ui/src/components/Tree/Tree.css | 40 +- .../ui/src/components/Tree/Tree.modern.css | 4 +- .../ui/src/components/Tree/Tree.stable.css | 4 +- .../ui/src/components/Tree/Tree.stories.tsx | 12 +- packages/ui/src/components/Tree/Tree.tsx | 146 +-- packages/ui/src/components/Tree/TreeItem.tsx | 217 ---- .../src/components/Tree/TreeItemRegistry.ts | 120 --- packages/ui/src/components/Tree/TreeRow.tsx | 141 +++ packages/ui/src/components/Tree/TreeStore.ts | 435 -------- packages/ui/src/components/Tree/context.ts | 15 +- packages/ui/src/components/Tree/mergeRefs.ts | 30 - packages/ui/src/components/Tree/rowDom.ts | 56 +- .../components/Tree/sticky/StickyScroll.tsx | 153 +++ .../src/components/Tree/sticky/stickyState.ts | 72 ++ .../Tree/{ => store}/RovingTabStop.ts | 56 +- .../Tree/{ => store}/TreeSelection.ts | 0 .../ui/src/components/Tree/store/TreeStore.ts | 506 ++++++++++ .../components/Tree/{ => store}/TypeAhead.ts | 0 packages/ui/src/components/Tree/treeModel.ts | 78 ++ packages/ui/src/index.ts | 2 +- packages/ui/storybook/Tree.demo.tsx | 84 +- test/webview/ui/tree.test.tsx | 940 +++++++++--------- test/webview/ui/treeModel.test.tsx | 78 ++ test/webview/ui/treeStickyState.test.ts | 81 ++ test/webview/ui/treeStore.test.ts | 111 +-- 26 files changed, 1883 insertions(+), 1614 deletions(-) delete mode 100644 packages/ui/src/components/Tree/TreeItem.tsx delete mode 100644 packages/ui/src/components/Tree/TreeItemRegistry.ts create mode 100644 packages/ui/src/components/Tree/TreeRow.tsx delete mode 100644 packages/ui/src/components/Tree/TreeStore.ts delete mode 100644 packages/ui/src/components/Tree/mergeRefs.ts create mode 100644 packages/ui/src/components/Tree/sticky/StickyScroll.tsx create mode 100644 packages/ui/src/components/Tree/sticky/stickyState.ts rename packages/ui/src/components/Tree/{ => store}/RovingTabStop.ts (50%) rename packages/ui/src/components/Tree/{ => store}/TreeSelection.ts (100%) create mode 100644 packages/ui/src/components/Tree/store/TreeStore.ts rename packages/ui/src/components/Tree/{ => store}/TypeAhead.ts (100%) create mode 100644 packages/ui/src/components/Tree/treeModel.ts create mode 100644 test/webview/ui/treeModel.test.tsx create mode 100644 test/webview/ui/treeStickyState.test.ts diff --git a/packages/ui/README.md b/packages/ui/README.md index 89ed2c1027..b33306230f 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -48,53 +48,58 @@ that override live. ## Tree -`Tree` and `TreeItem` form a declarative hierarchy; a row's children are its -child rows: +`Tree` renders a data model: `nodes` describe the hierarchy, `expandedIds` +and the selection props control its state, and every visible node becomes one +flat `treeitem` row with declared depth, exactly like the native tree renders +its list. ```tsx const [selectedItemId, setSelectedItemId] = useState("src"); -const [expanded, setExpanded] = useState(true); +const [expandedIds, setExpandedIds] = useState(["src"]); - - - -; +/>; ``` -`itemId` carries selection and registry identity. `label` is the row content: -a string also supplies the accessible name and the case-insensitive, buffered -type-ahead key, so matching never depends on rendered DOM text; a `ReactNode` -label must pass `textValue` for those, which the types enforce. `icon` renders -a codicon ahead of the label. `aria-label` or `aria-labelledby` override the -accessible name. - -`expanded` is what makes a row a branch: it adds the twistie and lets the row -nest child rows, including a branch whose children have not loaded yet. Only a -branch may have children, and passing them without `expanded` throws. Because -children are always child rows and never row content, wrapper components, -fragments, and arrays all work. `Tree` controls selection, each `TreeItem` -controls its own expansion, and neither defaults. - -Arrow Up/Down, Home, End, and type-ahead move focus through visible enabled -rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or +`id` carries selection, expansion, and registry identity, so ids must be +unique across the whole tree. `label` is the row content: a string also +supplies the accessible name and the case-insensitive, buffered type-ahead +key, so matching never depends on rendered DOM text; a `ReactNode` label must +pass `textValue` for those, which the types enforce. `icon` renders a codicon +ahead of the label, `action` fills the trailing slot, and `className` lands +on the row. + +`children` is what makes a node a branch: it adds the twistie and lets the +node expand, including an empty array for a branch whose children have not +loaded yet. Leaves omit it. `Tree` controls selection and expansion, and +neither defaults. + +Arrow Up/Down, Home, End, PageUp/PageDown, and type-ahead move focus through +visible enabled rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or returns to the parent. Enter and Space select the focused row and toggle a branch. Clicking a row does both; clicking the twistie only toggles, leaving -selection in place like the native tree. Interactive content in the trailing -`action` slot is isolated from selection and expansion. - -The tree claims only unmodified keys, plus Ctrl/Cmd+A and Escape in -multi-select, and the root `onKeyDown` runs before any of them, so host +selection in place like the native tree. Escape clears the selection and the +focus mark, reporting `undefined` through `onSelectedItemChange`. Interactive +content in the trailing `action` slot is isolated from selection and +expansion. + +The tree claims only unmodified keys, plus Ctrl/Cmd+A in multi-select and +Escape while it has focus or selection to clear, and the root `onKeyDown` +runs before any of them, so host shortcuts like Ctrl+C or Ctrl+X need no dedicated API: handle them there, read the focused or selected rows, and call `preventDefault()` to also stop the browser default. Keybinding hints stay where VS Code shows them, in @@ -102,21 +107,22 @@ menus and action-button tooltips, never on tree rows. `multiSelect` swaps the singular selection props for `selectedItemIds` and `onSelectedItemsChange` and marks the tree `aria-multiselectable`. Ctrl/Cmd -click toggles a row, Shift click, Shift arrows, and Shift+Home/End extend -from the anchor (the last row selected without Shift), Ctrl/Cmd+A takes every -visible enabled row, and Escape clears the selection. Modifier clicks never +click toggles a row, Shift click, Shift arrows, and Shift+Home/End/Page +extend from the anchor (the last row selected without Shift), Ctrl/Cmd+A takes every +visible enabled row. Modifier clicks never toggle a branch. Ranges follow tree order and skip disabled and collapsed rows. `stickyScroll` pins the ancestors of the topmost visible row against the -nearest scrolling ancestor, like VS Code's tree sticky scroll; a number caps -the depth that can pin, so branches deeper than that many levels scroll -normally (default 7). VS Code's `stickyScrollMaxItemCount` instead caps how -many of the nearest ancestors pin at once; count semantics would need a -scroll listener, which this design avoids. Pinning is `position: sticky` on -the branch rows themselves, so the browser does the push-out, and pinned -rows carry the native shadow through a `scroll-state` container query -(Chromium 133+, so every supported host). +nearest scrolling ancestor, like VS Code's tree sticky scroll: a number caps +how many of the nearest ancestors pin at once, matching +`workbench.tree.stickyScrollMaxItemCount` (default 7), and the widget never +takes more than 40% of the viewport. A zero-height `position: sticky` anchor +does the pinning, so the scroll listener only decides which rows the widget +shows, and the deepest pinned row slides out as its subtree ends. Pinned rows +are presentational copies of their real rows: clicking one scrolls the real +row out from under the widget and focuses it, and its twistie collapses the +branch in place. The widget follows both scroll and scroller resize. Webviews receive no `workbench.tree.*` settings, so honoring the user's own configuration is the host's job: read the settings, send them over, and keep @@ -140,21 +146,23 @@ watchConfigurationChanges( ); ``` -Navigation order, visibility, and hierarchy are read back from the rendered -rows, so reordering or reparenting needs no extra wiring. Collapsing a branch -unmounts its children, so cost tracks what is open rather than the size of the -tree: a 100k-node tree browsed a folder at a time mounts in ~350ms and keeps -keystrokes under a millisecond. The suite is not virtualized, so the limit is -rows open _at once_ — around 10k is comfortable, 50k degrades, and 100k -expanded at once needs a virtualized tree instead. +The flat projection in `treeModel.ts` is the single source of truth: order, +visibility, and hierarchy derive from `nodes` and `expandedIds`, never from +the DOM, and collapsed branches simply do not render, so cost tracks what is +open rather than the size of the tree. The folder splits by concern: `store/` +owns interaction state (focus, selection, tab stop, type-ahead), `sticky/` +owns pinning, and `Tree`/`TreeRow` render the projection. The suite is not +virtualized, so the limit is rows open _at once_; around 10k is comfortable. +Past that, the projection is virtualization-ready: render a slice of the rows +and pad the scroll height, while the store keeps navigating the full list. Rows are 22px tall and keep the VS Code twistie gutter, matching trees whose branch rows render icons. For file trees whose folders render without icons — the native Explorer default — `variant="explorer"` collapses that gutter on leaf rows so file icons align with branch twisties; don't combine it with branch icons, which pulls leaf icons out of alignment with branch content. -Indent guides appear on hover, with the focused and selected ancestor paths -always lit. The package's intentional Modern default insets rows 4px with 4px +Indent guides appear on hover; the selected ancestor paths stay lit, and the +focused path lights only while the tree has focus, like the native tree. The package's intentional Modern default insets rows 4px with 4px corners and keyboard-only focus outlines; `data-ui-style="stable"` on the document root makes them edge-to-edge and square, restoring VS Code's current stable focus behavior. diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css index c9f39cbe98..df48e32653 100644 --- a/packages/ui/src/components/Tree/Tree.css +++ b/packages/ui/src/components/Tree/Tree.css @@ -20,32 +20,32 @@ user-select: none; } -/* Pinned below its pinned ancestors; the subtree's end pushes it out. */ -.ui-tree-item--sticky > .ui-tree-item__row { +/* A zero-height sticky anchor; the browser pins it, the scroll listener + only decides which rows it shows. */ +.ui-tree-sticky { position: sticky; - top: calc((var(--ui-tree-level) - 1) * var(--ui-tree-row-height)); - z-index: calc(100 + var(--ui-tree-level)); - container-type: scroll-state; + top: 0; + z-index: 100; + height: 0; } -/* Painted only while actually pinned. A scroll-state container cannot style - itself, so the ::after child carries the pinned background and shadow, - behind the row content. Deeper rows paint above their ancestors, so the - stack shows one shadow, over the content it covers. */ -.ui-tree-item--sticky > .ui-tree-item__row::after { +.ui-tree-sticky__rows { position: absolute; - inset: 0; - z-index: -1; - content: ""; - pointer-events: none; + inset-inline: 0; + overflow: hidden; + box-shadow: var(--ui-tree-sticky-shadow) 0 6px 6px -6px; } -@container scroll-state(stuck: top) { - /* Pinned rows paint over their hover and selection backgrounds. */ - .ui-tree-item__row::after { - background: var(--ui-tree-sticky-background); - box-shadow: var(--ui-tree-sticky-shadow) 0 6px 6px -6px; - } +.ui-tree-sticky__rows > .ui-tree-item { + position: absolute; + inset-inline: 0; + background: var(--ui-tree-sticky-background); +} + +/* Pinned copies show indentation, never guide rails, like the native + widget; without this the tree-wide hover rule lights them up. */ +.ui-tree-sticky .ui-tree-item__indent { + display: none; } .ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) diff --git a/packages/ui/src/components/Tree/Tree.modern.css b/packages/ui/src/components/Tree/Tree.modern.css index b0d07e292c..632dadd624 100644 --- a/packages/ui/src/components/Tree/Tree.modern.css +++ b/packages/ui/src/components/Tree/Tree.modern.css @@ -5,7 +5,7 @@ :where(:root:not([data-ui-style="stable"])) .ui-tree--focused - .ui-tree-item:focus-visible + .ui-tree-item--focused:focus-visible > .ui-tree-item__row { outline: 1px solid var(--ui-list-focus-outline); outline-offset: -1px; @@ -13,7 +13,7 @@ :where(:root:not([data-ui-style="stable"])) .ui-tree--focused - .ui-tree-item[aria-selected="true"]:focus-visible + .ui-tree-item--focused[aria-selected="true"]:focus-visible > .ui-tree-item__row { outline-color: var(--ui-list-focus-and-selection-outline); } diff --git a/packages/ui/src/components/Tree/Tree.stable.css b/packages/ui/src/components/Tree/Tree.stable.css index d5f4de92c4..6f25fef9f4 100644 --- a/packages/ui/src/components/Tree/Tree.stable.css +++ b/packages/ui/src/components/Tree/Tree.stable.css @@ -1,6 +1,6 @@ :where(:root[data-ui-style="stable"]) .ui-tree--focused - .ui-tree-item:focus + .ui-tree-item--focused:focus > .ui-tree-item__row { outline: 1px solid var(--ui-list-focus-outline); outline-offset: -1px; @@ -8,7 +8,7 @@ :where(:root[data-ui-style="stable"]) .ui-tree--focused - .ui-tree-item[aria-selected="true"]:focus + .ui-tree-item--focused[aria-selected="true"]:focus > .ui-tree-item__row { outline-color: var(--ui-list-focus-and-selection-outline); } diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx index be6353472f..37dc804363 100644 --- a/packages/ui/src/components/Tree/Tree.stories.tsx +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -1,4 +1,4 @@ -import { expect, fireEvent, userEvent, within } from "storybook/test"; +import { expect, fireEvent, userEvent, waitFor, within } from "storybook/test"; import { PIXEL_ALL_THEMES } from "#storybook"; @@ -143,12 +143,12 @@ export const StickyScroll: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByRole("treeitem", { name: "alpha" })).toHaveClass( - "ui-tree-item--sticky", + // The programmatic scroll fires its scroll event asynchronously. + await waitFor(() => + expect( + canvasElement.querySelector(".ui-tree-sticky__rows"), + ).not.toBeNull(), ); - // The scroll, not the pinned offsets: headless Chrome paints no frame - // during play, so sticky positions never settle here. The snapshot is - // what proves they pin. await expect(canvas.getByTestId("scroller").scrollTop).toBeGreaterThan(0); }, }; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx index d1e830842d..63798d3376 100644 --- a/packages/ui/src/components/Tree/Tree.tsx +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -7,19 +7,17 @@ import { import { cx } from "#cx"; -import { - TreeContext, - TreeHierarchyContext, - type TreeHierarchyContextValue, -} from "./context"; -import { mergeRefs } from "./mergeRefs"; +import { TreeContext } from "./context"; +import { StickyScroll } from "./sticky/StickyScroll"; +import { TreeStore } from "./store/TreeStore"; import "./Tree.css"; import "./Tree.modern.css"; import "./Tree.stable.css"; -import { TreeStore } from "./TreeStore"; +import { flattenVisibleRows, type TreeNode } from "./treeModel"; +import { TreeRow } from "./TreeRow"; /** VS Code's workbench.tree.stickyScrollMaxItemCount default. */ -const DEFAULT_STICKY_LEVELS = 7; +const DEFAULT_STICKY_COUNT = 7; function focusBelongsToTree( tree: HTMLElement, @@ -30,8 +28,12 @@ function focusBelongsToTree( export interface TreeProps extends Omit< ComponentPropsWithRef<"div">, - "role" | "onSelect" + "role" | "onSelect" | "children" > { + nodes: readonly TreeNode[]; + /** Ids of the expanded branches; every other branch renders collapsed. */ + expandedIds?: readonly string[]; + onExpandedIdsChange?: (expandedIds: readonly string[]) => void; /** * "explorer" collapses the twistie gutter on leaf rows so file icons align * with branch twisties, like the native Explorer whose folders render @@ -39,21 +41,25 @@ export interface TreeProps extends Omit< */ variant?: "default" | "explorer"; selectedItemId?: string; - onSelectedItemChange?: (itemId: string) => void; + /** Escape clears the selection and reports undefined, like the native list. */ + onSelectedItemChange?: (itemId: string | undefined) => void; /** Ctrl/Cmd click toggles a row and Shift click extends from the anchor. */ multiSelect?: boolean; selectedItemIds?: readonly string[]; onSelectedItemsChange?: (itemIds: readonly string[]) => void; /** - * Pins ancestors of the topmost visible row against the nearest scrolling - * ancestor, like VS Code. A number caps the depth that can pin: branches - * deeper than that many levels scroll normally (default 7). + * Pins the ancestors of the topmost visible row against the nearest + * scrolling ancestor, like VS Code. A number caps how many of the nearest + * ancestors pin at once (default 7). */ stickyScroll?: boolean | number; } -/** A controlled, single-selection tree with native VS Code keyboard behavior. */ +/** A controlled tree with native VS Code keyboard behavior. */ export function Tree({ + nodes, + expandedIds, + onExpandedIdsChange, variant = "default", selectedItemId, onSelectedItemChange, @@ -62,11 +68,9 @@ export function Tree({ onSelectedItemsChange, stickyScroll = false, className, - children, onBlur, onFocus, onKeyDown, - ref, ...props }: TreeProps): React.JSX.Element { const selection = multiSelect @@ -76,72 +80,80 @@ export function Tree({ : [selectedItemId]; const [store] = useState(() => new TreeStore(selection)); const [hasDomFocus, setHasDomFocus] = useState(false); - const rootHierarchy: TreeHierarchyContextValue = { - level: 1, - pathItemIds: [], - stickyLevels: - stickyScroll === true ? DEFAULT_STICKY_LEVELS : Number(stickyScroll), + const rows = flattenVisibleRows(nodes, new Set(expandedIds)); + const toggleBranch = (id: string, expanded: boolean): void => { + const current = expandedIds ?? []; + onExpandedIdsChange?.( + expanded + ? [...current, id] + : current.filter((expandedId) => expandedId !== id), + ); }; - // Any commit can add, remove, reorder, or reveal rows. useLayoutEffect(() => { store.setConfiguration( selection, multiSelect ? onSelectedItemsChange - : ([itemId]) => { - if (itemId !== undefined) { - onSelectedItemChange?.(itemId); - } - }, + : ([itemId]) => onSelectedItemChange?.(itemId), multiSelect, + toggleBranch, ); - store.reconcile(); + store.setRows(rows); }); useEffect(() => () => store.dispose(), [store]); return ( - -
{ - onFocus?.(event); - if ( - !event.defaultPrevented && - focusBelongsToTree(event.currentTarget, event.target) - ) { - setHasDomFocus(true); - } - }} - onBlur={(event) => { - onBlur?.(event); - if ( - !event.defaultPrevented && - !focusBelongsToTree(event.currentTarget, event.relatedTarget) - ) { - setHasDomFocus(false); - } - }} - onKeyDown={(event) => { - onKeyDown?.(event); - if (!event.defaultPrevented) { - store.onKeyDown(event); +
{ + onFocus?.(event); + if ( + !event.defaultPrevented && + focusBelongsToTree(event.currentTarget, event.target) + ) { + setHasDomFocus(true); + store.setDomFocus(true); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !focusBelongsToTree(event.currentTarget, event.relatedTarget) + ) { + setHasDomFocus(false); + store.setDomFocus(false); + } + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented) { + store.onKeyDown(event); + } + }} + > + {stickyScroll ? ( + - {children} -
- + /> + ) : null} + {rows.map((row) => ( + + ))} +
); } diff --git a/packages/ui/src/components/Tree/TreeItem.tsx b/packages/ui/src/components/Tree/TreeItem.tsx deleted file mode 100644 index 7b8a9a5d2f..0000000000 --- a/packages/ui/src/components/Tree/TreeItem.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { - type ComponentPropsWithRef, - type CSSProperties, - type ReactNode, - use, - useLayoutEffect, - useRef, - useSyncExternalStore, -} from "react"; - -import { cx } from "#cx"; - -import { Icon } from "../Icon/Icon"; - -import { TreeHierarchyContext, useTreeContext } from "./context"; -import { mergeRefs } from "./mergeRefs"; -import { nestedInteractiveTarget } from "./rowDom"; - -import type { CodiconName } from "#codicons"; - -/** A string label doubles as the text value; a rich label must supply one. */ -type TreeItemLabel = - | { label: string; textValue?: string } - | { label: ReactNode; textValue: string }; - -export type TreeItemProps = Omit< - ComponentPropsWithRef<"div">, - "children" | "id" | "role" | "onSelect" -> & - TreeItemLabel & { - itemId: string; - icon?: CodiconName; - disabled?: boolean; - /** Controls expansion, and makes the row a branch. */ - expanded?: boolean; - onExpandedChange?: (expanded: boolean) => void; - /** Child rows; only a branch can have them. */ - children?: ReactNode; - action?: ReactNode; - }; - -function eventBelongsToRow( - event: { currentTarget: HTMLElement; target: EventTarget | null }, - row: HTMLElement | null, -): boolean { - return ( - event.target === event.currentTarget || - (event.target instanceof Node && row?.contains(event.target) === true) - ); -} - -/** A controlled tree row. Passing `expanded` makes it a branch that nests rows. */ -export function TreeItem({ - itemId, - label, - textValue, - icon, - expanded, - onExpandedChange, - children, - action, - disabled = false, - className, - style, - "aria-label": ariaLabel, - "aria-labelledby": ariaLabelledBy, - onClick, - onFocus, - ref, - ...props -}: TreeItemProps): React.JSX.Element { - const store = useTreeContext(); - const hierarchy = use(TreeHierarchyContext); - const internalRef = useRef(null); - const rowRef = useRef(null); - const chevronRef = useRef(null); - const isBranch = expanded !== undefined; - const isSticky = isBranch && hierarchy.level <= hierarchy.stickyLevels; - // A childless node mapped to [] is a leaf, so [] must not count. - const hasChildRows = Array.isArray(children) - ? children.length > 0 - : Boolean(children); - if (!isBranch && hasChildRows) { - throw new Error( - `TreeItem "${itemId}" has child rows, so it is a branch: pass expanded and onExpandedChange to control it.`, - ); - } - - const rowTextValue = textValue ?? (typeof label === "string" ? label : ""); - const setExpanded = isBranch ? onExpandedChange : undefined; - const { - activeGuideIds, - focused: isFocusedRow, - selected: isSelected, - tabIndex, - } = useSyncExternalStore( - store.subscribe, - () => store.getItemSnapshot(itemId), - () => store.getItemSnapshot(itemId), - ); - - useLayoutEffect(() => { - const element = internalRef.current; - return element ? store.registerItem(itemId, element) : undefined; - }, [itemId, store]); - - useLayoutEffect(() => { - store.updateItem(itemId, { - textValue: rowTextValue, - parentId: hierarchy.parentItemId, - setExpanded, - }); - }, [hierarchy.parentItemId, itemId, rowTextValue, setExpanded, store]); - - const groupHierarchy = { - level: hierarchy.level + 1, - parentItemId: itemId, - pathItemIds: [...hierarchy.pathItemIds, itemId], - stickyLevels: hierarchy.stickyLevels, - }; - - return ( -
{ - if (!eventBelongsToRow(event, rowRef.current)) { - return; - } - onFocus?.(event); - if (!event.defaultPrevented && event.target === event.currentTarget) { - store.onItemFocus(itemId); - } - }} - onClick={(event) => { - if (!eventBelongsToRow(event, rowRef.current)) { - return; - } - onClick?.(event); - if ( - event.defaultPrevented || - disabled || - nestedInteractiveTarget(event.target, event.currentTarget) !== null - ) { - return; - } - // Twistie clicks toggle without moving selection, like the native tree. - const onTwistie = - isBranch && - event.target instanceof Node && - chevronRef.current?.contains(event.target) === true; - if (!onTwistie) { - store.requestSelection(itemId, { - toggle: event.ctrlKey || event.metaKey, - range: event.shiftKey, - }); - } - // A selection-modifier click never toggles expansion, like the - // native tree; the twistie toggles regardless of modifiers. - if (onTwistie || !(event.ctrlKey || event.metaKey || event.shiftKey)) { - setExpanded?.(!expanded); - } - }} - > -
-
- {expanded && hasChildRows ? ( - -
- {children} -
-
- ) : null} -
- ); -} diff --git a/packages/ui/src/components/Tree/TreeItemRegistry.ts b/packages/ui/src/components/Tree/TreeItemRegistry.ts deleted file mode 100644 index c90fb15fe3..0000000000 --- a/packages/ui/src/components/Tree/TreeItemRegistry.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { comesAfter, isVisibleRow, queryVisibleRows } from "./rowDom"; - -/** Only what the DOM cannot answer; parentId outlives the row's removal. */ -export interface TreeStoreItem { - readonly id: string; - readonly element: HTMLElement; - textValue: string; - parentId?: string; - setExpanded?: (expanded: boolean) => void; -} - -/** A row's whole next state: every key is required, so absent values clear. */ -export interface TreeStoreItemUpdate { - textValue: string; - parentId: string | undefined; - setExpanded: ((expanded: boolean) => void) | undefined; -} - -/** Which rows exist and in what order. Knows nothing about interaction. */ -export class TreeItemRegistry { - private readonly items = new Map(); - private readonly itemsByElement = new Map(); - private root: HTMLElement | undefined; - private visibleItems: readonly TreeStoreItem[] = []; - private visibleItemsDirty = true; - - setRoot(root: HTMLElement | null): void { - this.root = root ?? undefined; - } - - /** The DOM moved, so the cached order is stale. */ - invalidate(): void { - this.visibleItemsDirty = true; - } - - register(id: string, element: HTMLElement): void { - if (this.items.has(id)) { - throw new Error( - `Tree keyboard navigation item id "${id}" is already registered by another row. Item ids must be unique.`, - ); - } - const item: TreeStoreItem = { id, element, textValue: "" }; - this.items.set(id, item); - this.itemsByElement.set(element, item); - } - - unregister(id: string): void { - const item = this.items.get(id); - if (item) { - this.items.delete(id); - this.itemsByElement.delete(item.element); - } - } - - update(id: string, update: TreeStoreItemUpdate): void { - const item = this.items.get(id); - if (item) { - Object.assign(item, update); - } - } - - get(id: string | undefined): TreeStoreItem | undefined { - return id === undefined ? undefined : this.items.get(id); - } - - fromElement(element: HTMLElement | null): TreeStoreItem | undefined { - return element === null ? undefined : this.itemsByElement.get(element); - } - - owns(element: HTMLElement): boolean { - return this.itemsByElement.has(element); - } - - /** Ancestor ids nearest first. The React tree makes parent links acyclic. */ - ancestorIds(id: string): readonly string[] { - const ancestorIds: string[] = []; - let parentId = this.items.get(id)?.parentId; - while (parentId !== undefined) { - ancestorIds.push(parentId); - parentId = this.items.get(parentId)?.parentId; - } - return ancestorIds; - } - - visible(): readonly TreeStoreItem[] { - if (this.visibleItemsDirty) { - this.visibleItems = queryVisibleRows(this.root) - .map((row) => this.itemsByElement.get(row)) - .filter((item): item is TreeStoreItem => item !== undefined); - this.visibleItemsDirty = false; - } - return this.visibleItems; - } - - isVisible(item: TreeStoreItem): boolean { - return isVisibleRow(item.element); - } - - /** Where a disabled or hidden row would sit among the visible ones. */ - insertionIndex(item: TreeStoreItem): number { - const visibleItems = this.visible(); - const index = visibleItems.findIndex((visible) => - comesAfter(item.element, visible.element), - ); - return index === -1 ? visibleItems.length : index; - } - - /** The first ancestor a tab stop or focus can land on. */ - findReachableAncestor( - ancestorIds: readonly string[], - ): TreeStoreItem | undefined { - for (const ancestorId of ancestorIds) { - const ancestor = this.items.get(ancestorId); - if (ancestor && this.isVisible(ancestor)) { - return ancestor; - } - } - return undefined; - } -} diff --git a/packages/ui/src/components/Tree/TreeRow.tsx b/packages/ui/src/components/Tree/TreeRow.tsx new file mode 100644 index 0000000000..d717479bcc --- /dev/null +++ b/packages/ui/src/components/Tree/TreeRow.tsx @@ -0,0 +1,141 @@ +import { + type CSSProperties, + type RefObject, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import { useTreeContext } from "./context"; +import { nestedInteractiveTarget } from "./rowDom"; + +import type { TreeRowModel } from "./treeModel"; + +/** The visual row, shared by real rows and pinned sticky copies. */ +export function TreeRowSurface({ + row, + activeGuideIds, + chevronRef, +}: { + row: TreeRowModel; + activeGuideIds: readonly string[]; + chevronRef?: RefObject; +}): React.JSX.Element { + const { node, expanded } = row; + return ( +
+
+ ); +} + +/** One interactive row of the flat projection. */ +export function TreeRow({ row }: { row: TreeRowModel }): React.JSX.Element { + const store = useTreeContext(); + const elementRef = useRef(null); + const chevronRef = useRef(null); + const { node, expanded } = row; + const { activeGuideIds, focused, selected, tabIndex } = useSyncExternalStore( + store.subscribe, + () => store.getItemSnapshot(node.id), + () => store.getItemSnapshot(node.id), + ); + + useLayoutEffect(() => { + const element = elementRef.current; + return element ? store.registerItem(node.id, element) : undefined; + }, [node.id, store]); + + return ( +
{ + if (event.target === event.currentTarget) { + store.onItemFocus(node.id); + } + }} + onClick={(event) => { + if ( + node.disabled || + nestedInteractiveTarget(event.target, event.currentTarget) !== null + ) { + return; + } + // Twistie clicks toggle without moving selection, like the native tree. + const onTwistie = + expanded !== undefined && + event.target instanceof Node && + chevronRef.current?.contains(event.target) === true; + if (!onTwistie) { + store.requestSelection(node.id, { + toggle: event.ctrlKey || event.metaKey, + range: event.shiftKey, + }); + } + // Modifier clicks only select; the twistie toggles regardless. + if ( + expanded !== undefined && + (onTwistie || !(event.ctrlKey || event.metaKey || event.shiftKey)) + ) { + store.toggleBranch(node.id, !expanded); + } + }} + > + +
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeStore.ts b/packages/ui/src/components/Tree/TreeStore.ts deleted file mode 100644 index 6b3d752c89..0000000000 --- a/packages/ui/src/components/Tree/TreeStore.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { RovingTabStop } from "./RovingTabStop"; -import { - closestRow, - isDisabled, - nestedInteractiveTarget, - readExpanded, -} from "./rowDom"; -import { - TreeItemRegistry, - type TreeStoreItem, - type TreeStoreItemUpdate, -} from "./TreeItemRegistry"; -import { TreeSelection, type SelectionModifiers } from "./TreeSelection"; -import { TypeAhead } from "./TypeAhead"; - -import type { KeyboardEvent } from "react"; - -export interface TreeItemSnapshot { - tabIndex: 0 | -1; - selected: boolean; - /** Outlives losing DOM focus, like the native list's focused row. */ - focused: boolean; - /** This row's own ancestors that own an active indent guide. */ - activeGuideIds: readonly string[]; -} - -const NO_GUIDES: readonly string[] = []; - -function sameIds(left: readonly string[], right: readonly string[]): boolean { - return ( - left.length === right.length && - left.every((id, index) => id === right[index]) - ); -} - -function sameMembers( - left: readonly string[], - right: readonly string[], -): boolean { - const rightIds = new Set(right); - return ( - new Set(left).size === rightIds.size && left.every((id) => rightIds.has(id)) - ); -} - -/** - * Interaction engine for one tree. Owns the subscription, model focus and - * indent guides, and coordinates the registry, tab stop, selection and - * type-ahead collaborators; each of those owns its own state. - */ -export class TreeStore { - private readonly registry = new TreeItemRegistry(); - private readonly tabStop = new RovingTabStop(this.registry); - private readonly selection: TreeSelection; - private readonly typeAhead = new TypeAhead(); - private readonly listeners = new Set<() => void>(); - private readonly itemSnapshots = new Map(); - private focusedElement: HTMLElement | undefined; - private guideOwnerIds: ReadonlySet = new Set(); - /** Lets focusItem tell whether focus() already published, avoiding a double notify. */ - private revision = 0; - private reconcileQueued = false; - /** What the tree itself last emitted, to tell echoes from external changes. */ - private emittedSelectionIds: readonly string[] | undefined; - - constructor(selectedIds: readonly string[] = []) { - this.selection = new TreeSelection(selectedIds); - this.tabStop.claim(selectedIds[0]); - } - - readonly subscribe = (onChange: () => void): (() => void) => { - this.listeners.add(onChange); - return () => this.listeners.delete(onChange); - }; - - readonly setRoot = (root: HTMLElement | null): void => { - this.registry.setRoot(root); - }; - - /** The DOM moved, so re-derive what depends on it. */ - readonly reconcile = (): void => { - this.registry.invalidate(); - const tabStopChanged = this.tabStop.reconcile(); - const focusChanged = this.reconcileFocusedItem(); - if (this.refreshGuideOwners() || tabStopChanged || focusChanged) { - this.publishChange(); - } - }; - - readonly setConfiguration = ( - selectedIds: readonly string[], - onSelectionChange?: (itemIds: readonly string[]) => void, - multiSelect = false, - ): void => { - const emittedIds = this.emittedSelectionIds; - this.emittedSelectionIds = undefined; - const changed = this.selection.configure( - selectedIds, - onSelectionChange && - ((itemIds): void => { - this.emittedSelectionIds = itemIds; - onSelectionChange(itemIds); - }), - multiSelect, - ); - if (!changed) { - return; - } - // Only an external selection change claims the tab stop. The echo of - // the user's own click or keystroke must not move it off their row. - if (emittedIds === undefined || !sameMembers(selectedIds, emittedIds)) { - this.tabStop.claim(selectedIds[0]); - } - this.tabStop.reconcile(); - this.refreshGuideOwners(); - this.publishChange(); - }; - - readonly dispose = (): void => { - this.typeAhead.reset(); - }; - - readonly requestSelection = ( - itemId: string, - modifiers: SelectionModifiers = {}, - ): void => { - this.selection.request(itemId, modifiers, (fromId, toId) => - this.rangeIds(fromId, toId), - ); - }; - - readonly registerItem = (id: string, element: HTMLElement): (() => void) => { - this.registry.register(id, element); - this.queueReconcile(); - - return (): void => { - if (this.registry.get(id)?.element !== element) { - return; - } - if (this.tabStop.is(id)) { - // Capture ancestry before deletion; reconciliation needs it. - this.tabStop.noteRemoval(this.registry.ancestorIds(id)); - } - this.registry.unregister(id); - this.itemSnapshots.delete(id); - this.queueReconcile(); - }; - }; - - /** - * Rows can mount or unmount in a commit that never re-renders Tree itself - * (expansion state held in a component below it, or memoized children), - * where Tree's own layout effect never runs. Registration queues a - * reconcile for when the commit settles. - */ - private queueReconcile(): void { - if (this.reconcileQueued) { - return; - } - this.reconcileQueued = true; - queueMicrotask(() => { - this.reconcileQueued = false; - this.reconcile(); - }); - } - - readonly updateItem = (id: string, update: TreeStoreItemUpdate): void => { - this.registry.update(id, update); - }; - - /** - * Every row subscribes, so a snapshot is only replaced when something - * this row renders differs. A guide owner elsewhere in the tree must not - * hand every row a new snapshot. - */ - readonly getItemSnapshot = (id: string): TreeItemSnapshot => { - const tabIndex = this.tabStop.tabIndexFor(id); - const selected = this.selection.has(id); - const focused = this.focusedItem?.id === id; - const activeGuideIds = this.activeGuideIdsFor(id); - const previous = this.itemSnapshots.get(id); - if ( - previous?.tabIndex === tabIndex && - previous.selected === selected && - previous.focused === focused && - sameIds(previous.activeGuideIds, activeGuideIds) - ) { - return previous; - } - - const snapshot = { tabIndex, selected, focused, activeGuideIds }; - this.itemSnapshots.set(id, snapshot); - return snapshot; - }; - - private activeGuideIdsFor(id: string): readonly string[] { - if (this.guideOwnerIds.size === 0) { - return NO_GUIDES; - } - const activeGuideIds = this.registry - .ancestorIds(id) - .filter((ancestorId) => this.guideOwnerIds.has(ancestorId)); - return activeGuideIds.length === 0 ? NO_GUIDES : activeGuideIds; - } - - readonly onItemFocus = (id: string): void => { - const item = this.registry.get(id); - const canReceiveFocus = item !== undefined && this.registry.isVisible(item); - if (canReceiveFocus) { - // The user took over; the initial selection no longer claims the - // tab stop when its branch is revealed later. - this.tabStop.release(); - } - const focusChanged = this.setFocusedElement( - canReceiveFocus ? item.element : undefined, - ); - const tabStopChanged = canReceiveFocus ? this.tabStop.set(item.id) : false; - if (this.refreshGuideOwners() || focusChanged || tabStopChanged) { - this.publishChange(); - } - }; - - readonly onKeyDown = (event: KeyboardEvent): void => { - if (this.isInteractiveTarget(event)) { - return; - } - const visibleItems = this.registry.visible(); - if ( - this.selection.isMultiSelect && - (event.ctrlKey || event.metaKey) && - !event.shiftKey && - !event.altKey && - event.key.toLowerCase() === "a" - ) { - this.selection.replaceWith(visibleItems.map((item) => item.id)); - event.preventDefault(); - return; - } - const currentItem = - this.registry.fromElement(closestRow(event.target)) ?? - this.focusedItem ?? - this.registry.get(this.tabStop.id) ?? - visibleItems[0]; - if (!currentItem) { - return; - } - - // A disabled or hidden row is absent from visibleItems, so take its - // neighbors from where it would sit in tree order. - const currentIndex = visibleItems.indexOf(currentItem); - const nextIndex = - currentIndex === -1 - ? this.registry.insertionIndex(currentItem) - : currentIndex + 1; - const previousIndex = - currentIndex === -1 ? nextIndex - 1 : currentIndex - 1; - const disabled = isDisabled(currentItem.element); - const expanded = readExpanded(currentItem.element); - let handled = true; - - switch (event.key) { - case "ArrowDown": - this.focusItem(visibleItems[nextIndex], event.shiftKey); - break; - case "ArrowUp": - this.focusItem(visibleItems[previousIndex], event.shiftKey); - break; - case "Home": - this.focusItem(visibleItems[0], event.shiftKey); - break; - case "End": - this.focusItem(visibleItems.at(-1), event.shiftKey); - break; - case "Escape": - // The native list clears a multi-selection on Escape (list.clear). - handled = this.selection.isMultiSelect && this.selection.ids.size > 0; - if (handled) { - this.selection.replaceWith([]); - } - break; - case "ArrowRight": - if (disabled) { - break; - } - if (expanded === false && currentItem.setExpanded) { - currentItem.setExpanded(true); - } else if (expanded === true) { - // Descendants directly follow their branch in tree order. - const firstChild = visibleItems[nextIndex]; - if (firstChild && currentItem.element.contains(firstChild.element)) { - this.focusItem(firstChild); - } - } - break; - case "ArrowLeft": - if (disabled) { - break; - } - if (expanded === true && currentItem.setExpanded) { - currentItem.setExpanded(false); - } else { - this.focusItem( - this.registry.findReachableAncestor( - this.registry.ancestorIds(currentItem.id), - ), - ); - } - break; - case "Enter": - case " ": - if (disabled) { - break; - } - this.requestSelection(currentItem.id, { - toggle: event.ctrlKey || event.metaKey, - }); - if (expanded !== null) { - currentItem.setExpanded?.(!expanded); - } - break; - default: - handled = false; - } - - if (handled) { - event.preventDefault(); - return; - } - - if ( - event.key.length !== 1 || - event.ctrlKey || - event.metaKey || - event.altKey - ) { - return; - } - - this.focusItem( - this.typeAhead.match(event.key, visibleItems, nextIndex, currentIndex), - ); - event.preventDefault(); - }; - - private get focusedItem(): TreeStoreItem | undefined { - return this.registry.fromElement(this.focusedElement ?? null); - } - - /** Every visible enabled row between two ids, in tree order. */ - private rangeIds(fromId: string, toId: string): readonly string[] { - const visibleItems = this.registry.visible(); - const from = visibleItems.findIndex((item) => item.id === fromId); - const to = visibleItems.findIndex((item) => item.id === toId); - if (from === -1 || to === -1) { - return [toId]; - } - return visibleItems - .slice(Math.min(from, to), Math.max(from, to) + 1) - .map((item) => item.id); - } - - private isInteractiveTarget(event: KeyboardEvent): boolean { - const interactiveTarget = nestedInteractiveTarget( - event.target, - event.currentTarget, - ); - // Row elements are the navigation surface, not embedded controls. - return ( - interactiveTarget !== null && - !( - interactiveTarget instanceof HTMLElement && - this.registry.owns(interactiveTarget) - ) - ); - } - - private guideOwnerId(item: TreeStoreItem): string | undefined { - return readExpanded(item.element) === true ? item.id : item.parentId; - } - - private refreshGuideOwners(): boolean { - const ownerIds = new Set(); - for (const id of [this.focusedItem?.id, ...this.selection.ids]) { - const item = this.registry.get(id); - const ownerId = item && this.guideOwnerId(item); - if (ownerId !== undefined) { - ownerIds.add(ownerId); - } - } - const unchanged = - ownerIds.size === this.guideOwnerIds.size && - [...ownerIds].every((id) => this.guideOwnerIds.has(id)); - if (unchanged) { - return false; - } - this.guideOwnerIds = ownerIds; - return true; - } - - private reconcileFocusedItem(): boolean { - const focusedItem = this.focusedItem; - if (focusedItem && this.registry.isVisible(focusedItem)) { - return false; - } - return this.setFocusedElement(undefined); - } - - private setFocusedElement(element: HTMLElement | undefined): boolean { - if (this.focusedElement === element) { - return false; - } - this.focusedElement = element; - return true; - } - - private focusItem(item: TreeStoreItem | undefined, extend = false): void { - if (!item) { - return; - } - if (extend && this.selection.isMultiSelect) { - this.requestSelection(item.id, { range: true }); - } - const tabStopChanged = this.tabStop.set(item.id); - const revisionBeforeFocus = this.revision; - item.element.focus(); - if (tabStopChanged && this.revision === revisionBeforeFocus) { - this.publishChange(); - } - } - - private publishChange(): void { - this.revision += 1; - this.listeners.forEach((listener) => listener()); - } -} diff --git a/packages/ui/src/components/Tree/context.ts b/packages/ui/src/components/Tree/context.ts index 3453927174..380fecae8f 100644 --- a/packages/ui/src/components/Tree/context.ts +++ b/packages/ui/src/components/Tree/context.ts @@ -1,21 +1,8 @@ import { createContext, use } from "react"; -import type { TreeStore } from "./TreeStore"; - -export interface TreeHierarchyContextValue { - level: number; - parentItemId?: string; - pathItemIds: readonly string[]; - /** Levels that pin on scroll; 0 disables sticky scroll. */ - stickyLevels: number; -} +import type { TreeStore } from "./store/TreeStore"; export const TreeContext = createContext(undefined); -export const TreeHierarchyContext = createContext({ - level: 1, - pathItemIds: [], - stickyLevels: 0, -}); export function useTreeContext(): TreeStore { const context = use(TreeContext); diff --git a/packages/ui/src/components/Tree/mergeRefs.ts b/packages/ui/src/components/Tree/mergeRefs.ts deleted file mode 100644 index 0a1d7d5652..0000000000 --- a/packages/ui/src/components/Tree/mergeRefs.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Ref, RefCallback } from "react"; - -/** - * Composes refs into one callback ref. A React 19 cleanup-style callback - * ref gets its cleanup run instead of a null call it never expects. - */ -export function mergeRefs( - ...refs: ReadonlyArray | undefined> -): RefCallback { - return (value) => { - const cleanups = refs.map((ref) => { - if (typeof ref === "function") { - const cleanup = ref(value); - return typeof cleanup === "function" ? cleanup : () => ref(null); - } - if (ref) { - ref.current = value; - return () => { - ref.current = null; - }; - } - return undefined; - }); - return () => { - for (const cleanup of cleanups) { - cleanup?.(); - } - }; - }; -} diff --git a/packages/ui/src/components/Tree/rowDom.ts b/packages/ui/src/components/Tree/rowDom.ts index b52c570d6c..5d8f77d298 100644 --- a/packages/ui/src/components/Tree/rowDom.ts +++ b/packages/ui/src/components/Tree/rowDom.ts @@ -1,8 +1,4 @@ -/** - * Every read of the rendered tree. The DOM is the model: order, visibility, - * expansion and disabled state are asked of it rather than mirrored, so this - * is the only file that needs to change if that contract does. - */ +/** The DOM reads the data model cannot answer: event targets. */ export const INTERACTIVE_SELECTOR = [ "a[href]", @@ -25,31 +21,6 @@ export const INTERACTIVE_SELECTOR = [ "[tabindex]:not([tabindex='-1'])", ].join(","); -const ROW_SELECTOR = '[role="treeitem"]'; - -/** Rows a user can reach: rendered, outside a hidden container, enabled. */ -const VISIBLE_ROW_SELECTOR = `${ROW_SELECTOR}:not([hidden]):not([hidden] *):not([aria-disabled="true"])`; - -export function isDisabled(element: HTMLElement): boolean { - return element.getAttribute("aria-disabled") === "true"; -} - -/** null on leaves. */ -export function readExpanded(element: HTMLElement): boolean | null { - const expanded = element.getAttribute("aria-expanded"); - return expanded === null ? null : expanded === "true"; -} - -export function isVisibleRow(element: HTMLElement): boolean { - return element.matches(VISIBLE_ROW_SELECTOR); -} - -export function queryVisibleRows( - root: HTMLElement | undefined, -): readonly HTMLElement[] { - return [...(root?.querySelectorAll(VISIBLE_ROW_SELECTOR) ?? [])]; -} - /** The interactive element the event targets, unless it is `container`. */ export function nestedInteractiveTarget( target: EventTarget | null, @@ -68,17 +39,22 @@ export function nestedInteractiveTarget( export function closestRow(target: EventTarget | null): HTMLElement | null { return target instanceof Element - ? target.closest(ROW_SELECTOR) + ? target.closest('[role="treeitem"]') : null; } -export function comesAfter( - reference: HTMLElement, - other: HTMLElement, -): boolean { - return ( - (reference.compareDocumentPosition(other) & - Node.DOCUMENT_POSITION_FOLLOWING) !== - 0 - ); +export function scrollableAncestor( + element: HTMLElement, +): HTMLElement | undefined { + for ( + let parent = element.parentElement; + parent !== null; + parent = parent.parentElement + ) { + const { overflowY } = getComputedStyle(parent); + if (overflowY === "auto" || overflowY === "scroll") { + return parent; + } + } + return undefined; } diff --git a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx new file mode 100644 index 0000000000..76ceac7863 --- /dev/null +++ b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx @@ -0,0 +1,153 @@ +import { + type CSSProperties, + type RefObject, + useRef, + useSyncExternalStore, +} from "react"; + +import { useTreeContext } from "../context"; +import { scrollableAncestor } from "../rowDom"; +import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; +import { TreeRowSurface } from "../TreeRow"; + +import { computeStickyState, NO_STICKY, type StickyState } from "./stickyState"; + +const NO_GUIDES: readonly string[] = []; + +function sameState(left: StickyState, right: StickyState): boolean { + return ( + left.pushOffset === right.pushOffset && + left.ids.length === right.ids.length && + left.ids.every((id, index) => id === right.ids[index]) + ); +} + +/** How far the tree has scrolled under the pinned widget. */ +function scrolledPx(widget: HTMLElement, tree: HTMLElement): number { + return widget.getBoundingClientRect().top - tree.getBoundingClientRect().top; +} + +/** The scroll position is an external store; subscribe to it as one. */ +function useStickyState( + rows: readonly TreeRowModel[], + maxCount: number, + widgetRef: RefObject, +): StickyState { + const cacheRef = useRef(NO_STICKY); + const subscribe = (notify: () => void): (() => void) => { + const tree = widgetRef.current?.parentElement; + const scroller = tree ? scrollableAncestor(tree) : undefined; + if (!scroller) { + return () => undefined; + } + scroller.addEventListener("scroll", notify, { passive: true }); + // The viewport cap tracks the scroller's height. + const observer = + typeof ResizeObserver === "undefined" + ? undefined + : new ResizeObserver(notify); + observer?.observe(scroller); + return () => { + scroller.removeEventListener("scroll", notify); + observer?.disconnect(); + }; + }; + const getSnapshot = (): StickyState => { + const widget = widgetRef.current; + const tree = widget?.parentElement; + if (!widget || !tree) { + return NO_STICKY; + } + const next = computeStickyState( + rows, + scrolledPx(widget, tree), + scrollableAncestor(tree)?.clientHeight ?? 0, + maxCount, + ); + if (!sameState(cacheRef.current, next)) { + cacheRef.current = next; + } + return cacheRef.current; + }; + return useSyncExternalStore(subscribe, getSnapshot, () => NO_STICKY); +} + +/** + * The pinned ancestors of the topmost visible row, like VS Code's sticky + * scroll widget. The widget itself pins through position: sticky; the + * scroll position only decides which rows it shows. + */ +export function StickyScroll({ + rows, + maxCount, +}: { + rows: readonly TreeRowModel[]; + maxCount: number; +}): React.JSX.Element { + const store = useTreeContext(); + const widgetRef = useRef(null); + const state = useStickyState(rows, maxCount, widgetRef); + + const revealPinnedRow = (row: TreeRowModel, pinnedIndex: number): void => { + const widget = widgetRef.current; + const tree = widget?.parentElement; + if (widget && tree) { + // Scroll the real row to just below the rows still pinned above it. + const rowPx = rows.indexOf(row) * ROW_HEIGHT_PX; + scrollableAncestor(tree)?.scrollBy( + 0, + rowPx - pinnedIndex * ROW_HEIGHT_PX - scrolledPx(widget, tree), + ); + } + store.focusRow(row.node.id); + }; + + const pinnedRows = state.ids + .map((id) => rows.find((row) => row.node.id === id)) + .filter((row) => row !== undefined); + + return ( + + ); +} diff --git a/packages/ui/src/components/Tree/sticky/stickyState.ts b/packages/ui/src/components/Tree/sticky/stickyState.ts new file mode 100644 index 0000000000..a180208547 --- /dev/null +++ b/packages/ui/src/components/Tree/sticky/stickyState.ts @@ -0,0 +1,72 @@ +import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; + +/** VS Code caps the sticky widget at 40% of the viewport. */ +const MAX_VIEWPORT_RATIO = 0.4; + +export interface StickyState { + /** Ids of the pinned ancestor chain, outermost first. */ + readonly ids: readonly string[]; + /** Upward shift in px while the last pinned subtree scrolls out. */ + readonly pushOffset: number; +} + +export const NO_STICKY: StickyState = { ids: [], pushOffset: 0 }; + +/** + * The ancestor chain to pin, like VS Code's findStickyState: the ancestors + * of the topmost row not covered by the widget, capped by `maxCount` and by + * viewport share. Pinned rows cover rows below, which can deepen the chain, + * so grow to a fixpoint. + */ +export function computeStickyState( + rows: readonly TreeRowModel[], + scrolledPx: number, + viewportPx: number, + maxCount: number, +): StickyState { + const cap = Math.min( + maxCount, + Math.floor((viewportPx * MAX_VIEWPORT_RATIO) / ROW_HEIGHT_PX), + ); + if (scrolledPx <= 0 || cap <= 0) { + return NO_STICKY; + } + const topIndex = Math.floor(scrolledPx / ROW_HEIGHT_PX); + let count = 0; + let chain: readonly string[] = []; + for (;;) { + const rowChain = rows[topIndex + count]?.pathIds ?? []; + const next = Math.min(rowChain.length, cap); + if (next <= count) { + break; + } + count = next; + chain = rowChain; + } + const ids = chain.slice(0, count); + if (ids.length === 0) { + return NO_STICKY; + } + return { ids, pushOffset: pushOffset(rows, scrolledPx, ids) }; +} + +/** How far the widget shifts up as the last pinned subtree ends. */ +function pushOffset( + rows: readonly TreeRowModel[], + scrolledPx: number, + ids: readonly string[], +): number { + const lastId = ids.at(-1); + let endIndex = -1; + rows.forEach((row, index) => { + if (row.node.id === lastId || row.pathIds.includes(lastId ?? "")) { + endIndex = index; + } + }); + if (endIndex === -1) { + return 0; + } + const subtreeBottom = (endIndex + 1) * ROW_HEIGHT_PX; + const widgetBottom = scrolledPx + ids.length * ROW_HEIGHT_PX; + return Math.min(0, subtreeBottom - widgetBottom); +} diff --git a/packages/ui/src/components/Tree/RovingTabStop.ts b/packages/ui/src/components/Tree/store/RovingTabStop.ts similarity index 50% rename from packages/ui/src/components/Tree/RovingTabStop.ts rename to packages/ui/src/components/Tree/store/RovingTabStop.ts index b821b10a8c..76e6b3e9bb 100644 --- a/packages/ui/src/components/Tree/RovingTabStop.ts +++ b/packages/ui/src/components/Tree/store/RovingTabStop.ts @@ -1,6 +1,13 @@ -import { isDisabled } from "./rowDom"; - -import type { TreeItemRegistry } from "./TreeItemRegistry"; +/** What the tab stop needs to know about the current row projection. */ +export interface RowLookup { + /** Present in the visible projection and enabled. */ + isReachable(id: string | undefined): boolean; + /** Present but disabled. */ + isDisabled(id: string): boolean; + /** Ancestors nearest first, remembered even for a just-removed row. */ + ancestorIds(id: string): readonly string[]; + firstReachableId(): string | undefined; +} /** * The single tabbable row. Falls back in order: a controlled selection @@ -10,9 +17,6 @@ import type { TreeItemRegistry } from "./TreeItemRegistry"; export class RovingTabStop { private tabStopId: string | undefined; private pendingSelectedId: string | undefined; - private removedAncestorIds: readonly string[] = []; - - constructor(private readonly registry: TreeItemRegistry) {} get id(): string | undefined { return this.tabStopId; @@ -44,43 +48,29 @@ export class RovingTabStop { return true; } - /** Ancestry captured before a row unmounts, while its links still exist. */ - noteRemoval(ancestorIds: readonly string[]): void { - this.removedAncestorIds = ancestorIds; - } - - reconcile(): boolean { - if (this.reconcilePendingSelection()) { + reconcile(rows: RowLookup): boolean { + if (this.reconcilePendingSelection(rows)) { return true; } - - const currentItem = this.registry.get(this.tabStopId); - if (currentItem && this.registry.isVisible(currentItem)) { + if (this.tabStopId !== undefined && rows.isReachable(this.tabStopId)) { return false; } - - // An unregistered tab stop is gone from the map; use the captured path. - const ancestor = this.registry.findReachableAncestor( - currentItem - ? this.registry.ancestorIds(currentItem.id) - : this.removedAncestorIds, - ); - this.removedAncestorIds = []; - return this.set(ancestor?.id ?? this.registry.visible()[0]?.id); + const ancestorId = + this.tabStopId === undefined + ? undefined + : rows.ancestorIds(this.tabStopId).find((id) => rows.isReachable(id)); + return this.set(ancestorId ?? rows.firstReachableId()); } - private reconcilePendingSelection(): boolean { + private reconcilePendingSelection(rows: RowLookup): boolean { const pendingSelectedId = this.pendingSelectedId; if (pendingSelectedId === undefined) { return false; } - const selectedItem = this.registry.get(pendingSelectedId); - if (!selectedItem) { - return false; - } - if (!this.registry.isVisible(selectedItem)) { - // Hidden keeps the claim until the branch reveals it; disabled drops it. - if (isDisabled(selectedItem.element)) { + if (!rows.isReachable(pendingSelectedId)) { + // Collapsed away keeps the claim until the branch reveals it; + // disabled drops it. + if (rows.isDisabled(pendingSelectedId)) { this.pendingSelectedId = undefined; } return false; diff --git a/packages/ui/src/components/Tree/TreeSelection.ts b/packages/ui/src/components/Tree/store/TreeSelection.ts similarity index 100% rename from packages/ui/src/components/Tree/TreeSelection.ts rename to packages/ui/src/components/Tree/store/TreeSelection.ts diff --git a/packages/ui/src/components/Tree/store/TreeStore.ts b/packages/ui/src/components/Tree/store/TreeStore.ts new file mode 100644 index 0000000000..723fd914cc --- /dev/null +++ b/packages/ui/src/components/Tree/store/TreeStore.ts @@ -0,0 +1,506 @@ +import { + closestRow, + nestedInteractiveTarget, + scrollableAncestor, +} from "../rowDom"; +import { parentId, ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; + +import { RovingTabStop, type RowLookup } from "./RovingTabStop"; +import { TreeSelection, type SelectionModifiers } from "./TreeSelection"; +import { TypeAhead } from "./TypeAhead"; + +import type { KeyboardEvent } from "react"; + +export interface TreeItemSnapshot { + tabIndex: 0 | -1; + selected: boolean; + /** Outlives losing DOM focus, like the native list's focused row. */ + focused: boolean; + /** This row's own ancestors that own an active indent guide. */ + activeGuideIds: readonly string[]; +} + +const NO_GUIDES: readonly string[] = []; + +function sameIds(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((id, index) => id === right[index]) + ); +} + +function sameMembers( + left: readonly string[], + right: readonly string[], +): boolean { + const rightIds = new Set(right); + return ( + new Set(left).size === rightIds.size && left.every((id) => rightIds.has(id)) + ); +} + +/** + * Interaction engine for one tree. Works from the flat row projection the + * tree renders, owns the subscription, model focus and indent guides, and + * coordinates the tab stop, selection and type-ahead collaborators. + */ +export class TreeStore { + private readonly tabStop = new RovingTabStop(); + private readonly selection: TreeSelection; + private readonly typeAhead = new TypeAhead(); + private readonly listeners = new Set<() => void>(); + private readonly itemSnapshots = new Map(); + private readonly elements = new Map(); + private readonly idsByElement = new Map(); + private rows: readonly TreeRowModel[] = []; + private navigableRows: readonly TreeRowModel[] = []; + private rowsById = new Map(); + /** One generation back, for the ancestors of a just-removed tab stop. */ + private previousRowsById = new Map(); + private focusedId: string | undefined; + private hasDomFocus = false; + private guideOwnerIds: ReadonlySet = new Set(); + private onToggleBranch: ((id: string, expanded: boolean) => void) | undefined; + /** Lets focusItem tell whether focus() already published, avoiding a double notify. */ + private revision = 0; + /** What the tree itself last emitted, to tell echoes from external changes. */ + private emittedSelectionIds: readonly string[] | undefined; + + constructor(selectedIds: readonly string[] = []) { + this.selection = new TreeSelection(selectedIds); + this.tabStop.claim(selectedIds[0]); + } + + readonly subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange); + return () => this.listeners.delete(onChange); + }; + + /** The projection changed, so re-derive everything that depends on it. */ + readonly setRows = (rows: readonly TreeRowModel[]): void => { + this.previousRowsById = this.rowsById; + this.rows = rows; + this.navigableRows = rows.filter((row) => !row.node.disabled); + this.rowsById = new Map(rows.map((row) => [row.node.id, row])); + const tabStopChanged = this.tabStop.reconcile(this.rowLookup); + const focusChanged = this.reconcileFocusedId(); + if (this.refreshGuideOwners() || tabStopChanged || focusChanged) { + this.publishChange(); + } + }; + + readonly setConfiguration = ( + selectedIds: readonly string[], + onSelectionChange?: (itemIds: readonly string[]) => void, + multiSelect = false, + onToggleBranch?: (id: string, expanded: boolean) => void, + ): void => { + this.onToggleBranch = onToggleBranch; + const emittedIds = this.emittedSelectionIds; + this.emittedSelectionIds = undefined; + const changed = this.selection.configure( + selectedIds, + onSelectionChange && + ((itemIds): void => { + this.emittedSelectionIds = itemIds; + onSelectionChange(itemIds); + }), + multiSelect, + ); + if (!changed) { + return; + } + // An echo of the tree's own emission must not re-claim the tab stop. + if (emittedIds === undefined || !sameMembers(selectedIds, emittedIds)) { + this.tabStop.claim(selectedIds[0]); + } + this.tabStop.reconcile(this.rowLookup); + this.refreshGuideOwners(); + this.publishChange(); + }; + + readonly dispose = (): void => { + this.typeAhead.reset(); + }; + + /** Focus guides only show while the tree really has DOM focus. */ + readonly setDomFocus = (hasDomFocus: boolean): void => { + if (this.hasDomFocus === hasDomFocus) { + return; + } + this.hasDomFocus = hasDomFocus; + if (this.refreshGuideOwners()) { + this.publishChange(); + } + }; + + readonly requestSelection = ( + itemId: string, + modifiers: SelectionModifiers = {}, + ): void => { + this.selection.request(itemId, modifiers, (fromId, toId) => + this.rangeIds(fromId, toId), + ); + }; + + readonly toggleBranch = (id: string, expanded: boolean): void => { + this.onToggleBranch?.(id, expanded); + }; + + readonly registerItem = (id: string, element: HTMLElement): (() => void) => { + if (this.elements.has(id)) { + throw new Error( + `Tree node id "${id}" is already registered by another row. Node ids must be unique.`, + ); + } + this.elements.set(id, element); + this.idsByElement.set(element, id); + + return (): void => { + if (this.elements.get(id) === element) { + this.elements.delete(id); + this.idsByElement.delete(element); + this.itemSnapshots.delete(id); + } + }; + }; + + /** Focuses a row's element, e.g. from a pinned sticky copy. */ + readonly focusRow = (id: string): void => { + this.elements.get(id)?.focus(); + }; + + /** Reuses the previous snapshot unless something this row renders changed. */ + readonly getItemSnapshot = (id: string): TreeItemSnapshot => { + const tabIndex = this.tabStop.tabIndexFor(id); + const selected = this.selection.has(id); + const focused = this.focusedId === id; + const activeGuideIds = this.activeGuideIdsFor(id); + const previous = this.itemSnapshots.get(id); + if ( + previous?.tabIndex === tabIndex && + previous.selected === selected && + previous.focused === focused && + sameIds(previous.activeGuideIds, activeGuideIds) + ) { + return previous; + } + + const snapshot = { tabIndex, selected, focused, activeGuideIds }; + this.itemSnapshots.set(id, snapshot); + return snapshot; + }; + + private activeGuideIdsFor(id: string): readonly string[] { + if (this.guideOwnerIds.size === 0) { + return NO_GUIDES; + } + const pathIds = this.rowsById.get(id)?.pathIds ?? NO_GUIDES; + const activeGuideIds = pathIds.filter((pathId) => + this.guideOwnerIds.has(pathId), + ); + return activeGuideIds.length === 0 ? NO_GUIDES : activeGuideIds; + } + + readonly onItemFocus = (id: string): void => { + const row = this.rowsById.get(id); + const canReceiveFocus = row !== undefined && !row.node.disabled; + if (canReceiveFocus) { + // The user took over, so the controlled selection's claim ends. + this.tabStop.release(); + } + const focusChanged = this.setFocusedId(canReceiveFocus ? id : undefined); + const tabStopChanged = canReceiveFocus ? this.tabStop.set(id) : false; + if (this.refreshGuideOwners() || focusChanged || tabStopChanged) { + this.publishChange(); + } + }; + + readonly onKeyDown = (event: KeyboardEvent): void => { + if (this.isInteractiveTarget(event)) { + return; + } + const navigable = this.navigableRows; + if ( + this.selection.isMultiSelect && + (event.ctrlKey || event.metaKey) && + !event.shiftKey && + !event.altKey && + event.key.toLowerCase() === "a" + ) { + this.selection.replaceWith(navigable.map((row) => row.node.id)); + event.preventDefault(); + return; + } + const currentRow = + this.rowFromElement(closestRow(event.target)) ?? + this.focusedRow ?? + this.rowsById.get(this.tabStop.id ?? "") ?? + navigable[0]; + if (!currentRow) { + return; + } + + // A disabled row is absent from the navigable list, so take its + // neighbors from where it sits in the full projection. + const currentIndex = navigable.indexOf(currentRow); + const nextIndex = + currentIndex === -1 + ? this.navigableCountBefore(currentRow) + : currentIndex + 1; + const previousIndex = + currentIndex === -1 ? nextIndex - 1 : currentIndex - 1; + const disabled = Boolean(currentRow.node.disabled); + const expanded = currentRow.expanded; + let handled = true; + + switch (event.key) { + case "ArrowDown": + this.focusItem(navigable[nextIndex], event.shiftKey); + break; + case "ArrowUp": + this.focusItem(navigable[previousIndex], event.shiftKey); + break; + case "Home": + this.focusItem(navigable[0], event.shiftKey); + break; + case "End": + this.focusItem(navigable.at(-1), event.shiftKey); + break; + case "PageDown": + this.focusItem( + navigable[ + Math.min( + navigable.length - 1, + (currentIndex === -1 ? nextIndex : currentIndex) + + this.pageSize(currentRow), + ) + ], + event.shiftKey, + ); + break; + case "PageUp": + this.focusItem( + navigable[ + Math.max( + 0, + (currentIndex === -1 ? previousIndex : currentIndex) - + this.pageSize(currentRow), + ) + ], + event.shiftKey, + ); + break; + case "Escape": { + // The native list clears selection and focus on Escape (list.clear). + const hadSelection = this.selection.ids.size > 0; + if (hadSelection) { + this.selection.replaceWith([]); + } + const focusChanged = this.setFocusedId(undefined); + if (this.refreshGuideOwners() || focusChanged) { + this.publishChange(); + } + handled = hadSelection || focusChanged; + break; + } + case "ArrowRight": + if (disabled) { + break; + } + if (expanded === false) { + this.toggleBranch(currentRow.node.id, true); + } else if (expanded === true) { + // Descendants directly follow their branch in the projection. + const firstChild = navigable[nextIndex]; + if (firstChild?.pathIds.includes(currentRow.node.id)) { + this.focusItem(firstChild); + } + } + break; + case "ArrowLeft": + if (disabled) { + break; + } + if (expanded === true) { + this.toggleBranch(currentRow.node.id, false); + } else { + this.focusItem(this.reachableAncestor(currentRow)); + } + break; + case "Enter": + case " ": + if (disabled) { + break; + } + this.requestSelection(currentRow.node.id, { + toggle: event.ctrlKey || event.metaKey, + }); + if (expanded !== undefined) { + this.toggleBranch(currentRow.node.id, !expanded); + } + break; + default: + handled = false; + } + + if (handled) { + event.preventDefault(); + return; + } + + if ( + event.key.length !== 1 || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + this.focusItem( + this.typeAhead.match(event.key, navigable, nextIndex, currentIndex), + ); + event.preventDefault(); + }; + + private get focusedRow(): TreeRowModel | undefined { + return this.focusedId === undefined + ? undefined + : this.rowsById.get(this.focusedId); + } + + private rowFromElement( + element: HTMLElement | null, + ): TreeRowModel | undefined { + const id = element === null ? undefined : this.idsByElement.get(element); + return id === undefined ? undefined : this.rowsById.get(id); + } + + /** Rows per viewport of the nearest scroller; 1 when nothing scrolls. */ + private pageSize(row: TreeRowModel): number { + const element = this.elements.get(row.node.id); + const scroller = element ? scrollableAncestor(element) : undefined; + return Math.max( + 1, + Math.floor((scroller?.clientHeight ?? 0) / ROW_HEIGHT_PX), + ); + } + + private navigableCountBefore(row: TreeRowModel): number { + const index = this.rows.indexOf(row); + return this.rows.slice(0, index).filter((other) => !other.node.disabled) + .length; + } + + private reachableAncestor(row: TreeRowModel): TreeRowModel | undefined { + for (let depth = row.pathIds.length - 1; depth >= 0; depth--) { + const ancestor = this.rowsById.get(row.pathIds[depth] ?? ""); + if (ancestor && !ancestor.node.disabled) { + return ancestor; + } + } + return undefined; + } + + private readonly rowLookup: RowLookup = { + isReachable: (id) => { + const row = id === undefined ? undefined : this.rowsById.get(id); + return row !== undefined && !row.node.disabled; + }, + isDisabled: (id) => this.rowsById.get(id)?.node.disabled === true, + ancestorIds: (id) => { + const row = this.rowsById.get(id) ?? this.previousRowsById.get(id); + return row ? [...row.pathIds].reverse() : NO_GUIDES; + }, + firstReachableId: () => this.navigableRows[0]?.node.id, + }; + + /** Every visible enabled row between two ids, in tree order. */ + private rangeIds(fromId: string, toId: string): readonly string[] { + const navigable = this.navigableRows; + const from = navigable.findIndex((row) => row.node.id === fromId); + const to = navigable.findIndex((row) => row.node.id === toId); + if (from === -1 || to === -1) { + return [toId]; + } + return navigable + .slice(Math.min(from, to), Math.max(from, to) + 1) + .map((row) => row.node.id); + } + + private isInteractiveTarget(event: KeyboardEvent): boolean { + const interactiveTarget = nestedInteractiveTarget( + event.target, + event.currentTarget, + ); + // Row elements are the navigation surface, not embedded controls. + return ( + interactiveTarget !== null && + !( + interactiveTarget instanceof HTMLElement && + this.idsByElement.has(interactiveTarget) + ) + ); + } + + private guideOwnerId(row: TreeRowModel): string | undefined { + return row.expanded === true ? row.node.id : parentId(row); + } + + private refreshGuideOwners(): boolean { + const ownerIds = new Set(); + const focusedId = this.hasDomFocus ? this.focusedId : undefined; + for (const id of [focusedId, ...this.selection.ids]) { + const row = id === undefined ? undefined : this.rowsById.get(id); + const ownerId = row && this.guideOwnerId(row); + if (ownerId !== undefined) { + ownerIds.add(ownerId); + } + } + const unchanged = + ownerIds.size === this.guideOwnerIds.size && + [...ownerIds].every((id) => this.guideOwnerIds.has(id)); + if (unchanged) { + return false; + } + this.guideOwnerIds = ownerIds; + return true; + } + + private reconcileFocusedId(): boolean { + const focusedRow = this.focusedRow; + if (focusedRow && !focusedRow.node.disabled) { + return false; + } + return this.setFocusedId(undefined); + } + + private setFocusedId(id: string | undefined): boolean { + if (this.focusedId === id) { + return false; + } + this.focusedId = id; + return true; + } + + private focusItem(row: TreeRowModel | undefined, extend = false): void { + if (!row) { + return; + } + if (extend && this.selection.isMultiSelect) { + this.requestSelection(row.node.id, { range: true }); + } + const tabStopChanged = this.tabStop.set(row.node.id); + const revisionBeforeFocus = this.revision; + this.elements.get(row.node.id)?.focus(); + if (tabStopChanged && this.revision === revisionBeforeFocus) { + this.publishChange(); + } + } + + private publishChange(): void { + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } +} diff --git a/packages/ui/src/components/Tree/TypeAhead.ts b/packages/ui/src/components/Tree/store/TypeAhead.ts similarity index 100% rename from packages/ui/src/components/Tree/TypeAhead.ts rename to packages/ui/src/components/Tree/store/TypeAhead.ts diff --git a/packages/ui/src/components/Tree/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts new file mode 100644 index 0000000000..5f7f288410 --- /dev/null +++ b/packages/ui/src/components/Tree/treeModel.ts @@ -0,0 +1,78 @@ +import type { ReactNode } from "react"; + +import type { CodiconName } from "#codicons"; + +/** VS Code's tree row height; Tree.css --ui-tree-row-height must match. */ +export const ROW_HEIGHT_PX = 22; + +/** A string label doubles as the text value; a rich label must supply one. */ +type TreeNodeLabel = + | { label: string; textValue?: string } + | { label: ReactNode; textValue: string }; + +/** + * One row of tree data. `children` is what makes a node a branch, including + * an empty array for a branch whose children have not loaded yet; leaves + * omit it. + */ +export type TreeNode = TreeNodeLabel & { + id: string; + icon?: CodiconName; + disabled?: boolean; + action?: ReactNode; + className?: string; + children?: readonly TreeNode[]; +}; + +/** A visible node projected onto the flat list the tree renders. */ +export interface TreeRowModel { + readonly node: TreeNode; + /** 1-based depth. */ + readonly level: number; + /** Ancestor ids, root first. */ + readonly pathIds: readonly string[]; + readonly posInSet: number; + readonly setSize: number; + readonly textValue: string; + /** undefined on leaves. */ + readonly expanded: boolean | undefined; +} + +/** The id of the row's parent branch. */ +export function parentId(row: TreeRowModel): string | undefined { + return row.pathIds.at(-1); +} + +/** + * Projects the visible tree onto the flat row list everything else works + * from: collapsed subtrees are absent, exactly like the rendered DOM. + */ +export function flattenVisibleRows( + nodes: readonly TreeNode[], + expandedIds: ReadonlySet, +): readonly TreeRowModel[] { + const rows: TreeRowModel[] = []; + const visit = ( + siblings: readonly TreeNode[], + pathIds: readonly string[], + ): void => { + siblings.forEach((node, index) => { + const expanded = node.children ? expandedIds.has(node.id) : undefined; + rows.push({ + node, + level: pathIds.length + 1, + pathIds, + posInSet: index + 1, + setSize: siblings.length, + textValue: + node.textValue ?? (typeof node.label === "string" ? node.label : ""), + expanded, + }); + if (expanded && node.children) { + visit(node.children, [...pathIds, node.id]); + } + }); + }; + visit(nodes, []); + return rows; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index cd64e1ade9..4ac37b0213 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -73,5 +73,5 @@ export { type TooltipProviderProps, } from "./components/Tooltip/Tooltip"; export { Tree, type TreeProps } from "./components/Tree/Tree"; -export { TreeItem, type TreeItemProps } from "./components/Tree/TreeItem"; +export type { TreeNode } from "./components/Tree/treeModel"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx index cf34552dc8..31d42d597d 100644 --- a/packages/ui/storybook/Tree.demo.tsx +++ b/packages/ui/storybook/Tree.demo.tsx @@ -2,10 +2,11 @@ import { useState } from "react"; import { IconButton } from "../src/components/IconButton/IconButton"; import { Tree, type TreeProps } from "../src/components/Tree/Tree"; -import { TreeItem } from "../src/components/Tree/TreeItem"; import type { CodiconName } from "#codicons"; +import type { TreeNode } from "../src/components/Tree/treeModel"; + export interface TreeDemoNode { id: string; label: string; @@ -20,21 +21,39 @@ export interface TreeDemoNode { export interface TreeDemoProps extends Omit< TreeProps, - "children" | "onSelectedItemChange" | "onSelectedItemsChange" + | "nodes" + | "expandedIds" + | "onExpandedIdsChange" + | "onSelectedItemChange" + | "onSelectedItemsChange" > { nodes: readonly TreeDemoNode[]; } -function initialCollapsedIds(nodes: readonly TreeDemoNode[]): Set { - const collapsedIds = new Set(); +function initialExpandedIds(nodes: readonly TreeDemoNode[]): readonly string[] { + const expandedIds: string[] = []; const visit = (node: TreeDemoNode): void => { - if (node.collapsed) { - collapsedIds.add(node.id); + if (node.children && !node.collapsed) { + expandedIds.push(node.id); } node.children?.forEach(visit); }; nodes.forEach(visit); - return collapsedIds; + return expandedIds; +} + +function toTreeNodes(nodes: readonly TreeDemoNode[]): readonly TreeNode[] { + return nodes.map((node) => ({ + id: node.id, + label: node.label, + icon: node.icon, + disabled: node.disabled, + className: node.className, + action: node.action && ( + + ), + children: node.children && toTreeNodes(node.children), + })); } /** Wraps a node tree in the selection and expansion state a Tree expects. */ @@ -49,47 +68,10 @@ export function TreeDemo({ const [selectedItemIds, setSelectedItemIds] = useState( initialSelectedItemIds ?? [], ); - const [collapsedIds, setCollapsedIds] = useState(() => - initialCollapsedIds(nodes), + const [expandedIds, setExpandedIds] = useState(() => + initialExpandedIds(nodes), ); - const setExpanded = (itemId: string, expanded: boolean): void => { - setCollapsedIds((previous) => { - const next = new Set(previous); - if (expanded) { - next.delete(itemId); - } else { - next.add(itemId); - } - return next; - }); - }; - - const renderNodes = ( - siblings: readonly TreeDemoNode[], - ): React.JSX.Element[] => - siblings.map((node) => ( - setExpanded(node.id, expanded)) - } - action={ - node.action && ( - - ) - } - > - {node.children && renderNodes(node.children)} - - )); - const selection = multiSelect ? { multiSelect: true, @@ -99,8 +81,12 @@ export function TreeDemo({ : { selectedItemId, onSelectedItemChange: setSelectedItemId }; return ( - - {renderNodes(nodes)} - + ); } diff --git a/test/webview/ui/tree.test.tsx b/test/webview/ui/tree.test.tsx index 4a08ec78f6..876cd58b91 100644 --- a/test/webview/ui/tree.test.tsx +++ b/test/webview/ui/tree.test.tsx @@ -1,8 +1,8 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; -import { createRef, Fragment, useState } from "react"; +import { createRef, useState } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Tree, TreeItem } from "@repo/ui"; +import { Tree, type TreeNode } from "@repo/ui"; const ACTIVE_GUIDE = "ui-tree-item__indent-slot--active"; @@ -13,109 +13,134 @@ const guideSlots = (name: string): Element[] => [ ...treeItem(name).querySelectorAll(".ui-tree-item__indent-slot"), ]; +/** Reports per-branch expansion changes from the whole-set callback. */ +function toBranchSpy( + current: readonly string[], + onExpandedChange: (itemId: string, expanded: boolean) => void, +): (next: readonly string[]) => void { + return (next) => { + const previous = new Set(current); + const nextSet = new Set(next); + for (const id of nextSet) { + if (!previous.has(id)) { + onExpandedChange(id, true); + } + } + for (const id of previous) { + if (!nextSet.has(id)) { + onExpandedChange(id, false); + } + } + }; +} + +const CONTROLLED_NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [ + { id: "child", label: "Child" }, + { id: "disabled", label: "Disabled", disabled: true }, + ], + }, + { id: "last", label: "Last" }, +]; + /** A branch with an enabled and a disabled child, plus a root-level sibling. */ function ControlledTree({ onSelectedItemChange = vi.fn(), onExpandedChange = vi.fn(), }: { - onSelectedItemChange?: (itemId: string) => void; + onSelectedItemChange?: (itemId: string | undefined) => void; onExpandedChange?: (expanded: boolean) => void; }): React.JSX.Element { - const [selectedItemId, setSelectedItemId] = useState("child"); - const [expanded, setExpanded] = useState(true); + const [selectedItemId, setSelectedItemId] = useState( + "child", + ); + const [expandedIds, setExpandedIds] = useState(["parent"]); return ( { + onExpandedChange(next.includes("parent")); + setExpandedIds(next); + }} selectedItemId={selectedItemId} onSelectedItemChange={(itemId) => { onSelectedItemChange(itemId); setSelectedItemId(itemId); }} - > - { - onExpandedChange(nextExpanded); - setExpanded(nextExpanded); - }} - > - - - - - + /> ); } +const NAV_NODES: readonly TreeNode[] = [ + { + id: "alpha", + label: "Alpha", + children: [ + { id: "disabled", label: "Disabled", disabled: true }, + { id: "apricot", label: "Apricot" }, + { id: "amber", label: "Amber" }, + ], + }, + { id: "beta", label: "Beta", children: [{ id: "blue", label: "Blue" }] }, + { + id: "bravo", + label: ( + <> + Bravo + + + ), + textValue: "Bravo", + }, +]; + /** Two branches whose labels share prefixes, for arrow keys and type-ahead. */ function NavTree({ onSelect = vi.fn(), onExpandedChange = vi.fn(), }: { - onSelect?: (itemId: string) => void; + onSelect?: (itemId: string | undefined) => void; onExpandedChange?: (itemId: string, expanded: boolean) => void; }): React.JSX.Element { - const [expandedIds, setExpandedIds] = useState(() => new Set(["alpha"])); - const branch = ( - itemId: string, - ): Pick< - React.ComponentProps, - "expanded" | "onExpandedChange" - > => ({ - expanded: expandedIds.has(itemId), - onExpandedChange: (nextExpanded: boolean) => { - onExpandedChange(itemId, nextExpanded); - setExpandedIds((current) => { - const next = new Set(current); - if (nextExpanded) { - next.add(itemId); - } else { - next.delete(itemId); - } - return next; - }); - }, - }); + const [expandedIds, setExpandedIds] = useState(["alpha"]); + const branchSpy = toBranchSpy(expandedIds, onExpandedChange); return ( - - - - - - - - - - - Bravo - - - } - textValue="Bravo" - /> - + { + branchSpy(next); + setExpandedIds(next); + }} + onSelectedItemChange={onSelect} + /> ); } +const REVEAL_NODES: readonly TreeNode[] = [ + { id: "top", label: "Top" }, + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, +]; + const revealTree = (expanded: boolean): React.JSX.Element => ( - - - - - - + ); describe("Tree", () => { @@ -128,9 +153,8 @@ describe("Tree", () => { className="custom-tree" style={{ width: "240px" }} ref={ref} - > - - , + nodes={[{ id: "file", label: "File" }]} + />, ); const tree = screen.getByRole("tree", { name: "Explorer" }); @@ -139,25 +163,7 @@ describe("Tree", () => { expect(ref.current).toBe(tree); }); - it("runs cleanup-style callback refs instead of calling them with null", () => { - const cleanup = vi.fn(); - const treeRef = vi.fn(() => cleanup); - const itemRef = vi.fn(() => cleanup); - const { unmount } = render( - - - , - ); - - unmount(); - expect(cleanup).toHaveBeenCalledTimes( - treeRef.mock.calls.length + itemRef.mock.calls.length, - ); - expect(treeRef).not.toHaveBeenCalledWith(null); - expect(itemRef).not.toHaveBeenCalledWith(null); - }); - - it("exposes levels, selection, disabled state, groups, and branch expansion", () => { + it("exposes levels, positions, selection, disabled state, and expansion", () => { render(); const parent = treeItem("Parent"); @@ -165,15 +171,16 @@ describe("Tree", () => { expect(parent).toHaveAttribute("aria-level", "1"); expect(parent).toHaveAttribute("aria-expanded", "true"); expect(parent).toHaveAttribute("aria-selected", "false"); + expect(parent).toHaveAttribute("aria-posinset", "1"); + expect(parent).toHaveAttribute("aria-setsize", "2"); expect(child).toHaveAttribute("aria-level", "2"); expect(child).toHaveAttribute("aria-selected", "true"); - expect(child).not.toHaveAttribute("aria-expanded"); + expect(child).toHaveAttribute("aria-posinset", "1"); + expect(child).toHaveAttribute("aria-setsize", "2"); expect(treeItem("Disabled")).toHaveAttribute("aria-disabled", "true"); - - const group = screen.getByRole("group"); - expect(group).not.toHaveAttribute("hidden"); - expect(group.closest('[role="treeitem"]')).toBe(parent); - expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + expect(treeItem("Last")).not.toHaveAttribute("aria-expanded"); + // Rows are flat siblings; depth is declared, not nested. + expect(screen.queryByRole("group")).toBeNull(); }); it("keeps exactly one visible enabled item in the tab order", () => { @@ -203,55 +210,41 @@ describe("Tree", () => { expect(treeItem("Child")).toHaveAttribute("tabindex", "-1"); }); - it("moves the tab stop to an ancestor when its row unmounts", async () => { + it("moves the tab stop to an ancestor when its row unmounts", () => { const renderTree = (showLeaf: boolean): React.JSX.Element => ( - - - - {showLeaf && } - - + ); const { rerender } = render(renderTree(true)); act(() => treeItem("Leaf").focus()); rerender(renderTree(false)); - await act(() => Promise.resolve()); expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); }); - it("leaves keys to interactive elements rendered outside rows", () => { - render( - - - - , - ); - - const input = screen.getByRole("textbox", { name: "New file" }); - act(() => input.focus()); - const arrowNotPrevented = fireEvent.keyDown(input, { key: "ArrowDown" }); - const typeAheadNotPrevented = fireEvent.keyDown(input, { key: "a" }); - - expect(document.activeElement).toBe(input); - expect(arrowNotPrevented).toBe(true); - expect(typeAheadNotPrevented).toBe(true); - }); - it("derives indent guide owners from focus and controlled selection", () => { + const nodes: readonly TreeNode[] = ["Alpha", "Beta"].map((branch) => ({ + id: branch, + label: branch, + children: [{ id: `${branch} leaf`, label: `${branch} leaf` }], + })); const renderTree = (selectedItemId?: string): React.JSX.Element => ( - - {["Alpha", "Beta"].map((branch) => ( - - - - ))} - + ); const { rerender } = render(renderTree()); @@ -269,15 +262,49 @@ describe("Tree", () => { expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); }); + it("drops focus guides on blur and keeps selection guides", () => { + const nodes: readonly TreeNode[] = ["Alpha", "Beta"].map((branch) => ({ + id: branch, + label: branch, + children: [{ id: `${branch} leaf`, label: `${branch} leaf` }], + })); + render( + , + ); + + act(() => treeItem("Beta leaf").focus()); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Alpha leaf")[0]).toHaveClass(ACTIVE_GUIDE); + + fireEvent.blur(treeItem("Beta leaf"), { relatedTarget: document.body }); + expect(guideSlots("Beta leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Alpha leaf")[0]).toHaveClass(ACTIVE_GUIDE); + }); + it("uses only the expanded focused branch as its indent guide owner", () => { render( - - - - - - - , + , ); act(() => treeItem("Branch").focus()); @@ -287,7 +314,7 @@ describe("Tree", () => { expect(slots[1]).toHaveClass(ACTIVE_GUIDE); }); - it("clears hidden, disabled, and unmounted focused guide owners", async () => { + it("clears hidden, disabled, and unmounted focused guide owners", () => { const renderTree = ({ expanded = true, disabled = false, @@ -297,13 +324,19 @@ describe("Tree", () => { disabled?: boolean; showChild?: boolean; }): React.JSX.Element => ( - - - {showChild && ( - - )} - - + ); const { rerender } = render(renderTree({})); @@ -320,17 +353,20 @@ describe("Tree", () => { act(() => treeItem("Child").focus()); rerender(renderTree({ showChild: false })); - await act(() => Promise.resolve()); rerender(renderTree({})); expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); }); it("updates controlled selection before the rerender is observable", () => { const renderTree = (selectedItemId: string): React.JSX.Element => ( - - - - + ); const { rerender } = render(renderTree("first")); @@ -357,12 +393,16 @@ describe("Tree", () => { it("uses focus from this tree only for active selection colors", () => { render( <> - - - - - - + + , ); @@ -381,101 +421,34 @@ describe("Tree", () => { }); }); -describe("TreeItem", () => { - it("names rows from the label unless the consumer overrides it", () => { +describe("Tree rows", () => { + it("names rows from the label or the explicit text value", () => { render( - - Custom labelled item - - Rich item} - textValue="Rich item" - /> - - - , + Rich item, textValue: "Rich item" }, + ]} + />, ); expect(treeItem("Plain item")).toBeInTheDocument(); expect(treeItem("Rich item")).toBeInTheDocument(); - expect(treeItem("Custom labelled item")).toBeInTheDocument(); - expect(treeItem("Custom label")).toBeInTheDocument(); expect( treeItem("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), ).toHaveClass("codicon-file"); }); - it("accepts child rows from fragments, arrays, and wrapper components", () => { - const WrappedRows = (): React.JSX.Element => ( - <> - - - ); + it("treats an empty children array as a branch awaiting children", () => { + const onExpandedIdsChange = vi.fn(); render( - - - - - {[]} - - - , - ); - - expect(treeItem("Branch")).toHaveAttribute("aria-expanded", "true"); - expect(treeItem("Wrapped")).toHaveAttribute("aria-level", "2"); - expect(treeItem("Listed")).toHaveAttribute("aria-level", "2"); - }); - - it("rejects child rows on a row that is not a branch", () => { - expect(() => - render( - - - - - , - ), - ).toThrow(/has child rows, so it is a branch/); - }); - - it("treats an empty children array as a leaf", () => { - render( - - - {[].map(() => null)} - - , - ); - - expect(treeItem("Leaf")).not.toHaveAttribute("aria-expanded"); - }); - - it("shows a twistie for a branch whose children are not loaded yet", () => { - const onExpandedChange = vi.fn(); - render( - - - , + , ); const lazy = treeItem("Lazy"); @@ -484,32 +457,7 @@ describe("TreeItem", () => { "codicon-chevron-right", ); fireEvent.keyDown(lazy, { key: "ArrowRight" }); - expect(onExpandedChange).toHaveBeenCalledWith(true); - }); - - it("pins branch rows down to the sticky scroll limit", () => { - const renderTree = (stickyScroll: boolean): React.JSX.Element => ( - - - - - - - - - - ); - const { rerender } = render(renderTree(false)); - expect(treeItem("One")).not.toHaveClass("ui-tree-item--sticky"); - - rerender(renderTree(true)); - expect(treeItem("One")).toHaveClass("ui-tree-item--sticky"); - expect(treeItem("Two")).toHaveClass("ui-tree-item--sticky"); - expect(treeItem("Three")).not.toHaveClass("ui-tree-item--sticky"); - expect(treeItem("Leaf")).not.toHaveClass("ui-tree-item--sticky"); - expect(treeItem("Three").style.getPropertyValue("--ui-tree-level")).toBe( - "3", - ); + expect(onExpandedIdsChange).toHaveBeenCalledWith(["lazy"]); }); it("reports controlled selection and expansion from a row click", () => { @@ -527,7 +475,6 @@ describe("TreeItem", () => { expect(onExpandedChange).toHaveBeenCalledWith(false); // Collapsing unmounts the subtree, so a mostly-closed tree only // renders what is open. - expect(screen.queryByRole("group", { hidden: true })).toBeNull(); expect( screen.queryByRole("treeitem", { name: "Child", hidden: true }), ).toBeNull(); @@ -535,20 +482,21 @@ describe("TreeItem", () => { it("toggles a branch from its twistie without changing selection", () => { const onSelectedItemChange = vi.fn(); - const onExpandedChange = vi.fn(); - const onClick = vi.fn(); + const onExpandedIdsChange = vi.fn(); render( - - - - - , + , ); const chevron = treeItem("Branch").querySelector(".ui-tree-item__chevron"); @@ -557,31 +505,33 @@ describe("TreeItem", () => { } fireEvent.click(chevron); - expect(onExpandedChange).toHaveBeenCalledWith(false); - expect(onClick).toHaveBeenCalledOnce(); + expect(onExpandedIdsChange).toHaveBeenCalledWith([]); expect(onSelectedItemChange).not.toHaveBeenCalled(); }); it("isolates a trailing action from tree selection and expansion", () => { const onAction = vi.fn(); const onSelectedItemChange = vi.fn(); - const onExpandedChange = vi.fn(); + const onExpandedIdsChange = vi.fn(); render( - - - Delete - - } - > - - - , + + Delete + + ), + children: [{ id: "child", label: "Child" }], + }, + ]} + expandedIds={["branch"]} + onExpandedIdsChange={onExpandedIdsChange} + />, ); const action = screen.getByRole("button", { name: "Delete" }); @@ -591,58 +541,7 @@ describe("TreeItem", () => { fireEvent.click(action); expect(onAction).toHaveBeenCalledOnce(); expect(onSelectedItemChange).not.toHaveBeenCalled(); - expect(onExpandedChange).not.toHaveBeenCalled(); - }); - - it("keeps parent row handlers isolated from descendant treeitems", () => { - const onParentClick = vi.fn(); - const onParentFocus = vi.fn(); - const onChildClick = vi.fn(); - const onChildFocus = vi.fn(); - const onSelectedItemChange = vi.fn(); - const onExpandedChange = vi.fn(); - render( - - - - - , - ); - - const child = treeItem("Child"); - const childContent = child.querySelector(".ui-tree-item__content"); - if (!childContent) { - throw new Error("Expected child row content."); - } - fireEvent.click(childContent); - expect(onChildClick).toHaveBeenCalledOnce(); - expect(onParentClick).not.toHaveBeenCalled(); - expect(onSelectedItemChange).toHaveBeenCalledWith("child"); - expect(onExpandedChange).not.toHaveBeenCalled(); - - fireEvent.focus(child); - expect(onChildFocus).toHaveBeenCalledOnce(); - expect(onParentFocus).not.toHaveBeenCalled(); - - onSelectedItemChange.mockClear(); - fireEvent.keyDown(child, { key: "Enter" }); - expect(onSelectedItemChange).toHaveBeenCalledWith("child"); - expect(onExpandedChange).not.toHaveBeenCalled(); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); }); it("does not activate a disabled row that receives programmatic focus", () => { @@ -657,20 +556,21 @@ describe("TreeItem", () => { expect(onSelectedItemChange).not.toHaveBeenCalled(); }); - it("forwards className, style, and ref, and marks the selected row", () => { - const ref = createRef(); + it("applies the node className and marks the selected row", () => { render( - - Selected action} - /> - - , + Selected action, + }, + { id: "plain", label: "Plain" }, + ]} + />, ); const selected = treeItem("Selected"); @@ -680,14 +580,16 @@ describe("TreeItem", () => { expect( screen.getByRole("button", { name: "Selected action" }).parentElement, ).toHaveClass("ui-tree-item__action"); - expect(selected.style.color).toBe("red"); expect(selected.firstElementChild).toHaveClass("ui-tree-item__row"); - expect(ref.current).toBe(selected); expect(treeItem("Plain")).toHaveAttribute("aria-selected", "false"); }); }); describe("Tree multi-select", () => { + const MULTI_NODES: readonly TreeNode[] = ["One", "Two", "Three", "Four"].map( + (label) => ({ id: label.toLowerCase(), label }), + ); + const MultiTree = ({ onSelectedItemsChange, }: { @@ -700,16 +602,13 @@ describe("Tree multi-select", () => { { onSelectedItemsChange(itemIds); setSelectedItemIds(itemIds); }} - > - {["One", "Two", "Three", "Four"].map((label) => ( - - ))} - + /> ); }; @@ -789,23 +688,27 @@ describe("Tree multi-select", () => { }); it("never toggles a branch from a modifier click", () => { - const onExpandedChange = vi.fn(); + const onExpandedIdsChange = vi.fn(); render( - - - - - , + , ); fireEvent.click(treeItem("Branch"), { ctrlKey: true }); fireEvent.click(treeItem("Branch"), { shiftKey: true }); - expect(onExpandedChange).not.toHaveBeenCalled(); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); // The twistie keeps toggling whatever the modifiers. const chevron = treeItem("Branch").querySelector(".ui-tree-item__chevron"); @@ -813,7 +716,7 @@ describe("Tree multi-select", () => { throw new Error("Expected a branch twistie."); } fireEvent.click(chevron, { ctrlKey: true }); - expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(onExpandedIdsChange).toHaveBeenCalledWith([]); }); it("selects every visible row with Ctrl+A", () => { @@ -835,23 +738,36 @@ describe("Tree multi-select", () => { expect(selectedNames()).toEqual(["One"]); }); - it("clears the selection with Escape", () => { + it("clears the selection and the focus mark with Escape", () => { render(); + act(() => treeItem("One").focus()); + expect(treeItem("One")).toHaveClass("ui-tree-item--focused"); expect(fireEvent.keyDown(treeItem("One"), { key: "Escape" })).toBe(false); expect(selectedNames()).toEqual([]); + expect(treeItem("One")).not.toHaveClass("ui-tree-item--focused"); // Nothing left to clear, so the key falls through to the host. expect(fireEvent.keyDown(treeItem("One"), { key: "Escape" })).toBe(true); }); it("lights the ancestor guide for every selected row", () => { render( - - - - - - , + , ); expect(guideSlots("A")[0]).toHaveClass(ACTIVE_GUIDE); @@ -865,10 +781,11 @@ describe("Tree multi-select", () => { aria-label="Single" selectedItemId="one" onSelectedItemChange={onSelectedItemChange} - > - - - , + nodes={[ + { id: "one", label: "One" }, + { id: "two", label: "Two" }, + ]} + />, ); expect(screen.getByRole("tree")).not.toHaveAttribute( @@ -919,37 +836,6 @@ describe("Tree keyboard navigation", () => { expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); }); - it("keeps navigation fresh across subtree-only commits", async () => { - // Expansion state lives below Tree, so collapsing commits only the - // branch subtree and Tree's own layout effect never runs. - function IsolatedBranch(): React.JSX.Element { - const [expanded, setExpanded] = useState(true); - return ( - - - - ); - } - render( - - - - , - ); - - act(() => treeItem("Branch").focus()); - fireEvent.keyDown(treeItem("Branch"), { key: "ArrowLeft" }); - await act(() => Promise.resolve()); - - fireEvent.keyDown(treeItem("Branch"), { key: "ArrowDown" }); - expect(document.activeElement).toBe(treeItem("Tail")); - }); - it("lets the host capture clipboard shortcuts through onKeyDown", () => { const captured: string[] = []; render( @@ -961,10 +847,11 @@ describe("Tree keyboard navigation", () => { event.preventDefault(); } }} - > - - - , + nodes={[ + { id: "copy", label: "Copy me" }, + { id: "cut", label: "Cut me" }, + ]} + />, ); act(() => treeItem("Copy me").focus()); @@ -976,6 +863,46 @@ describe("Tree keyboard navigation", () => { expect(document.activeElement).toBe(treeItem("Copy me")); }); + it("moves focus by a viewport page with PageUp and PageDown", () => { + render( +
+ ({ + id: `row-${index}`, + label: `Row ${index}`, + }))} + /> +
, + ); + // jsdom has no layout, so give the scroller a five-row viewport. + Object.defineProperty(screen.getByTestId("scroller"), "clientHeight", { + value: 5 * 22, + }); + + fireEvent.keyDown(treeItem("Row 0"), { key: "PageDown" }); + expect(document.activeElement).toBe(treeItem("Row 5")); + fireEvent.keyDown(treeItem("Row 5"), { key: "PageDown" }); + expect(document.activeElement).toBe(treeItem("Row 10")); + // The last page clamps to the final row. + fireEvent.keyDown(treeItem("Row 10"), { key: "PageDown" }); + expect(document.activeElement).toBe(treeItem("Row 11")); + + fireEvent.keyDown(treeItem("Row 11"), { key: "PageUp" }); + expect(document.activeElement).toBe(treeItem("Row 6")); + }); + + it("clears a single selection and the focus mark with Escape", () => { + const onSelectedItemChange = vi.fn(); + render(); + + act(() => treeItem("Child").focus()); + expect(fireEvent.keyDown(treeItem("Child"), { key: "Escape" })).toBe(false); + expect(onSelectedItemChange).toHaveBeenCalledWith(undefined); + expect(treeItem("Child")).toHaveAttribute("aria-selected", "false"); + expect(treeItem("Child")).not.toHaveClass("ui-tree-item--focused"); + }); + it("steps from a focused disabled row to its enabled neighbors", () => { render(); const disabled = treeItem("Disabled"); @@ -991,19 +918,22 @@ describe("Tree keyboard navigation", () => { it("ignores keys from interactive content nested in a row", () => { const onSelect = vi.fn(); render(); - fireEvent.keyDown(screen.getByRole("button", { name: "Action" }), { - key: "Enter", - }); + const button = screen.getByRole("button", { name: "Action" }); + + fireEvent.keyDown(button, { key: "Enter" }); expect(onSelect).not.toHaveBeenCalled(); + // Type-ahead stays out of embedded controls too. + expect(fireEvent.keyDown(button, { key: "a" })).toBe(true); }); - it("follows DOM order after rows reorder without item updates", () => { + it("follows node order after the data reorders", () => { const renderPair = (reversed: boolean): React.JSX.Element => { - const rows = ["One", "Two"].map((label) => ( - - )); + const nodes = ["One", "Two"].map((label) => ({ id: label, label })); return ( - {reversed ? rows.reverse() : rows} + ); }; const { rerender } = render(renderPair(false)); @@ -1015,23 +945,29 @@ describe("Tree keyboard navigation", () => { expect(document.activeElement).toBe(treeItem("One")); }); - it("rejects duplicate item ids across rows", () => { + it("rejects duplicate node ids across rows", () => { expect(() => render( - - - - , + , ), ).toThrow(/already registered by another row/i); }); - it("finds renamed items by type-ahead without re-registering", () => { + it("finds renamed items by type-ahead", () => { const renderNames = (label: string): React.JSX.Element => ( - - - - + ); const { rerender } = render(renderNames("Amber")); rerender(renderNames("Cedar")); @@ -1056,10 +992,13 @@ describe("Tree keyboard navigation", () => { it("keeps focus on a row the longer query still matches", () => { render( - - - - , + , ); // The first character moves on, as the native list does. @@ -1085,3 +1024,84 @@ describe("Tree keyboard navigation", () => { }); }); }); + +describe("Tree sticky scroll", () => { + it("renders the sticky anchor and pins nothing before scrolling", () => { + render( + , + ); + + expect(document.querySelector(".ui-tree-sticky")).not.toBeNull(); + expect(document.querySelector(".ui-tree-sticky__rows")).toBeNull(); + }); + + it("collapses a pinned branch from its twistie and reveals from its row", () => { + const onExpandedIdsChange = vi.fn(); + render( +
+ ({ + id: `file-${index}`, + label: `file-${index}`, + })), + }, + ], + }, + ]} + expandedIds={["alpha", "src"]} + onExpandedIdsChange={onExpandedIdsChange} + /> +
, + ); + const scroller = screen.getByTestId("scroller"); + Object.defineProperty(scroller, "clientHeight", { value: 10 * 22 }); + // jsdom has no layout: place the widget three rows below the tree + // top, as a real scroller would while pinning. + const widget = document.querySelector(".ui-tree-sticky"); + if (!widget?.parentElement) { + throw new Error("Expected the sticky widget."); + } + widget.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + widget.parentElement.getBoundingClientRect = () => + ({ top: -66 }) as DOMRect; + Object.assign(scroller, { scrollBy: vi.fn() }); + fireEvent.scroll(scroller); + + const pinned = [ + ...document.querySelectorAll(".ui-tree-sticky .ui-tree-item"), + ]; + expect(pinned.map((row) => row.textContent)).toEqual(["alpha", "src"]); + expect(pinned[1]).toHaveAttribute("aria-expanded", "true"); + + const twistie = pinned[1]?.querySelector(".ui-tree-item__chevron"); + if (!twistie) { + throw new Error("Expected a pinned twistie."); + } + fireEvent.click(twistie); + expect(onExpandedIdsChange).toHaveBeenCalledWith(["alpha"]); + + fireEvent.click(pinned[0]); + expect(document.activeElement).toBe(treeItem("alpha")); + }); +}); diff --git a/test/webview/ui/treeModel.test.tsx b/test/webview/ui/treeModel.test.tsx new file mode 100644 index 0000000000..764989dc6e --- /dev/null +++ b/test/webview/ui/treeModel.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + flattenVisibleRows, + parentId, + type TreeNode, +} from "@repo/ui/components/Tree/treeModel"; + +const NODES: readonly TreeNode[] = [ + { + id: "src", + label: "src", + children: [ + { id: "tree", label: Tree.tsx, textValue: "Tree.tsx" }, + { + id: "tests", + label: "tests", + children: [{ id: "unit", label: "unit" }], + }, + ], + }, + { id: "readme", label: "README.md" }, +]; + +describe("flattenVisibleRows", () => { + it("projects only the expanded subtrees, in tree order", () => { + const rows = flattenVisibleRows(NODES, new Set(["src"])); + expect(rows.map((row) => row.node.id)).toEqual([ + "src", + "tree", + "tests", + "readme", + ]); + + const all = flattenVisibleRows(NODES, new Set(["src", "tests"])); + expect(all.map((row) => row.node.id)).toEqual([ + "src", + "tree", + "tests", + "unit", + "readme", + ]); + }); + + it("declares levels, paths, and set positions", () => { + const rows = flattenVisibleRows(NODES, new Set(["src", "tests"])); + const byId = new Map(rows.map((row) => [row.node.id, row])); + + expect(byId.get("src")).toMatchObject({ + level: 1, + pathIds: [], + posInSet: 1, + setSize: 2, + }); + expect(byId.get("unit")).toMatchObject({ + level: 3, + pathIds: ["src", "tests"], + posInSet: 1, + setSize: 1, + }); + expect(byId.get("readme")).toMatchObject({ posInSet: 2, setSize: 2 }); + expect(parentId(byId.get("unit")!)).toBe("tests"); + expect(parentId(byId.get("src")!)).toBeUndefined(); + }); + + it("derives text values and expansion flags from the node shape", () => { + const rows = flattenVisibleRows(NODES, new Set(["src"])); + const byId = new Map(rows.map((row) => [row.node.id, row])); + + // A rich label uses its explicit text value. + expect(byId.get("tree")?.textValue).toBe("Tree.tsx"); + expect(byId.get("src")?.textValue).toBe("src"); + // Only branches carry an expansion flag. + expect(byId.get("src")?.expanded).toBe(true); + expect(byId.get("tests")?.expanded).toBe(false); + expect(byId.get("readme")?.expanded).toBeUndefined(); + }); +}); diff --git a/test/webview/ui/treeStickyState.test.ts b/test/webview/ui/treeStickyState.test.ts new file mode 100644 index 0000000000..03cdf7bc07 --- /dev/null +++ b/test/webview/ui/treeStickyState.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { computeStickyState } from "@repo/ui/components/Tree/sticky/stickyState"; +import { + flattenVisibleRows, + ROW_HEIGHT_PX, + type TreeNode, +} from "@repo/ui/components/Tree/treeModel"; + +const leaves = (prefix: string, count: number): TreeNode[] => + Array.from({ length: count }, (_, index) => ({ + id: `${prefix}/${index}`, + label: `${prefix}/${index}`, + })); + +// Row indices: 0 a, 1-3 a/*, 4 b, 5-7 b/*, 8 c, 9-13 c/*, 14 z. +const NODES: readonly TreeNode[] = [ + { + id: "a", + label: "a", + children: [ + ...leaves("a", 3), + { + id: "b", + label: "b", + children: [ + ...leaves("b", 3), + { id: "c", label: "c", children: leaves("c", 5) }, + ], + }, + ], + }, + { id: "z", label: "z" }, +]; + +const ROWS = flattenVisibleRows(NODES, new Set(["a", "b", "c"])); +const px = (rows: number): number => rows * ROW_HEIGHT_PX; +const VIEWPORT = px(10); + +describe("computeStickyState", () => { + it("pins nothing before scrolling or without viewport", () => { + expect(computeStickyState(ROWS, 0, VIEWPORT, 7).ids).toEqual([]); + expect(computeStickyState(ROWS, px(2), 0, 7).ids).toEqual([]); + }); + + it("pins the ancestors of the topmost row not covered by the widget", () => { + expect(computeStickyState(ROWS, px(1), VIEWPORT, 7).ids).toEqual(["a"]); + // Scrolled into c's subtree: pinned rows cover c's first children, + // which is what deepens the chain to a fixpoint. + expect(computeStickyState(ROWS, px(9), VIEWPORT, 7).ids).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("caps the chain at the pinned item count", () => { + expect(computeStickyState(ROWS, px(9), VIEWPORT, 2).ids).toEqual([ + "a", + "b", + ]); + }); + + it("caps the chain at 40% of the viewport", () => { + // A 40% share of 1.5 rows floors to one pinnable row. + expect(computeStickyState(ROWS, px(9), px(1.5) / 0.4, 7).ids).toEqual([ + "a", + ]); + }); + + it("pushes the widget out as the last pinned subtree ends", () => { + // c's subtree ends at row 13; the widget bottom would reach row 15. + const state = computeStickyState(ROWS, px(12), VIEWPORT, 7); + expect(state.ids).toEqual(["a", "b", "c"]); + expect(state.pushOffset).toBe(px(14) - (px(12) + px(3))); + }); + + it("pins nothing once past every branch", () => { + expect(computeStickyState(ROWS, px(14), VIEWPORT, 7).ids).toEqual([]); + }); +}); diff --git a/test/webview/ui/treeStore.test.ts b/test/webview/ui/treeStore.test.ts index fe60de1e66..22739344f8 100644 --- a/test/webview/ui/treeStore.test.ts +++ b/test/webview/ui/treeStore.test.ts @@ -1,79 +1,64 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; -import { TreeStore } from "@repo/ui/components/Tree/TreeStore"; +import { TreeStore } from "@repo/ui/components/Tree/store/TreeStore"; +import { + flattenVisibleRows, + type TreeNode, +} from "@repo/ui/components/Tree/treeModel"; /** * Every row subscribes to the store, so a snapshot that changes identity * re-renders that row. These assert the blast radius of a change. */ -interface Row { - id: string; - parentId?: string; - branch?: boolean; -} +const TWO_BRANCHES: readonly TreeNode[] = [ + { + id: "alpha", + label: "alpha", + children: [ + { id: "alpha-1", label: "alpha-1" }, + { id: "alpha-2", label: "alpha-2" }, + ], + }, + { + id: "beta", + label: "beta", + children: [ + { id: "beta-1", label: "beta-1" }, + { id: "beta-2", label: "beta-2" }, + ], + }, +]; -function setup(rows: readonly Row[]): { - store: TreeStore; - element: (id: string) => HTMLElement; -} { - const root = document.createElement("div"); - root.setAttribute("role", "tree"); - document.body.append(root); +const IDS = [ + "alpha", + "alpha-1", + "alpha-2", + "beta", + "beta-1", + "beta-2", +] as const; +function setup(): TreeStore { const store = new TreeStore(); - store.setRoot(root); - const elements = new Map(); - - for (const row of rows) { - const element = document.createElement("div"); - element.setAttribute("role", "treeitem"); - element.tabIndex = -1; - if (row.branch) { - element.setAttribute("aria-expanded", "true"); - } - // Nest under the parent so document order matches tree order. - (row.parentId ? elements.get(row.parentId) : root)?.append(element); - elements.set(row.id, element); - store.registerItem(row.id, element); - store.updateItem(row.id, { - textValue: row.id, - parentId: row.parentId, - setExpanded: row.branch ? () => undefined : undefined, - }); - } - store.reconcile(); - return { store, element: (id) => elements.get(id)! }; + store.setRows(flattenVisibleRows(TWO_BRANCHES, new Set(["alpha", "beta"]))); + // Focus guides only render while the tree has DOM focus. + store.setDomFocus(true); + return store; } -afterEach(() => { - document.body.replaceChildren(); -}); - -const TWO_BRANCHES: readonly Row[] = [ - { id: "alpha", branch: true }, - { id: "alpha-1", parentId: "alpha" }, - { id: "alpha-2", parentId: "alpha" }, - { id: "beta", branch: true }, - { id: "beta-1", parentId: "beta" }, - { id: "beta-2", parentId: "beta" }, -]; - describe("TreeStore snapshots", () => { it("leaves unrelated rows untouched when focus crosses branches", () => { - const { store, element } = setup(TWO_BRANCHES); + const store = setup(); store.onItemFocus("alpha-1"); - const before = new Map( - TWO_BRANCHES.map(({ id }) => [id, store.getItemSnapshot(id)]), - ); + const before = new Map(IDS.map((id) => [id, store.getItemSnapshot(id)])); // Focus moves into the other branch: the two focused rows change, and // so do the rows whose indent guide gains or loses its owner. - element("beta-1").focus(); store.onItemFocus("beta-1"); - const changed = TWO_BRANCHES.filter( - ({ id }) => store.getItemSnapshot(id) !== before.get(id), - ).map(({ id }) => id); + const changed = IDS.filter( + (id) => store.getItemSnapshot(id) !== before.get(id), + ); expect(changed.sort()).toEqual( ["alpha-1", "alpha-2", "beta-1", "beta-2"].sort(), @@ -81,16 +66,14 @@ describe("TreeStore snapshots", () => { }); it("leaves every other row untouched when selection changes", () => { - const { store } = setup(TWO_BRANCHES); + const store = setup(); store.setConfiguration(["alpha-1"]); - const before = new Map( - TWO_BRANCHES.map(({ id }) => [id, store.getItemSnapshot(id)]), - ); + const before = new Map(IDS.map((id) => [id, store.getItemSnapshot(id)])); store.setConfiguration(["alpha-2"]); - const changed = TWO_BRANCHES.filter( - ({ id }) => store.getItemSnapshot(id) !== before.get(id), - ).map(({ id }) => id); + const changed = IDS.filter( + (id) => store.getItemSnapshot(id) !== before.get(id), + ); // Both rows change selection; their shared guide owner does not move, // so their siblings and the other branch keep their snapshots. From 3fe1ebe2356f85ad7d2306718cf51e3cc457228b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 13 Aug 2026 13:03:15 +0000 Subject: [PATCH 3/3] refactor(ui): simplify tree implementation --- package.json | 1 + packages/ui/README.md | 170 ++- packages/ui/src/components/Tree/Tree.css | 29 +- .../ui/src/components/Tree/Tree.modern.css | 19 - .../ui/src/components/Tree/Tree.stable.css | 14 - .../ui/src/components/Tree/Tree.stories.tsx | 268 ++-- packages/ui/src/components/Tree/Tree.tsx | 247 ++-- .../src/components/Tree/TreeItem.stories.tsx | 51 - packages/ui/src/components/Tree/TreeRow.tsx | 135 +- packages/ui/src/components/Tree/context.ts | 13 - .../components/Tree/sticky/StickyScroll.tsx | 207 +-- .../components/Tree/store/RovingTabStop.ts | 81 -- .../components/Tree/store/TreeSelection.ts | 79 -- .../ui/src/components/Tree/store/TreeStore.ts | 506 -------- .../ui/src/components/Tree/store/TypeAhead.ts | 49 - packages/ui/src/components/Tree/treeModel.ts | 67 +- packages/ui/src/components/Tree/treePolicy.ts | 251 ++++ .../ui/src/components/Tree/treeTransition.ts | 394 ++++++ .../ui/src/components/Tree/useTreeAdapter.ts | 221 ++++ packages/ui/src/vscode-parity.stories.tsx | 15 +- packages/ui/storybook/Tree.demo.tsx | 22 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + test/webview/ui/tree.core.test.tsx | 237 ++++ test/webview/ui/tree.keyboard.test.tsx | 276 ++++ test/webview/ui/tree.rows.test.tsx | 195 +++ test/webview/ui/tree.selection.test.tsx | 275 ++++ test/webview/ui/tree.sticky.test.tsx | 97 ++ test/webview/ui/tree.test.tsx | 1107 ----------------- test/webview/ui/treeCommands.test.ts | 269 ++++ test/webview/ui/treeController.test.tsx | 53 + test/webview/ui/treeInteractionState.test.ts | 116 ++ test/webview/ui/treeModel.test.tsx | 78 +- test/webview/ui/treeStickyState.test.ts | 93 +- test/webview/ui/treeStore.test.ts | 82 -- test/webview/ui/treeTestHelpers.tsx | 86 ++ 36 files changed, 3156 insertions(+), 2654 deletions(-) delete mode 100644 packages/ui/src/components/Tree/Tree.modern.css delete mode 100644 packages/ui/src/components/Tree/Tree.stable.css delete mode 100644 packages/ui/src/components/Tree/TreeItem.stories.tsx delete mode 100644 packages/ui/src/components/Tree/context.ts delete mode 100644 packages/ui/src/components/Tree/store/RovingTabStop.ts delete mode 100644 packages/ui/src/components/Tree/store/TreeSelection.ts delete mode 100644 packages/ui/src/components/Tree/store/TreeStore.ts delete mode 100644 packages/ui/src/components/Tree/store/TypeAhead.ts create mode 100644 packages/ui/src/components/Tree/treePolicy.ts create mode 100644 packages/ui/src/components/Tree/treeTransition.ts create mode 100644 packages/ui/src/components/Tree/useTreeAdapter.ts create mode 100644 test/webview/ui/tree.core.test.tsx create mode 100644 test/webview/ui/tree.keyboard.test.tsx create mode 100644 test/webview/ui/tree.rows.test.tsx create mode 100644 test/webview/ui/tree.selection.test.tsx create mode 100644 test/webview/ui/tree.sticky.test.tsx delete mode 100644 test/webview/ui/tree.test.tsx create mode 100644 test/webview/ui/treeCommands.test.ts create mode 100644 test/webview/ui/treeController.test.tsx create mode 100644 test/webview/ui/treeInteractionState.test.ts delete mode 100644 test/webview/ui/treeStore.test.ts create mode 100644 test/webview/ui/treeTestHelpers.tsx diff --git a/package.json b/package.json index 2d8c339e43..8cb702743e 100644 --- a/package.json +++ b/package.json @@ -801,6 +801,7 @@ "@tanstack/react-query": "catalog:", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "catalog:", "@tsconfig/node22": "^22.0.5", "@types/mocha": "^10.0.10", "@types/node": "^22.20.1", diff --git a/packages/ui/README.md b/packages/ui/README.md index b33306230f..f8f169722f 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -48,10 +48,11 @@ that override live. ## Tree -`Tree` renders a data model: `nodes` describe the hierarchy, `expandedIds` -and the selection props control its state, and every visible node becomes one -flat `treeitem` row with declared depth, exactly like the native tree renders -its list. +`Tree` is controlled: `nodes` describe the hierarchy, `expandedIds` controls +branches, and the single- or multi-selection props control selection. Each +visible node renders as a flat `treeitem`, while normal keyboard navigation +keeps DOM focus on the `tree` container and identifies the active row with +`aria-activedescendant`. Focus and selection are independent. ```tsx const [selectedItemId, setSelectedItemId] = useState("src"); @@ -75,97 +76,80 @@ const [expandedIds, setExpandedIds] = useState(["src"]); />; ``` -`id` carries selection, expansion, and registry identity, so ids must be -unique across the whole tree. `label` is the row content: a string also -supplies the accessible name and the case-insensitive, buffered type-ahead -key, so matching never depends on rendered DOM text; a `ReactNode` label must -pass `textValue` for those, which the types enforce. `icon` renders a codicon -ahead of the label, `action` fills the trailing slot, and `className` lands -on the row. - -`children` is what makes a node a branch: it adds the twistie and lets the -node expand, including an empty array for a branch whose children have not -loaded yet. Leaves omit it. `Tree` controls selection and expansion, and -neither defaults. - -Arrow Up/Down, Home, End, PageUp/PageDown, and type-ahead move focus through -visible enabled rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or -returns to the parent. Enter and Space select the focused row and toggle a -branch. Clicking a row does both; clicking the twistie only toggles, leaving -selection in place like the native tree. Escape clears the selection and the -focus mark, reporting `undefined` through `onSelectedItemChange`. Interactive -content in the trailing `action` slot is isolated from selection and -expansion. - -The tree claims only unmodified keys, plus Ctrl/Cmd+A in multi-select and -Escape while it has focus or selection to clear, and the root `onKeyDown` -runs before any of them, so host -shortcuts like Ctrl+C or Ctrl+X need no dedicated API: handle them there, -read the focused or selected rows, and call `preventDefault()` to also stop -the browser default. Keybinding hints stay where VS Code shows them, in -menus and action-button tooltips, never on tree rows. - -`multiSelect` swaps the singular selection props for `selectedItemIds` and -`onSelectedItemsChange` and marks the tree `aria-multiselectable`. Ctrl/Cmd -click toggles a row, Shift click, Shift arrows, and Shift+Home/End/Page -extend from the anchor (the last row selected without Shift), Ctrl/Cmd+A takes every -visible enabled row. Modifier clicks never -toggle a branch. Ranges follow tree order and skip disabled and collapsed -rows. - -`stickyScroll` pins the ancestors of the topmost visible row against the -nearest scrolling ancestor, like VS Code's tree sticky scroll: a number caps -how many of the nearest ancestors pin at once, matching -`workbench.tree.stickyScrollMaxItemCount` (default 7), and the widget never -takes more than 40% of the viewport. A zero-height `position: sticky` anchor -does the pinning, so the scroll listener only decides which rows the widget -shows, and the deepest pinned row slides out as its subtree ends. Pinned rows -are presentational copies of their real rows: clicking one scrolls the real -row out from under the widget and focuses it, and its twistie collapses the -branch in place. The widget follows both scroll and scroller resize. - -Webviews receive no `workbench.tree.*` settings, so honoring the user's own -configuration is the host's job: read the settings, send them over, and keep -them live with `watchConfigurationChanges` from `src/configWatcher.ts`, which -debounces and only fires on a real change. - -```ts -const read = () => { - const tree = vscode.workspace.getConfiguration("workbench.tree"); - return ( - tree.get("enableStickyScroll") && - (tree.get("stickyScrollMaxItemCount") ?? 7) - ); -}; -watchConfigurationChanges( - [ - { setting: "workbench.tree.enableStickyScroll", getValue: read }, - { setting: "workbench.tree.stickyScrollMaxItemCount", getValue: read }, - ], - () => postToWebview({ stickyScroll: read() }), -); +Ids must be unique across the whole tree. A string `label` is also the +accessible name and type-navigation value; a rich label must provide +`textValue`. `children` marks a branch, including an empty array for a branch +whose children are still loading. `icon`, `action`, and `className` customize +the row. Actions are isolated from row selection and expansion. + +Arrow Up/Down, Home, End, PageUp/PageDown, and buffered prefix/fuzzy typing +move the active row through visible rows, including disabled rows. Arrow Right +expands a branch or enters it; Arrow Left collapses it or moves to its parent. +Disabled rows have `aria-disabled`, remain keyboard-navigation targets, and +cannot be selected or expanded. + +`expandMode="singleClick"` is the default: clicking an enabled branch selects +and toggles it, and Enter does the same. With `expandMode="doubleClick"`, a +single click or Enter only selects and a double click toggles expansion. Space +toggles a branch without selecting it, or selects a leaf. A normal-row twistie +toggles without changing selection. Alt-click recursively toggles descendant +branches unless Alt is configured as the multi-selection modifier. + +Escape clears selection. It also clears the active focus mark when the tree has +at most one selected row; after a larger multi-selection, a second Escape +clears the remaining focus mark. Once neither selection nor a focus mark +remains, Escape is left to the host. The root `onKeyDown` runs first, so a host +can intercept shortcuts with `preventDefault()`. + +`multiSelect` uses `selectedItemIds` and `onSelectedItemsChange` and sets +`aria-multiselectable`. `multiSelectModifier` chooses the toggle modifier: +`"ctrlCmd"` (the default) uses Ctrl/Cmd and `"alt"` uses Alt. Shift-click and +Shift+Arrow extend from the selection anchor; modifier clicks take precedence +over expansion. Ctrl/Cmd+A selects enabled visible rows in the active sibling +scope. + +`stickyScroll` pins ancestors against the nearest scrolling ancestor. `true` +uses a maximum of seven pinned rows; a number supplies the maximum, and the +widget is also capped at 40% of the viewport. The pinned region is a separate +tab stop: Arrow Up/Down move among pinned ancestors, Arrow Down/Right from the +deepest row enters its first visible child, Enter reveals, focuses, and selects +the real row, Arrow Left reveals and focuses it and collapses an expanded +branch, and Space only reveals and focuses it. A plain pointer click reveals, +focuses, and selects; a pinned twistie additionally toggles the branch. +Selection-modifier clicks update selection without revealing the real row. + +Webviews do not receive `workbench.tree.*` settings automatically. Consumers +that mirror native sticky-scroll preferences must read +`workbench.tree.enableStickyScroll` and +`workbench.tree.stickyScrollMaxItemCount` in the extension host and send the +values to the webview. + +```mermaid +flowchart LR + accTitle: Tree architecture + accDescr: Data and input flow through the pure Tree modules into the React and DOM adapter. + + Props[Nodes and controlled props] --> Model[treeModel.ts] + Events[Pointer and keyboard events] --> Policy[treePolicy.ts] + Policy --> Commands[Tree commands] + Model --> Transition[treeTransition.ts] + Commands --> Transition + Transition --> Adapter[useTreeAdapter.ts] + Adapter --> Rows[Tree.tsx and TreeRow.tsx] + Adapter --> Sticky[StickyScroll.tsx] ``` -The flat projection in `treeModel.ts` is the single source of truth: order, -visibility, and hierarchy derive from `nodes` and `expandedIds`, never from -the DOM, and collapsed branches simply do not render, so cost tracks what is -open rather than the size of the tree. The folder splits by concern: `store/` -owns interaction state (focus, selection, tab stop, type-ahead), `sticky/` -owns pinning, and `Tree`/`TreeRow` render the projection. The suite is not -virtualized, so the limit is rows open _at once_; around 10k is comfortable. -Past that, the projection is virtualization-ready: render a slice of the rows -and pad the scroll height, while the store keeps navigating the full list. - -Rows are 22px tall and keep the VS Code twistie gutter, matching trees whose -branch rows render icons. For file trees whose folders render without icons — -the native Explorer default — `variant="explorer"` collapses that gutter on -leaf rows so file icons align with branch twisties; don't combine it with -branch icons, which pulls leaf icons out of alignment with branch content. -Indent guides appear on hover; the selected ancestor paths stay lit, and the -focused path lights only while the tree has focus, like the native tree. The package's intentional Modern default insets rows 4px with 4px -corners and keyboard-only focus outlines; `data-ui-style="stable"` on the -document root makes them edge-to-edge and square, restoring VS Code's current -stable focus behavior. +The model, policy, and transitions stay pure. The adapter owns React and DOM +integration. The flat visible model supports future windowing, but the Tree is +not currently virtualized. + +Rows are 22px tall and keep the VS Code twistie gutter. For Explorer-style file +trees whose branches have no icons, `variant="explorer"` aligns leaf icons with +branch twisties; do not combine it with branch icons. Indent guides appear on +hover, selected ancestor paths stay active, and the focused path is active only +while the tree has focus. The package default uses inset Modern UI rows; +`data-ui-style="stable"` restores edge-to-edge square rows and stable focus +styling. ## Overlays diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css index df48e32653..e21e117e32 100644 --- a/packages/ui/src/components/Tree/Tree.css +++ b/packages/ui/src/components/Tree/Tree.css @@ -3,6 +3,7 @@ --ui-tree-row-height: 22px; width: 100%; min-width: 0; + outline: 0; } .ui-tree-item { @@ -27,13 +28,21 @@ top: 0; z-index: 100; height: 0; + outline: 0; } .ui-tree-sticky__rows { position: absolute; inset-inline: 0; overflow: hidden; - box-shadow: var(--ui-tree-sticky-shadow) 0 6px 6px -6px; +} + +.ui-tree-sticky__shadow { + position: absolute; + inset-inline: 0; + height: 3px; + box-shadow: var(--ui-tree-sticky-shadow) 0 6px 6px -6px inset; + pointer-events: none; } .ui-tree-sticky__rows > .ui-tree-item { @@ -161,7 +170,7 @@ .ui-tree-item[aria-selected="true"] > .ui-tree-item__row .ui-tree-item__action, .ui-tree-item__row:hover .ui-tree-item__action, -.ui-tree-item:focus > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item--focused > .ui-tree-item__row .ui-tree-item__action, .ui-tree-item__row:focus-within .ui-tree-item__action { display: inline-flex; } @@ -185,3 +194,19 @@ border-color: CanvasText; } } + +:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { + margin-inline: var(--ui-spacing-40); + border-radius: var(--ui-radius-small); +} + +.ui-tree--focused .ui-tree-item--focused > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +.ui-tree--focused + .ui-tree-item--focused[aria-selected="true"] + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.modern.css b/packages/ui/src/components/Tree/Tree.modern.css deleted file mode 100644 index 632dadd624..0000000000 --- a/packages/ui/src/components/Tree/Tree.modern.css +++ /dev/null @@ -1,19 +0,0 @@ -:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { - margin-inline: var(--ui-spacing-40); - border-radius: var(--ui-radius-small); -} - -:where(:root:not([data-ui-style="stable"])) - .ui-tree--focused - .ui-tree-item--focused:focus-visible - > .ui-tree-item__row { - outline: 1px solid var(--ui-list-focus-outline); - outline-offset: -1px; -} - -:where(:root:not([data-ui-style="stable"])) - .ui-tree--focused - .ui-tree-item--focused[aria-selected="true"]:focus-visible - > .ui-tree-item__row { - outline-color: var(--ui-list-focus-and-selection-outline); -} diff --git a/packages/ui/src/components/Tree/Tree.stable.css b/packages/ui/src/components/Tree/Tree.stable.css deleted file mode 100644 index 6f25fef9f4..0000000000 --- a/packages/ui/src/components/Tree/Tree.stable.css +++ /dev/null @@ -1,14 +0,0 @@ -:where(:root[data-ui-style="stable"]) - .ui-tree--focused - .ui-tree-item--focused:focus - > .ui-tree-item__row { - outline: 1px solid var(--ui-list-focus-outline); - outline-offset: -1px; -} - -:where(:root[data-ui-style="stable"]) - .ui-tree--focused - .ui-tree-item--focused[aria-selected="true"]:focus - > .ui-tree-item__row { - outline-color: var(--ui-list-focus-and-selection-outline); -} diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx index 37dc804363..41db40e9e0 100644 --- a/packages/ui/src/components/Tree/Tree.stories.tsx +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -6,42 +6,55 @@ import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; import type { Meta, StoryObj } from "@storybook/react-vite"; -// The native default Explorer: branch rows render without icons, so the -// explorer variant aligns leaf file icons with the branch twisties. +type NodeOptions = Partial>; +const node = (id: string, options: NodeOptions = {}): TreeDemoNode => ({ + id, + label: id, + ...options, +}); +const branch = ( + id: string, + children: readonly TreeDemoNode[], + options: NodeOptions = {}, +): TreeDemoNode => node(id, { ...options, children }); +const treeStyle = { width: "280px" }; + +// Branch rows render without icons, so explorer aligns leaf icons with twisties. const FILES: readonly TreeDemoNode[] = [ - { - id: "source", - label: "src", - children: [ - { - id: "components", - label: "components", - children: [ - { - id: "tree", - label: "Tree.tsx", - icon: "symbol-class", - action: { icon: "close", label: "Close Tree.tsx" }, - }, - { id: "styles", label: "Tree.css", icon: "symbol-color" }, - ], - }, - { id: "tests", label: "tests", icon: "beaker", disabled: true }, + branch( + "source", + [ + branch("components", [ + node("tree", { + label: "Tree.tsx", + icon: "symbol-class", + action: { icon: "close", label: "Close Tree.tsx" }, + }), + node("styles", { label: "Tree.css", icon: "symbol-color" }), + ]), + node("tests", { icon: "beaker", disabled: true }), ], - }, - { id: "readme", label: "README.md", icon: "markdown" }, + { label: "src" }, + ), + node("readme", { label: "README.md", icon: "markdown" }), ]; -const TreeStates = (): React.JSX.Element => ( +const singleTree = ( + ariaLabel: string, + selectedItemId: string, + nodes: readonly TreeDemoNode[] = FILES, + variant?: "explorer", +) => ( ); - +const TreeStates = (): React.JSX.Element => + singleTree("Explorer", "components", FILES, "explorer"); const meta: Meta = { title: "UI/Tree", component: TreeStates, @@ -50,87 +63,98 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const exerciseTree = async ({ - canvasElement, -}: { - canvasElement: HTMLElement; -}): Promise => { +const exerciseTree: NonNullable = async ({ canvasElement }) => { const canvas = within(canvasElement); - const selectedItem = (): HTMLElement => - canvas.getByRole("treeitem", { name: "components" }); - await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); - - // Click the trailing action while another row owns selection to prove - // action clicks never select their host row. The button is display:none - // until its row is hovered, selected, or focused, so query it hidden; - // the synthetic click still dispatches and bubbles. + const selected = canvas.getByRole("treeitem", { name: "components" }); const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await expect(selected).toHaveAttribute("aria-selected", "true"); await userEvent.click( canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), ); - await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + await expect(selected).toHaveAttribute("aria-selected", "true"); await expect(treeItem).toHaveAttribute("aria-selected", "false"); - await userEvent.click(treeItem); await expect(treeItem).toHaveAttribute("aria-selected", "true"); - const readme = canvas.getByRole("treeitem", { name: "README.md" }); await userEvent.click(readme); await expect(readme).toHaveAttribute("aria-selected", "true"); }; export const States: Story = { play: exerciseTree }; - export const Stable: Story = { globals: { uiStyle: "stable" }, play: exerciseTree, }; -const STORE_FILES: readonly TreeDemoNode[] = [ - { id: "TreeStore.ts", label: "TreeStore.ts", icon: "symbol-class" }, - { id: "context.ts", label: "context.ts", icon: "symbol-interface" }, - { - id: "Tree.tsx", - label: "Tree.tsx", - icon: "symbol-class", - className: "story-hover", - action: { icon: "close", label: "Close Tree.tsx" }, - }, +const ROW_STATES = [ + node("plain", { label: "Plain item", icon: "file" }), + branch("selected", [node("child", { label: "Child item" })], { + label: "Selected branch", + icon: "folder-opened", + }), + branch("collapsed", [node("hidden", { label: "Hidden item" })], { + label: "Collapsed branch", + icon: "folder", + collapsed: true, + }), + node("action", { + label: "Item with action", + className: "story-row-action", + action: { icon: "trash", label: "Delete item" }, + }), + node("disabled", { label: "Disabled item", disabled: true }), ]; +const rowStateParameters = { + pseudo: { hover: [".story-row-action > .ui-tree-item__row"] }, +}; +export const RowStates: Story = { + parameters: rowStateParameters, + render: () => singleTree("Tree row states", "selected", ROW_STATES), +}; +export const RowStatesStable: Story = { + ...RowStates, + globals: { uiStyle: "stable" }, +}; -const NESTED_FILES: readonly TreeDemoNode[] = [ - ...["src", "components", "Tree", "store"].reduceRight< - readonly TreeDemoNode[] - >((children, label) => [{ id: label, label, children }], STORE_FILES), - { id: "README.md", label: "README.md", icon: "markdown" }, +const NESTED_FILES = [ + branch("src", [ + branch("components", [ + branch("Tree", [ + node("Tree.tsx", { + icon: "symbol-class", + className: "story-hover", + action: { icon: "close", label: "Close Tree.tsx" }, + }), + node("TreeRow.tsx", { icon: "symbol-class" }), + node("useTreeAdapter.ts", { icon: "symbol-method" }), + branch("sticky", [node("StickyScroll.tsx", { icon: "symbol-class" })]), + ]), + ]), + ]), + node("README.md", { icon: "markdown" }), ]; - -const DEEP_FILES: readonly TreeDemoNode[] = ["alpha", "beta"].map((branch) => ({ - id: branch, - label: branch, - children: [ - { - id: `${branch}/src`, - label: "src", - children: Array.from({ length: 12 }, (_, index) => ({ - id: `${branch}/src/file-${index}`, - label: `file-${index}.ts`, - icon: "symbol-class" as const, - })), - }, - ], -})); +const DEEP_FILES = ["alpha", "beta"].map((name) => + branch(name, [ + branch( + `${name}/src`, + Array.from({ length: 12 }, (_, index) => + node(`${name}/src/file-${index}`, { + label: `file-${index}.ts`, + icon: "symbol-class", + }), + ), + { label: "src" }, + ), + ]), +); export const StickyScroll: Story = { render: () => (
{ - if (scroller) { - scroller.scrollTop = 143; - } + if (scroller) scroller.scrollTop = 143; }} > ), play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // The programmatic scroll fires its scroll event asynchronously. await waitFor(() => expect( canvasElement.querySelector(".ui-tree-sticky__rows"), ).not.toBeNull(), ); - await expect(canvas.getByTestId("scroller").scrollTop).toBeGreaterThan(0); + await expect( + within(canvasElement).getByTestId("scroller").scrollTop, + ).toBeGreaterThan(0); }, }; export const MultiSelect: Story = { - // Synthetic events do not move DOM focus, so force the outline. - parameters: { - pseudo: { - focus: ['[aria-label="README.md"]'], - focusVisible: ['[aria-label="README.md"]'], - }, - }, render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // A held modifier does not carry across separate userEvent calls. - await fireEvent.click(canvas.getByRole("treeitem", { name: "README.md" }), { - ctrlKey: true, - }); - await expect( - canvas.getByRole("treeitem", { name: "README.md" }), - ).toHaveAttribute("aria-selected", "true"); + const tree = canvas.getByRole("tree"); + const readme = canvas.getByRole("treeitem", { name: "README.md" }); + await fireEvent.click(readme, { ctrlKey: true }); + await expect(readme).toHaveAttribute("aria-selected", "true"); await expect( canvas.getByRole("treeitem", { name: "Tree.tsx" }), ).toHaveAttribute("aria-selected", "true"); - // A real focus call, since React listens for focusin, which the - // synthetic focus event does not bubble. - canvas.getByRole("treeitem", { name: "README.md" }).focus(); + await expect(canvasElement.ownerDocument.activeElement).toBe(tree); + await expect(tree).toHaveAttribute("aria-activedescendant", readme.id); }, }; -/** Focus is its own state: the outline moves without changing selection. */ export const Focused: Story = { - parameters: { - pseudo: { - focus: ['[aria-label="Tree.css"]'], - focusVisible: ['[aria-label="Tree.css"]'], - }, - }, - render: () => ( - - ), + render: () => singleTree("Focused explorer", "tree", FILES, "explorer"), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - canvas.getByRole("treeitem", { name: "Tree.css" }).focus(); - await expect( - canvas.getByRole("treeitem", { name: "Tree.css" }), - ).toHaveAttribute("aria-selected", "false"); + const tree = canvas.getByRole("tree"); + const styles = canvas.getByRole("treeitem", { name: "Tree.css" }); + tree.focus(); + await waitFor(() => expect(tree).toHaveClass("ui-tree--focused")); + await fireEvent.keyDown(tree, { key: "ArrowDown" }); + await expect(canvasElement.ownerDocument.activeElement).toBe(tree); + await expect(tree).toHaveAttribute("aria-activedescendant", styles.id); + await expect(styles).toHaveAttribute("aria-selected", "false"); }, }; export const Nested: Story = { - render: () => ( - - ), - // Real Tree.css hover, forced by the pseudo-states addon: the hovered tree - // reveals the faint indent guides next to the active guide of the selection. + render: () => + singleTree("Nested explorer", "StickyScroll.tsx", NESTED_FILES, "explorer"), parameters: { pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const deepLeaf = canvas.getByRole("treeitem", { name: "TreeStore.ts" }); + const deepLeaf = within(canvasElement).getByRole("treeitem", { + name: "StickyScroll.tsx", + }); await expect(deepLeaf).toHaveAttribute("aria-level", "5"); - - // Focus makes the selection render active. await userEvent.click(deepLeaf); await expect(deepLeaf).toHaveAttribute("aria-selected", "true"); }, diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx index 63798d3376..e117f9572b 100644 --- a/packages/ui/src/components/Tree/Tree.tsx +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -1,159 +1,138 @@ -import { - type ComponentPropsWithRef, - useEffect, - useLayoutEffect, - useState, -} from "react"; +import { type ComponentPropsWithRef, type Ref, useId, useRef } from "react"; import { cx } from "#cx"; -import { TreeContext } from "./context"; import { StickyScroll } from "./sticky/StickyScroll"; -import { TreeStore } from "./store/TreeStore"; import "./Tree.css"; -import "./Tree.modern.css"; -import "./Tree.stable.css"; -import { flattenVisibleRows, type TreeNode } from "./treeModel"; import { TreeRow } from "./TreeRow"; +import { type SelectionProps, useTreeAdapter } from "./useTreeAdapter"; -/** VS Code's workbench.tree.stickyScrollMaxItemCount default. */ -const DEFAULT_STICKY_COUNT = 7; +import type { TreeNode } from "./treeModel"; -function focusBelongsToTree( - tree: HTMLElement, - target: EventTarget | null, -): boolean { - return target instanceof Element && target.closest(".ui-tree") === tree; -} +const DEFAULT_STICKY_COUNT = 7; +const NO_IDS: readonly string[] = []; -export interface TreeProps extends Omit< +interface TreeBaseProps extends Omit< ComponentPropsWithRef<"div">, "role" | "onSelect" | "children" > { nodes: readonly TreeNode[]; - /** Ids of the expanded branches; every other branch renders collapsed. */ expandedIds?: readonly string[]; onExpandedIdsChange?: (expandedIds: readonly string[]) => void; - /** - * "explorer" collapses the twistie gutter on leaf rows so file icons align - * with branch twisties, like the native Explorer whose folders render - * without icons. Combining it with branch icons misaligns leaf icons. - */ variant?: "default" | "explorer"; - selectedItemId?: string; - /** Escape clears the selection and reports undefined, like the native list. */ - onSelectedItemChange?: (itemId: string | undefined) => void; - /** Ctrl/Cmd click toggles a row and Shift click extends from the anchor. */ - multiSelect?: boolean; - selectedItemIds?: readonly string[]; - onSelectedItemsChange?: (itemIds: readonly string[]) => void; - /** - * Pins the ancestors of the topmost visible row against the nearest - * scrolling ancestor, like VS Code. A number caps how many of the nearest - * ancestors pin at once (default 7). - */ + expandMode?: "singleClick" | "doubleClick"; + multiSelectModifier?: "ctrlCmd" | "alt"; stickyScroll?: boolean | number; } -/** A controlled tree with native VS Code keyboard behavior. */ -export function Tree({ - nodes, - expandedIds, - onExpandedIdsChange, - variant = "default", - selectedItemId, - onSelectedItemChange, - multiSelect = false, - selectedItemIds, - onSelectedItemsChange, - stickyScroll = false, - className, - onBlur, - onFocus, - onKeyDown, - ...props -}: TreeProps): React.JSX.Element { - const selection = multiSelect - ? (selectedItemIds ?? []) - : selectedItemId === undefined - ? [] - : [selectedItemId]; - const [store] = useState(() => new TreeStore(selection)); - const [hasDomFocus, setHasDomFocus] = useState(false); - const rows = flattenVisibleRows(nodes, new Set(expandedIds)); - const toggleBranch = (id: string, expanded: boolean): void => { - const current = expandedIds ?? []; - onExpandedIdsChange?.( - expanded - ? [...current, id] - : current.filter((expandedId) => expandedId !== id), - ); - }; +export type TreeProps = TreeBaseProps & SelectionProps; + +function setForwardedRef(ref: Ref | undefined, value: T | null): void { + if (typeof ref === "function") { + ref(value); + } else if (ref) { + ref.current = value; + } +} + +function focusBelongsToTree( + tree: HTMLElement, + target: EventTarget | null, +): boolean { + return target instanceof Element && target.closest(".ui-tree") === tree; +} - useLayoutEffect(() => { - store.setConfiguration( - selection, - multiSelect - ? onSelectedItemsChange - : ([itemId]) => onSelectedItemChange?.(itemId), - multiSelect, - toggleBranch, - ); - store.setRows(rows); +/** A controlled tree following the current VS Code workbench behavior. */ +export function Tree(props: TreeProps): React.JSX.Element { + const { + nodes: _nodes, + expandedIds = NO_IDS, + onExpandedIdsChange: _onExpandedIdsChange, + variant = "default", + expandMode = "singleClick", + multiSelectModifier = "ctrlCmd", + stickyScroll = false, + multiSelect = false, + selectedItemId: _selectedItemId, + onSelectedItemChange: _onSelectedItemChange, + selectedItemIds: _selectedItemIds, + onSelectedItemsChange: _onSelectedItemsChange, + className, + onBlur, + onFocus, + onKeyDown: _onKeyDown, + ref, + ...divProps + } = props; + const treeRef = useRef(null); + const treeDomId = useId(); + const adapter = useTreeAdapter({ + ...props, + expandedIds, + expandMode, + multiSelectModifier, + treeRef, }); - useEffect(() => () => store.dispose(), [store]); return ( - -
{ - onFocus?.(event); - if ( - !event.defaultPrevented && - focusBelongsToTree(event.currentTarget, event.target) - ) { - setHasDomFocus(true); - store.setDomFocus(true); - } - }} - onBlur={(event) => { - onBlur?.(event); - if ( - !event.defaultPrevented && - !focusBelongsToTree(event.currentTarget, event.relatedTarget) - ) { - setHasDomFocus(false); - store.setDomFocus(false); - } - }} - onKeyDown={(event) => { - onKeyDown?.(event); - if (!event.defaultPrevented) { - store.onKeyDown(event); - } - }} - > - {stickyScroll ? ( - - ) : null} - {rows.map((row) => ( - - ))} -
-
+
{ + treeRef.current = element; + setForwardedRef(ref, element); + }} + role="tree" + tabIndex={0} + aria-activedescendant={ + adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined + } + aria-multiselectable={multiSelect || undefined} + className={cx( + "ui-tree", + variant === "explorer" && "ui-tree--explorer", + adapter.hasDomFocus && "ui-tree--focused", + className, + )} + onFocus={(event) => { + onFocus?.(event); + if ( + !event.defaultPrevented && + focusBelongsToTree(event.currentTarget, event.target) + ) { + adapter.onTreeFocus( + event.target instanceof Element + ? event.target.closest("[data-tree-id]")?.dataset + .treeId + : undefined, + ); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !focusBelongsToTree(event.currentTarget, event.relatedTarget) + ) { + adapter.onTreeBlur(); + } + }} + onKeyDown={(event) => adapter.onKeyDown(event)} + > + {stickyScroll ? ( + + ) : null} + {adapter.model.rows.map((row) => ( + + ))} +
); } diff --git a/packages/ui/src/components/Tree/TreeItem.stories.tsx b/packages/ui/src/components/Tree/TreeItem.stories.tsx deleted file mode 100644 index d1f4860ec2..0000000000 --- a/packages/ui/src/components/Tree/TreeItem.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { PIXEL_ALL_THEMES } from "#storybook"; - -import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; - -import type { Meta, StoryObj } from "@storybook/react-vite"; - -const ITEM_STATES: readonly TreeDemoNode[] = [ - { id: "plain", label: "Plain item", icon: "file" }, - { - id: "selected", - label: "Selected branch", - icon: "folder-opened", - children: [{ id: "child", label: "Child item" }], - }, - { - id: "collapsed", - label: "Collapsed branch", - icon: "folder", - collapsed: true, - children: [{ id: "hidden", label: "Hidden item" }], - }, - { - id: "action", - label: "Item with action", - action: { icon: "trash", label: "Delete item" }, - }, - { id: "disabled", label: "Disabled item", disabled: true }, -]; - -const TreeItemStates = (): React.JSX.Element => ( - -); - -const meta: Meta = { - title: "UI/TreeItem", - component: TreeItemStates, - parameters: { pixel: PIXEL_ALL_THEMES }, -}; -export default meta; -type Story = StoryObj; - -export const States: Story = {}; - -export const Stable: Story = { - globals: { uiStyle: "stable" }, -}; diff --git a/packages/ui/src/components/Tree/TreeRow.tsx b/packages/ui/src/components/Tree/TreeRow.tsx index d717479bcc..dbb39af937 100644 --- a/packages/ui/src/components/Tree/TreeRow.tsx +++ b/packages/ui/src/components/Tree/TreeRow.tsx @@ -1,53 +1,44 @@ -import { - type CSSProperties, - type RefObject, - useLayoutEffect, - useRef, - useSyncExternalStore, -} from "react"; +import { type CSSProperties, type MouseEvent } from "react"; import { cx } from "#cx"; import { Icon } from "../Icon/Icon"; -import { useTreeContext } from "./context"; import { nestedInteractiveTarget } from "./rowDom"; import type { TreeRowModel } from "./treeModel"; +import type { TreeAdapter } from "./useTreeAdapter"; -/** The visual row, shared by real rows and pinned sticky copies. */ -export function TreeRowSurface({ +const NO_GUIDES: readonly string[] = []; + +function TreeRowSurface({ row, - activeGuideIds, - chevronRef, + activeGuideIds = NO_GUIDES, + actionsEnabled = false, }: { row: TreeRowModel; - activeGuideIds: readonly string[]; - chevronRef?: RefObject; + activeGuideIds?: readonly string[]; + actionsEnabled?: boolean; }): React.JSX.Element { const { node, expanded } = row; return (
); } -/** One interactive row of the flat projection. */ -export function TreeRow({ row }: { row: TreeRowModel }): React.JSX.Element { - const store = useTreeContext(); - const elementRef = useRef(null); - const chevronRef = useRef(null); - const { node, expanded } = row; - const { activeGuideIds, focused, selected, tabIndex } = useSyncExternalStore( - store.subscribe, - () => store.getItemSnapshot(node.id), - () => store.getItemSnapshot(node.id), - ); - - useLayoutEffect(() => { - const element = elementRef.current; - return element ? store.registerItem(node.id, element) : undefined; - }, [node.id, store]); +interface TreeRowProps { + readonly row: TreeRowModel; + readonly adapter?: TreeAdapter; + readonly focused?: boolean; + readonly selected?: boolean; + readonly activeGuideIds?: readonly string[]; + readonly actionsEnabled?: boolean; + readonly id?: string; + readonly className?: string; + readonly style?: CSSProperties; + readonly onClick?: ( + event: MouseEvent, + twistie: boolean, + ) => void; +} +export function TreeRow({ + row, + adapter, + focused = adapter?.focusedId === row.node.id, + selected = adapter?.selectedIds.has(row.node.id) ?? false, + activeGuideIds = adapter?.activeGuideIds(row), + actionsEnabled = focused && !row.node.disabled, + id, + className = adapter ? row.node.className : undefined, + style, + onClick, +}: TreeRowProps): React.JSX.Element { + const { node, expanded } = row; return (
adapter?.registerRow(node.id, element)} + data-tree-id={id ? node.id : undefined} role="treeitem" aria-label={row.textValue === "" ? undefined : row.textValue} aria-level={row.level} aria-posinset={row.posInSet} aria-setsize={row.setSize} - aria-selected={selected} + aria-selected={node.disabled ? undefined : selected} aria-disabled={node.disabled ? true : undefined} aria-expanded={expanded} - tabIndex={tabIndex} + tabIndex={-1} className={cx( "ui-tree-item", focused && "ui-tree-item--focused", - node.className, + className, )} - style={{ "--ui-tree-level": row.level } as CSSProperties} + style={{ ...style, "--ui-tree-level": row.level } as CSSProperties} onFocus={(event) => { - if (event.target === event.currentTarget) { - store.onItemFocus(node.id); - } + if (event.target === event.currentTarget) adapter?.onRowFocus(node.id); }} onClick={(event) => { if ( - node.disabled || - nestedInteractiveTarget(event.target, event.currentTarget) !== null - ) { + nestedInteractiveTarget(event.target, event.currentTarget) || + (event.target instanceof Element && + event.target.closest(".ui-tree-item__action")) + ) return; - } - // Twistie clicks toggle without moving selection, like the native tree. - const onTwistie = - expanded !== undefined && - event.target instanceof Node && - chevronRef.current?.contains(event.target) === true; - if (!onTwistie) { - store.requestSelection(node.id, { - toggle: event.ctrlKey || event.metaKey, - range: event.shiftKey, - }); - } - // Modifier clicks only select; the twistie toggles regardless. - if ( + const twistie = expanded !== undefined && - (onTwistie || !(event.ctrlKey || event.metaKey || event.shiftKey)) - ) { - store.toggleBranch(node.id, !expanded); - } + event.target instanceof Element && + event.target.closest(".ui-tree-item__chevron") !== null; + if (onClick) onClick(event, twistie); + else adapter?.onPointer(row, event, twistie, "row"); }} >
); diff --git a/packages/ui/src/components/Tree/context.ts b/packages/ui/src/components/Tree/context.ts deleted file mode 100644 index 380fecae8f..0000000000 --- a/packages/ui/src/components/Tree/context.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, use } from "react"; - -import type { TreeStore } from "./store/TreeStore"; - -export const TreeContext = createContext(undefined); - -export function useTreeContext(): TreeStore { - const context = use(TreeContext); - if (!context) { - throw new Error("Tree components must be rendered inside Tree."); - } - return context; -} diff --git a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx index 76ceac7863..926c81542b 100644 --- a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx +++ b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx @@ -1,47 +1,30 @@ import { - type CSSProperties, type RefObject, + useEffect, useRef, + useState, useSyncExternalStore, } from "react"; -import { useTreeContext } from "../context"; import { scrollableAncestor } from "../rowDom"; import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; -import { TreeRowSurface } from "../TreeRow"; +import { TreeRow } from "../TreeRow"; import { computeStickyState, NO_STICKY, type StickyState } from "./stickyState"; -const NO_GUIDES: readonly string[] = []; +import type { TreeAdapter } from "../useTreeAdapter"; -function sameState(left: StickyState, right: StickyState): boolean { - return ( - left.pushOffset === right.pushOffset && - left.ids.length === right.ids.length && - left.ids.every((id, index) => id === right.ids[index]) - ); -} - -/** How far the tree has scrolled under the pinned widget. */ -function scrolledPx(widget: HTMLElement, tree: HTMLElement): number { - return widget.getBoundingClientRect().top - tree.getBoundingClientRect().top; -} - -/** The scroll position is an external store; subscribe to it as one. */ function useStickyState( rows: readonly TreeRowModel[], maxCount: number, widgetRef: RefObject, ): StickyState { - const cacheRef = useRef(NO_STICKY); + const snapshotRef = useRef(NO_STICKY); const subscribe = (notify: () => void): (() => void) => { const tree = widgetRef.current?.parentElement; const scroller = tree ? scrollableAncestor(tree) : undefined; - if (!scroller) { - return () => undefined; - } + if (!scroller) return () => undefined; scroller.addEventListener("scroll", notify, { passive: true }); - // The viewport cap tracks the scroller's height. const observer = typeof ResizeObserver === "undefined" ? undefined @@ -55,98 +38,146 @@ function useStickyState( const getSnapshot = (): StickyState => { const widget = widgetRef.current; const tree = widget?.parentElement; - if (!widget || !tree) { - return NO_STICKY; - } + if (!widget || !tree) return NO_STICKY; const next = computeStickyState( rows, - scrolledPx(widget, tree), + widget.getBoundingClientRect().top - tree.getBoundingClientRect().top, scrollableAncestor(tree)?.clientHeight ?? 0, maxCount, ); - if (!sameState(cacheRef.current, next)) { - cacheRef.current = next; - } - return cacheRef.current; + const current = snapshotRef.current; + if ( + current.pushOffset !== next.pushOffset || + current.ids.length !== next.ids.length || + current.ids.some((id, index) => id !== next.ids[index]) + ) + snapshotRef.current = next; + return snapshotRef.current; }; return useSyncExternalStore(subscribe, getSnapshot, () => NO_STICKY); } -/** - * The pinned ancestors of the topmost visible row, like VS Code's sticky - * scroll widget. The widget itself pins through position: sticky; the - * scroll position only decides which rows it shows. - */ export function StickyScroll({ - rows, maxCount, + adapter, + treeRef, }: { - rows: readonly TreeRowModel[]; maxCount: number; + adapter: TreeAdapter; + treeRef: React.RefObject; }): React.JSX.Element { - const store = useTreeContext(); + const { rows, rowsById } = adapter.model; const widgetRef = useRef(null); const state = useStickyState(rows, maxCount, widgetRef); + const pinnedRows = state.ids + .map((id) => rowsById.get(id)) + .filter((row) => row !== undefined); + const pinnedHeight = pinnedRows.length * ROW_HEIGHT_PX + state.pushOffset; + const [requestedIndex, setRequestedIndex] = useState(0); + const focusedIndex = Math.max( + 0, + Math.min(requestedIndex, pinnedRows.length - 1), + ); - const revealPinnedRow = (row: TreeRowModel, pinnedIndex: number): void => { + useEffect(() => { + if ( + pinnedRows.length === 0 && + widgetRef.current?.contains(document.activeElement) + ) { + treeRef.current?.focus(); + } + }, [pinnedRows.length, treeRef]); + + const reveal = (row: TreeRowModel, index: number): void => { const widget = widgetRef.current; const tree = widget?.parentElement; - if (widget && tree) { - // Scroll the real row to just below the rows still pinned above it. - const rowPx = rows.indexOf(row) * ROW_HEIGHT_PX; - scrollableAncestor(tree)?.scrollBy( - 0, - rowPx - pinnedIndex * ROW_HEIGHT_PX - scrolledPx(widget, tree), - ); - } - store.focusRow(row.node.id); + if (!widget || !tree) return; + scrollableAncestor(tree)?.scrollBy( + 0, + rows.indexOf(row) * ROW_HEIGHT_PX - + index * ROW_HEIGHT_PX - + (widget.getBoundingClientRect().top - tree.getBoundingClientRect().top), + ); + }; + const revealAndDispatch = ( + row: TreeRowModel, + commands: Parameters[0], + ): void => { + reveal(row, focusedIndex); + adapter.dispatch(commands); }; - - const pinnedRows = state.ids - .map((id) => rows.find((row) => row.node.id === id)) - .filter((row) => row !== undefined); return ( -