From 9068b913b5d7025182078f16415622ae99631d11 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 14 Aug 2026 18:59:04 +0300 Subject: [PATCH] feat(ui): add an accessible VS Code-parity Tree A controlled Tree that follows current VS Code workbench behavior: every visible node renders as a flat `treeitem` row with declared aria-level, posinset, and setsize, while keyboard navigation keeps DOM focus on the container and names the active row with `aria-activedescendant`. Focus and selection stay independent, as they do natively. Arrow keys, Home, and End move the active row; Arrow Right and Left walk into and out of branches; Enter, Space, and the twistie follow VS Code's split between selecting and expanding, under either expand mode. Rows are 22px with the native twistie gutter and indent guides, and `variant="explorer"` aligns leaf icons with branch twisties for icon-less file trees. The model, the input policy, and the interaction transitions are pure modules; `useTreeAdapter` is the only place React state and the DOM meet. Pointer, focus, and key events are delegated from the container, which leaves rows as memoized presentation, so a keystroke re-renders the two rows it touched instead of every row. The flat projection leaves room for windowing later. Closes #1037 --- .storybook/main.ts | 6 +- AGENTS.md | 9 + package.json | 2 + packages/ui/README.md | 99 +++++- packages/ui/package.json | 1 + .../components/SearchInput/SearchInput.tsx | 8 +- packages/ui/src/components/Tree/Tree.css | 177 +++++++++++ .../ui/src/components/Tree/Tree.stories.tsx | 189 ++++++++++++ packages/ui/src/components/Tree/Tree.tsx | 117 +++++++ packages/ui/src/components/Tree/TreeRow.tsx | 81 +++++ packages/ui/src/components/Tree/rowDom.ts | 38 +++ packages/ui/src/components/Tree/treeModel.ts | 94 ++++++ packages/ui/src/components/Tree/treePolicy.ts | 172 +++++++++++ .../ui/src/components/Tree/treeTransition.ts | 289 ++++++++++++++++++ .../ui/src/components/Tree/useTreeAdapter.ts | 189 ++++++++++++ packages/ui/src/index.ts | 2 + packages/ui/src/ref.ts | 16 + packages/ui/src/tokens.css | 50 +++ packages/ui/src/vscode-parity.stories.tsx | 15 +- packages/ui/storybook/Tree.demo.tsx | 37 +++ packages/ui/tsconfig.json | 2 +- pnpm-lock.yaml | 21 ++ pnpm-workspace.yaml | 2 + test/webview/ui/tree.core.test.tsx | 202 ++++++++++++ test/webview/ui/tree.keyboard.test.tsx | 149 +++++++++ test/webview/ui/tree.rows.test.tsx | 167 ++++++++++ test/webview/ui/treeModel.test.tsx | 114 +++++++ test/webview/ui/treePolicy.test.ts | 170 +++++++++++ test/webview/ui/treeTestHelpers.tsx | 127 ++++++++ test/webview/ui/treeTransition.test.ts | 182 +++++++++++ 30 files changed, 2710 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/components/Tree/Tree.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/TreeRow.tsx create mode 100644 packages/ui/src/components/Tree/rowDom.ts create mode 100644 packages/ui/src/components/Tree/treeModel.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 packages/ui/src/ref.ts create mode 100644 packages/ui/storybook/Tree.demo.tsx 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/treeModel.test.tsx create mode 100644 test/webview/ui/treePolicy.test.ts create mode 100644 test/webview/ui/treeTestHelpers.tsx create mode 100644 test/webview/ui/treeTransition.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/AGENTS.md b/AGENTS.md index e40c60cedd..ac0a27720c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,15 @@ Non-negotiables: - Extension panels must call **both** `buildCommandHandlers` and `buildRequestHandlers` (empty `{}` is fine). This gives a compile error when anyone adds an action to the API without a matching handler. +- Every webview and Storybook build runs the React Compiler, so components + and hooks must follow the rules of React: no reading or writing a ref + during render, no mutating props, state, or anything already rendered, + and hooks called unconditionally. A component that breaks them is skipped + silently and loses its memoization. Parameter defaults that read another + prop (`focused = adapter?.focusedId === row.node.id`) are the usual + culprit; put those defaults in the body. `useMemo` and `useCallback` are + rarely needed, and when kept they must list every dependency, or + `react-hooks/preserve-manual-memoization` fails the lint. ## Code Style diff --git a/package.json b/package.json index f169c4d3f1..b22832a428 100644 --- a/package.json +++ b/package.json @@ -813,6 +813,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", @@ -856,6 +857,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..08a2e91c91 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -7,6 +7,11 @@ Its stable separation boundary is the public root exports, no monorepo runtime imports, and component CSS using only semantic `--ui-*` tokens. A future package build can emit those same entry points without API changes. +Consumers compile these components with the React Compiler, so they follow the +rules of React and lean on it for memoization. A component that breaks the +rules is skipped silently rather than reported, which for a list or a tree +costs a re-render per row, so check with the compiler and not only the linter. + ## CSS Import the semantic token mapping and codicon assets once in each real webview @@ -38,12 +43,89 @@ 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` is controlled: `nodes` describe the hierarchy, `expandedIds` controls +branches, and `selectedItemId` controls 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"); +const [expandedIds, setExpandedIds] = useState(["src"]); + +; +``` + +Ids must be unique across the whole tree, and a duplicate throws. A string +`label` is also the accessible name; 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 stay live on plain hover, as in the native list, and are +isolated from row selection and expansion. + +Arrow Up/Down, Home, and End move the active row through visible rows. Arrow Right +expands a branch or enters it; Arrow Left collapses it or moves to its parent. + +`expandMode="singleClick"` is the default: clicking a 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. + +Escape clears selection, then the active focus mark. Once neither remains, +Escape is left to the host. The root `onKeyDown` runs first, so a host +can intercept shortcuts with `preventDefault()`. + +```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] +``` + +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 @@ -79,7 +161,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 +178,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/package.json b/packages/ui/package.json index a2ea866658..2bc6616926 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "imports": { "#cx": "./src/cx.ts", "#codicons": "./src/codicons.ts", + "#ref": "./src/ref.ts", "#storybook": "./src/storybook.ts" }, "scripts": { diff --git a/packages/ui/src/components/SearchInput/SearchInput.tsx b/packages/ui/src/components/SearchInput/SearchInput.tsx index 154ffaefce..4231863f6f 100644 --- a/packages/ui/src/components/SearchInput/SearchInput.tsx +++ b/packages/ui/src/components/SearchInput/SearchInput.tsx @@ -1,6 +1,7 @@ import { type ChangeEvent, type ComponentProps, useRef } from "react"; import { cx } from "#cx"; +import { setForwardedRef } from "#ref"; import "../control.css"; import { Icon } from "../Icon/Icon"; @@ -54,12 +55,7 @@ export function SearchInput({ // Track the node for clear-and-refocus, honoring the consumer ref ref={(node) => { inputRef.current = node; - if (typeof ref === "function") { - return ref(node); - } - if (ref) { - ref.current = node; - } + setForwardedRef(ref, node); }} type="search" value={value} diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css new file mode 100644 index 0000000000..2da8a62d1f --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -0,0 +1,177 @@ +/* + * Row state reads from the ARIA the rows already declare. The `:not()` on hover + * mirrors native's `.monaco-list-row:hover:not(.selected):not(.focused)`: a + * selected or focused row keeps its own paint. + */ + +.ui-tree { + --ui-tree-indent-size: 8px; + --ui-tree-row-height: 22px; + width: 100%; + min-width: 0; + outline: 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: transparent; + cursor: pointer; + user-select: none; +} + +.ui-tree-item:not([aria-selected="true"], .ui-tree-item--focused) + > .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); +} + +/* 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; +} + +.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); +} + +.ui-tree-item__indent { + position: absolute; + inset-block: 0; + inset-inline-start: calc(2 * var(--ui-tree-indent-size)); + display: flex; + pointer-events: none; +} + +/* 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); +} + +.ui-tree-item__chevron > .ui-icon { + width: 10px; + font-size: 10px; +} + +/* 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__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:is([aria-selected="true"], .ui-tree-item--focused) + > .ui-tree-item__row + .ui-tree-item__action, +.ui-tree-item__row:is(:hover, :focus-within) .ui-tree-item__action { + display: inline-flex; +} + +/* Modern UI insets the rows; data-ui-style="stable" keeps them edge to edge. */ +:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { + margin-inline: var(--ui-spacing-40); + border-radius: var(--ui-radius-small); +} + +@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-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.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx new file mode 100644 index 0000000000..acdeeba3ab --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,189 @@ +import { expect, fireEvent, userEvent, waitFor, within } from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo } from "../../../storybook/Tree.demo"; +import { IconButton } from "../IconButton/IconButton"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import type { CodiconName } from "#codicons"; + +import type { TreeProps } from "./Tree"; +import type { TreeNode } from "./treeModel"; + +interface NodeOptions { + readonly label?: string; + readonly icon?: CodiconName; + readonly action?: React.ReactNode; + readonly className?: string; +} +const node = (id: string, options: NodeOptions = {}): TreeNode => ({ + id, + label: id, + ...options, +}); +const branch = ( + id: string, + children: readonly TreeNode[], + options: NodeOptions = {}, +): TreeNode => ({ ...node(id, options), children }); +const closeAction = (name: string): React.ReactNode => ( + +); + +/** Branch rows have no icons, so explorer aligns leaf icons with twisties. */ +const FILES: readonly TreeNode[] = [ + branch( + "source", + [ + branch("components", [ + node("tree", { + label: "Tree.tsx", + icon: "symbol-class", + action: closeAction("Tree.tsx"), + }), + node("styles", { label: "Tree.css", icon: "symbol-color" }), + ]), + node("tests", { icon: "beaker" }), + ], + { label: "src" }, + ), + node("readme", { label: "README.md", icon: "markdown" }), +]; + +const tree = (props: TreeProps): React.JSX.Element => ( + +); +const TreeStates = (): React.JSX.Element => + tree({ + "aria-label": "Explorer", + nodes: FILES, + selectedItemId: "components", + variant: "explorer", + }); +const meta: Meta = { + title: "UI/Tree", + component: TreeStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +const exerciseTree: NonNullable = async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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(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 ROW_STATES: readonly TreeNode[] = [ + 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", + }), + node("action", { + label: "Item with action", + className: "story-row-action", + action: , + }), +]; +export const RowStates: Story = { + parameters: { + pseudo: { hover: [".story-row-action > .ui-tree-item__row"] }, + }, + render: () => + tree({ + "aria-label": "Tree row states", + nodes: ROW_STATES, + selectedItemId: "selected", + expandedIds: ["selected"], + }), +}; +export const RowStatesStable: Story = { + ...RowStates, + globals: { uiStyle: "stable" }, +}; + +export const Focused: Story = { + render: () => + tree({ + "aria-label": "Focused explorer", + nodes: FILES, + selectedItemId: "tree", + variant: "explorer", + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const treeElement = canvas.getByRole("tree"); + const styles = canvas.getByRole("treeitem", { name: "Tree.css" }); + treeElement.focus(); + await waitFor(() => expect(treeElement).toHaveClass("ui-tree--focused")); + await fireEvent.keyDown(treeElement, { key: "ArrowDown" }); + await expect(canvasElement.ownerDocument.activeElement).toBe(treeElement); + await expect(treeElement).toHaveAttribute( + "aria-activedescendant", + styles.id, + ); + await expect(styles).toHaveAttribute("aria-selected", "false"); + }, +}; + +const NESTED_FILES: readonly TreeNode[] = [ + branch("src", [ + branch("components", [ + branch("Tree", [ + node("Tree.tsx", { + icon: "symbol-class", + className: "story-hover", + action: closeAction("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" }), +]; +export const Nested: Story = { + render: () => + tree({ + "aria-label": "Nested explorer", + nodes: NESTED_FILES, + selectedItemId: "StickyScroll.tsx", + variant: "explorer", + }), + parameters: { + pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, + }, + play: async ({ canvasElement }) => { + const deepLeaf = within(canvasElement).getByRole("treeitem", { + name: "StickyScroll.tsx", + }); + await expect(deepLeaf).toHaveAttribute("aria-level", "5"); + 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..6eb1a06e39 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,117 @@ +import { type ComponentPropsWithRef, useId, useRef } from "react"; + +import { cx } from "#cx"; +import { setForwardedRef } from "#ref"; + +import "./Tree.css"; +import { TreeRow } from "./TreeRow"; +import { useTreeAdapter, type SelectionProps } from "./useTreeAdapter"; + +import type { TreeNode } from "./treeModel"; +import type { TreeExpandMode } from "./treePolicy"; + +const NO_IDS: readonly string[] = []; + +/** The tree's own props; everything else lands on the container element. */ +interface TreeOwnProps extends SelectionProps { + readonly nodes: readonly TreeNode[]; + readonly expandedIds?: readonly string[]; + readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void; + /** `explorer` aligns leaf icons with branch twisties, as VS Code does. */ + readonly variant?: "default" | "explorer"; + readonly expandMode?: TreeExpandMode; +} + +type TreeContainerProps = Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" | "children" | keyof TreeOwnProps +>; + +export type TreeProps = TreeOwnProps & TreeContainerProps; + +/** Whether the focus or blur target is inside the tree rather than a portal. */ +function ownsTarget(tree: HTMLElement, target: EventTarget | null): boolean { + return target instanceof Node && tree.contains(target); +} + +/** A controlled tree following the current VS Code workbench behavior. */ +export function Tree({ + nodes, + expandedIds = NO_IDS, + onExpandedIdsChange, + selectedItemId, + onSelectedItemChange, + variant = "default", + expandMode = "singleClick", + className, + onFocus, + onBlur, + onKeyDown, + ref, + ...containerProps +}: TreeProps): React.JSX.Element { + const treeRef = useRef(null); + const treeDomId = useId(); + const adapter = useTreeAdapter({ + nodes, + expandedIds, + onExpandedIdsChange, + selectedItemId, + onSelectedItemChange, + expandMode, + onKeyDown, + treeRef, + }); + + return ( +
{ + treeRef.current = element; + setForwardedRef(ref, element); + }} + role="tree" + tabIndex={0} + aria-activedescendant={ + adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined + } + className={cx( + "ui-tree", + variant === "explorer" && "ui-tree--explorer", + adapter.hasDomFocus && "ui-tree--focused", + className, + )} + onFocus={(event) => { + onFocus?.(event); + if ( + !event.defaultPrevented && + ownsTarget(event.currentTarget, event.target) + ) { + adapter.onFocusIn(event.target); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !ownsTarget(event.currentTarget, event.relatedTarget) + ) { + adapter.onBlurOut(); + } + }} + onClick={adapter.onClick} + onKeyDown={adapter.onKeyDown} + > + {adapter.model.visibleRows.map((row) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeRow.tsx b/packages/ui/src/components/Tree/TreeRow.tsx new file mode 100644 index 0000000000..3c94b0f136 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeRow.tsx @@ -0,0 +1,81 @@ +import { type CSSProperties, memo } from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import type { TreeRowModel } from "./treeModel"; + +interface TreeRowProps { + readonly row: TreeRowModel; + /** Left out by rows rendered outside the tree, which only present. */ + readonly id?: string; + readonly focused?: boolean; + readonly selected?: boolean; + /** One character per ancestor, `1` where the indent guide is active. */ + readonly guideFlags?: string; +} + +/** Pure presentation: props compare by value, so `memo` skips untouched rows. */ +export const TreeRow = memo(function TreeRow({ + row, + id, + focused = false, + selected = false, + guideFlags = "", +}: TreeRowProps): React.JSX.Element { + const { node, expanded } = row; + const level = row.pathIds.length + 1; + return ( +
+
+
+
+ ); +}); diff --git a/packages/ui/src/components/Tree/rowDom.ts b/packages/ui/src/components/Tree/rowDom.ts new file mode 100644 index 0000000000..896c6da564 --- /dev/null +++ b/packages/ui/src/components/Tree/rowDom.ts @@ -0,0 +1,38 @@ +/** The DOM reads the data model cannot answer: what an event actually hit. */ + +/** + * Anything focusable in a row owns its own clicks and keys. `[tabindex]` covers + * the interactive ARIA roles: a role nothing can focus is one nothing can use. + */ +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[contenteditable]:not([contenteditable='false'])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +/** The focusable element the event hit, unless that element is `container`. */ +export function nestedInteractiveTarget( + target: EventTarget | null, + container: HTMLElement, +): Element | null { + if (!(target instanceof Element) || target === container) { + return null; + } + const focusable = target.closest(FOCUSABLE_SELECTOR); + return focusable !== null && + focusable !== container && + container.contains(focusable) + ? focusable + : null; +} + +/** The row element the event hit, if any. */ +export function closestRow(target: EventTarget | null): HTMLElement | null { + return target instanceof Element + ? target.closest("[data-tree-id]") + : null; +} diff --git a/packages/ui/src/components/Tree/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts new file mode 100644 index 0000000000..3f5a22269d --- /dev/null +++ b/packages/ui/src/components/Tree/treeModel.ts @@ -0,0 +1,94 @@ +import type { ReactNode } from "react"; + +import type { CodiconName } from "#codicons"; + +/** A string label doubles as the text value; a rich label must supply one. */ +type TreeNodeLabel = + | { readonly label: string; readonly textValue?: string } + | { readonly label: ReactNode; readonly textValue: string }; + +/** One node of tree data. `children` marks a branch, `[]` one still loading. */ +export type TreeNode = TreeNodeLabel & { + readonly id: string; + readonly icon?: CodiconName; + readonly action?: ReactNode; + readonly className?: string; + readonly children?: readonly TreeNode[]; +}; + +export interface TreeRowModel { + readonly node: TreeNode; + /** Ancestor ids, outermost first; the ARIA level is one past its length. */ + readonly pathIds: readonly string[]; + /** Flat rows have no group element, so each declares its own set. */ + readonly posInSet: number; + readonly setSize: number; + readonly textValue: string; + /** undefined on leaves. */ + readonly expanded: boolean | undefined; +} + +export interface TreeModel { + /** Rows under expanded ancestors only, in render order. */ + readonly visibleRows: readonly TreeRowModel[]; + /** Every row, hidden ones included. */ + readonly rows: readonly TreeRowModel[]; + readonly rowsById: ReadonlyMap; + readonly visibleIds: ReadonlySet; +} + +export function parentId(row: TreeRowModel): string | undefined { + return row.pathIds.at(-1); +} + +/** Ids are unique tree wide, as in VS Code, so a duplicate throws. */ +export function createTreeModel( + nodes: readonly TreeNode[], + expandedIds: ReadonlySet, +): TreeModel { + const visibleRows: TreeRowModel[] = []; + const rows: TreeRowModel[] = []; + const rowsById = new Map(); + + const visit = ( + siblings: readonly TreeNode[], + pathIds: readonly string[], + visible: boolean, + ): void => { + siblings.forEach((node, index) => { + if (rowsById.has(node.id)) { + throw new Error(`Tree node id "${node.id}" must be unique.`); + } + const expanded = node.children ? expandedIds.has(node.id) : undefined; + const row: TreeRowModel = { + node, + pathIds, + posInSet: index + 1, + setSize: siblings.length, + textValue: + node.textValue ?? (typeof node.label === "string" ? node.label : ""), + expanded, + }; + rows.push(row); + rowsById.set(node.id, row); + if (visible) { + visibleRows.push(row); + } + if (node.children) { + visit( + node.children, + [...pathIds, node.id], + visible && expanded === true, + ); + } + }); + }; + + visit(nodes, [], true); + return { + visibleRows, + rows, + rowsById, + visibleIds: new Set(visibleRows.map((row) => row.node.id)), + }; +} diff --git a/packages/ui/src/components/Tree/treePolicy.ts b/packages/ui/src/components/Tree/treePolicy.ts new file mode 100644 index 0000000000..1e96cb1779 --- /dev/null +++ b/packages/ui/src/components/Tree/treePolicy.ts @@ -0,0 +1,172 @@ +/** + * The VS Code key and pointer bindings, as the commands a gesture means for a + * row. "Policy" because it decides intent only: `treeTransition.ts` applies it. + */ + +import { parentId, type TreeRowModel } from "./treeModel"; + +/** Mirrors `workbench.tree.expandMode`, values included. */ +export type TreeExpandMode = "singleClick" | "doubleClick"; + +interface TreeCommandBehavior { + readonly expandMode: TreeExpandMode; +} + +type RowCommand = { + readonly type: Type; + readonly id: string; +} & Options; + +export type TreeCommand = + | RowCommand<"focus"> + | RowCommand<"move", { readonly offset: -1 | 1 }> + | RowCommand<"select"> + | RowCommand<"toggle", { readonly recursive: boolean }> + | { + readonly type: "dismiss"; + readonly clearSelection: boolean; + readonly clearFocus: boolean; + }; + +export interface PointerCommandInput extends TreeCommandBehavior { + readonly row: TreeRowModel; + readonly onTwistie: boolean; + /** `MouseEvent.detail`, so 2 on a double click. */ + readonly detail: number; + readonly altKey: boolean; +} + +export interface KeyboardCommandInput extends TreeCommandBehavior { + readonly key: string; + readonly row: TreeRowModel; + readonly visibleRows: readonly TreeRowModel[]; + /** Whether a control inside the row, not the row, has focus. */ + readonly fromAction: boolean; + readonly selectedCount: number; + readonly hasFocusedRow: boolean; +} + +interface KeyboardOutcome { + readonly commands: readonly TreeCommand[]; + readonly preventDefault: boolean; + /** Set when a row action navigates, so the row takes focus back. */ + readonly focusRowElementId: string | undefined; +} + +const NO_COMMANDS: readonly TreeCommand[] = []; + +/** The keys the tree claims even while a row action has focus. */ +const NAVIGATION_KEYS: ReadonlySet = new Set([ + "ArrowDown", + "ArrowUp", + "ArrowLeft", + "ArrowRight", + "Home", + "End", +]); + +const focusCommand = (id: string): TreeCommand => ({ type: "focus", id }); +const selectCommand = (id: string): TreeCommand => ({ type: "select", id }); +const toggleCommand = (id: string, recursive = false): TreeCommand => ({ + type: "toggle", + id, + recursive, +}); + +/** The commands a click on `row` means, twistie clicks included. */ +export function pointerCommands( + input: PointerCommandInput, +): readonly TreeCommand[] { + const { row, expandMode, detail } = input; + const id = row.node.id; + const toggle = toggleCommand(id, input.altKey); + if (input.onTwistie) { + return [focusCommand(id), toggle]; + } + const togglesBody = + row.expanded !== undefined && + (expandMode === "singleClick" ? detail <= 1 : detail === 2); + return togglesBody + ? [focusCommand(id), selectCommand(id), toggle] + : [focusCommand(id), selectCommand(id)]; +} + +/** The commands a key press means, plus who keeps the event afterwards. */ +export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome { + const { key, row, visibleRows } = input; + const id = row.node.id; + // A key pressed inside a row action belongs to it, unless it navigates. + if (input.fromAction && !NAVIGATION_KEYS.has(key)) { + return { + commands: NO_COMMANDS, + preventDefault: false, + focusRowElementId: undefined, + }; + } + const outcome = ( + commands: readonly TreeCommand[], + preventDefault = true, + ): KeyboardOutcome => ({ + commands, + preventDefault, + focusRowElementId: input.fromAction ? id : undefined, + }); + + switch (key) { + case "ArrowDown": + case "ArrowUp": + return outcome([ + { type: "move", id, offset: key === "ArrowDown" ? 1 : -1 }, + ]); + case "Home": + case "End": { + const target = key === "Home" ? visibleRows[0] : visibleRows.at(-1); + return outcome(target ? [focusCommand(target.node.id)] : NO_COMMANDS); + } + case "ArrowRight": { + if (row.expanded === false) { + return outcome([toggleCommand(id)]); + } + const child = row.expanded + ? visibleRows[visibleRows.indexOf(row) + 1] + : undefined; + return outcome( + child?.pathIds.includes(id) + ? [focusCommand(child.node.id)] + : NO_COMMANDS, + ); + } + case "ArrowLeft": { + if (row.expanded === true) { + return outcome([toggleCommand(id)]); + } + const parent = parentId(row); + return outcome(parent ? [focusCommand(parent)] : NO_COMMANDS); + } + case "Enter": { + const alsoToggles = + row.expanded !== undefined && input.expandMode === "singleClick"; + return outcome( + alsoToggles + ? [selectCommand(id), toggleCommand(id)] + : [selectCommand(id)], + ); + } + case " ": + // A leaf has nothing to toggle, so Space selects it instead. + return outcome([ + row.expanded === undefined ? selectCommand(id) : toggleCommand(id), + ]); + case "Escape": { + const clearSelection = input.selectedCount > 0; + const clearFocus = input.selectedCount <= 1 && input.hasFocusedRow; + return outcome( + [{ type: "dismiss", clearSelection, clearFocus }], + clearSelection || input.hasFocusedRow, + ); + } + default: + // Anything the tree does not handle stays with the host, Tab included. + return outcome(NO_COMMANDS, false); + } +} diff --git a/packages/ui/src/components/Tree/treeTransition.ts b/packages/ui/src/components/Tree/treeTransition.ts new file mode 100644 index 0000000000..e728ee48bf --- /dev/null +++ b/packages/ui/src/components/Tree/treeTransition.ts @@ -0,0 +1,289 @@ +/** + * The interaction state props cannot hold: focus, the tab stop, and container + * focus. `deriveTreeInteractionView` reads it against the current model and + * resolves what the rows render; `transitionTree` folds commands into it. + */ + +import { parentId, type TreeModel, type TreeRowModel } from "./treeModel"; + +import type { TreeCommand } from "./treePolicy"; + +/** The focused row, with its ancestors to fall back on if it disappears. */ +interface FocusTarget { + readonly id: string; + readonly pathIds: readonly string[]; +} + +export interface TreeInteractionState { + readonly focusTarget?: FocusTarget; + /** The row Tab returns to, which outlives a row leaving the viewport. */ + readonly tabTargetId?: string; + /** The selection whose tab stop the user already moved away from. */ + readonly dismissedSelectionKey?: string; + readonly hasDomFocus: boolean; +} + +/** What the rows render from, derived fresh on every render. */ +interface TreeInteractionView { + readonly state: TreeInteractionState; + readonly controlledKey: string; + readonly selectedIds: ReadonlySet; + readonly focusedId: string | undefined; + readonly guideOwnerIds: ReadonlySet; + readonly tabStopId: string | undefined; +} + +interface TransitionInput { + readonly model: TreeModel; + readonly controlledIds: readonly string[]; + readonly expandedIds: readonly string[]; +} + +interface TreeTransition { + readonly state: TreeInteractionState; + /** Set only when the commands changed it, since selection is controlled. */ + readonly selection?: readonly string[]; + readonly expandedIds?: readonly string[]; + readonly focusTree: boolean; +} + +/** Selections compare by value: the ids arrive fresh in props each render. */ +const selectionKey = (ids: readonly string[]): string => + JSON.stringify([...new Set(ids)].sort()); +const NO_SELECTION_KEY = selectionKey([]); + +const focusTarget = (row: TreeRowModel): FocusTarget => ({ + id: row.node.id, + pathIds: row.pathIds, +}); + +export function initialTreeInteractionState(): TreeInteractionState { + return { hasDomFocus: false }; +} + +/** + * Points the state at rows the model still has, returning it unchanged when it + * already does; callers compare by identity to spot data moving under them. + */ +function reconcile( + state: TreeInteractionState, + model: TreeModel, +): TreeInteractionState { + const { rowsById, visibleIds } = model; + if (state.focusTarget && !rowsById.has(state.focusTarget.id)) { + const fallbackId = state.focusTarget.pathIds.findLast((id) => + visibleIds.has(id), + ); + const fallback = fallbackId ? rowsById.get(fallbackId) : undefined; + return { + ...state, + focusTarget: fallback ? focusTarget(fallback) : undefined, + tabTargetId: fallbackId, + }; + } + if (state.tabTargetId && !rowsById.has(state.tabTargetId)) { + return { ...state, tabTargetId: undefined }; + } + return state; +} + +/** The guides VS Code draws solid: the paths down to selection and focus. */ +function activeGuideOwners( + visibleRows: readonly TreeRowModel[], + selectedIds: ReadonlySet, + focusedId: string | undefined, +): ReadonlySet { + const owners = new Set(); + for (const row of visibleRows) { + if (!selectedIds.has(row.node.id) && focusedId !== row.node.id) { + continue; + } + const ownerId = row.expanded ? row.node.id : parentId(row); + if (ownerId) { + owners.add(ownerId); + } + } + return owners; +} + +export function deriveTreeInteractionView( + state: TreeInteractionState, + model: TreeModel, + controlledIds: readonly string[], +): TreeInteractionView { + const { visibleRows, rowsById, visibleIds } = model; + const nextState = reconcile(state, model); + const { focusTarget: focus, tabTargetId } = nextState; + const focusedId = focus && visibleIds.has(focus.id) ? focus.id : undefined; + // Focus kept out of view holds the tab stop, so Tab cannot move the user. + const hiddenFocus = + focus !== undefined && !focusedId && rowsById.has(focus.id); + const selectedIds = new Set(controlledIds); + const controlledKey = selectionKey(controlledIds); + const claimedSelection = + nextState.dismissedSelectionKey === controlledKey + ? undefined + : visibleRows.find((row) => selectedIds.has(row.node.id))?.node.id; + const tabTarget = + tabTargetId && visibleIds.has(tabTargetId) ? tabTargetId : undefined; + + return { + state: nextState, + controlledKey, + selectedIds, + focusedId, + guideOwnerIds: activeGuideOwners( + visibleRows, + selectedIds, + nextState.hasDomFocus ? focusedId : undefined, + ), + tabStopId: + claimedSelection ?? + tabTarget ?? + (hiddenFocus ? undefined : visibleRows[0]?.node.id), + }; +} + +/** Adopts `row` as the focused row on first entry, never after. */ +export function treeFocusChanged( + state: TreeInteractionState, + focused: boolean, + row?: TreeRowModel, +): TreeInteractionState { + if (!focused) { + return state.hasDomFocus ? { ...state, hasDomFocus: false } : state; + } + return { + ...state, + focusTarget: state.focusTarget ?? (row ? focusTarget(row) : undefined), + hasDomFocus: true, + }; +} + +/** Focus moved to `row`, which also becomes the tab stop from now on. */ +export function rowFocused( + state: TreeInteractionState, + row: TreeRowModel, + controlledKey: string, +): TreeInteractionState { + return { + ...state, + focusTarget: focusTarget(row), + tabTargetId: row.node.id, + dismissedSelectionKey: controlledKey, + }; +} + +function togglingBranches( + row: TreeRowModel, + model: TreeModel, + recursive: boolean, +): readonly TreeRowModel[] { + if (!recursive) { + return [row]; + } + return model.rows.filter( + (candidate) => + candidate.node.children !== undefined && + (candidate === row || candidate.pathIds.includes(row.node.id)), + ); +} + +/** + * Expansion is data, so the ids come back in tree order. Ids the data does not + * have are kept, so a branch that loads later reopens. + */ +function toggleExpansion( + row: TreeRowModel, + model: TreeModel, + expandedIds: readonly string[], + recursive: boolean, +): readonly string[] { + const next = new Set(expandedIds); + for (const branch of togglingBranches(row, model, recursive)) { + if (row.expanded) { + next.delete(branch.node.id); + } else { + next.add(branch.node.id); + } + } + return [ + ...model.rows + .filter((candidate) => next.has(candidate.node.id)) + .map((candidate) => candidate.node.id), + ...[...next].filter((id) => !model.rowsById.has(id)), + ]; +} + +export function transitionTree( + state: TreeInteractionState, + commands: readonly TreeCommand[], + input: TransitionInput, +): TreeTransition { + const { model } = input; + const view = deriveTreeInteractionView(state, model, input.controlledIds); + let nextState = view.state; + let currentKey = view.controlledKey; + let selection: readonly string[] | undefined; + let expandedIds: readonly string[] | undefined; + let focusTree = false; + + const select = (ids: ReadonlySet): void => { + selection = model.rows + .filter((row) => ids.has(row.node.id)) + .map((row) => row.node.id); + currentKey = selectionKey(selection); + nextState = { ...nextState, dismissedSelectionKey: currentKey }; + }; + const focus = (row: TreeRowModel | undefined): void => { + if (!row || !model.visibleIds.has(row.node.id)) { + return; + } + nextState = rowFocused(nextState, row, currentKey); + focusTree = true; + }; + + for (const command of commands) { + const row = "id" in command ? model.rowsById.get(command.id) : undefined; + switch (command.type) { + case "focus": + focus(row); + break; + case "select": + if (row) { + select(new Set([row.node.id])); + } + break; + case "move": { + if (!row) { + break; + } + const rows = model.visibleRows; + const index = rows.indexOf(row) + command.offset; + focus(rows[Math.min(Math.max(index, 0), rows.length - 1)]); + break; + } + case "toggle": + if (row?.expanded !== undefined) { + expandedIds = toggleExpansion( + row, + model, + expandedIds ?? input.expandedIds, + command.recursive, + ); + } + break; + case "dismiss": + if (command.clearSelection) { + select(new Set()); + } + if (command.clearFocus) { + nextState = { ...nextState, focusTarget: undefined }; + focusTree = true; + } + currentKey = NO_SELECTION_KEY; + break; + } + } + return { state: nextState, selection, expandedIds, focusTree }; +} diff --git a/packages/ui/src/components/Tree/useTreeAdapter.ts b/packages/ui/src/components/Tree/useTreeAdapter.ts new file mode 100644 index 0000000000..e405645795 --- /dev/null +++ b/packages/ui/src/components/Tree/useTreeAdapter.ts @@ -0,0 +1,189 @@ +import { type KeyboardEvent, type MouseEvent, useMemo, useState } from "react"; + +import { closestRow, nestedInteractiveTarget } from "./rowDom"; +import { createTreeModel, type TreeNode, type TreeRowModel } from "./treeModel"; +import { + keyboardCommands, + pointerCommands, + type TreeCommand, + type TreeExpandMode, +} from "./treePolicy"; +import { + deriveTreeInteractionView, + initialTreeInteractionState, + rowFocused, + transitionTree, + treeFocusChanged, + type TreeInteractionState, +} from "./treeTransition"; + +const NO_IDS: readonly string[] = []; +const NO_GUIDES = ""; + +export interface SelectionProps { + readonly selectedItemId?: string; + readonly onSelectedItemChange?: (itemId: string | undefined) => void; +} + +interface AdapterOptions extends SelectionProps { + readonly nodes: readonly TreeNode[]; + readonly expandedIds: readonly string[]; + readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void; + readonly expandMode: TreeExpandMode; + readonly onKeyDown?: (event: KeyboardEvent) => void; + readonly treeRef: React.RefObject; +} + +function rowElement(tree: HTMLElement | null, id: string): HTMLElement | null { + return ( + tree?.querySelector(`[data-tree-id="${CSS.escape(id)}"]`) ?? + null + ); +} + +function hitTwistie(row: TreeRowModel, target: EventTarget): boolean { + return ( + row.expanded !== undefined && + target instanceof Element && + target.closest(".ui-tree-item__chevron") !== null + ); +} + +/** + * Where the pure modules meet React and the DOM. Events arrive delegated from + * the container, which leaves rows as memoized presentation. + */ +export function useTreeAdapter(options: AdapterOptions) { + const { nodes, expandedIds, expandMode, treeRef } = options; + // Explicit: memoized rows compare against these row objects, and a consumer + // of the published package may not run the React Compiler. + const model = useMemo( + () => createTreeModel(nodes, new Set(expandedIds)), + [nodes, expandedIds], + ); + const { visibleRows, rowsById } = model; + const selectedItemIds = + options.selectedItemId === undefined ? NO_IDS : [options.selectedItemId]; + const [state, setState] = useState( + initialTreeInteractionState, + ); + const view = deriveTreeInteractionView(state, model, selectedItemIds); + // Identity, not value: the view returns this same state unless the data + // moved, and then the reconciled one renders instead. + if (view.state !== state) { + setState(view.state); + } + + const dispatch = (commands: readonly TreeCommand[]): void => { + const result = transitionTree(state, commands, { + model, + controlledIds: selectedItemIds, + expandedIds, + }); + setState(result.state); + if (result.selection) { + options.onSelectedItemChange?.(result.selection[0]); + } + if (result.expandedIds) { + options.onExpandedIdsChange?.(result.expandedIds); + } + if (result.focusTree) { + treeRef.current?.focus(); + } + }; + + const rowFor = (target: EventTarget | null): TreeRowModel | undefined => { + const id = closestRow(target)?.dataset.treeId; + return id ? rowsById.get(id) : undefined; + }; + const onFocusIn = (target: EventTarget | null): void => { + const row = rowFor(target); + // A row focused in its own right becomes the focus target; entering the + // container only adopts one. + if (row && target === closestRow(target)) { + setState((current) => rowFocused(current, row, view.controlledKey)); + } + const entered = row ?? rowsById.get(view.tabStopId ?? ""); + setState((current) => treeFocusChanged(current, true, entered)); + }; + const onClick = (event: MouseEvent): void => { + const element = closestRow(event.target); + const row = rowFor(event.target); + if (!row || !element) { + return; + } + // Focusable content and the action bar handle their own clicks. + if ( + nestedInteractiveTarget(event.target, element) || + (event.target instanceof Element && + event.target.closest(".ui-tree-item__action")) + ) { + return; + } + dispatch( + pointerCommands({ + expandMode, + row, + onTwistie: hitTwistie(row, event.target), + detail: event.detail, + altKey: event.altKey, + }), + ); + }; + const onKeyDown = (event: KeyboardEvent): void => { + options.onKeyDown?.(event); + if (event.defaultPrevented) { + return; + } + const row = + rowFor(event.target) ?? + (view.focusedId ? rowsById.get(view.focusedId) : undefined) ?? + (view.tabStopId ? rowsById.get(view.tabStopId) : undefined) ?? + visibleRows[0]; + if (!row) { + return; + } + const interactive = nestedInteractiveTarget( + event.target, + event.currentTarget, + ); + const result = keyboardCommands({ + expandMode, + key: event.key, + row, + visibleRows, + fromAction: + interactive instanceof HTMLElement && + interactive.dataset.treeId === undefined, + selectedCount: view.selectedIds.size, + hasFocusedRow: view.focusedId !== undefined, + }); + if (result.focusRowElementId) { + rowElement(treeRef.current, result.focusRowElementId)?.focus(); + } + dispatch(result.commands); + if (result.preventDefault) { + event.preventDefault(); + } + }; + + return { + model, + focusedId: view.focusedId, + tabStopId: view.tabStopId, + hasDomFocus: view.state.hasDomFocus, + selectedIds: view.selectedIds, + /** One character per ancestor, `1` where its guide is active. */ + guideFlags: (row: TreeRowModel): string => + view.guideOwnerIds.size === 0 + ? NO_GUIDES + : row.pathIds + .map((id) => (view.guideOwnerIds.has(id) ? "1" : "0")) + .join(""), + dispatch, + onFocusIn, + onBlurOut: () => setState((current) => treeFocusChanged(current, false)), + onClick, + onKeyDown, + }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0a..4ac37b0213 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 type { TreeNode } from "./components/Tree/treeModel"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/ref.ts b/packages/ui/src/ref.ts new file mode 100644 index 0000000000..1ceeaca75b --- /dev/null +++ b/packages/ui/src/ref.ts @@ -0,0 +1,16 @@ +import type { Ref } from "react"; + +/** + * Hands a node to a consumer's `ref` prop, whichever form it takes, so a + * component can keep its own ref to a node it also forwards. + */ +export function setForwardedRef( + ref: Ref | undefined, + value: T | null, +): void { + if (typeof ref === "function") { + ref(value); + } else if (ref) { + ref.current = value; + } +} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index e4345fa09f..ffb5b035a1 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -147,11 +147,61 @@ --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) + ); + /* No list.selectionOutline or list.hoverOutline color exists; native feeds + both from activeContrastBorder. */ + --ui-list-selection-outline: var(--vscode-contrastActiveBorder, transparent); + --ui-list-inactive-focus-outline: var( + --vscode-list-inactiveFocusOutline, + transparent + ); + --ui-list-hover-outline: var(--vscode-contrastActiveBorder, transparent); + --ui-list-focus-and-selection-outline: var( + --vscode-list-focusAndSelectionOutline, + var(--vscode-contrastActiveBorder, 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) + ); + /* Menus */ --ui-menu-background: var(--vscode-menu-background); --ui-menu-foreground: var(--vscode-menu-foreground); diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index d37c2067f4..a384d08849 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -9,6 +9,7 @@ import { VscodeToolbarButton, } from "@vscode-elements/react-elements"; import { useState } from "react"; +import { expect, waitFor } from "storybook/test"; import { Button } from "./components/Button/Button"; import { @@ -184,11 +185,13 @@ const MenuParity = (): React.JSX.Element => ( style={{ display: "grid", gridTemplateColumns: "220px 220px", - gap: "16px", + gap: "8px 16px", alignItems: "start", fontSize: "13px", }} > + Ours + VS Code Elements @@ -230,5 +233,15 @@ export const Menu: Story = { render: () => , play: async ({ canvasElement }) => { await openMenu(canvasElement, "Menu"); + const reference = canvasElement.querySelector("vscode-context-menu"); + await expect(reference).not.toBeNull(); + // Opening our portalled menu clicks outside the reference menu. Reopen + // it after that click so Pixel always captures both implementations. + reference?.setAttribute("show", ""); + await waitFor(() => + expect( + reference?.shadowRoot?.querySelector(".context-menu"), + ).not.toBeNull(), + ); }, }; diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx new file mode 100644 index 0000000000..e111a279e2 --- /dev/null +++ b/packages/ui/storybook/Tree.demo.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; + +import { Tree, type TreeProps } from "../src/components/Tree/Tree"; + +import type { TreeNode } from "../src/components/Tree/treeModel"; + +/** Every branch id, so a demo tree starts fully open unless told otherwise. */ +function branchIds(nodes: readonly TreeNode[]): readonly string[] { + return nodes.flatMap((node) => + node.children ? [node.id, ...branchIds(node.children)] : [], + ); +} + +/** Holds the selection and expansion state a controlled `Tree` expects. */ +export function TreeDemo({ + selectedItemId, + expandedIds, + ...treeProps +}: Omit< + TreeProps, + "onSelectedItemChange" | "onExpandedIdsChange" +>): React.JSX.Element { + const [selected, setSelected] = useState(selectedItemId); + const [expanded, setExpanded] = useState( + () => expandedIds ?? branchIds(treeProps.nodes), + ); + + return ( + + ); +} 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..4e23127f43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ catalogs: '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4 + '@testing-library/user-event': + specifier: ^14.6.3 + version: 14.6.3 '@types/react': specifier: ^19.2.18 version: 19.2.18 @@ -58,6 +61,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 @@ -170,6 +176,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8) + '@testing-library/user-event': + specifier: 'catalog:' + version: 14.6.3(@testing-library/dom@10.4.1) '@tsconfig/node22': specifier: ^22.0.5 version: 22.0.5 @@ -299,6 +308,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 +4990,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 +10571,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..7e9abca651 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ catalog: "@storybook/addon-docs": ^10.5.7 "@storybook/react-vite": ^10.5.7 "@tanstack/react-query": ^5.101.4 + "@testing-library/user-event": ^14.6.3 "@types/react": ^19.2.18 "@types/react-dom": ^19.2.4 "@types/vscode-webview": ^1.57.5 @@ -20,6 +21,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.core.test.tsx b/test/webview/ui/tree.core.test.tsx new file mode 100644 index 0000000000..4d371d8964 --- /dev/null +++ b/test/webview/ui/tree.core.test.tsx @@ -0,0 +1,202 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; + +import { Tree, type TreeNode } from "@repo/ui"; + +import { + BASIC_NODES, + activeGuides, + activeRow, + clickRow, + press, + renderTree, + row, + rowNames, + selectedRows, + tree, +} from "./treeTestHelpers"; + +/** The ARIA a flat row declares for itself, there being no groups. */ +const semantics = (name: string): Record => { + const item = row(name); + return { + level: item.getAttribute("aria-level"), + posInSet: item.getAttribute("aria-posinset"), + setSize: item.getAttribute("aria-setsize"), + expanded: item.getAttribute("aria-expanded"), + tabIndex: item.getAttribute("tabindex"), + }; +}; + +describe("Tree", () => { + it("forwards container props and declares flat row semantics", () => { + const ref = createRef(); + renderTree({ + "aria-label": "Explorer", + variant: "explorer", + className: "custom-tree", + style: { width: "240px" }, + ref, + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + const container = screen.getByRole("tree", { name: "Explorer" }); + expect(container).toHaveClass( + "ui-tree", + "ui-tree--explorer", + "custom-tree", + ); + expect(container).toHaveStyle({ width: "240px" }); + expect(container).toHaveAttribute("tabindex", "0"); + expect(ref.current).toBe(container); + expect(rowNames()).toEqual(["Parent", "Child", "Sibling", "Last"]); + expect(semantics("Parent")).toEqual({ + level: "1", + posInSet: "1", + setSize: "2", + expanded: "true", + tabIndex: "-1", + }); + expect(semantics("Sibling")).toEqual({ + level: "2", + posInSet: "2", + setSize: "2", + expanded: null, + tabIndex: "-1", + }); + expect(semantics("Last")).toEqual({ + level: "1", + posInSet: "2", + setSize: "2", + expanded: null, + tabIndex: "-1", + }); + expect(screen.queryByRole("group")).toBeNull(); + }); + + it("keeps DOM focus on the container while the active row moves", () => { + renderTree({ + "aria-label": "Files", + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + act(() => tree().focus()); + expect(activeRow()).toBe("Parent"); + press("ArrowDown"); + expect(activeRow()).toBe("Child"); + expect(document.activeElement).toBe(tree()); + }); + + it("keeps focus through a collapse, a reveal, and a removal", () => { + const withChild: readonly TreeNode[] = [ + { id: "top", label: "Top" }, + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + ]; + const view = renderTree({ + "aria-label": "Reveal", + nodes: withChild, + expandedIds: ["parent"], + }); + clickRow("Child"); + expect(activeRow()).toBe("Child"); + view.update({ expandedIds: [] }); + expect(activeRow()).toBeUndefined(); + view.update({ expandedIds: ["parent"] }); + expect(activeRow()).toBe("Child"); + view.update({ + nodes: [withChild[0], { id: "parent", label: "Parent", children: [] }], + }); + expect(activeRow()).toBe("Parent"); + }); + + it("draws indent guides for the selected row, and the focused one in focus", () => { + const nodes: readonly TreeNode[] = ["Alpha", "Beta"].map((branch) => ({ + id: branch, + label: branch, + children: [{ id: `${branch} leaf`, label: `${branch} leaf` }], + })); + const view = renderTree({ + "aria-label": "Guides", + nodes, + expandedIds: ["Alpha", "Beta"], + }); + expect(activeGuides("Alpha leaf")).toEqual([false]); + clickRow("Beta leaf"); + expect(activeGuides("Beta leaf")).toEqual([true]); + view.update({ selectedItemId: "Alpha leaf" }); + expect(activeGuides("Alpha leaf")).toEqual([true]); + fireEvent.blur(row("Beta leaf"), { relatedTarget: document.body }); + expect(activeGuides("Beta leaf")).toEqual([false]); + expect(activeGuides("Alpha leaf")).toEqual([true]); + }); + + it("activates only the guide of the branch a row belongs to", () => { + renderTree({ + "aria-label": "Nested guides", + nodes: [ + { + id: "root", + label: "Root", + children: [ + { + id: "branch", + label: "Branch", + children: [{ id: "leaf", label: "Leaf" }], + }, + ], + }, + ], + expandedIds: ["root", "branch"], + }); + clickRow("Branch"); + expect(activeGuides("Leaf")).toEqual([false, true]); + }); + + it("follows controlled selection and keeps the focus mark while blurred", () => { + const view = renderTree({ + "aria-label": "Selection", + nodes: BASIC_NODES, + expandedIds: ["parent"], + selectedItemId: "child", + }); + expect(selectedRows()).toEqual(["Child"]); + view.update({ selectedItemId: "last" }); + expect(selectedRows()).toEqual(["Last"]); + act(() => row("Child").focus()); + expect(row("Child")).toHaveClass("ui-tree-item--focused"); + fireEvent.blur(row("Child"), { relatedTarget: document.body }); + expect(row("Child")).toHaveClass("ui-tree-item--focused"); + expect(tree()).not.toHaveClass("ui-tree--focused"); + }); + + it("scopes the focused styling to the tree the user is in", () => { + render( + <> + + + , + ); + const first = screen.getByRole("tree", { name: "First" }); + const second = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(row("First item")); + expect(first).toHaveClass("ui-tree--focused"); + expect(second).not.toHaveClass("ui-tree--focused"); + fireEvent.blur(row("First item"), { relatedTarget: row("Second item") }); + fireEvent.focus(row("Second item")); + expect(first).not.toHaveClass("ui-tree--focused"); + expect(second).toHaveClass("ui-tree--focused"); + }); +}); diff --git a/test/webview/ui/tree.keyboard.test.tsx b/test/webview/ui/tree.keyboard.test.tsx new file mode 100644 index 0000000000..0d39ada9a4 --- /dev/null +++ b/test/webview/ui/tree.keyboard.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + BASIC_NODES, + activeRow, + clickRow, + expandedRows, + press, + renderStatefulTree, + renderTree, + row, + rowNames, + selectedRows, + tree, +} from "./treeTestHelpers"; + +import type { TreeNode, TreeProps } from "@repo/ui"; + +/** Two branches and a leaf whose rich label holds a live control. */ +const NAV_NODES: readonly TreeNode[] = [ + { + id: "alpha", + label: "Alpha", + children: [ + { id: "apricot", label: "Apricot" }, + { id: "amber", label: "Amber" }, + ], + }, + { id: "beta", label: "Beta", children: [{ id: "blue", label: "Blue" }] }, + { + id: "bravo", + label: ( + <> + Bravo + + + ), + textValue: "Bravo", + }, +]; + +const navTree = (props: Partial = {}) => + renderStatefulTree({ + "aria-label": "Navigation", + nodes: NAV_NODES, + expandedIds: ["alpha"], + ...props, + }); + +describe("Tree keyboard navigation", () => { + it("moves the active row with arrows, Home, and End", () => { + navTree(); + for (const [key, active] of [ + ["ArrowDown", "Apricot"], + ["ArrowDown", "Amber"], + ["End", "Bravo"], + ["Home", "Alpha"], + ["ArrowUp", "Alpha"], + ] as const) { + press(key); + expect(activeRow()).toBe(active); + } + }); + + it("expands, enters, leaves, and collapses a branch", () => { + navTree(); + press("ArrowRight", "Beta"); + expect(rowNames()).toContain("Blue"); + press("ArrowRight", "Beta"); + expect(activeRow()).toBe("Blue"); + press("ArrowLeft"); + expect(activeRow()).toBe("Beta"); + press("ArrowLeft"); + expect(rowNames()).not.toContain("Blue"); + }); + + it("selects with Enter and toggles with Space", () => { + navTree({ expandedIds: [] }); + press("Enter", "Beta"); + expect(selectedRows()).toEqual(["Beta"]); + expect(expandedRows()).toEqual(["Beta"]); + press(" ", "Alpha"); + expect(expandedRows()).toEqual(["Alpha", "Beta"]); + expect(selectedRows()).toEqual(["Beta"]); + // A leaf has nothing to toggle, so Space selects it. + press(" ", "Bravo"); + expect(selectedRows()).toEqual(["Bravo"]); + }); + + it("only selects on Enter under doubleClick", () => { + navTree({ expandedIds: [], expandMode: "doubleClick" }); + press("Enter", "Beta"); + expect(selectedRows()).toEqual(["Beta"]); + expect(expandedRows()).toEqual([]); + }); + + it("clears selection, then the focus mark, before yielding Escape", () => { + renderStatefulTree({ + "aria-label": "Escape", + nodes: BASIC_NODES, + expandedIds: ["parent"], + selectedItemId: "child", + }); + clickRow("Child"); + expect(press("Escape")).toBe(false); + expect(selectedRows()).toEqual([]); + expect(row("Child")).not.toHaveClass("ui-tree-item--focused"); + // Nothing left to clear, so the host gets the key. + expect(press("Escape")).toBe(true); + }); + + it("lets the host claim keys first", () => { + const captured: string[] = []; + navTree({ + onKeyDown: (event) => { + if (event.ctrlKey && event.key === "c") { + captured.push(event.key); + event.preventDefault(); + } + }, + }); + clickRow("Alpha"); + fireEvent.keyDown(tree(), { key: "c", ctrlKey: true }); + expect(captured).toEqual(["c"]); + // The tree never saw the key: nothing moved, nothing changed. + expect(activeRow()).toBe("Alpha"); + expect(selectedRows()).toEqual(["Alpha"]); + }); + + it("leaves a control's own keys alone and takes navigation back", () => { + navTree(); + const action = screen.getByRole("button", { name: "Action" }); + fireEvent.keyDown(action, { key: "Enter" }); + expect(selectedRows()).toEqual([]); + fireEvent.keyDown(action, { key: "ArrowRight" }); + expect(document.activeElement).toBe(row("Bravo")); + }); + + it("navigates the current order after the data reorders", () => { + const nodes = ["One", "Two"].map((label) => ({ id: label, label })); + const view = renderTree({ "aria-label": "Reorder", nodes }); + press("ArrowDown"); + expect(activeRow()).toBe("Two"); + view.update({ nodes: [...nodes].reverse() }); + press("ArrowDown"); + expect(activeRow()).toBe("One"); + }); +}); diff --git a/test/webview/ui/tree.rows.test.tsx b/test/webview/ui/tree.rows.test.tsx new file mode 100644 index 0000000000..614160a07a --- /dev/null +++ b/test/webview/ui/tree.rows.test.tsx @@ -0,0 +1,167 @@ +import { fireEvent, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { + BASIC_NODES, + clickRow, + clickTwistie, + press, + renderStatefulTree, + renderTree, + row, + rowNames, + selectedRows, + tree, +} from "./treeTestHelpers"; + +import type { TreeNode } from "@repo/ui"; + +describe("Tree rows", () => { + it("renders icons, rich labels, class names, and an action slot", () => { + renderTree({ + "aria-label": "Rows", + selectedItemId: "selected", + nodes: [ + { id: "plain", label: "Plain item", icon: "file" }, + { id: "rich", label: Rich item, textValue: "Rich item" }, + { + id: "selected", + label: "Selected", + className: "custom-item", + action: , + }, + ], + }); + expect( + row("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), + ).toHaveClass("codicon-file"); + expect(row("Rich item")).toContainHTML("Rich item"); + expect(row("Selected")).toHaveClass("ui-tree-item", "custom-item"); + expect(selectedRows()).toEqual(["Selected"]); + expect( + screen.getByRole("button", { name: "Selected action" }).parentElement, + ).toHaveClass("ui-tree-item__action"); + }); + + it("treats an empty children array as a branch that has not loaded", () => { + const { emitted } = renderStatefulTree({ + "aria-label": "Lazy", + nodes: [{ id: "lazy", label: "Lazy", children: [] }], + expandedIds: [], + }); + expect(row("Lazy")).toHaveAttribute("aria-expanded", "false"); + expect( + row("Lazy").querySelector(".ui-tree-item__chevron > .ui-icon"), + ).toHaveClass("codicon-chevron-right"); + press("ArrowRight", "Lazy"); + expect(row("Lazy")).toHaveAttribute("aria-expanded", "true"); + expect(emitted.expandedIds.at(-1)).toEqual(["lazy"]); + }); + + it("expands on a single click, and never from the twistie's selection", () => { + renderStatefulTree({ + "aria-label": "Single click", + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + clickRow("Parent"); + expect(selectedRows()).toEqual(["Parent"]); + expect(rowNames()).toEqual(["Parent", "Last"]); + clickTwistie("Parent"); + expect(rowNames()).toEqual(["Parent", "Child", "Sibling", "Last"]); + expect(selectedRows()).toEqual(["Parent"]); + }); + + it("waits for the second click under doubleClick", () => { + renderStatefulTree({ + "aria-label": "Double click", + nodes: BASIC_NODES, + expandedIds: ["parent"], + expandMode: "doubleClick", + }); + clickRow("Parent", { detail: 1 }); + expect(selectedRows()).toEqual(["Parent"]); + expect(rowNames()).toContain("Child"); + clickRow("Parent", { detail: 2 }); + expect(rowNames()).not.toContain("Child"); + }); + + it("expands every descendant branch on an Alt twistie click", () => { + renderStatefulTree({ + "aria-label": "Recursive", + nodes: [ + { + id: "root", + label: "Root", + children: [ + { + id: "one", + label: "One", + children: [{ id: "deep", label: "Deep" }], + }, + { id: "two", label: "Two", children: [] }, + ], + }, + ], + expandedIds: ["one"], + }); + clickTwistie("Root", { altKey: true }); + expect(rowNames()).toEqual(["Root", "One", "Deep", "Two"]); + }); + + it("keeps a row action live and out of the row's way", async () => { + const onAction = vi.fn(); + const { emitted } = renderStatefulTree({ + "aria-label": "Actions", + nodes: [ + { + ...BASIC_NODES[0], + action: ( + + ), + }, + ], + expandedIds: ["parent"], + }); + const action = screen.getByRole("button", { name: "Delete" }); + // Live before the row is ever touched, like a native action bar. + fireEvent.click(action); + expect(onAction).toHaveBeenCalledOnce(); + expect(selectedRows()).toEqual([]); + expect(emitted.expandedIds).toEqual([]); + const user = userEvent.setup(); + await user.tab(); + expect(document.activeElement).toBe(tree()); + await user.tab(); + expect(document.activeElement).toBe(action); + }); + + it("leaves a click on anything focusable in a row to that element", () => { + const nodes: readonly TreeNode[] = [ + { + id: "row", + textValue: "Row", + label: ( + <> + Row + Link + + + Widget + + + ), + }, + ]; + renderStatefulTree({ "aria-label": "Nested", nodes }); + for (const name of ["link", "textbox", "button"] as const) { + fireEvent.click(screen.getByRole(name)); + } + expect(selectedRows()).toEqual([]); + fireEvent.click(screen.getByTestId("text")); + expect(selectedRows()).toEqual(["Row"]); + }); +}); diff --git a/test/webview/ui/treeModel.test.tsx b/test/webview/ui/treeModel.test.tsx new file mode 100644 index 0000000000..08e6030f95 --- /dev/null +++ b/test/webview/ui/treeModel.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + parentId, + type TreeNode, + type TreeRowModel, +} 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" }, +]; + +const model = (...expandedIds: string[]) => + createTreeModel(NODES, new Set(expandedIds)); +const ids = (rows: readonly TreeRowModel[]): string[] => + rows.map((row) => row.node.id); +const rowOf = (id: string): TreeRowModel => { + const row = model("src", "tests").rowsById.get(id); + if (!row) throw new Error(`Expected row ${id}.`); + return row; +}; + +describe("createTreeModel", () => { + it.each([ + [[], ["src", "readme"]], + [["src"], ["src", "tree", "tests", "readme"]], + [ + ["src", "tests"], + ["src", "tree", "tests", "unit", "readme"], + ], + ] as const)( + "projects expanded subtrees %j in tree order", + (expanded, rows) => { + expect(ids(model(...expanded).visibleRows)).toEqual(rows); + }, + ); + + it("keeps hidden rows addressable while only visible ones are projected", () => { + const collapsed = model(); + expect(ids(collapsed.rows)).toEqual([ + "src", + "tree", + "tests", + "unit", + "readme", + ]); + expect([...collapsed.visibleIds]).toEqual(["src", "readme"]); + expect(collapsed.rowsById.get("unit")?.textValue).toBe("unit"); + }); + + it("derives row metadata from hierarchy, labels, and expansion", () => { + expect(rowOf("src")).toMatchObject({ + pathIds: [], + posInSet: 1, + setSize: 2, + textValue: "src", + expanded: true, + }); + expect(rowOf("unit")).toMatchObject({ + pathIds: ["src", "tests"], + posInSet: 1, + setSize: 1, + expanded: undefined, + }); + expect(rowOf("readme")).toMatchObject({ posInSet: 2, setSize: 2 }); + // A rich label carries its own text value. + expect(rowOf("tree").textValue).toBe("Tree.tsx"); + expect(parentId(rowOf("unit"))).toBe("tests"); + expect(parentId(rowOf("src"))).toBeUndefined(); + }); + + it.each([ + [ + "siblings", + [ + { id: "dup", label: "One" }, + { id: "dup", label: "Two" }, + ], + ], + [ + "a collapsed branch", + [ + { + id: "collapsed", + label: "Collapsed", + children: [ + { id: "dup", label: "One" }, + { id: "dup", label: "Two" }, + ], + }, + ], + ], + ] satisfies ReadonlyArray)( + "rejects an id reused by %s", + (_case, nodes) => { + expect(() => createTreeModel(nodes, new Set())).toThrow( + /must be unique/i, + ); + }, + ); +}); diff --git a/test/webview/ui/treePolicy.test.ts b/test/webview/ui/treePolicy.test.ts new file mode 100644 index 0000000000..3fe4f92cd5 --- /dev/null +++ b/test/webview/ui/treePolicy.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + type TreeModel, + type TreeNode, + type TreeRowModel, +} from "@repo/ui/components/Tree/treeModel"; +import { + keyboardCommands, + pointerCommands, + type KeyboardCommandInput, + type PointerCommandInput, +} from "@repo/ui/components/Tree/treePolicy"; + +const NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + { id: "last", label: "Last" }, +]; +const model = createTreeModel(NODES, new Set(["parent"])); +const collapsedModel = createTreeModel(NODES, new Set()); +const row = (id: string, from: TreeModel = model): TreeRowModel => { + const found = from.rowsById.get(id); + if (!found) throw new Error(`Expected row ${id}.`); + return found; +}; + +const pointer = (overrides: Partial = {}) => + pointerCommands({ + expandMode: "singleClick", + row: row("parent"), + onTwistie: false, + detail: 1, + altKey: false, + ...overrides, + }); +const keyboard = (overrides: Partial = {}) => + keyboardCommands({ + expandMode: "singleClick", + key: "ArrowDown", + row: row("parent"), + visibleRows: model.visibleRows, + fromAction: false, + selectedCount: 0, + hasFocusedRow: false, + ...overrides, + }); + +const FOCUS_PARENT = { type: "focus", id: "parent" }; +const SELECT_PARENT = { type: "select", id: "parent" }; +const TOGGLE_PARENT = { type: "toggle", id: "parent", recursive: false }; + +describe("pointerCommands", () => { + it("focuses and selects before expanding, so a click cannot reorder them", () => { + expect(pointer()).toEqual([FOCUS_PARENT, SELECT_PARENT, TOGGLE_PARENT]); + }); + + it("keeps a twistie click off the selection, and Alt recursive", () => { + expect(pointer({ onTwistie: true, detail: 2, altKey: true })).toEqual([ + FOCUS_PARENT, + { type: "toggle", id: "parent", recursive: true }, + ]); + }); + + it.each([ + [1, [FOCUS_PARENT, SELECT_PARENT]], + [2, [FOCUS_PARENT, SELECT_PARENT, TOGGLE_PARENT]], + ])("expands on click %i under doubleClick", (detail, commands) => { + expect(pointer({ expandMode: "doubleClick", detail })).toEqual(commands); + }); + + it("leaves a leaf nothing to expand", () => { + expect(pointer({ row: row("last") })).toEqual([ + { type: "focus", id: "last" }, + { type: "select", id: "last" }, + ]); + }); +}); + +describe("keyboardCommands", () => { + it.each([ + ["ArrowDown", 1], + ["ArrowUp", -1], + ] as const)("moves the active row on %s", (key, offset) => { + expect(keyboard({ key })).toEqual({ + commands: [{ type: "move", id: "parent", offset }], + preventDefault: true, + focusRowElementId: undefined, + }); + }); + + it.each([ + ["Home", "parent"], + ["End", "last"], + ])("jumps to the %s row", (key, id) => { + expect(keyboard({ key }).commands).toEqual([{ type: "focus", id }]); + }); + + it("walks into and out of branches", () => { + expect(keyboard({ key: "ArrowRight" }).commands).toEqual([ + { type: "focus", id: "child" }, + ]); + expect(keyboard({ key: "ArrowLeft", row: row("child") }).commands).toEqual([ + FOCUS_PARENT, + ]); + expect(keyboard({ key: "ArrowLeft" }).commands).toEqual([TOGGLE_PARENT]); + expect( + keyboard({ + key: "ArrowRight", + row: row("parent", collapsedModel), + visibleRows: collapsedModel.visibleRows, + }).commands, + ).toEqual([TOGGLE_PARENT]); + }); + + it("keeps selection and expansion apart on Enter and Space", () => { + expect(keyboard({ key: "Enter" }).commands).toEqual([ + SELECT_PARENT, + TOGGLE_PARENT, + ]); + expect( + keyboard({ key: "Enter", expandMode: "doubleClick" }).commands, + ).toEqual([SELECT_PARENT]); + expect(keyboard({ key: " " }).commands).toEqual([TOGGLE_PARENT]); + expect(keyboard({ key: " ", row: row("child") }).commands).toEqual([ + { type: "select", id: "child" }, + ]); + }); + + it.each([ + [0, false, false, false], + [1, false, true, false], + [1, true, true, true], + ])( + "dismisses %i selected rows with focus %s", + (selectedCount, hasFocusedRow, clearSelection, clearFocus) => { + expect( + keyboard({ key: "Escape", selectedCount, hasFocusedRow }), + ).toMatchObject({ + commands: [{ type: "dismiss", clearSelection, clearFocus }], + preventDefault: clearSelection || hasFocusedRow, + }); + }, + ); + + it("leaves unclaimed keys, Tab included, to the host", () => { + expect(keyboard({ key: "Tab" })).toEqual({ + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }); + }); + + it("gives a row action its own keys and takes the navigating ones back", () => { + expect(keyboard({ key: "Enter", fromAction: true })).toEqual({ + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }); + expect(keyboard({ key: "ArrowDown", fromAction: true })).toMatchObject({ + commands: [{ type: "move", id: "parent", offset: 1 }], + preventDefault: true, + focusRowElementId: "parent", + }); + }); +}); diff --git a/test/webview/ui/treeTestHelpers.tsx b/test/webview/ui/treeTestHelpers.tsx new file mode 100644 index 0000000000..a22090052d --- /dev/null +++ b/test/webview/ui/treeTestHelpers.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; + +import { Tree, type TreeNode, type TreeProps } from "@repo/ui"; + +/** A branch of two plus a leaf: depth, sibling order, and hideable rows. */ +export const BASIC_NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [ + { id: "child", label: "Child" }, + { id: "sibling", label: "Sibling" }, + ], + }, + { id: "last", label: "Last" }, +]; + +export const tree = (): HTMLElement => screen.getByRole("tree"); + +export const row = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + +const nameOf = (item: Element | null): string => + item?.getAttribute("aria-label") ?? ""; + +/** The visible rows, by name, in render order. */ +export const rowNames = (): string[] => + screen.getAllByRole("treeitem").map(nameOf); + +/** The row `aria-activedescendant` points at, which is the focused row. */ +export const activeRow = (): string | undefined => { + const id = tree().getAttribute("aria-activedescendant"); + const focused = id ? document.getElementById(id) : null; + return focused ? nameOf(focused) : undefined; +}; + +export const selectedRows = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-selected") === "true") + .map(nameOf); + +/** The branch rows currently open, by name. */ +export const expandedRows = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-expanded") === "true") + .map(nameOf); + +/** + * Sends a key to the container, where DOM focus lives; `from` targets a row, as + * a click leaves it. Returns false when the tree claimed the key. + */ +export const press = (key: string, from?: string): boolean => + fireEvent.keyDown(from ? row(from) : tree(), { key }); + +export const clickRow = (name: string, init?: MouseEventInit): void => { + fireEvent.click(row(name), init); +}; + +/** Clicks a branch's twistie rather than its body. */ +export const clickTwistie = (name: string, init?: MouseEventInit): void => { + const chevron = row(name).querySelector(".ui-tree-item__chevron"); + if (!chevron) { + throw new Error(`Expected a twistie on ${name}.`); + } + fireEvent.click(chevron, init); +}; + +/** A row's indent guides, outermost first: true where one is drawn active. */ +export const activeGuides = (name: string): boolean[] => + [...row(name).querySelectorAll(".ui-tree-item__indent-slot")].map((slot) => + slot.classList.contains("ui-tree-item__indent-slot--active"), + ); + +/** A fully controlled Tree, for tests that drive the props themselves. */ +export function renderTree(props: TreeProps) { + const view = render(); + return { + ...view, + /** Re-renders with props changed, as a consumer's state would. */ + update: (next: Partial): void => + view.rerender(), + }; +} + +function StatefulTree({ + onSelectedItemChange, + onExpandedIdsChange, + ...props +}: TreeProps): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState(props.selectedItemId); + const [expandedIds, setExpandedIds] = useState(props.expandedIds); + return ( + { + onSelectedItemChange?.(id); + setSelectedItemId(id); + }} + expandedIds={expandedIds} + onExpandedIdsChange={(ids) => { + onExpandedIdsChange?.(ids); + setExpandedIds(ids); + }} + /> + ); +} + +/** + * A Tree that keeps its own state, so a gesture's result lands in the DOM. + * `emitted` records what it reported to its consumer, newest last. + */ +export function renderStatefulTree(props: TreeProps) { + const selection: Array = []; + const expandedIds: Array = []; + const view = render( + selection.push(id)} + onExpandedIdsChange={(ids) => expandedIds.push(ids)} + />, + ); + return { ...view, emitted: { selection, expandedIds } }; +} diff --git a/test/webview/ui/treeTransition.test.ts b/test/webview/ui/treeTransition.test.ts new file mode 100644 index 0000000000..e41f6eb55e --- /dev/null +++ b/test/webview/ui/treeTransition.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + type TreeModel, + type TreeNode, + type TreeRowModel, +} from "@repo/ui/components/Tree/treeModel"; +import { + deriveTreeInteractionView, + initialTreeInteractionState, + rowFocused, + transitionTree, + type TreeInteractionState, +} from "@repo/ui/components/Tree/treeTransition"; + +const NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + { id: "last", label: "Last" }, +]; +const OPEN = createTreeModel(NODES, new Set(["parent"])); +const CLOSED = createTreeModel(NODES, new Set()); + +const rowOf = (model: TreeModel, id: string): TreeRowModel => { + const row = model.rowsById.get(id); + if (!row) throw new Error(`Expected row ${id}.`); + return row; +}; + +/** Focuses a row the way a click does, through the view's own selection key. */ +function focusRow( + id: string, + model: TreeModel, + controlledIds: readonly string[] = [], +): TreeInteractionState { + const state = initialTreeInteractionState(); + const { controlledKey } = deriveTreeInteractionView( + state, + model, + controlledIds, + ); + return rowFocused(state, rowOf(model, id), controlledKey); +} + +const view = ( + state: TreeInteractionState, + model: TreeModel, + controlledIds: readonly string[] = [], +) => deriveTreeInteractionView(state, model, controlledIds); + +const transition = ( + state: TreeInteractionState, + commands: Parameters[1], + model: TreeModel, + overrides: { + expandedIds?: readonly string[]; + controlledIds?: readonly string[]; + } = {}, +) => + transitionTree(state, commands, { + model, + controlledIds: overrides.controlledIds ?? [], + expandedIds: overrides.expandedIds ?? ["parent"], + }); + +describe("deriveTreeInteractionView", () => { + it("returns the state untouched while every remembered row still exists", () => { + const state = focusRow("child", OPEN); + expect(view(state, OPEN).state).toBe(state); + expect(view(state, OPEN).focusedId).toBe("child"); + }); + + it("keeps focus on a row a collapse only hid", () => { + const hidden = view(focusRow("child", OPEN), CLOSED); + expect(hidden.state.focusTarget?.id).toBe("child"); + expect(hidden.focusedId).toBeUndefined(); + // The tab stop stays away from the first row, which would move the user. + expect(hidden.tabStopId).toBeUndefined(); + }); + + it("falls back to the nearest visible ancestor when the row is gone", () => { + const removed = createTreeModel( + [{ id: "parent", label: "Parent", children: [] }, NODES[1]], + new Set(["parent"]), + ); + const reconciled = view(focusRow("child", OPEN), removed); + expect(reconciled.state.focusTarget?.id).toBe("parent"); + expect(reconciled.state.tabTargetId).toBe("parent"); + expect(reconciled.focusedId).toBe("parent"); + }); + + it("gives an unclaimed selection the tab stop, until focus claims it", () => { + const state = focusRow("parent", OPEN); + expect(view(state, OPEN, ["last"]).tabStopId).toBe("last"); + const claimed = transition(state, [{ type: "select", id: "last" }], OPEN); + expect(view(claimed.state, OPEN, ["last"]).tabStopId).toBe("parent"); + }); + + it("draws guides down to the selected row, and the focused one when in focus", () => { + const selected = view(initialTreeInteractionState(), OPEN, ["child"]); + expect([...selected.guideOwnerIds]).toEqual(["parent"]); + const blurred = view(focusRow("child", OPEN), OPEN); + expect([...blurred.guideOwnerIds]).toEqual([]); + }); +}); + +describe("transitionTree", () => { + it("clears the selection on dismiss, and focus only when asked", () => { + const state = focusRow("child", OPEN, ["child"]); + const selectionOnly = transition( + state, + [{ type: "dismiss", clearSelection: true, clearFocus: false }], + OPEN, + { controlledIds: ["child"] }, + ); + expect(selectionOnly.selection).toEqual([]); + expect(selectionOnly.state.focusTarget?.id).toBe("child"); + + const cleared = transition( + state, + [{ type: "dismiss", clearSelection: true, clearFocus: true }], + OPEN, + { controlledIds: ["child"] }, + ); + expect(cleared.state.focusTarget).toBeUndefined(); + // Tab still returns to where the user was. + expect(cleared.state.tabTargetId).toBe("child"); + }); + + it("moves focus within the visible rows, clamped at both ends", () => { + const state = focusRow("parent", OPEN); + const down = transition( + state, + [{ type: "move", id: "parent", offset: 1 }], + OPEN, + ); + expect(down.state.focusTarget?.id).toBe("child"); + const up = transition( + state, + [{ type: "move", id: "parent", offset: -1 }], + OPEN, + ); + expect(up.state.focusTarget?.id).toBe("parent"); + }); + + it("emits expansion in tree order, keeping ids the data does not have yet", () => { + const expanded = transition( + initialTreeInteractionState(), + [{ type: "toggle", id: "parent", recursive: false }], + CLOSED, + { expandedIds: ["ghost"] }, + ); + expect(expanded.expandedIds).toEqual(["parent", "ghost"]); + }); + + it("toggles every branch under a recursive toggle", () => { + const nested = createTreeModel( + [ + { + id: "root", + label: "Root", + children: [ + { id: "one", label: "One", children: [] }, + { id: "two", label: "Two", children: [] }, + ], + }, + ], + new Set(["one"]), + ); + const expanded = transition( + initialTreeInteractionState(), + [{ type: "toggle", id: "root", recursive: true }], + nested, + { expandedIds: ["one"] }, + ); + expect(expanded.expandedIds).toEqual(["root", "one", "two"]); + }); +});