diff --git a/.storybook/main.ts b/.storybook/main.ts index 5b262100d..54f71a684 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 660003403..8cb702743 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", @@ -844,6 +845,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 ec7936934..f8f169722 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,12 +38,118 @@ 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 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"); +const [expandedIds, setExpandedIds] = useState(["src"]); + +; +``` + +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 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 +185,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 +202,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/Tree.css b/packages/ui/src/components/Tree/Tree.css new file mode 100644 index 000000000..e21e117e3 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -0,0 +1,212 @@ +.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: var(--ui-tree-row-background, transparent); + cursor: pointer; + user-select: none; +} + +/* A zero-height sticky anchor; the browser pins it, the scroll listener + only decides which rows it shows. */ +.ui-tree-sticky { + position: sticky; + top: 0; + z-index: 100; + height: 0; + outline: 0; +} + +.ui-tree-sticky__rows { + position: absolute; + inset-inline: 0; + overflow: hidden; +} + +.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 { + 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"]) + > .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--focused > .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; + } +} + +: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.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx new file mode 100644 index 000000000..41db40e9e --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,234 @@ +import { expect, fireEvent, userEvent, waitFor, 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"; + +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[] = [ + 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 }), + ], + { label: "src" }, + ), + node("readme", { label: "README.md", icon: "markdown" }), +]; + +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, + 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 = [ + 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 = [ + 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 = ["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; + }} + > + +
+ ), + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelector(".ui-tree-sticky__rows"), + ).not.toBeNull(), + ); + await expect( + within(canvasElement).getByTestId("scroller").scrollTop, + ).toBeGreaterThan(0); + }, +}; + +export const MultiSelect: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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"); + await expect(canvasElement.ownerDocument.activeElement).toBe(tree); + await expect(tree).toHaveAttribute("aria-activedescendant", readme.id); + }, +}; + +export const Focused: Story = { + render: () => singleTree("Focused explorer", "tree", FILES, "explorer"), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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: () => + singleTree("Nested explorer", "StickyScroll.tsx", NESTED_FILES, "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 000000000..e117f9572 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,138 @@ +import { type ComponentPropsWithRef, type Ref, useId, useRef } from "react"; + +import { cx } from "#cx"; + +import { StickyScroll } from "./sticky/StickyScroll"; +import "./Tree.css"; +import { TreeRow } from "./TreeRow"; +import { type SelectionProps, useTreeAdapter } from "./useTreeAdapter"; + +import type { TreeNode } from "./treeModel"; + +const DEFAULT_STICKY_COUNT = 7; +const NO_IDS: readonly string[] = []; + +interface TreeBaseProps extends Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" | "children" +> { + nodes: readonly TreeNode[]; + expandedIds?: readonly string[]; + onExpandedIdsChange?: (expandedIds: readonly string[]) => void; + variant?: "default" | "explorer"; + expandMode?: "singleClick" | "doubleClick"; + multiSelectModifier?: "ctrlCmd" | "alt"; + stickyScroll?: boolean | number; +} + +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; +} + +/** 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, + }); + + return ( +
{ + 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/TreeRow.tsx b/packages/ui/src/components/Tree/TreeRow.tsx new file mode 100644 index 000000000..dbb39af93 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeRow.tsx @@ -0,0 +1,134 @@ +import { type CSSProperties, type MouseEvent } from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import { nestedInteractiveTarget } from "./rowDom"; + +import type { TreeRowModel } from "./treeModel"; +import type { TreeAdapter } from "./useTreeAdapter"; + +const NO_GUIDES: readonly string[] = []; + +function TreeRowSurface({ + row, + activeGuideIds = NO_GUIDES, + actionsEnabled = false, +}: { + row: TreeRowModel; + activeGuideIds?: readonly string[]; + actionsEnabled?: boolean; +}): React.JSX.Element { + const { node, expanded } = row; + return ( +
+
+ ); +} + +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={node.disabled ? undefined : selected} + aria-disabled={node.disabled ? true : undefined} + aria-expanded={expanded} + tabIndex={-1} + className={cx( + "ui-tree-item", + focused && "ui-tree-item--focused", + className, + )} + style={{ ...style, "--ui-tree-level": row.level } as CSSProperties} + onFocus={(event) => { + if (event.target === event.currentTarget) adapter?.onRowFocus(node.id); + }} + onClick={(event) => { + if ( + nestedInteractiveTarget(event.target, event.currentTarget) || + (event.target instanceof Element && + event.target.closest(".ui-tree-item__action")) + ) + return; + const twistie = + expanded !== undefined && + 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/rowDom.ts b/packages/ui/src/components/Tree/rowDom.ts new file mode 100644 index 000000000..5d8f77d29 --- /dev/null +++ b/packages/ui/src/components/Tree/rowDom.ts @@ -0,0 +1,60 @@ +/** The DOM reads the data model cannot answer: event targets. */ + +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(","); + +/** 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('[role="treeitem"]') + : null; +} + +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 000000000..926c81542 --- /dev/null +++ b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx @@ -0,0 +1,184 @@ +import { + type RefObject, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { scrollableAncestor } from "../rowDom"; +import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; +import { TreeRow } from "../TreeRow"; + +import { computeStickyState, NO_STICKY, type StickyState } from "./stickyState"; + +import type { TreeAdapter } from "../useTreeAdapter"; + +function useStickyState( + rows: readonly TreeRowModel[], + maxCount: number, + widgetRef: RefObject, +): StickyState { + 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; + scroller.addEventListener("scroll", notify, { passive: true }); + 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, + widget.getBoundingClientRect().top - tree.getBoundingClientRect().top, + scrollableAncestor(tree)?.clientHeight ?? 0, + maxCount, + ); + 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); +} + +export function StickyScroll({ + maxCount, + adapter, + treeRef, +}: { + maxCount: number; + adapter: TreeAdapter; + treeRef: React.RefObject; +}): React.JSX.Element { + 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), + ); + + 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) 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); + }; + + return ( +
0 ? 0 : -1} + onFocus={(event) => { + if (event.target === event.currentTarget) + setRequestedIndex(focusedIndex); + }} + onKeyDown={(event) => { + const row = pinnedRows[focusedIndex]; + if (!row) return; + if (event.key === "ArrowUp") + setRequestedIndex(Math.max(0, focusedIndex - 1)); + else if (event.key === "ArrowDown" || event.key === "ArrowRight") { + if (pinnedRows[focusedIndex + 1]) setRequestedIndex(focusedIndex + 1); + else { + const child = rows[rows.indexOf(row) + 1]; + if (child?.pathIds.includes(row.node.id)) { + adapter.dispatch([{ type: "focus", id: child.node.id }]); + } + } + } else if (event.key === "Enter") { + revealAndDispatch(row, [ + { type: "focus", id: row.node.id }, + { type: "select", id: row.node.id }, + ]); + } else if (event.key === "ArrowLeft") { + revealAndDispatch(row, [ + { type: "focus", id: row.node.id }, + ...(row.expanded + ? [{ type: "toggle" as const, id: row.node.id }] + : []), + ]); + } else if (event.key === " ") { + revealAndDispatch(row, [{ type: "focus", id: row.node.id }]); + } else return; + event.preventDefault(); + event.stopPropagation(); + }} + > + {pinnedRows.length > 0 ? ( + <> +
+ {pinnedRows.map((row, index) => ( + { + if (!adapter.isSelectionGesture(event)) reveal(row, index); + adapter.onPointer(row, event, twistie, "sticky"); + }} + /> + ))} +
+
+ + ) : null} +
+ ); +} 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 000000000..a18020854 --- /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/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts new file mode 100644 index 000000000..8bf287e66 --- /dev/null +++ b/packages/ui/src/components/Tree/treeModel.ts @@ -0,0 +1,107 @@ +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` marks a branch, including an empty array + * for a branch whose children have not loaded yet. + */ +export type TreeNode = TreeNodeLabel & { + id: string; + icon?: CodiconName; + disabled?: boolean; + action?: ReactNode; + className?: string; + children?: readonly TreeNode[]; +}; + +/** A node projected onto the flat tree model. */ +export interface TreeRowModel { + readonly node: TreeNode; + readonly level: number; + readonly pathIds: readonly string[]; + readonly posInSet: number; + readonly setSize: number; + readonly textValue: string; + /** undefined on leaves. */ + readonly expanded: boolean | undefined; +} + +export interface TreeModel { + readonly rows: readonly TreeRowModel[]; + readonly allRows: readonly TreeRowModel[]; + readonly rowsById: ReadonlyMap; + readonly visibleIds: ReadonlySet; +} + +export function parentId(row: TreeRowModel): string | undefined { + return row.pathIds.at(-1); +} + +/** Builds visible and complete flat projections in one traversal. */ +export function createTreeModel( + nodes: readonly TreeNode[], + expandedIds: ReadonlySet, +): TreeModel { + const rows: TreeRowModel[] = []; + const allRows: 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, + level: pathIds.length + 1, + pathIds, + posInSet: index + 1, + setSize: siblings.length, + textValue: + node.textValue ?? (typeof node.label === "string" ? node.label : ""), + expanded, + }; + allRows.push(row); + rowsById.set(node.id, row); + if (visible) { + rows.push(row); + } + if (node.children) { + visit( + node.children, + [...pathIds, node.id], + visible && expanded === true, + ); + } + }); + }; + + visit(nodes, [], true); + return { + rows, + allRows, + rowsById, + visibleIds: new Set(rows.map((row) => row.node.id)), + }; +} + +export function flattenVisibleRows( + nodes: readonly TreeNode[], + expandedIds: ReadonlySet, +): readonly TreeRowModel[] { + return createTreeModel(nodes, expandedIds).rows; +} diff --git a/packages/ui/src/components/Tree/treePolicy.ts b/packages/ui/src/components/Tree/treePolicy.ts new file mode 100644 index 000000000..861f4f23e --- /dev/null +++ b/packages/ui/src/components/Tree/treePolicy.ts @@ -0,0 +1,251 @@ +import { parentId, type TreeRowModel } from "./treeModel"; + +export interface TreeCommandBehavior { + readonly expandMode: "singleClick" | "doubleClick"; + readonly multiSelect: boolean; + readonly multiSelectModifier: "ctrlCmd" | "alt"; +} + +export interface TreeModifiers { + readonly ctrlKey: boolean; + readonly metaKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +export interface SelectCommandOptions { + readonly toggle?: boolean; + readonly range?: boolean; + readonly preserveHidden?: boolean; +} + +type RowCommand = { + readonly type: Type; + readonly id: string; +} & Options; + +export type TreeCommand = + | RowCommand<"focus" | "selectScope"> + | RowCommand< + "move", + { + readonly offset: -1 | 1; + readonly page?: boolean; + readonly extend?: boolean; + } + > + | RowCommand<"select", { readonly options?: SelectCommandOptions }> + | RowCommand<"toggle", { readonly recursive?: boolean }> + | RowCommand<"typeahead", { readonly key: string }> + | { + readonly type: "dismiss"; + readonly clearSelection: boolean; + readonly clearFocus: boolean; + }; + +interface CommandInput extends TreeCommandBehavior { + readonly row: TreeRowModel; + readonly modifiers: TreeModifiers; +} + +export interface PointerCommandInput extends CommandInput { + readonly source: "row" | "sticky"; + readonly onTwistie: boolean; + readonly detail: number; +} + +export interface KeyboardCommandInput extends CommandInput { + readonly key: string; + readonly rows: readonly TreeRowModel[]; + readonly fromAction: boolean; + readonly selectedCount: number; + readonly hasFocusedRow: boolean; +} + +const MOVEMENT_KEYS = { + ArrowDown: [1, false], + ArrowUp: [-1, false], + PageDown: [1, true], + PageUp: [-1, true], +} as const; +const NAVIGATION_KEYS = new Set([ + ...Object.keys(MOVEMENT_KEYS), + "ArrowLeft", + "ArrowRight", + "Home", + "End", +]); +const rowCommand = ( + type: Type, + id: string, +): RowCommand => ({ type, id }); +const selectCommand = (id: string, options?: SelectCommandOptions) => ({ + type: "select" as const, + id, + ...(options ? { options } : {}), +}); +const toggleCommand = (id: string, recursive?: boolean) => ({ + type: "toggle" as const, + id, + ...(recursive === undefined ? {} : { recursive }), +}); + +export function isSelectionModifier( + event: TreeModifiers, + behavior: TreeCommandBehavior, +): boolean { + return Boolean( + behavior.multiSelect && + (behavior.multiSelectModifier === "alt" + ? event.altKey + : event.ctrlKey || event.metaKey), + ); +} + +export function isSelectionGesture( + event: TreeModifiers, + behavior: TreeCommandBehavior, +): boolean { + return ( + isSelectionModifier(event, behavior) || + (behavior.multiSelect && event.shiftKey) + ); +} + +export function pointerCommands( + input: PointerCommandInput, +): readonly TreeCommand[] { + const { row, source, onTwistie, modifiers } = input; + if (row.node.disabled) return []; + const id = row.node.id; + if (isSelectionGesture(modifiers, input)) { + const select = selectCommand(id, { + toggle: isSelectionModifier(modifiers, input), + range: modifiers.shiftKey, + preserveHidden: false, + }); + return source === "sticky" ? [select] : [rowCommand("focus", id), select]; + } + + const toggle = toggleCommand( + id, + modifiers.altKey && input.multiSelectModifier !== "alt", + ); + if (source === "sticky") { + return [ + rowCommand("focus", id), + selectCommand(id), + ...(onTwistie ? [toggle] : []), + ]; + } + if (onTwistie) return [rowCommand("focus", id), toggle]; + const toggleBody = + row.expanded !== undefined && + ((input.expandMode === "singleClick" && input.detail <= 1) || + (input.expandMode === "doubleClick" && input.detail === 2)); + return [ + rowCommand("focus", id), + selectCommand(id), + ...(toggleBody ? [toggle] : []), + ]; +} + +export function keyboardCommands(input: KeyboardCommandInput) { + const { key, row, rows, modifiers } = input; + if (input.fromAction && !NAVIGATION_KEYS.has(key)) { + return { + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }; + } + const result = (commands: readonly TreeCommand[], preventDefault = true) => ({ + commands, + preventDefault, + focusRowElementId: input.fromAction ? row.node.id : undefined, + }); + const id = row.node.id; + if ( + input.multiSelect && + (modifiers.ctrlKey || modifiers.metaKey) && + !modifiers.shiftKey && + !modifiers.altKey && + key.toLocaleLowerCase() === "a" + ) { + return result([rowCommand("selectScope", id)]); + } + if (key in MOVEMENT_KEYS) { + const [offset, page] = MOVEMENT_KEYS[key as keyof typeof MOVEMENT_KEYS]; + return result([ + { type: "move", id, offset, page, extend: !page && modifiers.shiftKey }, + ]); + } + if (key === "Home" || key === "End") { + const target = key === "Home" ? rows[0] : rows.at(-1); + return result(target ? [rowCommand("focus", target.node.id)] : []); + } + + const selectionModifier = isSelectionModifier(modifiers, input); + switch (key) { + case "ArrowRight": { + if (row.expanded === false && !row.node.disabled) { + return result([toggleCommand(id)]); + } + const child = row.expanded ? rows[rows.indexOf(row) + 1] : undefined; + return result( + child?.pathIds.includes(id) ? [rowCommand("focus", child.node.id)] : [], + ); + } + case "ArrowLeft": { + if (row.expanded === true && !row.node.disabled) { + return result([toggleCommand(id)]); + } + const parent = parentId(row); + return result(parent ? [rowCommand("focus", parent)] : []); + } + case "Enter": + if (row.node.disabled) return result([]); + if (selectionModifier && modifiers.shiftKey) { + return result([selectCommand(id, { toggle: true })]); + } + return result([ + selectCommand(id, { toggle: selectionModifier }), + ...(row.expanded !== undefined && input.expandMode === "singleClick" + ? [toggleCommand(id)] + : []), + ]); + case " ": + if (row.node.disabled) return result([]); + return result([ + row.expanded === undefined + ? selectCommand(id, { toggle: selectionModifier }) + : toggleCommand(id), + ]); + case "Escape": { + const clearSelection = input.selectedCount > 0; + return result( + [ + { + type: "dismiss", + clearSelection, + clearFocus: input.selectedCount <= 1 && input.hasFocusedRow, + }, + ], + clearSelection || input.hasFocusedRow, + ); + } + default: { + const typeahead = + key.length === 1 && + !modifiers.ctrlKey && + !modifiers.metaKey && + !modifiers.altKey; + return result( + typeahead + ? [{ type: "typeahead", id, key: key.toLocaleLowerCase() }] + : [], + typeahead, + ); + } + } +} diff --git a/packages/ui/src/components/Tree/treeTransition.ts b/packages/ui/src/components/Tree/treeTransition.ts new file mode 100644 index 000000000..f22151006 --- /dev/null +++ b/packages/ui/src/components/Tree/treeTransition.ts @@ -0,0 +1,394 @@ +import { parentId, type TreeModel, type TreeRowModel } from "./treeModel"; + +import type { SelectCommandOptions, TreeCommand } from "./treePolicy"; + +interface FocusTarget { + readonly id: string; + readonly pathIds: readonly string[]; +} + +export interface TreeInteractionState { + readonly focusTarget?: FocusTarget; + readonly tabTargetId?: string; + readonly dismissedSelectionKey?: string; + readonly anchorKey: string; + readonly anchorId?: string; + readonly hasDomFocus: boolean; + readonly typeQuery?: string; + readonly typeExpires?: number; +} + +interface TransitionInput { + readonly model: TreeModel; + readonly controlledIds: readonly string[]; + readonly expandedIds: readonly string[]; + readonly multiSelect: boolean; + readonly pageOffset?: number; + readonly now: number; +} + +const selectionKey = (ids: readonly string[]): string => + JSON.stringify([...new Set(ids)].sort()); +const focusTarget = (row: TreeRowModel): FocusTarget => ({ + id: row.node.id, + pathIds: row.pathIds, +}); + +export function initialTreeInteractionState( + controlledIds: readonly string[], +): TreeInteractionState { + return { + anchorKey: selectionKey(controlledIds), + anchorId: controlledIds[0], + hasDomFocus: false, + }; +} + +export function deriveTreeInteractionView( + state: TreeInteractionState, + model: TreeModel, + controlledIds: readonly string[], +) { + const { rows, rowsById, visibleIds } = model; + let nextState = state; + 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; + nextState = { + ...state, + focusTarget: fallback ? focusTarget(fallback) : undefined, + tabTargetId: fallbackId, + }; + } else if (state.tabTargetId && !rowsById.has(state.tabTargetId)) { + nextState = { ...state, tabTargetId: undefined }; + } + + const controlledKey = selectionKey(controlledIds); + const selectedIds = new Set( + controlledIds.filter((id) => !rowsById.get(id)?.node.disabled), + ); + const focusedId = + nextState.focusTarget && visibleIds.has(nextState.focusTarget.id) + ? nextState.focusTarget.id + : undefined; + const selectedTabStop = + nextState.dismissedSelectionKey === controlledKey + ? undefined + : rows.find((row) => selectedIds.has(row.node.id))?.node.id; + const tabTarget = + nextState.tabTargetId && visibleIds.has(nextState.tabTargetId) + ? nextState.tabTargetId + : undefined; + const hiddenFocus = + nextState.focusTarget && + rowsById.has(nextState.focusTarget.id) && + !focusedId; + const guideOwnerIds = new Set(); + for (const row of rows) { + if ( + selectedIds.has(row.node.id) || + (nextState.hasDomFocus && focusedId === row.node.id) + ) { + const ownerId = row.expanded ? row.node.id : parentId(row); + if (ownerId) guideOwnerIds.add(ownerId); + } + } + return { + state: nextState, + controlledKey, + selectedIds, + focusedId, + tabStopId: + selectedTabStop ?? + tabTarget ?? + (hiddenFocus ? undefined : rows[0]?.node.id), + anchorId: + nextState.anchorKey === controlledKey + ? nextState.anchorId + : controlledIds[0], + guideOwnerIds, + }; +} + +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, + }; +} + +export function rowFocused( + state: TreeInteractionState, + row: TreeRowModel, + controlledKey: string, +): TreeInteractionState { + return { + ...state, + focusTarget: focusTarget(row), + tabTargetId: row.node.id, + dismissedSelectionKey: controlledKey, + }; +} + +function selectionRange( + rows: readonly TreeRowModel[], + selectedIds: ReadonlySet, + anchorId: string, + targetId: string, +): Set | undefined { + const enabledIds = rows + .filter((row) => !row.node.disabled) + .map((row) => row.node.id); + const anchor = enabledIds.indexOf(anchorId); + const target = enabledIds.indexOf(targetId); + if (anchor < 0 || target < 0) return undefined; + + const ids = new Set(selectedIds); + let start = anchor; + let end = anchor; + while (start > 0 && ids.has(enabledIds[start - 1] ?? "")) start--; + while (end < enabledIds.length - 1 && ids.has(enabledIds[end + 1] ?? "")) + end++; + for (const id of enabledIds.slice(start, end + 1)) ids.delete(id); + for (const id of enabledIds.slice( + Math.min(anchor, target), + Math.max(anchor, target) + 1, + )) { + ids.add(id); + } + return ids; +} + +function selectRow( + model: TreeModel, + selectedIds: ReadonlySet, + anchorId: string | undefined, + multiSelect: boolean, + row: TreeRowModel | undefined, + { toggle, range, preserveHidden = true }: SelectCommandOptions = {}, +): readonly [Set, string] | undefined { + if (!row || row.node.disabled) return undefined; + const id = row.node.id; + if (!multiSelect) return [new Set([id]), id]; + + const ids = new Set( + preserveHidden + ? selectedIds + : [...selectedIds].filter((selectedId) => + model.visibleIds.has(selectedId), + ), + ); + if (range && anchorId) { + const rangeIds = selectionRange(model.rows, ids, anchorId, id); + if (rangeIds) return [rangeIds, anchorId]; + } + if (toggle && ids.delete(id)) return [ids, id]; + if (!toggle) ids.clear(); + ids.add(id); + return [ids, id]; +} + +function scopedSelection( + model: TreeModel, + selectedIds: ReadonlySet, + row: TreeRowModel, +): Set { + const scopeId = parentId(row); + const descendants = model.rows.filter( + (candidate) => + !candidate.node.disabled && + (scopeId === undefined || candidate.pathIds.includes(scopeId)), + ); + const ids = new Set(descendants.map((candidate) => candidate.node.id)); + const scope = scopeId ? model.rowsById.get(scopeId) : undefined; + if ( + scope && + !scope.node.disabled && + descendants.every((candidate) => selectedIds.has(candidate.node.id)) + ) { + ids.add(scope.node.id); + } + return ids; +} + +function toggleBranches( + row: TreeRowModel, + model: TreeModel, + expandedIds: readonly string[], + recursive: boolean, +): string[] { + const next = new Set(expandedIds); + const affected = recursive + ? model.allRows.filter( + (candidate) => + !candidate.node.disabled && + candidate.node.children && + (candidate === row || candidate.pathIds.includes(row.node.id)), + ) + : [row]; + for (const branch of affected) { + if (row.expanded) next.delete(branch.node.id); + else next.add(branch.node.id); + } + const knownIds = new Set(model.allRows.map((candidate) => candidate.node.id)); + return [ + ...model.allRows + .filter((candidate) => next.has(candidate.node.id)) + .map((row) => row.node.id), + ...[...next].filter((id) => !knownIds.has(id)), + ]; +} + +function typeaheadMatch( + rows: readonly TreeRowModel[], + query: string, + current: TreeRowModel, +): TreeRowModel | undefined { + const repeated = + query.length > 1 && [...query].every((key) => key === query[0]); + const value = (repeated ? query[0] : query)?.toLocaleLowerCase() ?? ""; + const start = + query.length === 1 || repeated + ? rows.indexOf(current) + 1 + : rows.indexOf(current); + const ordered = rows.map((_, offset) => rows[(start + offset) % rows.length]); + const fuzzy = (row: TreeRowModel): boolean => { + let index = 0; + for (const character of row.textValue.toLocaleLowerCase()) { + if (character === value[index] && ++index === value.length) return true; + } + return false; + }; + return ( + ordered.find((row) => + row?.textValue.toLocaleLowerCase().startsWith(value), + ) ?? ordered.find((row) => row && fuzzy(row)) + ); +} + +export function transitionTree( + state: TreeInteractionState, + commands: readonly TreeCommand[], + input: TransitionInput, +) { + const { model } = input; + const view = deriveTreeInteractionView(state, model, input.controlledIds); + let nextState = view.state; + let selectedIds = view.selectedIds; + let currentKey = view.controlledKey; + let anchorId = view.anchorId; + let selection: readonly string[] | undefined; + let expandedIds: readonly string[] | undefined; + let focusTree = false; + const updateState = (updates: Partial): void => { + nextState = { ...nextState, ...updates }; + }; + const setAnchor = (id: string | undefined): void => { + anchorId = id; + updateState({ anchorKey: currentKey, anchorId: id }); + }; + const emit = (ids: ReadonlySet, nextAnchor?: string): void => { + selection = model.allRows + .filter((row) => ids.has(row.node.id) && !row.node.disabled) + .map((row) => row.node.id); + selectedIds = new Set(selection); + currentKey = selectionKey(selection); + updateState({ dismissedSelectionKey: currentKey }); + if (nextAnchor !== undefined) setAnchor(nextAnchor); + }; + 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": { + const result = selectRow( + model, + selectedIds, + anchorId, + input.multiSelect, + row, + command.options, + ); + if (result) emit(result[0], result[1]); + break; + } + case "move": { + if (!row) break; + const offset = command.page + ? (input.pageOffset ?? command.offset) + : command.offset; + const index = Math.max( + 0, + Math.min(model.rows.length - 1, model.rows.indexOf(row) + offset), + ); + const target = model.rows[index]; + if (!target) break; + if (command.extend && input.multiSelect) { + const rangeAnchor = anchorId ?? row.node.id; + const ids = selectionRange( + model.rows, + selectedIds, + rangeAnchor, + target.node.id, + ); + if (ids) emit(ids, rangeAnchor); + } else setAnchor(target.node.id); + focus(target); + break; + } + case "toggle": + if (row && !row.node.disabled && row.expanded !== undefined) { + expandedIds = toggleBranches( + row, + model, + expandedIds ?? input.expandedIds, + command.recursive ?? false, + ); + } + break; + case "selectScope": + if (row) emit(scopedSelection(model, selectedIds, row)); + break; + case "dismiss": + if (command.clearSelection) emit(new Set()); + updateState({ + focusTarget: command.clearFocus ? undefined : nextState.focusTarget, + dismissedSelectionKey: command.clearSelection + ? "[]" + : nextState.dismissedSelectionKey, + }); + currentKey = "[]"; + setAnchor(undefined); + focusTree ||= command.clearFocus; + break; + case "typeahead": { + if (!row) break; + const query = + nextState.typeQuery && input.now < (nextState.typeExpires ?? 0) + ? nextState.typeQuery + command.key + : command.key; + updateState({ typeQuery: query, typeExpires: input.now + 800 }); + focus(typeaheadMatch(model.rows, query, row)); + 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 000000000..3e4812e46 --- /dev/null +++ b/packages/ui/src/components/Tree/useTreeAdapter.ts @@ -0,0 +1,221 @@ +import { + type KeyboardEvent, + type MouseEvent, + useMemo, + useRef, + useState, +} from "react"; + +import { + closestRow, + nestedInteractiveTarget, + scrollableAncestor, +} from "./rowDom"; +import { + createTreeModel, + ROW_HEIGHT_PX, + type TreeNode, + type TreeRowModel, +} from "./treeModel"; +import { + isSelectionGesture, + keyboardCommands, + pointerCommands, + type TreeCommand, + type TreeCommandBehavior, +} from "./treePolicy"; +import { + deriveTreeInteractionView, + initialTreeInteractionState, + rowFocused, + transitionTree, + treeFocusChanged, + type TreeInteractionState, +} from "./treeTransition"; + +const NO_IDS: readonly string[] = []; + +export type SelectionProps = + | { + readonly multiSelect?: false; + readonly selectedItemId?: string; + readonly onSelectedItemChange?: (itemId: string | undefined) => void; + readonly selectedItemIds?: never; + readonly onSelectedItemsChange?: never; + } + | { + readonly multiSelect: true; + readonly selectedItemIds?: readonly string[]; + readonly onSelectedItemsChange?: (itemIds: readonly string[]) => void; + readonly selectedItemId?: never; + readonly onSelectedItemChange?: never; + }; + +interface AdapterOptions extends Omit { + readonly nodes: readonly TreeNode[]; + readonly expandedIds: readonly string[]; + readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void; + readonly onKeyDown?: (event: KeyboardEvent) => void; + readonly treeRef: React.RefObject; +} + +function controlledIds(selection: SelectionProps): readonly string[] { + return selection.multiSelect + ? (selection.selectedItemIds ?? NO_IDS) + : selection.selectedItemId === undefined + ? NO_IDS + : [selection.selectedItemId]; +} + +export function useTreeAdapter(options: AdapterOptions & SelectionProps) { + const { nodes, expandedIds, treeRef } = options; + const model = useMemo( + () => createTreeModel(nodes, new Set(expandedIds)), + [nodes, expandedIds], + ); + const { rows, rowsById } = model; + const selected = controlledIds(options); + const rowElementsRef = useRef(new Map()); + const [state, setState] = useState(() => + initialTreeInteractionState(selected), + ); + const view = deriveTreeInteractionView(state, model, selected); + if (view.state !== state) setState(view.state); + + const pageOffset = (row: TreeRowModel, direction: 1 | -1): number => { + const tree = treeRef.current; + const scroller = tree && scrollableAncestor(tree); + const element = rowElementsRef.current.get(row.node.id); + if (!scroller || !element) return direction; + const viewport = scroller.getBoundingClientRect(); + if (viewport.height > 0) { + const visibleRows = rows.filter((candidate) => { + const bounds = rowElementsRef.current + .get(candidate.node.id) + ?.getBoundingClientRect(); + return ( + bounds && bounds.bottom > viewport.top && bounds.top < viewport.bottom + ); + }); + const boundary = direction === 1 ? visibleRows.at(-1) : visibleRows[0]; + const offset = boundary ? rows.indexOf(boundary) - rows.indexOf(row) : 0; + if (offset !== 0) return offset; + scroller.scrollBy?.(0, direction * scroller.clientHeight); + } + return ( + direction * Math.max(1, Math.floor(scroller.clientHeight / ROW_HEIGHT_PX)) + ); + }; + + const dispatch = (commands: readonly TreeCommand[]): void => { + const move = commands.find((command) => command.type === "move"); + const row = move ? rowsById.get(move.id) : undefined; + const result = transitionTree(state, commands, { + model, + controlledIds: selected, + expandedIds, + multiSelect: Boolean(options.multiSelect), + pageOffset: move?.page && row ? pageOffset(row, move.offset) : undefined, + now: Date.now(), + }); + setState(result.state); + if (result.selection) { + if (options.multiSelect) + options.onSelectedItemsChange?.(result.selection); + else options.onSelectedItemChange?.(result.selection[0]); + } + if (result.expandedIds) options.onExpandedIdsChange?.(result.expandedIds); + if (result.focusTree) treeRef.current?.focus(); + }; + + const behavior: TreeCommandBehavior = { + ...options, + multiSelect: Boolean(options.multiSelect), + }; + const registerRow = (id: string, element: HTMLDivElement | null): void => { + if (element) rowElementsRef.current.set(id, element); + else rowElementsRef.current.delete(id); + }; + const onTreeFocus = (rowId?: string): void => { + const row = view.state.focusTarget + ? undefined + : rowsById.get(rowId ?? view.tabStopId ?? ""); + setState((current) => treeFocusChanged(current, true, row)); + }; + const onRowFocus = (id: string): void => { + const row = rowsById.get(id); + if (row) + setState((current) => rowFocused(current, row, view.controlledKey)); + }; + const onPointer = ( + row: TreeRowModel, + event: MouseEvent, + onTwistie: boolean, + source: "row" | "sticky", + ): void => { + dispatch( + pointerCommands({ + ...behavior, + row, + source, + onTwistie, + detail: event.detail, + modifiers: event, + }), + ); + }; + const onKeyDown = (event: KeyboardEvent): void => { + options.onKeyDown?.(event); + if (event.defaultPrevented) return; + const id = closestRow(event.target)?.dataset.treeId; + const row = + (id ? rowsById.get(id) : undefined) ?? + (view.focusedId ? rowsById.get(view.focusedId) : undefined) ?? + (view.tabStopId ? rowsById.get(view.tabStopId) : undefined) ?? + rows[0]; + if (!row) return; + const interactive = nestedInteractiveTarget( + event.target, + event.currentTarget, + ); + const result = keyboardCommands({ + ...behavior, + key: event.key, + row, + rows, + fromAction: + interactive instanceof HTMLElement && + interactive.dataset.treeId === undefined, + selectedCount: view.selectedIds.size, + hasFocusedRow: view.focusedId !== undefined, + modifiers: event, + }); + if (result.focusRowElementId) { + rowElementsRef.current.get(result.focusRowElementId)?.focus(); + } + dispatch(result.commands); + if (result.preventDefault) event.preventDefault(); + }; + const owners = view.guideOwnerIds; + + return { + model, + focusedId: view.focusedId, + tabStopId: view.tabStopId, + hasDomFocus: view.state.hasDomFocus, + selectedIds: view.selectedIds, + activeGuideIds: (row: TreeRowModel) => + row.pathIds.filter((id) => owners.has(id)), + registerRow, + dispatch, + isSelectionGesture: (event: MouseEvent) => + isSelectionGesture(event, behavior), + onTreeFocus, + onTreeBlur: () => setState((current) => treeFocusChanged(current, false)), + onRowFocus, + onPointer, + onKeyDown, + }; +} + +export type TreeAdapter = ReturnType; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0..4ac37b021 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/tokens.css b/packages/ui/src/tokens.css index e4345fa09..63f854d07 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/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index d37c2067f..a384d0884 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 000000000..35e3565b8 --- /dev/null +++ b/packages/ui/storybook/Tree.demo.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; + +import { IconButton } from "../src/components/IconButton/IconButton"; +import { Tree, type TreeProps } from "../src/components/Tree/Tree"; + +import type { CodiconName } from "#codicons"; + +import type { TreeNode } from "../src/components/Tree/treeModel"; + +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[]; +} + +type DistributiveOmit = T extends unknown + ? Omit> + : never; + +export type TreeDemoProps = DistributiveOmit< + TreeProps, + | "nodes" + | "expandedIds" + | "onExpandedIdsChange" + | "onSelectedItemChange" + | "onSelectedItemsChange" +> & { + nodes: readonly TreeDemoNode[]; +}; + +function initialExpandedIds(nodes: readonly TreeDemoNode[]): readonly string[] { + const expandedIds: string[] = []; + const visit = (node: TreeDemoNode): void => { + if (node.children && !node.collapsed) { + expandedIds.push(node.id); + } + node.children?.forEach(visit); + }; + nodes.forEach(visit); + 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. */ +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 [expandedIds, setExpandedIds] = useState(() => + initialExpandedIds(nodes), + ); + + const selection = multiSelect + ? ({ + multiSelect: true, + selectedItemIds, + onSelectedItemsChange: setSelectedItemIds, + } as const) + : ({ + multiSelect: false, + selectedItemId, + onSelectedItemChange: setSelectedItemId, + } as const); + + return ( + + ); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index de3f039b9..d8416421b 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 f8b6827e3..4e23127f4 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 f1346e857..7e9abca65 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 000000000..e6951b6fe --- /dev/null +++ b/test/webview/ui/tree.core.test.tsx @@ -0,0 +1,237 @@ +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 { + ACTIVE_GUIDE, + ControlledTree, + activeTreeItem, + guideSlots, + treeItem, +} from "./treeTestHelpers"; + +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", () => { + it("forwards root semantics and exposes flat row semantics", () => { + const ref = createRef(); + const first = render( + , + ); + const explorer = screen.getByRole("tree", { name: "Explorer" }); + expect(explorer).toHaveClass("ui-tree", "ui-tree--explorer", "custom-tree"); + expect(explorer).toHaveStyle({ width: "240px" }); + expect(ref.current).toBe(explorer); + first.unmount(); + render(); + const tree = screen.getByRole("tree"); + const parent = treeItem("Parent"); + const child = treeItem("Child"); + expect(tree).toHaveAttribute("tabindex", "0"); + for (const item of screen.getAllByRole("treeitem")) { + expect(item).toHaveAttribute("tabindex", "-1"); + } + 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).toHaveAttribute("aria-posinset", "1"); + expect(child).toHaveAttribute("aria-setsize", "2"); + expect(treeItem("Disabled")).toHaveAttribute("aria-disabled", "true"); + expect(treeItem("Disabled")).not.toHaveAttribute("aria-selected"); + expect(treeItem("Last")).not.toHaveAttribute("aria-expanded"); + expect(screen.queryByRole("group")).toBeNull(); + }); + it("keeps native container focus through visibility and removal changes", () => { + const reveal = render(revealTree(false)); + act(() => screen.getByRole("tree").focus()); + activeTreeItem("Top"); + reveal.rerender(revealTree(true)); + activeTreeItem("Top"); + fireEvent.click(treeItem("Parent")); + reveal.rerender(revealTree(false)); + reveal.rerender(revealTree(true)); + activeTreeItem("Parent"); + reveal.unmount(); + const removalTree = (showLeaf: boolean): React.JSX.Element => ( + + ); + const removal = render(removalTree(true)); + fireEvent.click(treeItem("Leaf")); + removal.rerender(removalTree(false)); + activeTreeItem("Parent"); + }); + it("derives indent guides from focus, selection, and depth", () => { + const nodes: readonly TreeNode[] = ["Alpha", "Beta"].map((branch) => ({ + id: branch, + label: branch, + children: [{ id: `${branch} leaf`, label: `${branch} leaf` }], + })); + const guides = (selectedItemId?: string): React.JSX.Element => ( + + ); + const view = render(guides()); + expect(screen.getByRole("tree")).not.toHaveAttribute( + "aria-activedescendant", + ); + expect(guideSlots("Alpha leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + fireEvent.click(treeItem("Beta leaf")); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + view.rerender(guides("Alpha leaf")); + activeTreeItem("Beta leaf"); + 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); + view.unmount(); + render( + , + ); + fireEvent.click(treeItem("Branch")); + const slots = guideSlots("Leaf"); + expect(slots).toHaveLength(2); + expect(slots[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(slots[1]).toHaveClass(ACTIVE_GUIDE); + }); + it("restores hidden or remounted model focus and its guide", () => { + const focusTree = ( + expanded = true, + showChild = true, + ): React.JSX.Element => ( + + ); + const view = render(focusTree()); + fireEvent.click(treeItem("Child")); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + view.rerender(focusTree(false)); + expect(screen.getByRole("tree")).toHaveAttribute("tabindex", "0"); + view.rerender(focusTree()); + activeTreeItem("Child"); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + fireEvent.click(treeItem("Child")); + view.rerender(focusTree(true, false)); + view.rerender(focusTree()); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + }); + it("updates controlled selection and preserves inactive focus styling", () => { + const selectionTree = (selectedItemId: string): React.JSX.Element => ( + + ); + const selection = render(selectionTree("first")); + selection.rerender(selectionTree("second")); + expect(treeItem("First")).toHaveAttribute("aria-selected", "false"); + expect(treeItem("Second")).toHaveAttribute("aria-selected", "true"); + selection.unmount(); + render(); + const child = treeItem("Child"); + act(() => child.focus()); + expect(child).toHaveClass("ui-tree-item--focused"); + fireEvent.blur(child, { relatedTarget: document.body }); + expect(child).toHaveClass("ui-tree-item--focused"); + expect(screen.getByRole("tree")).not.toHaveClass("ui-tree--focused"); + }); + it("scopes active selection colors to the focused tree", () => { + render( + <> + + + , + ); + const first = screen.getByRole("tree", { name: "First" }); + const second = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(treeItem("First item")); + expect(first).toHaveClass("ui-tree--focused"); + expect(second).not.toHaveClass("ui-tree--focused"); + fireEvent.blur(treeItem("First item"), { + relatedTarget: treeItem("Second item"), + }); + fireEvent.focus(treeItem("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 000000000..c805ab5a1 --- /dev/null +++ b/test/webview/ui/tree.keyboard.test.tsx @@ -0,0 +1,276 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tree, type TreeNode } from "@repo/ui"; + +import { + ControlledTree, + activeTreeItem, + press, + treeItem, +} from "./treeTestHelpers"; + +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 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", + }, +]; + +function NavTree({ + onSelect = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelect?: (itemId: string | undefined) => void; + onExpandedChange?: (itemId: string, expanded: boolean) => void; +}): React.JSX.Element { + const [expandedIds, setExpandedIds] = useState(["alpha"]); + const branchSpy = toBranchSpy(expandedIds, onExpandedChange); + return ( + { + branchSpy(ids); + setExpandedIds(ids); + }} + onSelectedItemChange={onSelect} + /> + ); +} + +describe("Tree keyboard navigation", () => { + it("moves through visible and disabled rows with arrows, Home, and End", () => { + render(); + for (const [from, key, to] of [ + ["Alpha", "ArrowDown", "Disabled"], + ["Disabled", "ArrowDown", "Apricot"], + ["Apricot", "ArrowDown", "Amber"], + ["Amber", "End", "Bravo"], + ["Bravo", "Home", "Alpha"], + ["Alpha", "ArrowUp", "Alpha"], + ] as const) { + press(from, key, to); + } + const disabled = treeItem("Disabled"); + act(() => disabled.focus()); + press("Disabled", "ArrowDown", "Apricot"); + act(() => disabled.focus()); + press("Disabled", "ArrowUp", "Alpha"); + }); + it("expands, enters, returns to, and collapses a branch", () => { + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + press("Beta", "ArrowRight", "Blue"); + press("Blue", "ArrowLeft", "Beta"); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowLeft" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + it("uses VS Code Enter and Space behavior for each expansion mode", () => { + const onSelect = vi.fn(); + const onExpandedIdsChange = vi.fn(); + const tree = (expandMode?: "doubleClick"): React.JSX.Element => ( + + ); + const view = render(tree()); + fireEvent.keyDown(treeItem("Beta"), { key: "Enter" }); + expect(onSelect).toHaveBeenLastCalledWith("beta"); + expect(onExpandedIdsChange).toHaveBeenLastCalledWith(["beta"]); + onSelect.mockClear(); + onExpandedIdsChange.mockClear(); + fireEvent.keyDown(treeItem("Beta"), { key: " " }); + expect(onSelect).not.toHaveBeenCalled(); + expect(onExpandedIdsChange).toHaveBeenLastCalledWith(["beta"]); + view.rerender(tree("doubleClick")); + onExpandedIdsChange.mockClear(); + fireEvent.keyDown(treeItem("Beta"), { key: "Enter" }); + expect(onSelect).toHaveBeenLastCalledWith("beta"); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); + }); + it("delegates host shortcuts and embedded control keys", () => { + const captured: string[] = []; + const onSelect = vi.fn(); + render( + { + if (event.ctrlKey && (event.key === "c" || event.key === "x")) { + captured.push(event.key); + event.preventDefault(); + } + }} + onSelectedItemChange={onSelect} + nodes={NAV_NODES} + />, + ); + fireEvent.click(treeItem("Alpha")); + for (const key of ["c", "x"]) { + fireEvent.keyDown(treeItem("Alpha"), { key, ctrlKey: true }); + } + expect(captured).toEqual(["c", "x"]); + activeTreeItem("Alpha"); + const button = screen.getByRole("button", { name: "Action" }); + fireEvent.keyDown(button, { key: "Enter" }); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(fireEvent.keyDown(button, { key: "a" })).toBe(true); + }); + it("moves by viewport pages and clamps at the final row", () => { + render( +
+ ({ + id: `row-${index}`, + label: `Row ${index}`, + }))} + /> +
, + ); + Object.defineProperty(screen.getByTestId("scroller"), "clientHeight", { + value: 5 * 22, + }); + for (const [from, key, to] of [ + ["Row 0", "PageDown", "Row 5"], + ["Row 5", "PageDown", "Row 10"], + ["Row 10", "PageDown", "Row 11"], + ["Row 11", "PageUp", "Row 6"], + ] as const) { + press(from, key, to); + } + }); + it("clears single selection and focus with Escape", () => { + const onSelectedItemChange = vi.fn(); + render(); + fireEvent.click(treeItem("Child")); + 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("follows reordered data", () => { + const pair = (reversed: boolean): React.JSX.Element => { + const nodes = ["One", "Two"].map((label) => ({ id: label, label })); + return ( + + ); + }; + const view = render(pair(false)); + press("One", "ArrowDown", "Two"); + view.rerender(pair(true)); + press("Two", "ArrowDown", "One"); + }); + it("rejects duplicate ids across the full tree", () => { + for (const nodes of [ + [ + { id: "dup", label: "One" }, + { id: "dup", label: "Two" }, + ], + [ + { + id: "collapsed", + label: "Collapsed", + children: [ + { id: "hidden", label: "One" }, + { id: "hidden", label: "Two" }, + ], + }, + ], + ] satisfies ReadonlyArray) { + expect(() => + render(), + ).toThrow(/must be unique/i); + } + }); + describe("type-ahead", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + it("uses current labels, wraps case-insensitively, and buffers input", () => { + const names = (label: string): React.JSX.Element => ( + + ); + const renamed = render(names("Amber")); + renamed.rerender(names("Cedar")); + press("Alpha", "c", "Cedar"); + renamed.unmount(); + render(); + press("Amber", "B", "Beta"); + press("Beta", "b", "Bravo"); + press("Bravo", "b", "Beta"); + fireEvent.keyDown(treeItem("Beta"), { key: "a" }); + void act(() => vi.advanceTimersByTime(800)); + press("Beta", "a", "Alpha"); + }); + it("keeps a longer matching query focused", () => { + render( + , + ); + press("Amber", "a", "Amethyst"); + press("Amethyst", "m", "Amethyst"); + }); + it("clears a multi-character buffer after the timeout", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "a" }); + press("Apricot", "m", "Amber"); + void act(() => vi.advanceTimersByTime(800)); + press("Amber", "a", "Alpha"); + }); + }); +}); diff --git a/test/webview/ui/tree.rows.test.tsx b/test/webview/ui/tree.rows.test.tsx new file mode 100644 index 000000000..b0e57c0d4 --- /dev/null +++ b/test/webview/ui/tree.rows.test.tsx @@ -0,0 +1,195 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { Tree } from "@repo/ui"; + +import { + BRANCH_NODES, + ControlledTree, + chevron, + treeItem, +} from "./treeTestHelpers"; + +describe("Tree rows", () => { + it("forwards row content and selected styling", () => { + render( + Rich item, textValue: "Rich item" }, + { + id: "selected", + label: "Selected", + className: "custom-item", + action: , + }, + ]} + />, + ); + expect(treeItem("Rich item")).toBeInTheDocument(); + expect( + treeItem("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), + ).toHaveClass("codicon-file"); + const selected = treeItem("Selected"); + expect(selected).toHaveClass("ui-tree-item", "custom-item"); + expect(selected).toHaveAttribute("aria-selected", "true"); + expect(selected.firstElementChild).toHaveClass("ui-tree-item__row"); + expect( + screen.getByRole("button", { name: "Selected action" }).parentElement, + ).toHaveClass("ui-tree-item__action"); + }); + it("treats an empty children array as an expandable branch", () => { + const onExpandedIdsChange = 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(onExpandedIdsChange).toHaveBeenCalledWith(["lazy"]); + }); + it("uses the configured click expansion mode and isolates the twistie", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + const single = render( + , + ); + fireEvent.click(treeItem("Parent")); + expect(onSelectedItemChange).toHaveBeenCalledWith("parent"); + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(screen.queryByRole("treeitem", { name: "Child" })).toBeNull(); + single.unmount(); + const onExpandedIdsChange = vi.fn(); + render( + , + ); + fireEvent.click(treeItem("Branch"), { detail: 1 }); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); + fireEvent.click(treeItem("Branch"), { detail: 2 }); + expect(onExpandedIdsChange).toHaveBeenCalledWith([]); + onExpandedIdsChange.mockClear(); + onSelectedItemChange.mockClear(); + fireEvent.click(chevron("Branch")); + expect(onExpandedIdsChange).toHaveBeenCalledWith([]); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + it("keeps a trailing action in the tab flow without activating the row", async () => { + const onAction = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedIdsChange = vi.fn(); + render( + + Delete + + ), + }, + ]} + expandedIds={["branch"]} + onExpandedIdsChange={onExpandedIdsChange} + onSelectedItemChange={onSelectedItemChange} + />, + ); + const user = userEvent.setup(); + const action = screen.getByRole("button", { name: "Delete" }); + expect(treeItem("Branch")).toHaveAccessibleName("Branch"); + expect(action.parentElement).toHaveAttribute("inert"); + fireEvent.click(treeItem("Branch")); + onSelectedItemChange.mockClear(); + onExpandedIdsChange.mockClear(); + expect(action.parentElement).not.toHaveAttribute("inert"); + await user.tab(); + expect(document.activeElement).toBe(action); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(screen.getByRole("tree")); + fireEvent.click(action); + expect(onAction).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); + }); + it("keeps disabled rows focusable and their actions inert", () => { + const onAction = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedIdsChange = vi.fn(); + render( + Delete, + }, + ]} + expandedIds={[]} + onExpandedIdsChange={onExpandedIdsChange} + onSelectedItemChange={onSelectedItemChange} + />, + ); + const disabled = treeItem("Branch"); + const action = screen.getByRole("button", { name: "Delete" }); + act(() => disabled.focus()); + for (const key of ["Enter", " "]) fireEvent.keyDown(disabled, { key }); + fireEvent.click(disabled); + expect(document.activeElement).toBe(disabled); + expect(disabled).toHaveAttribute("aria-disabled", "true"); + expect(disabled).not.toHaveAttribute("aria-selected"); + expect(action.parentElement).toHaveAttribute("inert"); + expect(onAction).not.toHaveBeenCalled(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); + }); + it("leaves disabled descendants unchanged during recursive expansion", () => { + const onExpandedIdsChange = vi.fn(); + render( + , + ); + fireEvent.click(chevron("Root"), { altKey: true }); + expect(onExpandedIdsChange).toHaveBeenCalledWith(["root", "enabled"]); + }); +}); diff --git a/test/webview/ui/tree.selection.test.tsx b/test/webview/ui/tree.selection.test.tsx new file mode 100644 index 000000000..7bf6257ac --- /dev/null +++ b/test/webview/ui/tree.selection.test.tsx @@ -0,0 +1,275 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { Tree, type TreeNode } from "@repo/ui"; + +import { + ACTIVE_GUIDE, + BRANCH_NODES, + activeTreeItem, + chevron, + guideSlots, + press, + treeItem, +} from "./treeTestHelpers"; + +describe("Tree multi-select", () => { + const MULTI_NODES: readonly TreeNode[] = ["One", "Two", "Three", "Four"].map( + (label) => ({ id: label.toLowerCase(), label }), + ); + const MultiTree = ({ + onSelectedItemsChange = vi.fn(), + }: { + onSelectedItemsChange?: (ids: readonly string[]) => void; + }): React.JSX.Element => { + const [ids, setIds] = useState(["one"]); + return ( + { + onSelectedItemsChange(next); + setIds(next); + }} + /> + ); + }; + const selectedNames = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-selected") === "true") + .map((item) => item.getAttribute("aria-label") ?? ""); + it("toggles, replaces, and keeps model focus on the last interacted row", () => { + const onSelectedItemsChange = vi.fn(); + render(); + expect(screen.getByRole("tree")).toHaveAttribute( + "aria-multiselectable", + "true", + ); + expect(selectedNames()).toEqual(["One"]); + 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"]); + fireEvent.click(treeItem("Two"), { ctrlKey: true }); + activeTreeItem("Two"); + }); + it("extends and shrinks anchored ranges with click and arrow modifiers", () => { + render(); + fireEvent.click(treeItem("Two")); + fireEvent.click(treeItem("Four"), { shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + fireEvent.click(treeItem("Three"), { shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three"]); + press("Three", "ArrowDown", "Four", true); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + }); + it("extends ranges across disabled rows and shrinks from the anchor", () => { + const nodes: readonly TreeNode[] = [ + { id: "one", label: "One" }, + { id: "disabled", label: "Disabled", disabled: true }, + { id: "three", label: "Three" }, + { id: "four", label: "Four" }, + ]; + const RangeTree = (): React.JSX.Element => { + const [ids, setIds] = useState(["one"]); + return ( + + ); + }; + render(); + press("One", "ArrowDown", "Disabled", true); + expect(selectedNames()).toEqual(["One"]); + press("Disabled", "ArrowDown", "Three", true); + expect(selectedNames()).toEqual(["One", "Three"]); + press("Three", "ArrowDown", "Four", true); + expect(selectedNames()).toEqual(["One", "Three", "Four"]); + press("Four", "ArrowUp", "Three", true); + expect(selectedNames()).toEqual(["One", "Three"]); + }); + it("keeps the range anchor when controlled selection order changes", () => { + const onSelectedItemsChange = vi.fn(); + const view = render( + , + ); + view.rerender( + , + ); + fireEvent.click(treeItem("Four"), { shiftKey: true }); + expect(onSelectedItemsChange).toHaveBeenCalledWith([ + "one", + "two", + "three", + "four", + ]); + }); + it("uses the configured selection modifier for keyboard toggles", () => { + const onSelectedItemsChange = vi.fn(); + render( + , + ); + fireEvent.keyDown(treeItem("Two"), { + key: "Enter", + ctrlKey: true, + shiftKey: true, + }); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["two"]); + fireEvent.keyDown(treeItem("Two"), { + key: "Enter", + altKey: true, + shiftKey: true, + }); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["one", "two"]); + }); + it("starts a range at controlled selection but not for Shift+Home or Shift+End", () => { + const first = render(); + fireEvent.click(treeItem("Three"), { shiftKey: true }); + expect(selectedNames()).toEqual(["One", "Two", "Three"]); + first.unmount(); + render(); + fireEvent.click(treeItem("Two")); + press("Two", "End", "Four", true); + expect(selectedNames()).toEqual(["Two"]); + press("Four", "Home", "One", true); + expect(selectedNames()).toEqual(["Two"]); + }); + it("gives selection modifiers precedence over expansion", () => { + const onExpandedIdsChange = vi.fn(); + render( + , + ); + for (const init of [{ ctrlKey: true }, { shiftKey: true }]) { + fireEvent.click(treeItem("Branch"), init); + } + fireEvent.click(chevron("Branch"), { ctrlKey: true }); + expect(onExpandedIdsChange).not.toHaveBeenCalled(); + }); + it("scopes Ctrl+A to enabled visible siblings before their parent", () => { + const ScopedTree = (): React.JSX.Element => { + const [ids, setIds] = useState([]); + return ( + + ); + }; + const scoped = render(); + act(() => treeItem("One").focus()); + fireEvent.keyDown(treeItem("One"), { key: "a", ctrlKey: true }); + expect(selectedNames()).toEqual(["One", "Three"]); + fireEvent.keyDown(treeItem("One"), { key: "a", ctrlKey: true }); + expect(selectedNames()).toEqual(["Parent", "One", "Three"]); + act(() => treeItem("Parent").focus()); + fireEvent.keyDown(treeItem("Parent"), { key: "a", ctrlKey: true }); + expect(selectedNames()).toEqual(["Parent", "One", "Three", "Outside"]); + scoped.unmount(); + render(); + expect( + fireEvent.keyDown(treeItem("One"), { + key: "A", + ctrlKey: true, + shiftKey: true, + }), + ).toBe(true); + expect(selectedNames()).toEqual(["One"]); + }); + it("clears selection and focus with Escape, then leaves the key to the host", () => { + render(); + act(() => treeItem("One").focus()); + expect(fireEvent.keyDown(treeItem("One"), { key: "Escape" })).toBe(false); + expect(selectedNames()).toEqual([]); + expect(treeItem("One")).not.toHaveClass("ui-tree-item--focused"); + expect(fireEvent.keyDown(treeItem("One"), { key: "Escape" })).toBe(true); + }); + it("lights guides for every selected row and ignores modifiers in single-select", () => { + const guides = render( + , + ); + expect(guideSlots("A")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("B")[0]).toHaveClass(ACTIVE_GUIDE); + guides.unmount(); + const onSelectedItemChange = vi.fn(); + render( + , + ); + expect(screen.getByRole("tree")).not.toHaveAttribute( + "aria-multiselectable", + ); + fireEvent.click(treeItem("Two"), { ctrlKey: true }); + expect(onSelectedItemChange).toHaveBeenCalledWith("two"); + }); +}); diff --git a/test/webview/ui/tree.sticky.test.tsx b/test/webview/ui/tree.sticky.test.tsx new file mode 100644 index 000000000..c37f8a90e --- /dev/null +++ b/test/webview/ui/tree.sticky.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { Tree } from "@repo/ui"; + +import { BRANCH_NODES } from "./treeTestHelpers"; + +describe("Tree sticky scroll", () => { + it("renders an empty sticky anchor before scrolling", () => { + render( + , + ); + expect(document.querySelector(".ui-tree-sticky")).not.toBeNull(); + expect(document.querySelector(".ui-tree-sticky__rows")).toBeNull(); + }); + it("preserves pinned pointer, focus, and accessibility behavior", () => { + const onExpandedIdsChange = vi.fn(); + const onSelectedItemChange = vi.fn(); + render( +
+ ({ + id: `file-${index}`, + label: `file-${index}`, + })), + }, + ], + }, + ]} + expandedIds={["alpha", "src"]} + onExpandedIdsChange={onExpandedIdsChange} + onSelectedItemChange={onSelectedItemChange} + /> +
, + ); + const scroller = screen.getByTestId("scroller"); + Object.defineProperty(scroller, "clientHeight", { value: 10 * 22 }); + 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(document.querySelector(".ui-tree-sticky__shadow")).not.toBeNull(); + expect(widget).not.toHaveAttribute("aria-hidden"); + expect(widget).toHaveAttribute("tabindex", "0"); + expect(pinned[0]).toHaveAttribute("role", "treeitem"); + expect(pinned[0]).toHaveAccessibleName("alpha"); + expect(pinned[0]).toHaveAttribute("aria-level", "1"); + expect(pinned[0]).toHaveAttribute("aria-posinset", "1"); + expect(pinned[0]).toHaveAttribute("aria-setsize", "1"); + expect(pinned[0]).toHaveAttribute("aria-selected", "false"); + expect(pinned[1]).toHaveAttribute("aria-expanded", "true"); + const twistie = pinned[1]?.querySelector(".ui-tree-item__chevron"); + expect(twistie).not.toBeNull(); + fireEvent.click(twistie!); + expect(onExpandedIdsChange).toHaveBeenCalledWith(["alpha"]); + expect(onSelectedItemChange).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).toHaveBeenCalledWith("src"); + onSelectedItemChange.mockClear(); + fireEvent.click(pinned[0]); + expect(onSelectedItemChange).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).toHaveBeenCalledWith("alpha"); + expect(onExpandedIdsChange).toHaveBeenCalledOnce(); + const realAlpha = document.querySelector( + '[data-tree-id="alpha"]', + ); + expect(screen.getByRole("tree")).toHaveAttribute( + "aria-activedescendant", + realAlpha?.id, + ); + expect(document.activeElement).toBe(screen.getByRole("tree")); + vi.mocked(scroller.scrollBy).mockClear(); + fireEvent.click(pinned[0], { ctrlKey: true }); + expect(scroller.scrollBy).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/webview/ui/treeCommands.test.ts b/test/webview/ui/treeCommands.test.ts new file mode 100644 index 000000000..430f089f6 --- /dev/null +++ b/test/webview/ui/treeCommands.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + 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: "disabled", label: "Disabled", disabled: true, children: [] }, + ], + }, + { id: "last", label: "Last" }, +]; +const model = createTreeModel(NODES, new Set(["parent", "disabled"])); +const row = (id: string): TreeRowModel => { + const result = model.rowsById.get(id); + if (!result) throw new Error(`Expected row ${id}.`); + return result; +}; +const modifiers = { + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, +}; +const pointerInput = ( + overrides: Partial = {}, +): PointerCommandInput => ({ + row: row("parent"), + source: "row", + onTwistie: false, + detail: 1, + expandMode: "singleClick", + multiSelect: false, + multiSelectModifier: "ctrlCmd", + modifiers, + ...overrides, +}); +const keyboardInput = ( + overrides: Partial = {}, +): KeyboardCommandInput => ({ + key: "ArrowDown", + row: row("parent"), + rows: model.rows, + fromAction: false, + selectedCount: 0, + hasFocusedRow: false, + expandMode: "singleClick", + multiSelect: false, + multiSelectModifier: "ctrlCmd", + modifiers, + ...overrides, +}); + +describe("pointerCommands", () => { + it("preserves pointer command ordering and expansion precedence", () => { + expect(pointerCommands(pointerInput())).toEqual([ + { type: "focus", id: "parent" }, + { type: "select", id: "parent" }, + { type: "toggle", id: "parent", recursive: false }, + ]); + expect( + pointerCommands( + pointerInput({ + onTwistie: true, + detail: 2, + modifiers: { ...modifiers, altKey: true }, + }), + ), + ).toEqual([ + { type: "focus", id: "parent" }, + { type: "toggle", id: "parent", recursive: true }, + ]); + expect( + pointerCommands(pointerInput({ expandMode: "doubleClick", detail: 1 })), + ).toEqual([ + { type: "focus", id: "parent" }, + { type: "select", id: "parent" }, + ]); + }); + + it("gives selection gestures precedence over twistie expansion", () => { + expect( + pointerCommands( + pointerInput({ + multiSelect: true, + onTwistie: true, + modifiers: { ...modifiers, ctrlKey: true, shiftKey: true }, + }), + ), + ).toEqual([ + { type: "focus", id: "parent" }, + { + type: "select", + id: "parent", + options: { toggle: true, range: true, preserveHidden: false }, + }, + ]); + }); + + it("preserves sticky focus and selection differences", () => { + expect( + pointerCommands(pointerInput({ source: "sticky", onTwistie: true })), + ).toEqual([ + { type: "focus", id: "parent" }, + { type: "select", id: "parent" }, + { type: "toggle", id: "parent", recursive: false }, + ]); + expect( + pointerCommands( + pointerInput({ + source: "sticky", + multiSelect: true, + modifiers: { ...modifiers, shiftKey: true }, + }), + ), + ).toEqual([ + { + type: "select", + id: "parent", + options: { toggle: false, range: true, preserveHidden: false }, + }, + ]); + }); + + it("ignores disabled rows", () => { + expect(pointerCommands(pointerInput({ row: row("disabled") }))).toEqual([]); + }); +}); + +describe("keyboardCommands", () => { + it.each([ + [ + "ArrowDown", + { type: "move", id: "parent", offset: 1, page: false, extend: true }, + ], + [ + "ArrowUp", + { type: "move", id: "parent", offset: -1, page: false, extend: true }, + ], + [ + "PageDown", + { type: "move", id: "parent", offset: 1, page: true, extend: false }, + ], + [ + "PageUp", + { + type: "move", + id: "parent", + offset: -1, + page: true, + extend: false, + }, + ], + ] as const)("maps %s to movement", (key, command) => { + expect( + keyboardCommands( + keyboardInput({ + key, + modifiers: { ...modifiers, shiftKey: true }, + }), + ), + ).toEqual({ + commands: [command], + preventDefault: true, + focusRowElementId: undefined, + }); + }); + + it("maps tree hierarchy navigation", () => { + expect( + keyboardCommands(keyboardInput({ key: "ArrowRight" })).commands, + ).toEqual([{ type: "focus", id: "child" }]); + expect( + keyboardCommands(keyboardInput({ key: "ArrowLeft", row: row("child") })) + .commands, + ).toEqual([{ type: "focus", id: "parent" }]); + expect( + keyboardCommands( + keyboardInput({ key: "ArrowLeft", row: row("disabled") }), + ).commands, + ).toEqual([{ type: "focus", id: "parent" }]); + }); + + it("maps Enter and Space without conflating selection and expansion", () => { + expect(keyboardCommands(keyboardInput({ key: "Enter" })).commands).toEqual([ + { type: "select", id: "parent", options: { toggle: false } }, + { type: "toggle", id: "parent" }, + ]); + expect( + keyboardCommands( + keyboardInput({ key: "Enter", expandMode: "doubleClick" }), + ).commands, + ).toEqual([{ type: "select", id: "parent", options: { toggle: false } }]); + expect( + keyboardCommands( + keyboardInput({ + key: "Enter", + multiSelect: true, + modifiers: { ...modifiers, ctrlKey: true, shiftKey: true }, + }), + ).commands, + ).toEqual([{ type: "select", id: "parent", options: { toggle: true } }]); + expect(keyboardCommands(keyboardInput({ key: " " })).commands).toEqual([ + { type: "toggle", id: "parent" }, + ]); + expect( + keyboardCommands( + keyboardInput({ + key: " ", + row: row("child"), + multiSelect: true, + modifiers: { ...modifiers, ctrlKey: true }, + }), + ).commands, + ).toEqual([{ type: "select", id: "child", options: { toggle: true } }]); + }); + + it("maps selection, dismissal, and typeahead branches", () => { + expect( + keyboardCommands( + keyboardInput({ + key: "a", + multiSelect: true, + modifiers: { ...modifiers, ctrlKey: true }, + }), + ).commands, + ).toEqual([{ type: "selectScope", id: "parent" }]); + expect( + keyboardCommands( + keyboardInput({ key: "Escape", selectedCount: 1, hasFocusedRow: true }), + ), + ).toMatchObject({ + commands: [{ type: "dismiss", clearSelection: true, clearFocus: true }], + preventDefault: true, + }); + expect(keyboardCommands(keyboardInput({ key: "B" })).commands).toEqual([ + { type: "typeahead", id: "parent", key: "b" }, + ]); + }); + + it("delegates embedded action keys except navigation", () => { + expect( + keyboardCommands(keyboardInput({ key: "Enter", fromAction: true })), + ).toEqual({ + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }); + expect( + keyboardCommands(keyboardInput({ key: "ArrowDown", fromAction: true })), + ).toMatchObject({ + focusRowElementId: "parent", + preventDefault: true, + }); + }); +}); diff --git a/test/webview/ui/treeController.test.tsx b/test/webview/ui/treeController.test.tsx new file mode 100644 index 000000000..6e2185560 --- /dev/null +++ b/test/webview/ui/treeController.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useRef, useState } from "react"; +import { describe, expect, it } from "vitest"; + +import { useTreeAdapter } from "@repo/ui/components/Tree/useTreeAdapter"; + +function ControlledTreeController(): React.JSX.Element { + const [selectedItemIds, setSelectedItemIds] = useState([ + "one", + ]); + const treeRef = useRef(null); + const adapter = useTreeAdapter({ + nodes: [ + { id: "one", label: "One" }, + { id: "two", label: "Two" }, + { id: "three", label: "Three" }, + ], + expandedIds: [], + expandMode: "singleClick", + multiSelect: true, + multiSelectModifier: "ctrlCmd", + selectedItemIds, + onSelectedItemsChange: setSelectedItemIds, + treeRef, + }); + + return ( +
+ {adapter.tabStopId} + {selectedItemIds.join(",")} + {adapter.model.rows.map((row) => ( +
adapter.registerRow(row.node.id, element)} + /> + ))} +
+ ); +} + +describe("useTreeAdapter", () => { + it("keeps the Shift+Arrow target as the tab target after the selection echo", () => { + render(); + const one = document.querySelector('[data-tree-id="one"]'); + if (!one) throw new Error("Expected the first row."); + + fireEvent.keyDown(one, { key: "ArrowDown", shiftKey: true }); + + expect(screen.getByTestId("selection")).toHaveTextContent("one,two"); + expect(screen.getByTestId("tab-stop")).toHaveTextContent("two"); + }); +}); diff --git a/test/webview/ui/treeInteractionState.test.ts b/test/webview/ui/treeInteractionState.test.ts new file mode 100644 index 000000000..40b51c5cb --- /dev/null +++ b/test/webview/ui/treeInteractionState.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + type TreeModel, + type TreeNode, +} from "@repo/ui/components/Tree/treeModel"; +import { + deriveTreeInteractionView, + initialTreeInteractionState, + rowFocused, + transitionTree, +} from "@repo/ui/components/Tree/treeTransition"; + +const NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + { id: "last", label: "Last" }, +]; + +const derive = ( + state: ReturnType, + model: TreeModel, + controlledIds: readonly string[] = [], +) => deriveTreeInteractionView(state, model, controlledIds); + +const focusedChild = (controlledIds: readonly string[] = []) => { + const model = createTreeModel(NODES, new Set(["parent"])); + const child = model.rowsById.get("child"); + if (!child) throw new Error("Expected child row."); + return { + model, + state: rowFocused( + initialTreeInteractionState(controlledIds), + child, + JSON.stringify(controlledIds), + ), + }; +}; + +describe("tree interaction transitions", () => { + it("keeps hidden focus and falls back only when the row is removed", () => { + const { state } = focusedChild(); + const collapsed = createTreeModel(NODES, new Set()); + const hidden = derive(state, collapsed); + expect(hidden.state).toBe(state); + expect(hidden.focusedId).toBeUndefined(); + expect(hidden.tabStopId).toBeUndefined(); + expect(hidden.state.focusTarget?.id).toBe("child"); + + const removed = createTreeModel( + [{ id: "parent", label: "Parent", children: [] }, NODES[1]], + new Set(["parent"]), + ); + const reconciled = derive(state, removed); + expect(reconciled.state).not.toBe(state); + expect(reconciled.state.focusTarget?.id).toBe("parent"); + expect(reconciled.state.tabTargetId).toBe("parent"); + }); + + it("keeps the anchor when controlled selection changes order only", () => { + const model = createTreeModel(NODES, new Set(["parent"])); + const state = initialTreeInteractionState(["child", "last"]); + expect(derive(state, model, ["last", "child"]).anchorId).toBe("child"); + }); + + it("gives an unclaimed controlled selection precedence over the tab target", () => { + const model = createTreeModel(NODES, new Set(["parent"])); + const parent = model.rowsById.get("parent"); + if (!parent) throw new Error("Expected parent row."); + const state = rowFocused(initialTreeInteractionState([]), parent, "[]"); + expect(derive(state, model, ["last"]).tabStopId).toBe("last"); + + const emitted = transitionTree(state, [{ type: "select", id: "last" }], { + model, + controlledIds: [], + expandedIds: ["parent"], + multiSelect: false, + now: 0, + }); + expect(derive(emitted.state, model, ["last"]).tabStopId).toBe("parent"); + }); + + it("resets the anchor on Escape and clears focus only when requested", () => { + const { model, state } = focusedChild(["child"]); + const input = { + model, + controlledIds: ["child"], + expandedIds: ["parent"], + multiSelect: false, + now: 0, + }; + const selectionOnly = transitionTree( + state, + [{ type: "dismiss", clearSelection: true, clearFocus: false }], + input, + ).state; + expect(selectionOnly.focusTarget?.id).toBe("child"); + expect(selectionOnly.dismissedSelectionKey).toBe("[]"); + expect(selectionOnly).toMatchObject({ + anchorKey: "[]", + anchorId: undefined, + }); + + const cleared = transitionTree( + state, + [{ type: "dismiss", clearSelection: true, clearFocus: true }], + input, + ).state; + expect(cleared.focusTarget).toBeUndefined(); + expect(cleared.tabTargetId).toBe("child"); + }); +}); diff --git a/test/webview/ui/treeModel.test.tsx b/test/webview/ui/treeModel.test.tsx new file mode 100644 index 000000000..cd4f2ea66 --- /dev/null +++ b/test/webview/ui/treeModel.test.tsx @@ -0,0 +1,72 @@ +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" }, +]; +const rowsById = (expandedIds: readonly string[]) => + Object.fromEntries( + flattenVisibleRows(NODES, new Set(expandedIds)).map((row) => [ + row.node.id, + row, + ]), + ); +describe("flattenVisibleRows", () => { + it.each([ + [["src"], ["src", "tree", "tests", "readme"]], + [ + ["src", "tests"], + ["src", "tree", "tests", "unit", "readme"], + ], + ] as const)( + "projects expanded subtrees %j in tree order", + (expanded, ids) => { + expect( + flattenVisibleRows(NODES, new Set(expanded)).map((row) => row.node.id), + ).toEqual(ids); + }, + ); + it("derives row metadata from hierarchy, labels, and expansion", () => { + const rows = rowsById(["src", "tests"]); + expect(rows.src).toMatchObject({ + level: 1, + pathIds: [], + posInSet: 1, + setSize: 2, + textValue: "src", + expanded: true, + }); + expect(rows.unit).toMatchObject({ + level: 3, + pathIds: ["src", "tests"], + posInSet: 1, + setSize: 1, + }); + expect(rows.readme).toMatchObject({ + posInSet: 2, + setSize: 2, + expanded: undefined, + }); + expect(rows.tree.textValue).toBe("Tree.tsx"); + expect(rows.tests.expanded).toBe(true); + expect(parentId(rows.unit)).toBe("tests"); + expect(parentId(rows.src)).toBeUndefined(); + }); +}); diff --git a/test/webview/ui/treeStickyState.test.ts b/test/webview/ui/treeStickyState.test.ts new file mode 100644 index 000000000..dfec1bb86 --- /dev/null +++ b/test/webview/ui/treeStickyState.test.ts @@ -0,0 +1,58 @@ +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 ROWS = flattenVisibleRows( + [ + { + 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" }, + ], + new Set(["a", "b", "c"]), +); +const px = (rows: number): number => rows * ROW_HEIGHT_PX; +const VIEWPORT = px(10); +describe("computeStickyState", () => { + it.each([ + ["before scrolling", 0, VIEWPORT, 7, []], + ["without a viewport", px(2), 0, 7, []], + ["in the first subtree", px(1), VIEWPORT, 7, ["a"]], + ["at the deepest subtree", px(9), VIEWPORT, 7, ["a", "b", "c"]], + ["at the item cap", px(9), VIEWPORT, 2, ["a", "b"]], + ["at 40% of the viewport", px(9), px(1.5) / 0.4, 7, ["a"]], + ["past every branch", px(14), VIEWPORT, 7, []], + ] as const)( + "pins the expected chain %s", + (_case, scrollTop, height, cap, ids) => { + expect(computeStickyState(ROWS, scrollTop, height, cap).ids).toEqual(ids); + }, + ); + it("pushes the widget out as the last pinned subtree ends", () => { + 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))); + }); +}); diff --git a/test/webview/ui/treeTestHelpers.tsx b/test/webview/ui/treeTestHelpers.tsx new file mode 100644 index 000000000..f83d5afd7 --- /dev/null +++ b/test/webview/ui/treeTestHelpers.tsx @@ -0,0 +1,86 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { useState } from "react"; +import { expect, vi } from "vitest"; + +import { Tree, type TreeNode } from "@repo/ui"; + +export const ACTIVE_GUIDE = "ui-tree-item__indent-slot--active"; + +export const treeItem = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + +export const activeTreeItem = (name: string): void => { + const item = treeItem(name); + const tree = screen.getByRole("tree"); + expect(tree).toHaveAttribute("aria-activedescendant", item.id); + expect(document.activeElement).toBe(tree); +}; + +export const press = ( + from: string, + key: string, + to: string, + shiftKey = false, +): void => { + fireEvent.keyDown(treeItem(from), { key, shiftKey }); + activeTreeItem(to); +}; + +export const guideSlots = (name: string): Element[] => [ + ...treeItem(name).querySelectorAll(".ui-tree-item__indent-slot"), +]; + +export const chevron = (name: string): Element => { + const element = treeItem(name).querySelector(".ui-tree-item__chevron"); + if (!element) throw new Error(`Expected a twistie for ${name}.`); + return element; +}; + +const CONTROLLED_NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [ + { id: "child", label: "Child" }, + { id: "disabled", label: "Disabled", disabled: true }, + ], + }, + { id: "last", label: "Last" }, +]; + +export function ControlledTree({ + onSelectedItemChange = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelectedItemChange?: (itemId: string | undefined) => void; + onExpandedChange?: (expanded: boolean) => void; +}): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState( + "child", + ); + const [expandedIds, setExpandedIds] = useState(["parent"]); + return ( + { + onExpandedChange(ids.includes("parent")); + setExpandedIds(ids); + }} + selectedItemId={selectedItemId} + onSelectedItemChange={(id) => { + onSelectedItemChange(id); + setSelectedItemId(id); + }} + /> + ); +} + +export const BRANCH_NODES: readonly TreeNode[] = [ + { + id: "branch", + label: "Branch", + children: [{ id: "child", label: "Child" }], + }, +];