From b6069dadab11923fde2400f6c0ef560d46c2d6c3 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sun, 9 Aug 2026 15:46:20 +0300 Subject: [PATCH 1/6] feat(ui): add accessible tree suite Add the Tree, TreeItem, and TreeGroup compound components: ARIA tree semantics with roving tabindex, hierarchy navigation, buffered type-ahead, and controlled selection and expansion. Styling matches VS Code tree geometry and states across the shared, Modern, and stable styles, with an explorer variant for icon-less branch rows. Cover the suite with unit tests and pixel stories across all four themes; the nested story forces real :hover via storybook-addon-pseudo-states so screenshots capture hover and selection together. --- .storybook/main.ts | 6 +- package.json | 1 + packages/ui/README.md | 82 +- packages/ui/src/components/Tree/Tree.css | 155 ++++ .../ui/src/components/Tree/Tree.modern.css | 19 + .../ui/src/components/Tree/Tree.stable.css | 14 + .../ui/src/components/Tree/Tree.stories.tsx | 132 +++ packages/ui/src/components/Tree/Tree.tsx | 122 +++ .../src/components/Tree/TreeItem.stories.tsx | 51 ++ packages/ui/src/components/Tree/TreeItem.tsx | 222 ++++++ packages/ui/src/components/Tree/TreeStore.ts | 523 ++++++++++++ packages/ui/src/components/Tree/context.ts | 23 + packages/ui/src/index.ts | 2 + packages/ui/src/tokens.css | 44 + packages/ui/storybook/Tree.demo.tsx | 97 +++ packages/ui/tsconfig.json | 2 +- pnpm-lock.yaml | 15 + pnpm-workspace.yaml | 1 + test/webview/ui/tree.test.tsx | 752 ++++++++++++++++++ 19 files changed, 2253 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/components/Tree/Tree.css create mode 100644 packages/ui/src/components/Tree/Tree.modern.css create mode 100644 packages/ui/src/components/Tree/Tree.stable.css create mode 100644 packages/ui/src/components/Tree/Tree.stories.tsx create mode 100644 packages/ui/src/components/Tree/Tree.tsx create mode 100644 packages/ui/src/components/Tree/TreeItem.stories.tsx create mode 100644 packages/ui/src/components/Tree/TreeItem.tsx create mode 100644 packages/ui/src/components/Tree/TreeStore.ts create mode 100644 packages/ui/src/components/Tree/context.ts create mode 100644 packages/ui/storybook/Tree.demo.tsx create mode 100644 test/webview/ui/tree.test.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts index 5b262100d4..54f71a684c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -6,7 +6,11 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: ["../packages/*/src/**/*.stories.@(ts|tsx)"], - addons: ["@storybook/addon-a11y", "@storybook/addon-docs"], + addons: [ + "@storybook/addon-a11y", + "@storybook/addon-docs", + "storybook-addon-pseudo-states", + ], framework: { name: "@storybook/react-vite", options: {}, diff --git a/package.json b/package.json index 660003403e..2d8c339e43 100644 --- a/package.json +++ b/package.json @@ -844,6 +844,7 @@ "react": "catalog:", "react-dom": "catalog:", "storybook": "catalog:", + "storybook-addon-pseudo-states": "catalog:", "typescript": "catalog:", "typescript-eslint": "^8.66.0", "utf-8-validate": "^6.0.6", diff --git a/packages/ui/README.md b/packages/ui/README.md index ec79369344..58ee1098cf 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,12 +38,77 @@ Every component forwards `className` and `style` to its root element, and default rules use single-class specificity, so a consumer class imported after the library overrides any default (width, height, spacing). -Where VS Code's stable rendering and its Modern UI preview -(`workbench.experimental.modernUI`) diverge, components follow Modern UI, -and new components should too. Webviews get no signal for the setting, so -the default cannot follow the host. Until the design settles, -`data-ui-style="stable"` on the document root restores the stable-parity -menu motion; Storybook's "UI style" toolbar switch toggles it live. +VS Code currently uses its stable UI by default; Modern UI remains behind the +experimental `workbench.experimental.modernUI` setting. `@repo/ui` +intentionally uses Modern UI as its package default because webviews receive no +host signal for that setting. The divergence is isolated: set +`data-ui-style="stable"` on the document root to restore stable row geometry, +focus behavior, and menu motion. Storybook's "UI style" toolbar switch toggles +that override live. + +## Tree + +`Tree` and `TreeItem` form a declarative hierarchy; a row's children are its +child rows: + +```tsx +const [selectedItemId, setSelectedItemId] = useState("src"); +const [expanded, setExpanded] = useState(true); + + + + + +; +``` + +`itemId` carries selection and registry identity. `label` is the row content: +a string also supplies the accessible name and the case-insensitive, buffered +type-ahead key, so matching never depends on rendered DOM text; a `ReactNode` +label must pass `textValue` for those, which the types enforce. `icon` renders +a codicon ahead of the label. `aria-label` or `aria-labelledby` override the +accessible name. + +`expanded` is what makes a row a branch: it adds the twistie and lets the row +nest child rows, including a branch whose children have not loaded yet. Only a +branch may have children, and passing them without `expanded` throws. Because +children are always child rows and never row content, wrapper components, +fragments, and arrays all work. `Tree` controls selection, each `TreeItem` +controls its own expansion, neither defaults, and there is no multi-selection. + +Arrow Up/Down, Home, End, and type-ahead move focus through visible enabled +rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or +returns to the parent. Enter and Space select the focused row and toggle a +branch. Clicking a row does both; clicking the twistie only toggles, leaving +selection in place like the native tree. Interactive content in the trailing +`action` slot is isolated from selection and expansion. + +Navigation order, visibility, and hierarchy are read back from the rendered +rows, so reordering or reparenting needs no extra wiring. Collapsed branches +stay mounted (their `role="group"` container is `hidden`) and every row +renders: the suite is not virtualized, so keep trees to the low thousands of +rows. + +Rows are 22px tall and keep the VS Code twistie gutter, matching trees whose +branch rows render icons. For file trees whose folders render without icons — +the native Explorer default — `variant="explorer"` collapses that gutter on +leaf rows so file icons align with branch twisties; don't combine it with +branch icons, which pulls leaf icons out of alignment with branch content. +Indent guides appear on hover, with the focused and selected ancestor paths +always lit. The package's intentional Modern default insets rows 4px with 4px +corners and keyboard-only focus outlines; `data-ui-style="stable"` on the +document root makes them edge-to-edge and square, restoring VS Code's current +stable focus behavior. ## Overlays @@ -79,7 +144,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 +161,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 0000000000..2418026227 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -0,0 +1,155 @@ +.ui-tree { + --ui-tree-indent-size: 8px; + width: 100%; + min-width: 0; +} + +.ui-tree-item { + outline: 0; +} + +.ui-tree-item__row { + position: relative; + display: flex; + align-items: center; + height: 22px; + padding-inline-end: var(--ui-spacing-120); + cursor: pointer; + user-select: 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; +} + +/* 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: 22px; + 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: 22px; + overflow: hidden; + white-space: nowrap; +} + +.ui-tree-item__content > .ui-icon { + margin-inline-end: var(--ui-spacing-60); + flex: none; +} + +.ui-tree-item__action { + display: none; + align-items: center; + align-self: stretch; + flex: none; + gap: 2px; +} + +.ui-tree-item[aria-selected="true"] > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item__row:hover .ui-tree-item__action, +.ui-tree-item:focus > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item__row:focus-within .ui-tree-item__action { + display: inline-flex; +} + +/* Collapse wins over consumer display values on the group. */ +.ui-tree-group[hidden] { + display: none !important; +} + +@media (prefers-reduced-motion: no-preference) { + .ui-tree-item__indent-slot { + transition: border-color 100ms linear; + } +} + +@media (forced-colors: active) { + .ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) + > .ui-tree-item__row:hover, + .ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: HighlightText; + background: Highlight; + } + + .ui-tree:hover .ui-tree-item__indent-slot, + .ui-tree-item__indent-slot--active { + border-color: CanvasText; + } +} diff --git a/packages/ui/src/components/Tree/Tree.modern.css b/packages/ui/src/components/Tree/Tree.modern.css new file mode 100644 index 0000000000..b0d07e292c --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.modern.css @@ -0,0 +1,19 @@ +:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { + margin-inline: var(--ui-spacing-40); + border-radius: var(--ui-radius-small); +} + +:where(:root:not([data-ui-style="stable"])) + .ui-tree--focused + .ui-tree-item:focus-visible + > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +:where(:root:not([data-ui-style="stable"])) + .ui-tree--focused + .ui-tree-item[aria-selected="true"]:focus-visible + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.stable.css b/packages/ui/src/components/Tree/Tree.stable.css new file mode 100644 index 0000000000..d5f4de92c4 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stable.css @@ -0,0 +1,14 @@ +:where(:root[data-ui-style="stable"]) + .ui-tree--focused + .ui-tree-item:focus + > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +:where(:root[data-ui-style="stable"]) + .ui-tree--focused + .ui-tree-item[aria-selected="true"]:focus + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx new file mode 100644 index 0000000000..42d81ed3bd --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,132 @@ +import { expect, userEvent, within } from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +// The native default Explorer: branch rows render without icons, so the +// explorer variant aligns leaf file icons with the branch twisties. +const FILES: readonly TreeDemoNode[] = [ + { + id: "source", + label: "src", + children: [ + { + id: "components", + label: "components", + children: [ + { + id: "tree", + label: "Tree.tsx", + icon: "symbol-class", + action: { icon: "close", label: "Close Tree.tsx" }, + }, + { id: "styles", label: "Tree.css", icon: "symbol-color" }, + ], + }, + { id: "tests", label: "tests", icon: "beaker", disabled: true }, + ], + }, + { id: "readme", label: "README.md", icon: "markdown" }, +]; + +const TreeStates = (): React.JSX.Element => ( + +); + +const meta: Meta = { + title: "UI/Tree", + component: TreeStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +const exerciseTree = async ({ + canvasElement, +}: { + canvasElement: HTMLElement; +}): Promise => { + const canvas = within(canvasElement); + const selectedItem = (): HTMLElement => + canvas.getByRole("treeitem", { name: "components" }); + await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + + // Click the trailing action while another row owns selection to prove + // action clicks never select their host row. The button is display:none + // until its row is hovered, selected, or focused, so query it hidden; + // the synthetic click still dispatches and bubbles. + const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await userEvent.click( + canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), + ); + await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + await expect(treeItem).toHaveAttribute("aria-selected", "false"); + + await userEvent.click(treeItem); + await expect(treeItem).toHaveAttribute("aria-selected", "true"); + + const readme = canvas.getByRole("treeitem", { name: "README.md" }); + await userEvent.click(readme); + await expect(readme).toHaveAttribute("aria-selected", "true"); +}; + +export const States: Story = { play: exerciseTree }; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, + play: exerciseTree, +}; + +const STORE_FILES: readonly TreeDemoNode[] = [ + { id: "TreeStore.ts", label: "TreeStore.ts", icon: "symbol-class" }, + { id: "context.ts", label: "context.ts", icon: "symbol-interface" }, + { + id: "Tree.tsx", + label: "Tree.tsx", + icon: "symbol-class", + className: "story-hover", + action: { icon: "close", label: "Close Tree.tsx" }, + }, +]; + +const NESTED_FILES: readonly TreeDemoNode[] = [ + ...["src", "components", "Tree", "store"].reduceRight< + readonly TreeDemoNode[] + >((children, label) => [{ id: label, label, children }], STORE_FILES), + { id: "README.md", label: "README.md", icon: "markdown" }, +]; + +export const Nested: Story = { + render: () => ( + + ), + // Real Tree.css hover, forced by the pseudo-states addon: the hovered tree + // reveals the faint indent guides next to the active guide of the selection. + parameters: { + pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const deepLeaf = canvas.getByRole("treeitem", { name: "TreeStore.ts" }); + await expect(deepLeaf).toHaveAttribute("aria-level", "5"); + + // Focus makes the selection render active. + await userEvent.click(deepLeaf); + await expect(deepLeaf).toHaveAttribute("aria-selected", "true"); + }, +}; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx new file mode 100644 index 0000000000..cfd4c03a58 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,122 @@ +import { + type ComponentPropsWithRef, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { cx } from "#cx"; + +import { + TreeContext, + TreeHierarchyContext, + type TreeHierarchyContextValue, +} from "./context"; +import "./Tree.css"; +import "./Tree.modern.css"; +import "./Tree.stable.css"; +import { TreeStore } from "./TreeStore"; + +const ROOT_HIERARCHY: Readonly = { + level: 1, + pathItemIds: [], +} as const; + +function focusBelongsToTree( + tree: HTMLElement, + target: EventTarget | null, +): boolean { + return target instanceof Element && target.closest(".ui-tree") === tree; +} + +export interface TreeProps extends Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" +> { + /** + * "explorer" collapses the twistie gutter on leaf rows so file icons align + * with branch twisties, like the native Explorer whose folders render + * without icons. Combining it with branch icons misaligns leaf icons. + */ + variant?: "default" | "explorer"; + selectedItemId?: string; + onSelectedItemChange?: (itemId: string) => void; +} + +/** A controlled, single-selection tree with native VS Code keyboard behavior. */ +export function Tree({ + variant = "default", + selectedItemId, + onSelectedItemChange, + className, + children, + onBlur, + onFocus, + onKeyDown, + ref, + ...props +}: TreeProps): React.JSX.Element { + const [store] = useState( + () => new TreeStore(selectedItemId, onSelectedItemChange), + ); + const [hasDomFocus, setHasDomFocus] = useState(false); + + useLayoutEffect(() => { + store.setConfiguration(selectedItemId, onSelectedItemChange); + }, [onSelectedItemChange, selectedItemId, store]); + + // Any commit can add, remove, reorder, or reveal rows. + useLayoutEffect(() => store.reconcile()); + useEffect(() => () => store.dispose(), [store]); + + return ( + + +
{ + store.setRoot(element); + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }} + role="tree" + className={cx( + "ui-tree", + variant === "explorer" && "ui-tree--explorer", + hasDomFocus && "ui-tree--focused", + className, + )} + onFocus={(event) => { + onFocus?.(event); + if ( + !event.defaultPrevented && + focusBelongsToTree(event.currentTarget, event.target) + ) { + setHasDomFocus(true); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !focusBelongsToTree(event.currentTarget, event.relatedTarget) + ) { + setHasDomFocus(false); + } + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented) { + store.onKeyDown(event); + } + }} + > + {children} +
+
+
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeItem.stories.tsx b/packages/ui/src/components/Tree/TreeItem.stories.tsx new file mode 100644 index 0000000000..d1f4860ec2 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.stories.tsx @@ -0,0 +1,51 @@ +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const ITEM_STATES: readonly TreeDemoNode[] = [ + { id: "plain", label: "Plain item", icon: "file" }, + { + id: "selected", + label: "Selected branch", + icon: "folder-opened", + children: [{ id: "child", label: "Child item" }], + }, + { + id: "collapsed", + label: "Collapsed branch", + icon: "folder", + collapsed: true, + children: [{ id: "hidden", label: "Hidden item" }], + }, + { + id: "action", + label: "Item with action", + action: { icon: "trash", label: "Delete item" }, + }, + { id: "disabled", label: "Disabled item", disabled: true }, +]; + +const TreeItemStates = (): React.JSX.Element => ( + +); + +const meta: Meta = { + title: "UI/TreeItem", + component: TreeItemStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, +}; diff --git a/packages/ui/src/components/Tree/TreeItem.tsx b/packages/ui/src/components/Tree/TreeItem.tsx new file mode 100644 index 0000000000..fa42d3b7fa --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.tsx @@ -0,0 +1,222 @@ +import { + type ComponentPropsWithRef, + type MouseEvent as ReactMouseEvent, + type ReactNode, + use, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import { TreeHierarchyContext, useTreeContext } from "./context"; +import { INTERACTIVE_SELECTOR } from "./TreeStore"; + +import type { CodiconName } from "#codicons"; + +/** A string label doubles as the text value; a rich label must supply one. */ +type TreeItemLabel = + | { label: string; textValue?: string } + | { label: ReactNode; textValue: string }; + +export type TreeItemProps = Omit< + ComponentPropsWithRef<"div">, + "children" | "id" | "role" | "onSelect" +> & + TreeItemLabel & { + itemId: string; + icon?: CodiconName; + disabled?: boolean; + /** Controls expansion, and makes the row a branch. */ + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + /** Child rows; only a branch can have them. */ + children?: ReactNode; + action?: ReactNode; + }; + +function eventBelongsToRow( + event: { currentTarget: HTMLElement; target: EventTarget | null }, + row: HTMLElement | null, +): boolean { + return ( + event.target === event.currentTarget || + (event.target instanceof Node && row?.contains(event.target) === true) + ); +} + +function isNestedInteractiveTarget( + event: ReactMouseEvent, +): boolean { + const target = event.target; + if (!(target instanceof Element) || target === event.currentTarget) { + return false; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + return ( + interactiveTarget !== null && + interactiveTarget !== event.currentTarget && + event.currentTarget.contains(interactiveTarget) + ); +} + +/** A controlled tree row. Passing `expanded` makes it a branch that nests rows. */ +export function TreeItem({ + itemId, + label, + textValue, + icon, + expanded, + onExpandedChange, + children, + action, + disabled = false, + className, + style, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + onClick, + onFocus, + ref, + ...props +}: TreeItemProps): React.JSX.Element { + const store = useTreeContext(); + const hierarchy = use(TreeHierarchyContext); + const internalRef = useRef(null); + const rowRef = useRef(null); + const chevronRef = useRef(null); + const isBranch = expanded !== undefined; + if (!isBranch && children) { + throw new Error( + `TreeItem "${itemId}" has child rows, so it is a branch: pass expanded and onExpandedChange to control it.`, + ); + } + + const rowTextValue = textValue ?? (typeof label === "string" ? label : ""); + const setExpanded = isBranch ? onExpandedChange : undefined; + const { + guideOwnerIds, + selected: isSelected, + tabIndex, + } = useSyncExternalStore( + store.subscribe, + () => store.getItemSnapshot(itemId), + () => store.getItemSnapshot(itemId), + ); + + useLayoutEffect(() => { + const element = internalRef.current; + return element ? store.registerItem(itemId, element) : undefined; + }, [itemId, store]); + + useLayoutEffect(() => { + store.updateItem(itemId, { + textValue: rowTextValue, + parentId: hierarchy.parentItemId, + setExpanded, + }); + }, [hierarchy.parentItemId, itemId, rowTextValue, setExpanded, store]); + + const groupHierarchy = { + level: hierarchy.level + 1, + parentItemId: itemId, + pathItemIds: [...hierarchy.pathItemIds, itemId], + }; + + return ( +
{ + internalRef.current = element; + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }} + role="treeitem" + aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? rowTextValue)} + aria-labelledby={ariaLabelledBy} + aria-level={hierarchy.level} + aria-selected={isSelected} + aria-disabled={disabled || undefined} + aria-expanded={expanded} + tabIndex={tabIndex} + className={cx("ui-tree-item", className)} + style={style} + onFocus={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onFocus?.(event); + if (!event.defaultPrevented && event.target === event.currentTarget) { + store.onItemFocus(itemId); + } + }} + onClick={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onClick?.(event); + if ( + event.defaultPrevented || + disabled || + isNestedInteractiveTarget(event) + ) { + return; + } + // Twistie clicks toggle without moving selection, like the native tree. + const onTwistie = + isBranch && + event.target instanceof Node && + chevronRef.current?.contains(event.target) === true; + if (!onTwistie) { + store.requestSelection(itemId); + } + setExpanded?.(!expanded); + }} + > +
+
+ {isBranch && children ? ( + + + + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeStore.ts b/packages/ui/src/components/Tree/TreeStore.ts new file mode 100644 index 0000000000..e893d00b0c --- /dev/null +++ b/packages/ui/src/components/Tree/TreeStore.ts @@ -0,0 +1,523 @@ +import type { KeyboardEvent } from "react"; + +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(","); + +/** Matching this gives visibility and tree order without mirroring the DOM. */ +const VISIBLE_ROW_SELECTOR = + '[role="treeitem"]:not([hidden]):not([hidden] *):not([aria-disabled="true"])'; + +const TYPE_AHEAD_TIMEOUT_MS = 500; + +/** Repeating one character cycles matches instead of growing the query. */ +function nextTypeAheadQuery(buffer: string, character: string): string { + const isRepeat = + buffer.length > 0 && [...buffer].every((value) => value === character); + return isRepeat ? character : `${buffer}${character}`; +} + +function findTypeAheadMatch( + items: readonly TreeStoreItem[], + fromIndex: number, + query: string, +): TreeStoreItem | undefined { + for (let offset = 0; offset < items.length; offset++) { + const item = items[(fromIndex + offset) % items.length]; + if (item?.textValue.toLocaleLowerCase().startsWith(query)) { + return item; + } + } + return undefined; +} + +function isDisabled(element: HTMLElement): boolean { + return element.getAttribute("aria-disabled") === "true"; +} + +/** null on leaves. */ +function readExpanded(element: HTMLElement): boolean | null { + const expanded = element.getAttribute("aria-expanded"); + return expanded === null ? null : expanded === "true"; +} + +/** Only what the DOM cannot answer; parentId outlives the row's removal. */ +export interface TreeStoreItem { + readonly id: string; + readonly element: HTMLElement; + textValue: string; + parentId?: string; + setExpanded?: (expanded: boolean) => void; +} + +export type TreeStoreItemUpdate = Omit; + +export interface TreeItemSnapshot { + tabIndex: 0 | -1; + selected: boolean; + /** Tree-wide; a row filters its own ancestor path. */ + guideOwnerIds: ReadonlySet; +} + +/** + * Registry and interaction engine for one tree: roving tab stop, focus and + * selection reconciliation, and VS Code keyboard behavior over rows that + * register themselves as they mount. + */ +export class TreeStore { + private readonly items = new Map(); + private readonly itemsByElement = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly itemSnapshots = new Map(); + private root: HTMLElement | undefined; + private tabStopId: string | undefined; + private removedTabStopAncestorIds: readonly string[] = []; + private focusedElement: HTMLElement | undefined; + private selectedId: string | undefined; + private pendingSelectedId: string | undefined; + private onSelectedItemChange: ((itemId: string) => void) | undefined; + private visibleItems: readonly TreeStoreItem[] = []; + private visibleItemsDirty = true; + private guideOwnerIds: ReadonlySet = new Set(); + private revision = 0; + private typeAheadBuffer = ""; + private typeAheadTimer: ReturnType | undefined; + + constructor( + selectedId?: string, + onSelectedItemChange?: (itemId: string) => void, + ) { + this.selectedId = selectedId; + this.pendingSelectedId = selectedId; + this.onSelectedItemChange = onSelectedItemChange; + } + + readonly subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange); + return () => this.listeners.delete(onChange); + }; + + readonly setRoot = (root: HTMLElement | null): void => { + this.root = root ?? undefined; + }; + + /** The DOM moved, so re-derive what depends on it. */ + readonly reconcile = (): void => { + this.visibleItemsDirty = true; + const tabStopChanged = this.reconcileTabStop(); + const focusChanged = this.reconcileFocusedItem(); + if (this.refreshGuideOwners() || tabStopChanged || focusChanged) { + this.publishChange(); + } + }; + + readonly setConfiguration = ( + selectedId: string | undefined, + onSelectedItemChange?: (itemId: string) => void, + ): void => { + this.onSelectedItemChange = onSelectedItemChange; + if (this.selectedId === selectedId) { + return; + } + this.selectedId = selectedId; + this.pendingSelectedId = selectedId; + this.reconcilePendingSelection(); + this.refreshGuideOwners(); + this.publishChange(); + }; + + readonly dispose = (): void => { + clearTimeout(this.typeAheadTimer); + this.typeAheadTimer = undefined; + this.typeAheadBuffer = ""; + }; + + readonly requestSelection = (itemId: string): void => { + this.onSelectedItemChange?.(itemId); + }; + + readonly registerItem = (id: string, element: HTMLElement): (() => void) => { + if (this.items.has(id)) { + throw new Error( + `Tree keyboard navigation item id "${id}" is already registered by another row. Item ids must be unique.`, + ); + } + const item: TreeStoreItem = { id, element, textValue: "" }; + this.items.set(id, item); + this.itemsByElement.set(element, item); + + return (): void => { + if (this.items.get(id) !== item) { + return; + } + if (id === this.tabStopId) { + // Capture ancestry before deletion; reconciliation needs it. + this.removedTabStopAncestorIds = this.ancestorIds(id); + } + this.items.delete(id); + this.itemsByElement.delete(element); + this.itemSnapshots.delete(id); + }; + }; + + readonly updateItem = (id: string, update: TreeStoreItemUpdate): void => { + const item = this.items.get(id); + if (item) { + // Full next state, not a patch: absent keys clear. + Object.assign(item, update); + } + }; + + readonly getItemSnapshot = (id: string): TreeItemSnapshot => { + const tabIndex: 0 | -1 = this.tabStopId === id ? 0 : -1; + const selected = this.selectedId === id; + const previous = this.itemSnapshots.get(id); + if ( + previous?.tabIndex === tabIndex && + previous.selected === selected && + previous.guideOwnerIds === this.guideOwnerIds + ) { + return previous; + } + + const snapshot = { tabIndex, selected, guideOwnerIds: this.guideOwnerIds }; + this.itemSnapshots.set(id, snapshot); + return snapshot; + }; + + readonly onItemFocus = (id: string): void => { + const item = this.items.get(id); + const canReceiveFocus = item !== undefined && this.isReachable(item); + if (canReceiveFocus) { + // The user took over; the initial selection no longer claims the + // tab stop when its branch is revealed later. + this.pendingSelectedId = undefined; + } + const focusChanged = this.setFocusedElement( + canReceiveFocus ? item.element : undefined, + ); + const tabStopChanged = canReceiveFocus + ? this.setTabStopValue(item.id) + : false; + if (this.refreshGuideOwners() || focusChanged || tabStopChanged) { + this.publishChange(); + } + }; + + readonly onKeyDown = (event: KeyboardEvent): void => { + if (this.isInteractiveTarget(event)) { + return; + } + const visibleItems = this.getVisibleItems(); + const currentItem = + this.findEventItem(event) ?? + this.focusedItem ?? + this.getItem(this.tabStopId) ?? + visibleItems[0]; + if (!currentItem) { + return; + } + + // A disabled or hidden row is absent from visibleItems, so take its + // neighbors from where it would sit in tree order. + const currentIndex = visibleItems.indexOf(currentItem); + const nextIndex = + currentIndex === -1 + ? this.findInsertionIndex(currentItem, visibleItems) + : currentIndex + 1; + const previousIndex = + currentIndex === -1 ? nextIndex - 1 : currentIndex - 1; + const disabled = isDisabled(currentItem.element); + const expanded = readExpanded(currentItem.element); + let handled = true; + + switch (event.key) { + case "ArrowDown": + this.focusItem(visibleItems[nextIndex]); + break; + case "ArrowUp": + this.focusItem(visibleItems[previousIndex]); + break; + case "Home": + this.focusItem(visibleItems[0]); + break; + case "End": + this.focusItem(visibleItems.at(-1)); + break; + case "ArrowRight": + if (disabled) { + break; + } + if (expanded === false && currentItem.setExpanded) { + currentItem.setExpanded(true); + } else if (expanded === true) { + // Descendants directly follow their branch in tree order. + const firstChild = visibleItems[nextIndex]; + if (firstChild && currentItem.element.contains(firstChild.element)) { + this.focusItem(firstChild); + } + } + break; + case "ArrowLeft": + if (disabled) { + break; + } + if (expanded === true && currentItem.setExpanded) { + currentItem.setExpanded(false); + } else { + this.focusItem( + this.findReachableAncestor(this.ancestorIds(currentItem.id)), + ); + } + break; + case "Enter": + case " ": + if (disabled) { + break; + } + this.requestSelection(currentItem.id); + if (expanded !== null) { + currentItem.setExpanded?.(!expanded); + } + break; + default: + handled = false; + } + + if (handled) { + event.preventDefault(); + return; + } + + if ( + event.key.length !== 1 || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + this.typeAheadBuffer = nextTypeAheadQuery( + this.typeAheadBuffer, + event.key.toLocaleLowerCase(), + ); + clearTimeout(this.typeAheadTimer); + this.typeAheadTimer = setTimeout(() => { + this.typeAheadBuffer = ""; + this.typeAheadTimer = undefined; + }, TYPE_AHEAD_TIMEOUT_MS); + + this.focusItem( + findTypeAheadMatch(visibleItems, nextIndex, this.typeAheadBuffer), + ); + event.preventDefault(); + }; + + private get focusedItem(): TreeStoreItem | undefined { + return this.focusedElement === undefined + ? undefined + : this.itemsByElement.get(this.focusedElement); + } + + private getItem(id: string | undefined): TreeStoreItem | undefined { + return id === undefined ? undefined : this.items.get(id); + } + + private isReachable(item: TreeStoreItem): boolean { + return item.element.matches(VISIBLE_ROW_SELECTOR); + } + + private getVisibleItems(): readonly TreeStoreItem[] { + if (this.visibleItemsDirty) { + const rows = + this.root?.querySelectorAll(VISIBLE_ROW_SELECTOR) ?? []; + this.visibleItems = [...rows] + .map((row) => this.itemsByElement.get(row)) + .filter((item): item is TreeStoreItem => item !== undefined); + this.visibleItemsDirty = false; + } + return this.visibleItems; + } + + private findInsertionIndex( + item: TreeStoreItem, + visibleItems: readonly TreeStoreItem[], + ): number { + const index = visibleItems.findIndex( + (visible) => + (item.element.compareDocumentPosition(visible.element) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0, + ); + return index === -1 ? visibleItems.length : index; + } + + private findReachableAncestor( + ancestorIds: readonly string[], + ): TreeStoreItem | undefined { + for (const ancestorId of ancestorIds) { + const ancestor = this.items.get(ancestorId); + if (ancestor && this.isReachable(ancestor)) { + return ancestor; + } + } + return undefined; + } + + /** Ancestor ids nearest first. The React tree makes parent links acyclic. */ + private ancestorIds(id: string): readonly string[] { + const ancestorIds: string[] = []; + let parentId = this.items.get(id)?.parentId; + while (parentId !== undefined) { + ancestorIds.push(parentId); + parentId = this.items.get(parentId)?.parentId; + } + return ancestorIds; + } + + private findEventItem( + event: KeyboardEvent, + ): TreeStoreItem | undefined { + const row = + event.target instanceof Element + ? event.target.closest('[role="treeitem"]') + : null; + return row === null ? undefined : this.itemsByElement.get(row); + } + + private isInteractiveTarget(event: KeyboardEvent): boolean { + const target = event.target; + if (!(target instanceof Element)) { + return false; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + if ( + interactiveTarget === null || + interactiveTarget === event.currentTarget || + !event.currentTarget.contains(interactiveTarget) + ) { + return false; + } + // Row elements are the navigation surface, not embedded controls. + return !( + interactiveTarget instanceof HTMLElement && + this.itemsByElement.has(interactiveTarget) + ); + } + + private guideOwnerId(item: TreeStoreItem): string | undefined { + return readExpanded(item.element) === true ? item.id : item.parentId; + } + + private refreshGuideOwners(): boolean { + const ownerIds = [this.focusedItem, this.getItem(this.selectedId)] + .map((item) => item && this.guideOwnerId(item)) + .filter((id): id is string => id !== undefined); + if ( + ownerIds.length === this.guideOwnerIds.size && + ownerIds.every((id) => this.guideOwnerIds.has(id)) + ) { + return false; + } + this.guideOwnerIds = new Set(ownerIds); + return true; + } + + private reconcilePendingSelection(): boolean { + const pendingSelectedId = this.pendingSelectedId; + if (pendingSelectedId === undefined) { + return false; + } + const selectedItem = this.items.get(pendingSelectedId); + if (!selectedItem) { + return false; + } + if (!this.isReachable(selectedItem)) { + // Hidden keeps the claim until the branch reveals it; disabled drops it. + if (isDisabled(selectedItem.element)) { + this.pendingSelectedId = undefined; + } + return false; + } + this.pendingSelectedId = undefined; + return this.setTabStopValue(pendingSelectedId); + } + + private reconcileTabStop(): boolean { + if (this.reconcilePendingSelection()) { + return true; + } + + const currentItem = this.getItem(this.tabStopId); + if (currentItem && this.isReachable(currentItem)) { + return false; + } + + // An unregistered tab stop is gone from the map; use the captured path. + const ancestor = this.findReachableAncestor( + currentItem + ? this.ancestorIds(currentItem.id) + : this.removedTabStopAncestorIds, + ); + return this.setTabStopValue(ancestor?.id ?? this.getVisibleItems()[0]?.id); + } + + private reconcileFocusedItem(): boolean { + const focusedItem = this.focusedItem; + if (focusedItem && this.isReachable(focusedItem)) { + return false; + } + return this.setFocusedElement(undefined); + } + + private setTabStopValue(id: string | undefined): boolean { + if (this.tabStopId === id) { + return false; + } + this.tabStopId = id; + return true; + } + + private setFocusedElement(element: HTMLElement | undefined): boolean { + if (this.focusedElement === element) { + return false; + } + this.focusedElement = element; + return true; + } + + private focusItem(item: TreeStoreItem | undefined): void { + if (!item) { + return; + } + const tabStopChanged = this.setTabStopValue(item.id); + const revisionBeforeFocus = this.revision; + item.element.focus(); + if (tabStopChanged && this.revision === revisionBeforeFocus) { + this.publishChange(); + } + } + + private publishChange(): void { + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } +} diff --git a/packages/ui/src/components/Tree/context.ts b/packages/ui/src/components/Tree/context.ts new file mode 100644 index 0000000000..035e2aff98 --- /dev/null +++ b/packages/ui/src/components/Tree/context.ts @@ -0,0 +1,23 @@ +import { createContext, use } from "react"; + +import type { TreeStore } from "./TreeStore"; + +export interface TreeHierarchyContextValue { + level: number; + parentItemId?: string; + pathItemIds: readonly string[]; +} + +export const TreeContext = createContext(undefined); +export const TreeHierarchyContext = createContext({ + level: 1, + pathItemIds: [], +}); + +export function useTreeContext(): TreeStore { + const context = use(TreeContext); + if (!context) { + throw new Error("Tree components must be rendered inside Tree."); + } + return context; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0a..cd64e1ade9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,4 +72,6 @@ export { TooltipProvider, type TooltipProviderProps, } from "./components/Tooltip/Tooltip"; +export { Tree, type TreeProps } from "./components/Tree/Tree"; +export { TreeItem, type TreeItemProps } from "./components/Tree/TreeItem"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index e4345fa09f..df1fab02aa 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -147,11 +147,55 @@ --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-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) + ); + /* Menus */ --ui-menu-background: var(--vscode-menu-background); --ui-menu-foreground: var(--vscode-menu-foreground); diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx new file mode 100644 index 0000000000..f47754c266 --- /dev/null +++ b/packages/ui/storybook/Tree.demo.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; + +import { IconButton } from "../src/components/IconButton/IconButton"; +import { Tree, type TreeProps } from "../src/components/Tree/Tree"; +import { TreeItem } from "../src/components/Tree/TreeItem"; + +import type { CodiconName } from "#codicons"; + +export interface TreeDemoNode { + id: string; + label: string; + icon?: CodiconName; + action?: { icon: CodiconName; label: string }; + disabled?: boolean; + className?: string; + /** Branches start expanded unless this says otherwise. */ + collapsed?: boolean; + children?: readonly TreeDemoNode[]; +} + +export interface TreeDemoProps extends Omit< + TreeProps, + "children" | "onSelectedItemChange" +> { + nodes: readonly TreeDemoNode[]; +} + +function initialCollapsedIds(nodes: readonly TreeDemoNode[]): Set { + const collapsedIds = new Set(); + const visit = (node: TreeDemoNode): void => { + if (node.collapsed) { + collapsedIds.add(node.id); + } + node.children?.forEach(visit); + }; + nodes.forEach(visit); + return collapsedIds; +} + +/** Wraps a node tree in the selection and expansion state a Tree expects. */ +export function TreeDemo({ + nodes, + selectedItemId: initialSelectedItemId, + ...treeProps +}: TreeDemoProps): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState(initialSelectedItemId); + const [collapsedIds, setCollapsedIds] = useState(() => + initialCollapsedIds(nodes), + ); + + const setExpanded = (itemId: string, expanded: boolean): void => { + setCollapsedIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.delete(itemId); + } else { + next.add(itemId); + } + return next; + }); + }; + + const renderNodes = ( + siblings: readonly TreeDemoNode[], + ): React.JSX.Element[] => + siblings.map((node) => ( + setExpanded(node.id, expanded)) + } + action={ + node.action && ( + + ) + } + > + {node.children && renderNodes(node.children)} + + )); + + return ( + + {renderNodes(nodes)} + + ); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index de3f039b95..d8416421b9 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "resolveJsonModule": true }, - "include": ["src", "storybook.preview.ts"] + "include": ["src", "storybook", "storybook.preview.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b6827e3b..721bf5543f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ catalogs: storybook: specifier: ^10.5.7 version: 10.5.7 + storybook-addon-pseudo-states: + specifier: ^10.5.7 + version: 10.5.7 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -299,6 +302,9 @@ importers: storybook: specifier: 'catalog:' version: 10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) + storybook-addon-pseudo-states: + specifier: 'catalog:' + version: 10.5.7(storybook@10.5.7) typescript: specifier: 'catalog:' version: 6.0.3 @@ -4978,6 +4984,11 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + storybook-addon-pseudo-states@10.5.7: + resolution: {integrity: sha512-ZX8duQTWmIzI/z6T/evmcQN5QTeCTJS+OKgoKDNFzuc97C3PPORan5RaNSMXJiZJo5uUTIAELjr+BNV0VX7cmw==} + peerDependencies: + storybook: ^10.5.7 + storybook@10.5.7: resolution: {integrity: sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==} hasBin: true @@ -10554,6 +10565,10 @@ snapshots: stdin-discarder@0.2.2: {} + storybook-addon-pseudo-states@10.5.7(storybook@10.5.7): + dependencies: + storybook: 10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) + storybook@10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6): dependencies: '@storybook/global': 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1346e857f..12273baaa3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,7 @@ catalog: react: ^19.2.8 react-dom: ^19.2.8 storybook: ^10.5.7 + storybook-addon-pseudo-states: ^10.5.7 typescript: ^6.0.3 vite: ^8.2.1 diff --git a/test/webview/ui/tree.test.tsx b/test/webview/ui/tree.test.tsx new file mode 100644 index 0000000000..7d417221a4 --- /dev/null +++ b/test/webview/ui/tree.test.tsx @@ -0,0 +1,752 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createRef, Fragment, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tree, TreeItem } from "@repo/ui"; + +const ACTIVE_GUIDE = "ui-tree-item__indent-slot--active"; + +const treeItem = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + +/** Rows inside a collapsed branch are still rendered, just inaccessible. */ +const anyTreeItem = (name: string): HTMLElement => + screen.getByRole("treeitem", { name, hidden: true }); + +const guideSlots = (name: string): Element[] => [ + ...anyTreeItem(name).querySelectorAll(".ui-tree-item__indent-slot"), +]; + +/** A branch with an enabled and a disabled child, plus a root-level sibling. */ +function ControlledTree({ + onSelectedItemChange = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelectedItemChange?: (itemId: string) => void; + onExpandedChange?: (expanded: boolean) => void; +}): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState("child"); + const [expanded, setExpanded] = useState(true); + + return ( + { + onSelectedItemChange(itemId); + setSelectedItemId(itemId); + }} + > + { + onExpandedChange(nextExpanded); + setExpanded(nextExpanded); + }} + > + + + + + + ); +} + +/** Two branches whose labels share prefixes, for arrow keys and type-ahead. */ +function NavTree({ + onSelect = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelect?: (itemId: string) => void; + onExpandedChange?: (itemId: string, expanded: boolean) => void; +}): React.JSX.Element { + const [expandedIds, setExpandedIds] = useState(() => new Set(["alpha"])); + const branch = ( + itemId: string, + ): Pick< + React.ComponentProps, + "expanded" | "onExpandedChange" + > => ({ + expanded: expandedIds.has(itemId), + onExpandedChange: (nextExpanded: boolean) => { + onExpandedChange(itemId, nextExpanded); + setExpandedIds((current) => { + const next = new Set(current); + if (nextExpanded) { + next.add(itemId); + } else { + next.delete(itemId); + } + return next; + }); + }, + }); + + return ( + + + + + + + + + + + Bravo + + + } + textValue="Bravo" + /> + + ); +} + +const revealTree = (expanded: boolean): React.JSX.Element => ( + + + + + + +); + +describe("Tree", () => { + it("forwards tree semantics, className, style, and ref", () => { + const ref = createRef(); + render( + + + , + ); + + const tree = screen.getByRole("tree", { name: "Explorer" }); + expect(tree).toHaveClass("ui-tree", "ui-tree--explorer", "custom-tree"); + expect(tree).toHaveStyle({ width: "240px" }); + expect(ref.current).toBe(tree); + }); + + it("exposes levels, selection, disabled state, groups, and branch expansion", () => { + render(); + + const parent = treeItem("Parent"); + const child = treeItem("Child"); + expect(parent).toHaveAttribute("aria-level", "1"); + expect(parent).toHaveAttribute("aria-expanded", "true"); + expect(parent).toHaveAttribute("aria-selected", "false"); + expect(child).toHaveAttribute("aria-level", "2"); + expect(child).toHaveAttribute("aria-selected", "true"); + expect(child).not.toHaveAttribute("aria-expanded"); + expect(treeItem("Disabled")).toHaveAttribute("aria-disabled", "true"); + + const group = screen.getByRole("group"); + expect(group).not.toHaveAttribute("hidden"); + expect(group.closest('[role="treeitem"]')).toBe(parent); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("keeps exactly one visible enabled item in the tab order", () => { + render(); + + const tabStops = screen + .getAllByRole("treeitem") + .filter((item) => item.tabIndex === 0); + expect(tabStops).toEqual([treeItem("Child")]); + expect(treeItem("Disabled")).toHaveAttribute("tabindex", "-1"); + }); + + it("hands the tab stop to a selection revealed by expansion", () => { + const { rerender } = render(revealTree(false)); + expect(treeItem("Top")).toHaveAttribute("tabindex", "0"); + + rerender(revealTree(true)); + expect(treeItem("Child")).toHaveAttribute("tabindex", "0"); + }); + + it("keeps the tab stop with the user once they focus another row", () => { + const { rerender } = render(revealTree(false)); + act(() => treeItem("Parent").focus()); + + rerender(revealTree(true)); + expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); + expect(treeItem("Child")).toHaveAttribute("tabindex", "-1"); + }); + + it("moves the tab stop to an ancestor when its row unmounts", async () => { + const renderTree = (showLeaf: boolean): React.JSX.Element => ( + + + + {showLeaf && } + + + ); + const { rerender } = render(renderTree(true)); + act(() => treeItem("Leaf").focus()); + + rerender(renderTree(false)); + await act(() => Promise.resolve()); + expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); + }); + + it("leaves keys to interactive elements rendered outside rows", () => { + render( + + + + , + ); + + const input = screen.getByRole("textbox", { name: "New file" }); + act(() => input.focus()); + const arrowNotPrevented = fireEvent.keyDown(input, { key: "ArrowDown" }); + const typeAheadNotPrevented = fireEvent.keyDown(input, { key: "a" }); + + expect(document.activeElement).toBe(input); + expect(arrowNotPrevented).toBe(true); + expect(typeAheadNotPrevented).toBe(true); + }); + + it("derives indent guide owners from focus and controlled selection", () => { + const renderTree = (selectedItemId?: string): React.JSX.Element => ( + + {["Alpha", "Beta"].map((branch) => ( + + + + ))} + + ); + const { rerender } = render(renderTree()); + + expect(treeItem("Alpha")).toHaveAttribute("tabindex", "0"); + expect(guideSlots("Alpha leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Beta leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Beta leaf").focus()); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + + rerender(renderTree("Alpha leaf")); + expect(treeItem("Alpha leaf")).toHaveAttribute("tabindex", "0"); + expect(treeItem("Beta leaf")).toHaveAttribute("tabindex", "-1"); + expect(guideSlots("Alpha leaf")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("uses only the expanded focused branch as its indent guide owner", () => { + render( + + + + + + + , + ); + + act(() => treeItem("Branch").focus()); + const slots = guideSlots("Leaf"); + expect(slots).toHaveLength(2); + expect(slots[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(slots[1]).toHaveClass(ACTIVE_GUIDE); + }); + + it("clears hidden, disabled, and unmounted focused guide owners", async () => { + const renderTree = ({ + expanded = true, + disabled = false, + showChild = true, + }: { + expanded?: boolean; + disabled?: boolean; + showChild?: boolean; + }): React.JSX.Element => ( + + + {showChild && ( + + )} + + + ); + const { rerender } = render(renderTree({})); + + act(() => treeItem("Child").focus()); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + rerender(renderTree({ expanded: false })); + rerender(renderTree({ expanded: true })); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Child").focus()); + rerender(renderTree({ disabled: true })); + rerender(renderTree({})); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Child").focus()); + rerender(renderTree({ showChild: false })); + await act(() => Promise.resolve()); + rerender(renderTree({})); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + }); + + it("updates controlled selection before the rerender is observable", () => { + const renderTree = (selectedItemId: string): React.JSX.Element => ( + + + + + ); + const { rerender } = render(renderTree("first")); + + rerender(renderTree("second")); + expect(treeItem("First")).toHaveAttribute("aria-selected", "false"); + expect(treeItem("Second")).toHaveAttribute("aria-selected", "true"); + }); + + it("uses focus from this tree only for active selection colors", () => { + render( + <> + + + + + + + , + ); + + const firstTree = screen.getByRole("tree", { name: "First" }); + const secondTree = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(treeItem("First item")); + expect(firstTree).toHaveClass("ui-tree--focused"); + expect(secondTree).not.toHaveClass("ui-tree--focused"); + + fireEvent.blur(treeItem("First item"), { + relatedTarget: treeItem("Second item"), + }); + fireEvent.focus(treeItem("Second item")); + expect(firstTree).not.toHaveClass("ui-tree--focused"); + expect(secondTree).toHaveClass("ui-tree--focused"); + }); +}); + +describe("TreeItem", () => { + it("names rows from the label unless the consumer overrides it", () => { + render( + + Custom labelled item + + Rich item} + textValue="Rich item" + /> + + + , + ); + + expect(treeItem("Plain item")).toBeInTheDocument(); + expect(treeItem("Rich item")).toBeInTheDocument(); + expect(treeItem("Custom labelled item")).toBeInTheDocument(); + expect(treeItem("Custom label")).toBeInTheDocument(); + expect( + treeItem("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), + ).toHaveClass("codicon-file"); + }); + + it("accepts child rows from fragments, arrays, and wrapper components", () => { + const WrappedRows = (): React.JSX.Element => ( + <> + + + ); + render( + + + + + {[]} + + + , + ); + + expect(treeItem("Branch")).toHaveAttribute("aria-expanded", "true"); + expect(treeItem("Wrapped")).toHaveAttribute("aria-level", "2"); + expect(treeItem("Listed")).toHaveAttribute("aria-level", "2"); + }); + + it("rejects child rows on a row that is not a branch", () => { + expect(() => + render( + + + + + , + ), + ).toThrow(/has child rows, so it is a branch/); + }); + + it("shows a twistie for a branch whose children are not loaded yet", () => { + const onExpandedChange = vi.fn(); + render( + + + , + ); + + const lazy = treeItem("Lazy"); + expect(lazy).toHaveAttribute("aria-expanded", "false"); + expect(lazy.querySelector(".ui-tree-item__chevron > .ui-icon")).toHaveClass( + "codicon-chevron-right", + ); + fireEvent.keyDown(lazy, { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + + it("reports controlled selection and expansion from a row click", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + , + ); + + fireEvent.click(treeItem("Parent")); + expect(onSelectedItemChange).toHaveBeenCalledWith("parent"); + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(screen.getByRole("group", { hidden: true })).toHaveAttribute( + "hidden", + ); + }); + + it("toggles a branch from its twistie without changing selection", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + const onClick = vi.fn(); + render( + + + + + , + ); + + const chevron = treeItem("Branch").querySelector(".ui-tree-item__chevron"); + if (!chevron) { + throw new Error("Expected a branch twistie."); + } + fireEvent.click(chevron); + + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(onClick).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("isolates a trailing action from tree selection and expansion", () => { + const onAction = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + Delete + + } + > + + + , + ); + + const action = screen.getByRole("button", { name: "Delete" }); + expect(treeItem("Branch")).toHaveAccessibleName("Branch"); + expect(action.parentElement).toHaveClass("ui-tree-item__action"); + + fireEvent.click(action); + expect(onAction).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("keeps parent row handlers isolated from descendant treeitems", () => { + const onParentClick = vi.fn(); + const onParentFocus = vi.fn(); + const onChildClick = vi.fn(); + const onChildFocus = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + + + , + ); + + const child = treeItem("Child"); + const childContent = child.querySelector(".ui-tree-item__content"); + if (!childContent) { + throw new Error("Expected child row content."); + } + fireEvent.click(childContent); + expect(onChildClick).toHaveBeenCalledOnce(); + expect(onParentClick).not.toHaveBeenCalled(); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + + fireEvent.focus(child); + expect(onChildFocus).toHaveBeenCalledOnce(); + expect(onParentFocus).not.toHaveBeenCalled(); + + onSelectedItemChange.mockClear(); + fireEvent.keyDown(child, { key: "Enter" }); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("does not activate a disabled row that receives programmatic focus", () => { + const onSelectedItemChange = vi.fn(); + render(); + const disabled = treeItem("Disabled"); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "Enter" }); + fireEvent.keyDown(disabled, { key: " " }); + + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("forwards className, style, and ref, and marks the selected row", () => { + const ref = createRef(); + render( + + Selected action} + /> + + , + ); + + const selected = treeItem("Selected"); + expect(selected).toHaveClass("ui-tree-item", "custom-item"); + // Selection is what keeps the action slot visible, per Tree.css. + expect(selected).toHaveAttribute("aria-selected", "true"); + expect( + screen.getByRole("button", { name: "Selected action" }).parentElement, + ).toHaveClass("ui-tree-item__action"); + expect(selected.style.color).toBe("red"); + expect(selected.firstElementChild).toHaveClass("ui-tree-item__row"); + expect(ref.current).toBe(selected); + expect(treeItem("Plain")).toHaveAttribute("aria-selected", "false"); + }); +}); + +describe("Tree keyboard navigation", () => { + it("moves through visible enabled items with arrows, Home, and End", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Apricot")); + fireEvent.keyDown(treeItem("Apricot"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Amber")); + fireEvent.keyDown(treeItem("Amber"), { key: "End" }); + expect(document.activeElement).toBe(treeItem("Bravo")); + fireEvent.keyDown(treeItem("Bravo"), { key: "Home" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + fireEvent.keyDown(treeItem("Alpha"), { key: "ArrowUp" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + + it("expands a branch, enters it, collapses, and returns to the parent", () => { + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(document.activeElement).toBe(treeItem("Blue")); + fireEvent.keyDown(treeItem("Blue"), { key: "ArrowLeft" }); + expect(document.activeElement).toBe(treeItem("Beta")); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowLeft" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("selects and toggles a branch with Enter and Space", () => { + const onSelect = vi.fn(); + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "Enter" }); + expect(onSelect).toHaveBeenLastCalledWith("beta"); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(treeItem("Beta"), { key: " " }); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("steps from a focused disabled row to its enabled neighbors", () => { + render(); + const disabled = treeItem("Disabled"); + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Apricot")); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowUp" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + + it("ignores keys from interactive content nested in a row", () => { + const onSelect = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Action" }), { + key: "Enter", + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("follows DOM order after rows reorder without item updates", () => { + const renderPair = (reversed: boolean): React.JSX.Element => { + const rows = ["One", "Two"].map((label) => ( + + )); + return ( + {reversed ? rows.reverse() : rows} + ); + }; + const { rerender } = render(renderPair(false)); + fireEvent.keyDown(treeItem("One"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Two")); + + rerender(renderPair(true)); + fireEvent.keyDown(treeItem("Two"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("One")); + }); + + it("rejects duplicate item ids across rows", () => { + expect(() => + render( + + + + , + ), + ).toThrow(/already registered by another row/i); + }); + + it("finds renamed items by type-ahead without re-registering", () => { + const renderNames = (label: string): React.JSX.Element => ( + + + + + ); + const { rerender } = render(renderNames("Amber")); + rerender(renderNames("Cedar")); + + fireEvent.keyDown(treeItem("Alpha"), { key: "c" }); + expect(document.activeElement).toBe(treeItem("Cedar")); + }); + + describe("type-ahead", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("matches case-insensitively, wraps, and cycles repeated characters", () => { + render(); + fireEvent.keyDown(treeItem("Amber"), { key: "B" }); + expect(document.activeElement).toBe(treeItem("Beta")); + fireEvent.keyDown(treeItem("Beta"), { key: "b" }); + expect(document.activeElement).toBe(treeItem("Bravo")); + fireEvent.keyDown(treeItem("Bravo"), { key: "b" }); + expect(document.activeElement).toBe(treeItem("Beta")); + }); + + it("buffers characters and clears the buffer after the timeout", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "a" }); + fireEvent.keyDown(treeItem("Apricot"), { key: "m" }); + expect(document.activeElement).toBe(treeItem("Amber")); + + act(() => { + vi.advanceTimersByTime(500); + }); + fireEvent.keyDown(treeItem("Amber"), { key: "a" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + }); +}); From 22cceeb3fa4f3f30cca09876f1d86876db223b9d Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 12 Aug 2026 00:23:27 +0300 Subject: [PATCH 2/6] feat(ui): add tree sticky scroll Pin the ancestors of the topmost visible row like VS Code's tree sticky scroll, opt in with `stickyScroll` and cap the pinned levels with a number. Branch rows pin themselves with `position: sticky` at an offset derived from their level, so the browser produces the push-out as a subtree scrolls past and no scroll listener or duplicate row widget is needed. Rows past the cap and leaves never pin. Pinned rows paint over scrolled content with sideBarStickyScroll's background. Webviews receive no workbench.tree.* settings, so the README shows the host how to read them and pass them in. --- packages/ui/README.md | 17 ++++++++ packages/ui/src/components/Tree/Tree.css | 17 ++++++-- .../ui/src/components/Tree/Tree.stories.tsx | 40 +++++++++++++++++++ packages/ui/src/components/Tree/Tree.tsx | 20 +++++++--- packages/ui/src/components/Tree/TreeItem.tsx | 14 ++++--- packages/ui/src/components/Tree/context.ts | 3 ++ packages/ui/src/tokens.css | 5 +++ test/webview/ui/tree.test.tsx | 25 ++++++++++++ 8 files changed, 128 insertions(+), 13 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index 58ee1098cf..e7d6ca0bcf 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -93,6 +93,20 @@ branch. Clicking a row does both; clicking the twistie only toggles, leaving selection in place like the native tree. Interactive content in the trailing `action` slot is isolated from selection and expansion. +`stickyScroll` pins the ancestors of the topmost visible row against the +nearest scrolling ancestor, like VS Code's tree sticky scroll; pass a number +to cap how many levels pin at once. Pinning is `position: sticky` on the +branch rows themselves, so the browser does the push-out and there is no +scroll listener. Webviews receive no `workbench.tree.*` settings, so to honor +the user's own configuration the host has to read them and pass them in: + +```ts +const tree = vscode.workspace.getConfiguration("workbench.tree"); +const stickyScroll = + tree.get("enableStickyScroll") && + (tree.get("stickyScrollMaxItemCount") ?? 7); +``` + Navigation order, visibility, and hierarchy are read back from the rendered rows, so reordering or reparenting needs no extra wiring. Collapsed branches stay mounted (their `role="group"` container is `hidden`) and every row @@ -141,6 +155,9 @@ until the exit animation ends. High contrast, `forced-colors`, and - Overlay shadows are darker than native in dark themes: menus in VS Code use `shadow-lg`, which webviews cannot read, so the closest available `widget.shadow` stands in. +- Sticky rows have no drop shadow. VS Code draws one under its sticky + widget; CSS cannot tell a pinned row from an unpinned one, and a scroll + listener would undo the point of doing this without JavaScript. - 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. diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css index 2418026227..93f568edd6 100644 --- a/packages/ui/src/components/Tree/Tree.css +++ b/packages/ui/src/components/Tree/Tree.css @@ -1,5 +1,6 @@ .ui-tree { --ui-tree-indent-size: 8px; + --ui-tree-row-height: 22px; width: 100%; min-width: 0; } @@ -12,12 +13,21 @@ position: relative; display: flex; align-items: center; - height: 22px; + height: var(--ui-tree-row-height); padding-inline-end: var(--ui-spacing-120); + background: var(--ui-tree-row-background, transparent); cursor: pointer; user-select: none; } +/* Pinned below its pinned ancestors; the subtree's end pushes it out. */ +.ui-tree-item--sticky > .ui-tree-item__row { + position: sticky; + top: calc((var(--ui-tree-level) - 1) * var(--ui-tree-row-height)); + z-index: calc(100 - var(--ui-tree-level)); + --ui-tree-row-background: var(--ui-tree-sticky-background); +} + .ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) > .ui-tree-item__row:hover { color: var(--ui-list-hover-foreground); @@ -74,7 +84,8 @@ align-items: center; justify-content: center; width: 16px; - height: 22px; + 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); @@ -104,7 +115,7 @@ align-items: center; min-width: 0; flex: 1; - line-height: 22px; + line-height: var(--ui-tree-row-height); overflow: hidden; white-space: nowrap; } diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx index 42d81ed3bd..1bedc8b5f4 100644 --- a/packages/ui/src/components/Tree/Tree.stories.tsx +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -105,6 +105,46 @@ const NESTED_FILES: readonly TreeDemoNode[] = [ { id: "README.md", label: "README.md", icon: "markdown" }, ]; +const DEEP_FILES: readonly TreeDemoNode[] = ["alpha", "beta"].map((branch) => ({ + id: branch, + label: branch, + children: [ + { + id: `${branch}/src`, + label: "src", + children: Array.from({ length: 12 }, (_, index) => ({ + id: `${branch}/src/file-${index}`, + label: `file-${index}.ts`, + icon: "symbol-class" as const, + })), + }, + ], +})); + +export const StickyScroll: Story = { + render: () => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const scroller = canvasElement.querySelector("div"); + if (!scroller) { + throw new Error("Expected a scroll container."); + } + // Into alpha's files, so alpha and src stay pinned above them. + scroller.scrollTop = 160; + await expect( + within(canvasElement).getByRole("treeitem", { name: "alpha" }), + ).toHaveClass("ui-tree-item--sticky"); + }, +}; + export const Nested: Story = { render: () => ( = { - level: 1, - pathItemIds: [], -} as const; +/** VS Code's workbench.tree.stickyScrollMaxItemCount default. */ +const DEFAULT_STICKY_LEVELS = 7; function focusBelongsToTree( tree: HTMLElement, @@ -41,6 +39,11 @@ export interface TreeProps extends Omit< variant?: "default" | "explorer"; selectedItemId?: string; onSelectedItemChange?: (itemId: string) => void; + /** + * Pins ancestors of the topmost visible row against the nearest scrolling + * ancestor, like VS Code. A number caps how many levels pin at once. + */ + stickyScroll?: boolean | number; } /** A controlled, single-selection tree with native VS Code keyboard behavior. */ @@ -48,6 +51,7 @@ export function Tree({ variant = "default", selectedItemId, onSelectedItemChange, + stickyScroll = false, className, children, onBlur, @@ -60,6 +64,12 @@ export function Tree({ () => new TreeStore(selectedItemId, onSelectedItemChange), ); const [hasDomFocus, setHasDomFocus] = useState(false); + const rootHierarchy: TreeHierarchyContextValue = { + level: 1, + pathItemIds: [], + stickyLevels: + stickyScroll === true ? DEFAULT_STICKY_LEVELS : Number(stickyScroll), + }; useLayoutEffect(() => { store.setConfiguration(selectedItemId, onSelectedItemChange); @@ -71,7 +81,7 @@ export function Tree({ return ( - +
{ diff --git a/packages/ui/src/components/Tree/TreeItem.tsx b/packages/ui/src/components/Tree/TreeItem.tsx index fa42d3b7fa..5a2fc8ba02 100644 --- a/packages/ui/src/components/Tree/TreeItem.tsx +++ b/packages/ui/src/components/Tree/TreeItem.tsx @@ -1,5 +1,6 @@ import { type ComponentPropsWithRef, + type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode, use, @@ -89,6 +90,7 @@ export function TreeItem({ const rowRef = useRef(null); const chevronRef = useRef(null); const isBranch = expanded !== undefined; + const isSticky = isBranch && hierarchy.level <= hierarchy.stickyLevels; if (!isBranch && children) { throw new Error( `TreeItem "${itemId}" has child rows, so it is a branch: pass expanded and onExpandedChange to control it.`, @@ -124,6 +126,7 @@ export function TreeItem({ level: hierarchy.level + 1, parentItemId: itemId, pathItemIds: [...hierarchy.pathItemIds, itemId], + stickyLevels: hierarchy.stickyLevels, }; return ( @@ -145,8 +148,12 @@ export function TreeItem({ aria-disabled={disabled || undefined} aria-expanded={expanded} tabIndex={tabIndex} - className={cx("ui-tree-item", className)} - style={style} + className={cx( + "ui-tree-item", + isSticky && "ui-tree-item--sticky", + className, + )} + style={{ "--ui-tree-level": hierarchy.level, ...style } as CSSProperties} onFocus={(event) => { if (!eventBelongsToRow(event, rowRef.current)) { return; @@ -194,9 +201,6 @@ export function TreeItem({ {action ? {action} : null}
- {isBranch && children ? ( + {expanded && children ? ( -