From b425f517877938181f39c4d6e0243c2093fa0075 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 14 Aug 2026 18:59:26 +0300 Subject: [PATCH] feat(ui): add multi-select, type navigation, paging, and sticky scroll Multi-selection with `selectedItemIds`, ranges anchored the way the native list anchors them, Ctrl/Cmd toggling under either `multiSelectModifier`, and Ctrl/Cmd+A scoped to the active sibling group before widening to its parent, which is what `list.selectAll` does for a tree. Buffered prefix and fuzzy type navigation, PageUp and PageDown measured against the scroller's viewport, and sticky scroll: ancestors pin against the nearest scrolling ancestor with VS Code's pinned-count and 40% viewport caps, the deepest row sliding out as its subtree ends, and its own tab stop for keyboard reveal. --- packages/ui/README.md | 37 ++- packages/ui/src/components/Tree/Tree.css | 36 +++ .../ui/src/components/Tree/Tree.stories.tsx | 71 ++++++ packages/ui/src/components/Tree/Tree.tsx | 32 ++- packages/ui/src/components/Tree/TreeRow.tsx | 5 +- packages/ui/src/components/Tree/rowDom.ts | 28 ++ .../components/Tree/sticky/StickyScroll.tsx | 205 +++++++++++++++ .../src/components/Tree/sticky/stickyState.ts | 72 ++++++ packages/ui/src/components/Tree/treeModel.ts | 3 + packages/ui/src/components/Tree/treePolicy.ts | 171 +++++++++++-- .../ui/src/components/Tree/treeTransition.ts | 241 +++++++++++++++++- .../ui/src/components/Tree/useTreeAdapter.ts | 160 +++++++++--- packages/ui/src/tokens.css | 9 + packages/ui/storybook/Tree.demo.tsx | 35 ++- test/webview/ui/tree.keyboard.test.tsx | 102 +++++++- test/webview/ui/tree.rows.test.tsx | 2 +- test/webview/ui/tree.selection.test.tsx | 209 +++++++++++++++ test/webview/ui/tree.sticky.test.tsx | 97 +++++++ test/webview/ui/treeController.test.tsx | 49 ++++ test/webview/ui/treePolicy.test.ts | 177 +++++++++++-- test/webview/ui/treeStickyState.test.ts | 58 +++++ test/webview/ui/treeTestHelpers.tsx | 99 ++++--- test/webview/ui/treeTransition.test.ts | 167 +++++++++++- 23 files changed, 1916 insertions(+), 149 deletions(-) create mode 100644 packages/ui/src/components/Tree/sticky/StickyScroll.tsx create mode 100644 packages/ui/src/components/Tree/sticky/stickyState.ts create mode 100644 test/webview/ui/tree.selection.test.tsx create mode 100644 test/webview/ui/tree.sticky.test.tsx create mode 100644 test/webview/ui/treeController.test.tsx create mode 100644 test/webview/ui/treeStickyState.test.ts diff --git a/packages/ui/README.md b/packages/ui/README.md index 08a2e91c9..dddedb1d7 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -54,7 +54,7 @@ that override live. ## Tree `Tree` is controlled: `nodes` describe the hierarchy, `expandedIds` controls -branches, and `selectedItemId` controls selection. Each +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. @@ -87,7 +87,8 @@ whose children are still loading. `icon`, `action`, and `className` customize the row. Actions stay live on plain hover, as in the native list, and are isolated from row selection and expansion. -Arrow Up/Down, Home, and End move the active row through visible rows. Arrow Right +Arrow Up/Down, Home, End, PageUp/PageDown, and buffered prefix/fuzzy typing +move the active row through visible rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or moves to its parent. `expandMode="singleClick"` is the default: clicking a branch selects @@ -95,12 +96,37 @@ 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. +branches unless Alt is configured as the multi-selection modifier. -Escape clears selection, then the active focus mark. Once neither remains, -Escape is left to the host. The root `onKeyDown` runs first, so a host +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 the 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 @@ -113,6 +139,7 @@ flowchart LR 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 diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css index 2da8a62d1..f65468690 100644 --- a/packages/ui/src/components/Tree/Tree.css +++ b/packages/ui/src/components/Tree/Tree.css @@ -27,6 +27,42 @@ 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-selected="true"], .ui-tree-item--focused) > .ui-tree-item__row:hover { color: var(--ui-list-hover-foreground); diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx index acdeeba3a..097944ec9 100644 --- a/packages/ui/src/components/Tree/Tree.stories.tsx +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -126,6 +126,77 @@ export const RowStatesStable: Story = { globals: { uiStyle: "stable" }, }; +/** Two deep branches, so a short scroller always has ancestors to pin. */ +const DEEP_FILES: readonly TreeNode[] = ["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: () => + tree({ + "aria-label": "Multi-select explorer", + nodes: FILES, + variant: "explorer", + multiSelect: true, + selectedItemIds: ["tree", "styles"], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const treeElement = 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(treeElement); + await expect(treeElement).toHaveAttribute( + "aria-activedescendant", + readme.id, + ); + }, +}; + export const Focused: Story = { render: () => tree({ diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx index 6eb1a06e3..ab080f657 100644 --- a/packages/ui/src/components/Tree/Tree.tsx +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -3,23 +3,29 @@ import { type ComponentPropsWithRef, useId, useRef } from "react"; import { cx } from "#cx"; import { setForwardedRef } from "#ref"; +import { StickyScroll } from "./sticky/StickyScroll"; import "./Tree.css"; import { TreeRow } from "./TreeRow"; import { useTreeAdapter, type SelectionProps } from "./useTreeAdapter"; import type { TreeNode } from "./treeModel"; -import type { TreeExpandMode } from "./treePolicy"; +import type { TreeExpandMode, TreeMultiSelectModifier } from "./treePolicy"; +/** VS Code's `workbench.tree.stickyScrollMaxItemCount` default. */ +const DEFAULT_STICKY_COUNT = 7; const NO_IDS: readonly string[] = []; /** The tree's own props; everything else lands on the container element. */ -interface TreeOwnProps extends SelectionProps { +interface TreeOwnProps { readonly nodes: readonly TreeNode[]; readonly expandedIds?: readonly string[]; readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void; /** `explorer` aligns leaf icons with branch twisties, as VS Code does. */ readonly variant?: "default" | "explorer"; readonly expandMode?: TreeExpandMode; + readonly multiSelectModifier?: TreeMultiSelectModifier; + /** Pins ancestors while scrolling; a number caps how many. */ + readonly stickyScroll?: boolean | number; } type TreeContainerProps = Omit< @@ -27,7 +33,7 @@ type TreeContainerProps = Omit< "role" | "onSelect" | "children" | keyof TreeOwnProps >; -export type TreeProps = TreeOwnProps & TreeContainerProps; +export type TreeProps = TreeOwnProps & SelectionProps & TreeContainerProps; /** Whether the focus or blur target is inside the tree rather than a portal. */ function ownsTarget(tree: HTMLElement, target: EventTarget | null): boolean { @@ -39,10 +45,15 @@ export function Tree({ nodes, expandedIds = NO_IDS, onExpandedIdsChange, + multiSelect, selectedItemId, onSelectedItemChange, + selectedItemIds, + onSelectedItemsChange, variant = "default", expandMode = "singleClick", + multiSelectModifier = "ctrlCmd", + stickyScroll = false, className, onFocus, onBlur, @@ -52,13 +63,16 @@ export function Tree({ }: TreeProps): React.JSX.Element { const treeRef = useRef(null); const treeDomId = useId(); + const selection: SelectionProps = multiSelect + ? { multiSelect: true, selectedItemIds, onSelectedItemsChange } + : { multiSelect: false, selectedItemId, onSelectedItemChange }; const adapter = useTreeAdapter({ + ...selection, nodes, expandedIds, onExpandedIdsChange, - selectedItemId, - onSelectedItemChange, expandMode, + multiSelectModifier, onKeyDown, treeRef, }); @@ -75,6 +89,7 @@ export function Tree({ aria-activedescendant={ adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined } + aria-multiselectable={multiSelect ? true : undefined} className={cx( "ui-tree", variant === "explorer" && "ui-tree--explorer", @@ -102,6 +117,13 @@ export function Tree({ onClick={adapter.onClick} onKeyDown={adapter.onKeyDown} > + {stickyScroll ? ( + + ) : null} {adapter.model.visibleRows.map((row) => (