Skip to content

Commit aa01acb

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 5118c9b commit aa01acb

23 files changed

Lines changed: 1916 additions & 149 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.
@@ -87,20 +87,46 @@ whose children are still loading. `icon`, `action`, and `className` customize
8787
the row. Actions stay live on plain hover, as in the native list, and are
8888
isolated from row selection and expansion.
8989

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

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

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

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

118145
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
@@ -27,6 +27,42 @@
2727
user-select: none;
2828
}
2929

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

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,77 @@ export const RowStatesStable: Story = {
126126
globals: { uiStyle: "stable" },
127127
};
128128

129+
/** Two deep branches, so a short scroller always has ancestors to pin. */
130+
const DEEP_FILES: readonly TreeNode[] = ["alpha", "beta"].map((name) =>
131+
branch(name, [
132+
branch(
133+
`${name}/src`,
134+
Array.from({ length: 12 }, (_, index) =>
135+
node(`${name}/src/file-${index}`, {
136+
label: `file-${index}.ts`,
137+
icon: "symbol-class",
138+
}),
139+
),
140+
{ label: "src" },
141+
),
142+
]),
143+
);
144+
145+
export const StickyScroll: Story = {
146+
render: () => (
147+
<div
148+
data-testid="scroller"
149+
style={{ height: "140px", overflow: "auto", width: "280px" }}
150+
ref={(scroller) => {
151+
if (scroller) scroller.scrollTop = 143;
152+
}}
153+
>
154+
<TreeDemo
155+
aria-label="Sticky explorer"
156+
variant="explorer"
157+
stickyScroll
158+
nodes={DEEP_FILES}
159+
/>
160+
</div>
161+
),
162+
play: async ({ canvasElement }) => {
163+
await waitFor(() =>
164+
expect(
165+
canvasElement.querySelector(".ui-tree-sticky__rows"),
166+
).not.toBeNull(),
167+
);
168+
await expect(
169+
within(canvasElement).getByTestId("scroller").scrollTop,
170+
).toBeGreaterThan(0);
171+
},
172+
};
173+
174+
export const MultiSelect: Story = {
175+
render: () =>
176+
tree({
177+
"aria-label": "Multi-select explorer",
178+
nodes: FILES,
179+
variant: "explorer",
180+
multiSelect: true,
181+
selectedItemIds: ["tree", "styles"],
182+
}),
183+
play: async ({ canvasElement }) => {
184+
const canvas = within(canvasElement);
185+
const treeElement = canvas.getByRole("tree");
186+
const readme = canvas.getByRole("treeitem", { name: "README.md" });
187+
await fireEvent.click(readme, { ctrlKey: true });
188+
await expect(readme).toHaveAttribute("aria-selected", "true");
189+
await expect(
190+
canvas.getByRole("treeitem", { name: "Tree.tsx" }),
191+
).toHaveAttribute("aria-selected", "true");
192+
await expect(canvasElement.ownerDocument.activeElement).toBe(treeElement);
193+
await expect(treeElement).toHaveAttribute(
194+
"aria-activedescendant",
195+
readme.id,
196+
);
197+
},
198+
};
199+
129200
export const Focused: Story = {
130201
render: () =>
131202
tree({

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,37 @@ import { type ComponentPropsWithRef, useId, useRef } from "react";
33
import { cx } from "#cx";
44
import { setForwardedRef } from "#ref";
55

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

1011
import type { TreeNode } from "./treeModel";
11-
import type { TreeExpandMode } from "./treePolicy";
12+
import type { TreeExpandMode, TreeMultiSelectModifier } from "./treePolicy";
1213

14+
/** VS Code's `workbench.tree.stickyScrollMaxItemCount` default. */
15+
const DEFAULT_STICKY_COUNT = 7;
1316
const NO_IDS: readonly string[] = [];
1417

1518
/** The tree's own props; everything else lands on the container element. */
16-
interface TreeOwnProps extends SelectionProps {
19+
interface TreeOwnProps {
1720
readonly nodes: readonly TreeNode[];
1821
readonly expandedIds?: readonly string[];
1922
readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void;
2023
/** `explorer` aligns leaf icons with branch twisties, as VS Code does. */
2124
readonly variant?: "default" | "explorer";
2225
readonly expandMode?: TreeExpandMode;
26+
readonly multiSelectModifier?: TreeMultiSelectModifier;
27+
/** Pins ancestors while scrolling; a number caps how many. */
28+
readonly stickyScroll?: boolean | number;
2329
}
2430

2531
type TreeContainerProps = Omit<
2632
ComponentPropsWithRef<"div">,
2733
"role" | "onSelect" | "children" | keyof TreeOwnProps
2834
>;
2935

30-
export type TreeProps = TreeOwnProps & TreeContainerProps;
36+
export type TreeProps = TreeOwnProps & SelectionProps & TreeContainerProps;
3137

3238
/** Whether the focus or blur target is inside the tree rather than a portal. */
3339
function ownsTarget(tree: HTMLElement, target: EventTarget | null): boolean {
@@ -39,10 +45,15 @@ export function Tree({
3945
nodes,
4046
expandedIds = NO_IDS,
4147
onExpandedIdsChange,
48+
multiSelect,
4249
selectedItemId,
4350
onSelectedItemChange,
51+
selectedItemIds,
52+
onSelectedItemsChange,
4453
variant = "default",
4554
expandMode = "singleClick",
55+
multiSelectModifier = "ctrlCmd",
56+
stickyScroll = false,
4657
className,
4758
onFocus,
4859
onBlur,
@@ -52,13 +63,16 @@ export function Tree({
5263
}: TreeProps): React.JSX.Element {
5364
const treeRef = useRef<HTMLDivElement>(null);
5465
const treeDomId = useId();
66+
const selection: SelectionProps = multiSelect
67+
? { multiSelect: true, selectedItemIds, onSelectedItemsChange }
68+
: { multiSelect: false, selectedItemId, onSelectedItemChange };
5569
const adapter = useTreeAdapter({
70+
...selection,
5671
nodes,
5772
expandedIds,
5873
onExpandedIdsChange,
59-
selectedItemId,
60-
onSelectedItemChange,
6174
expandMode,
75+
multiSelectModifier,
6276
onKeyDown,
6377
treeRef,
6478
});
@@ -75,6 +89,7 @@ export function Tree({
7589
aria-activedescendant={
7690
adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined
7791
}
92+
aria-multiselectable={multiSelect ? true : undefined}
7893
className={cx(
7994
"ui-tree",
8095
variant === "explorer" && "ui-tree--explorer",
@@ -102,6 +117,13 @@ export function Tree({
102117
onClick={adapter.onClick}
103118
onKeyDown={adapter.onKeyDown}
104119
>
120+
{stickyScroll ? (
121+
<StickyScroll
122+
maxCount={stickyScroll === true ? DEFAULT_STICKY_COUNT : stickyScroll}
123+
adapter={adapter}
124+
treeRef={treeRef}
125+
/>
126+
) : null}
105127
{adapter.model.visibleRows.map((row) => (
106128
<TreeRow
107129
key={row.node.id}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ interface TreeRowProps {
1414
readonly selected?: boolean;
1515
/** One character per ancestor, `1` where the indent guide is active. */
1616
readonly guideFlags?: string;
17+
/** Positions a pinned copy inside the sticky widget. */
18+
readonly style?: CSSProperties;
1719
}
1820

1921
/** Pure presentation: props compare by value, so `memo` skips untouched rows. */
@@ -23,6 +25,7 @@ export const TreeRow = memo(function TreeRow({
2325
focused = false,
2426
selected = false,
2527
guideFlags = "",
28+
style,
2629
}: TreeRowProps): React.JSX.Element {
2730
const { node, expanded } = row;
2831
const level = row.pathIds.length + 1;
@@ -43,7 +46,7 @@ export const TreeRow = memo(function TreeRow({
4346
focused && "ui-tree-item--focused",
4447
node.className,
4548
)}
46-
style={{ "--ui-tree-level": level } as CSSProperties}
49+
style={{ ...style, "--ui-tree-level": level } as CSSProperties}
4750
>
4851
<div className="ui-tree-item__row">
4952
<span className="ui-tree-item__indent" aria-hidden="true">

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,37 @@ export function nestedInteractiveTarget(
3030
: null;
3131
}
3232

33+
/** Whether a click landed on the twistie rather than the row body. */
34+
export function hitTwistie(
35+
row: { readonly expanded: boolean | undefined },
36+
target: EventTarget | null,
37+
): boolean {
38+
return (
39+
row.expanded !== undefined &&
40+
target instanceof Element &&
41+
target.closest(".ui-tree-item__chevron") !== null
42+
);
43+
}
44+
3345
/** The row element the event hit, if any. */
3446
export function closestRow(target: EventTarget | null): HTMLElement | null {
3547
return target instanceof Element
3648
? target.closest<HTMLElement>("[data-tree-id]")
3749
: null;
3850
}
51+
52+
export function scrollableAncestor(
53+
element: HTMLElement,
54+
): HTMLElement | undefined {
55+
for (
56+
let parent = element.parentElement;
57+
parent !== null;
58+
parent = parent.parentElement
59+
) {
60+
const { overflowY } = getComputedStyle(parent);
61+
if (overflowY === "auto" || overflowY === "scroll") {
62+
return parent;
63+
}
64+
}
65+
return undefined;
66+
}

0 commit comments

Comments
 (0)