Skip to content

Commit c2b8232

Browse files
committed
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.
1 parent cb58b18 commit c2b8232

20 files changed

Lines changed: 1494 additions & 59 deletions

packages/ui/README.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ that override live.
5454
## Tree
5555

5656
`Tree` is controlled: `nodes` describe the hierarchy, `expandedIds` controls
57-
branches, and `selectedItemId` controls selection. Each
57+
branches, and the single- or multi-selection props control selection. Each
5858
visible node renders as a flat `treeitem`, while normal keyboard navigation
5959
keeps DOM focus on the `tree` container and identifies the active row with
6060
`aria-activedescendant`. Focus and selection are independent.
@@ -88,20 +88,46 @@ whose children are still loading. `icon`, `action`, and `className` customize
8888
the row. Actions stay live on plain hover, as in the native list, and are
8989
isolated from row selection and expansion.
9090

91-
Arrow Up/Down, Home, and End move the active row through visible rows. Arrow Right
91+
Arrow Up/Down, Home, End, PageUp/PageDown, and buffered prefix/fuzzy typing
92+
move the active row through visible rows. Arrow Right
9293
expands a branch or enters it; Arrow Left collapses it or moves to its parent.
9394

9495
`expandMode="singleClick"` is the default: clicking a branch selects
9596
and toggles it, and Enter does the same. With `expandMode="doubleClick"`, a
9697
single click or Enter only selects and a double click toggles expansion. Space
9798
toggles a branch without selecting it, or selects a leaf. A normal-row twistie
9899
toggles without changing selection. Alt-click recursively toggles descendant
99-
branches.
100+
branches unless Alt is configured as the multi-selection modifier.
100101

101-
Escape clears selection, then the active focus mark. Once neither remains,
102-
Escape is left to the host. The root `onKeyDown` runs first, so a host
102+
Escape clears selection. It also clears the active focus mark when the tree has
103+
at most one selected row; after a larger multi-selection, a second Escape
104+
clears the remaining focus mark. Once neither selection nor a focus mark
105+
remains, Escape is left to the host. The root `onKeyDown` runs first, so a host
103106
can intercept shortcuts with `preventDefault()`.
104107

108+
`multiSelect` uses `selectedItemIds` and `onSelectedItemsChange` and sets
109+
`aria-multiselectable`. `multiSelectModifier` chooses the toggle modifier:
110+
`"ctrlCmd"` (the default) uses Ctrl/Cmd and `"alt"` uses Alt. Shift-click and
111+
Shift+Arrow extend from the selection anchor; modifier clicks take precedence
112+
over expansion. Ctrl/Cmd+A selects the visible rows in the active sibling
113+
scope.
114+
115+
`stickyScroll` pins ancestors against the nearest scrolling ancestor. `true`
116+
uses a maximum of seven pinned rows; a number supplies the maximum, and the
117+
widget is also capped at 40% of the viewport. The pinned region is a separate
118+
tab stop: Arrow Up/Down move among pinned ancestors, Arrow Down/Right from the
119+
deepest row enters its first visible child, Enter reveals, focuses, and selects
120+
the real row, Arrow Left reveals and focuses it and collapses an expanded
121+
branch, and Space only reveals and focuses it. A plain pointer click reveals,
122+
focuses, and selects; a pinned twistie additionally toggles the branch.
123+
Selection-modifier clicks update selection without revealing the real row.
124+
125+
Webviews do not receive `workbench.tree.*` settings automatically. Consumers
126+
that mirror native sticky-scroll preferences must read
127+
`workbench.tree.enableStickyScroll` and
128+
`workbench.tree.stickyScrollMaxItemCount` in the extension host and send the
129+
values to the webview.
130+
105131
```mermaid
106132
flowchart LR
107133
accTitle: Tree architecture
@@ -114,6 +140,7 @@ flowchart LR
114140
Commands --> Transition
115141
Transition --> Adapter[useTreeAdapter.ts]
116142
Adapter --> Rows[Tree.tsx and TreeRow.tsx]
143+
Adapter --> Sticky[StickyScroll.tsx]
117144
```
118145

119146
The model, policy, and transitions stay pure. The adapter owns React and DOM

packages/ui/src/components/Tree/Tree.css

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,42 @@
2121
user-select: none;
2222
}
2323

24+
/* A zero-height sticky anchor; the browser pins it, the scroll listener
25+
only decides which rows it shows. */
26+
.ui-tree-sticky {
27+
position: sticky;
28+
top: 0;
29+
z-index: 100;
30+
height: 0;
31+
outline: 0;
32+
}
33+
34+
.ui-tree-sticky__rows {
35+
position: absolute;
36+
inset-inline: 0;
37+
overflow: hidden;
38+
}
39+
40+
.ui-tree-sticky__shadow {
41+
position: absolute;
42+
inset-inline: 0;
43+
height: 3px;
44+
box-shadow: var(--ui-tree-sticky-shadow) 0 6px 6px -6px inset;
45+
pointer-events: none;
46+
}
47+
48+
.ui-tree-sticky__rows > .ui-tree-item {
49+
position: absolute;
50+
inset-inline: 0;
51+
background: var(--ui-tree-sticky-background);
52+
}
53+
54+
/* Pinned copies show indentation, never guide rails, like the native
55+
widget; without this the tree-wide hover rule lights them up. */
56+
.ui-tree-sticky .ui-tree-item__indent {
57+
display: none;
58+
}
59+
2460
/* Native skips hover on selected and focused rows, keeping their outlines. */
2561
.ui-tree-item:not([aria-selected="true"]):not(.ui-tree-item--focused)
2662
> .ui-tree-item__row:hover {

packages/ui/src/components/Tree/Tree.stories.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,75 @@ const NESTED_FILES = [
132132
]),
133133
node("README.md", { icon: "markdown" }),
134134
];
135+
const DEEP_FILES = ["alpha", "beta"].map((name) =>
136+
branch(name, [
137+
branch(
138+
`${name}/src`,
139+
Array.from({ length: 12 }, (_, index) =>
140+
node(`${name}/src/file-${index}`, {
141+
label: `file-${index}.ts`,
142+
icon: "symbol-class",
143+
}),
144+
),
145+
{ label: "src" },
146+
),
147+
]),
148+
);
149+
150+
export const StickyScroll: Story = {
151+
render: () => (
152+
<div
153+
data-testid="scroller"
154+
style={{ height: "140px", overflow: "auto", ...treeStyle }}
155+
ref={(scroller) => {
156+
if (scroller) scroller.scrollTop = 143;
157+
}}
158+
>
159+
<TreeDemo
160+
aria-label="Sticky explorer"
161+
variant="explorer"
162+
stickyScroll
163+
nodes={DEEP_FILES}
164+
/>
165+
</div>
166+
),
167+
play: async ({ canvasElement }) => {
168+
await waitFor(() =>
169+
expect(
170+
canvasElement.querySelector(".ui-tree-sticky__rows"),
171+
).not.toBeNull(),
172+
);
173+
await expect(
174+
within(canvasElement).getByTestId("scroller").scrollTop,
175+
).toBeGreaterThan(0);
176+
},
177+
};
178+
179+
export const MultiSelect: Story = {
180+
render: () => (
181+
<TreeDemo
182+
aria-label="Multi-select explorer"
183+
variant="explorer"
184+
multiSelect
185+
selectedItemIds={["tree", "styles"]}
186+
nodes={FILES}
187+
style={treeStyle}
188+
/>
189+
),
190+
play: async ({ canvasElement }) => {
191+
const canvas = within(canvasElement);
192+
const tree = canvas.getByRole("tree");
193+
const readme = canvas.getByRole("treeitem", { name: "README.md" });
194+
await fireEvent.click(readme, { ctrlKey: true });
195+
await expect(readme).toHaveAttribute("aria-selected", "true");
196+
await expect(
197+
canvas.getByRole("treeitem", { name: "Tree.tsx" }),
198+
).toHaveAttribute("aria-selected", "true");
199+
await expect(canvasElement.ownerDocument.activeElement).toBe(tree);
200+
await expect(tree).toHaveAttribute("aria-activedescendant", readme.id);
201+
},
202+
};
203+
135204
export const Focused: Story = {
136205
render: () => singleTree("Focused explorer", "tree", FILES, "explorer"),
137206
play: async ({ canvasElement }) => {

packages/ui/src/components/Tree/Tree.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { type ComponentPropsWithRef, type Ref, useId, useRef } from "react";
22

33
import { cx } from "#cx";
44

5+
import { StickyScroll } from "./sticky/StickyScroll";
56
import "./Tree.css";
67
import { TreeRow } from "./TreeRow";
78
import { type SelectionProps, useTreeAdapter } from "./useTreeAdapter";
89

910
import type { TreeNode } from "./treeModel";
1011

12+
const DEFAULT_STICKY_COUNT = 7;
1113
const NO_IDS: readonly string[] = [];
1214

1315
interface TreeBaseProps extends Omit<
@@ -19,6 +21,8 @@ interface TreeBaseProps extends Omit<
1921
onExpandedIdsChange?: (expandedIds: readonly string[]) => void;
2022
variant?: "default" | "explorer";
2123
expandMode?: "singleClick" | "doubleClick";
24+
multiSelectModifier?: "ctrlCmd" | "alt";
25+
stickyScroll?: boolean | number;
2226
}
2327

2428
export type TreeProps = TreeBaseProps & SelectionProps;
@@ -46,8 +50,13 @@ export function Tree(props: TreeProps): React.JSX.Element {
4650
onExpandedIdsChange: _onExpandedIdsChange,
4751
variant = "default",
4852
expandMode = "singleClick",
53+
multiSelectModifier = "ctrlCmd",
54+
stickyScroll = false,
55+
multiSelect = false,
4956
selectedItemId: _selectedItemId,
5057
onSelectedItemChange: _onSelectedItemChange,
58+
selectedItemIds: _selectedItemIds,
59+
onSelectedItemsChange: _onSelectedItemsChange,
5160
className,
5261
onBlur,
5362
onFocus,
@@ -61,6 +70,7 @@ export function Tree(props: TreeProps): React.JSX.Element {
6170
...props,
6271
expandedIds,
6372
expandMode,
73+
multiSelectModifier,
6474
treeRef,
6575
});
6676

@@ -76,6 +86,7 @@ export function Tree(props: TreeProps): React.JSX.Element {
7686
aria-activedescendant={
7787
adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined
7888
}
89+
aria-multiselectable={multiSelect || undefined}
7990
className={cx(
8091
"ui-tree",
8192
variant === "explorer" && "ui-tree--explorer",
@@ -107,6 +118,13 @@ export function Tree(props: TreeProps): React.JSX.Element {
107118
}}
108119
onKeyDown={(event) => adapter.onKeyDown(event)}
109120
>
121+
{stickyScroll ? (
122+
<StickyScroll
123+
maxCount={stickyScroll === true ? DEFAULT_STICKY_COUNT : stickyScroll}
124+
adapter={adapter}
125+
treeRef={treeRef}
126+
/>
127+
) : null}
110128
{adapter.model.rows.map((row) => (
111129
<TreeRow
112130
key={row.node.id}

packages/ui/src/components/Tree/TreeRow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function TreeRow(props: TreeRowProps): React.JSX.Element {
111111
event.target instanceof Element &&
112112
event.target.closest(".ui-tree-item__chevron") !== null;
113113
if (onClick) onClick(event, twistie);
114-
else adapter?.onPointer(row, event, twistie);
114+
else adapter?.onPointer(row, event, twistie, "row");
115115
}}
116116
>
117117
<TreeRowSurface row={row} activeGuideIds={activeGuideIds} />

packages/ui/src/components/Tree/rowDom.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,19 @@ export function closestRow(target: EventTarget | null): HTMLElement | null {
4242
? target.closest<HTMLElement>('[role="treeitem"]')
4343
: null;
4444
}
45+
46+
export function scrollableAncestor(
47+
element: HTMLElement,
48+
): HTMLElement | undefined {
49+
for (
50+
let parent = element.parentElement;
51+
parent !== null;
52+
parent = parent.parentElement
53+
) {
54+
const { overflowY } = getComputedStyle(parent);
55+
if (overflowY === "auto" || overflowY === "scroll") {
56+
return parent;
57+
}
58+
}
59+
return undefined;
60+
}

0 commit comments

Comments
 (0)