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) => (
diff --git a/packages/ui/src/components/Tree/rowDom.ts b/packages/ui/src/components/Tree/rowDom.ts
index 896c6da56..3b101c68f 100644
--- a/packages/ui/src/components/Tree/rowDom.ts
+++ b/packages/ui/src/components/Tree/rowDom.ts
@@ -30,9 +30,37 @@ export function nestedInteractiveTarget(
: null;
}
+/** Whether a click landed on the twistie rather than the row body. */
+export function hitTwistie(
+ row: { readonly expanded: boolean | undefined },
+ target: EventTarget | null,
+): boolean {
+ return (
+ row.expanded !== undefined &&
+ target instanceof Element &&
+ target.closest(".ui-tree-item__chevron") !== null
+ );
+}
+
/** The row element the event hit, if any. */
export function closestRow(target: EventTarget | null): HTMLElement | null {
return target instanceof Element
? target.closest("[data-tree-id]")
: null;
}
+
+export function scrollableAncestor(
+ element: HTMLElement,
+): HTMLElement | undefined {
+ for (
+ let parent = element.parentElement;
+ parent !== null;
+ parent = parent.parentElement
+ ) {
+ const { overflowY } = getComputedStyle(parent);
+ if (overflowY === "auto" || overflowY === "scroll") {
+ return parent;
+ }
+ }
+ return undefined;
+}
diff --git a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx
new file mode 100644
index 000000000..05490aba2
--- /dev/null
+++ b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx
@@ -0,0 +1,205 @@
+import {
+ type RefObject,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+
+import { hitTwistie, scrollableAncestor } from "../rowDom";
+import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel";
+import { TreeRow } from "../TreeRow";
+
+import { computeStickyState, NO_STICKY, type StickyState } from "./stickyState";
+
+import type { TreeAdapter } from "../useTreeAdapter";
+
+function useStickyState(
+ rows: readonly TreeRowModel[],
+ maxCount: number,
+ widgetRef: RefObject,
+): StickyState {
+ const snapshotRef = useRef(NO_STICKY);
+ const subscribe = (notify: () => void): (() => void) => {
+ const tree = widgetRef.current?.parentElement;
+ const scroller = tree ? scrollableAncestor(tree) : undefined;
+ if (!scroller) return () => undefined;
+ scroller.addEventListener("scroll", notify, { passive: true });
+ const observer =
+ typeof ResizeObserver === "undefined"
+ ? undefined
+ : new ResizeObserver(notify);
+ observer?.observe(scroller);
+ return () => {
+ scroller.removeEventListener("scroll", notify);
+ observer?.disconnect();
+ };
+ };
+ const getSnapshot = (): StickyState => {
+ const widget = widgetRef.current;
+ const tree = widget?.parentElement;
+ if (!widget || !tree) return NO_STICKY;
+ const next = computeStickyState(
+ rows,
+ widget.getBoundingClientRect().top - tree.getBoundingClientRect().top,
+ scrollableAncestor(tree)?.clientHeight ?? 0,
+ maxCount,
+ );
+ const current = snapshotRef.current;
+ if (
+ current.pushOffset !== next.pushOffset ||
+ current.ids.length !== next.ids.length ||
+ current.ids.some((id, index) => id !== next.ids[index])
+ )
+ snapshotRef.current = next;
+ return snapshotRef.current;
+ };
+ return useSyncExternalStore(subscribe, getSnapshot, () => NO_STICKY);
+}
+
+export function StickyScroll({
+ maxCount,
+ adapter,
+ treeRef,
+}: {
+ maxCount: number;
+ adapter: TreeAdapter;
+ treeRef: React.RefObject;
+}): React.JSX.Element {
+ const { visibleRows, rowsById } = adapter.model;
+ const widgetRef = useRef(null);
+ const state = useStickyState(visibleRows, maxCount, widgetRef);
+ const pinnedRows = state.ids
+ .map((id) => rowsById.get(id))
+ .filter((row) => row !== undefined);
+ const pinnedHeight = pinnedRows.length * ROW_HEIGHT_PX + state.pushOffset;
+ const [requestedIndex, setRequestedIndex] = useState(0);
+ const focusedIndex = Math.max(
+ 0,
+ Math.min(requestedIndex, pinnedRows.length - 1),
+ );
+
+ useEffect(() => {
+ if (
+ pinnedRows.length === 0 &&
+ widgetRef.current?.contains(document.activeElement)
+ ) {
+ treeRef.current?.focus();
+ }
+ }, [pinnedRows.length, treeRef]);
+
+ const reveal = (row: TreeRowModel, index: number): void => {
+ const widget = widgetRef.current;
+ const tree = widget?.parentElement;
+ if (!widget || !tree) return;
+ scrollableAncestor(tree)?.scrollBy(
+ 0,
+ visibleRows.indexOf(row) * ROW_HEIGHT_PX -
+ index * ROW_HEIGHT_PX -
+ (widget.getBoundingClientRect().top - tree.getBoundingClientRect().top),
+ );
+ };
+ const revealAndDispatch = (
+ row: TreeRowModel,
+ commands: Parameters[0],
+ ): void => {
+ reveal(row, focusedIndex);
+ adapter.dispatch(commands);
+ };
+
+ return (
+ 0 ? 0 : -1}
+ onFocus={(event) => {
+ if (event.target === event.currentTarget)
+ setRequestedIndex(focusedIndex);
+ }}
+ onKeyDown={(event) => {
+ const row = pinnedRows[focusedIndex];
+ if (!row) return;
+ if (event.key === "ArrowUp")
+ setRequestedIndex(Math.max(0, focusedIndex - 1));
+ else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
+ if (pinnedRows[focusedIndex + 1]) setRequestedIndex(focusedIndex + 1);
+ else {
+ const child = visibleRows[visibleRows.indexOf(row) + 1];
+ if (child?.pathIds.includes(row.node.id)) {
+ adapter.dispatch([{ type: "focus", id: child.node.id }]);
+ }
+ }
+ } else if (event.key === "Enter") {
+ revealAndDispatch(row, [
+ { type: "focus", id: row.node.id },
+ {
+ type: "select",
+ id: row.node.id,
+ toggle: false,
+ range: false,
+ preserveHidden: true,
+ },
+ ]);
+ } else if (event.key === "ArrowLeft") {
+ revealAndDispatch(row, [
+ { type: "focus", id: row.node.id },
+ ...(row.expanded
+ ? [
+ {
+ type: "toggle" as const,
+ id: row.node.id,
+ recursive: false,
+ },
+ ]
+ : []),
+ ]);
+ } else if (event.key === " ") {
+ revealAndDispatch(row, [{ type: "focus", id: row.node.id }]);
+ } else return;
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ {pinnedRows.length > 0 ? (
+ <>
+
{
+ const index = [...event.currentTarget.children].findIndex(
+ (child) => child.contains(event.target as Node),
+ );
+ const row = pinnedRows[index];
+ if (!row) return;
+ if (!adapter.isSelectionGesture(event)) reveal(row, index);
+ adapter.onPointer(
+ row,
+ event,
+ hitTwistie(row, event.target),
+ "sticky",
+ );
+ }}
+ >
+ {pinnedRows.map((row, index) => (
+
+ ))}
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/packages/ui/src/components/Tree/sticky/stickyState.ts b/packages/ui/src/components/Tree/sticky/stickyState.ts
new file mode 100644
index 000000000..a18020854
--- /dev/null
+++ b/packages/ui/src/components/Tree/sticky/stickyState.ts
@@ -0,0 +1,72 @@
+import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel";
+
+/** VS Code caps the sticky widget at 40% of the viewport. */
+const MAX_VIEWPORT_RATIO = 0.4;
+
+export interface StickyState {
+ /** Ids of the pinned ancestor chain, outermost first. */
+ readonly ids: readonly string[];
+ /** Upward shift in px while the last pinned subtree scrolls out. */
+ readonly pushOffset: number;
+}
+
+export const NO_STICKY: StickyState = { ids: [], pushOffset: 0 };
+
+/**
+ * The ancestor chain to pin, like VS Code's findStickyState: the ancestors
+ * of the topmost row not covered by the widget, capped by `maxCount` and by
+ * viewport share. Pinned rows cover rows below, which can deepen the chain,
+ * so grow to a fixpoint.
+ */
+export function computeStickyState(
+ rows: readonly TreeRowModel[],
+ scrolledPx: number,
+ viewportPx: number,
+ maxCount: number,
+): StickyState {
+ const cap = Math.min(
+ maxCount,
+ Math.floor((viewportPx * MAX_VIEWPORT_RATIO) / ROW_HEIGHT_PX),
+ );
+ if (scrolledPx <= 0 || cap <= 0) {
+ return NO_STICKY;
+ }
+ const topIndex = Math.floor(scrolledPx / ROW_HEIGHT_PX);
+ let count = 0;
+ let chain: readonly string[] = [];
+ for (;;) {
+ const rowChain = rows[topIndex + count]?.pathIds ?? [];
+ const next = Math.min(rowChain.length, cap);
+ if (next <= count) {
+ break;
+ }
+ count = next;
+ chain = rowChain;
+ }
+ const ids = chain.slice(0, count);
+ if (ids.length === 0) {
+ return NO_STICKY;
+ }
+ return { ids, pushOffset: pushOffset(rows, scrolledPx, ids) };
+}
+
+/** How far the widget shifts up as the last pinned subtree ends. */
+function pushOffset(
+ rows: readonly TreeRowModel[],
+ scrolledPx: number,
+ ids: readonly string[],
+): number {
+ const lastId = ids.at(-1);
+ let endIndex = -1;
+ rows.forEach((row, index) => {
+ if (row.node.id === lastId || row.pathIds.includes(lastId ?? "")) {
+ endIndex = index;
+ }
+ });
+ if (endIndex === -1) {
+ return 0;
+ }
+ const subtreeBottom = (endIndex + 1) * ROW_HEIGHT_PX;
+ const widgetBottom = scrolledPx + ids.length * ROW_HEIGHT_PX;
+ return Math.min(0, subtreeBottom - widgetBottom);
+}
diff --git a/packages/ui/src/components/Tree/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts
index 3f5a22269..ffee3a93f 100644
--- a/packages/ui/src/components/Tree/treeModel.ts
+++ b/packages/ui/src/components/Tree/treeModel.ts
@@ -2,6 +2,9 @@ import type { ReactNode } from "react";
import type { CodiconName } from "#codicons";
+/** VS Code's tree row height; Tree.css --ui-tree-row-height must match. */
+export const ROW_HEIGHT_PX = 22;
+
/** A string label doubles as the text value; a rich label must supply one. */
type TreeNodeLabel =
| { readonly label: string; readonly textValue?: string }
diff --git a/packages/ui/src/components/Tree/treePolicy.ts b/packages/ui/src/components/Tree/treePolicy.ts
index 1e96cb177..1bd7d55fc 100644
--- a/packages/ui/src/components/Tree/treePolicy.ts
+++ b/packages/ui/src/components/Tree/treePolicy.ts
@@ -8,8 +8,30 @@ import { parentId, type TreeRowModel } from "./treeModel";
/** Mirrors `workbench.tree.expandMode`, values included. */
export type TreeExpandMode = "singleClick" | "doubleClick";
-interface TreeCommandBehavior {
+/** Mirrors `workbench.list.multiSelectModifier`, values included. */
+export type TreeMultiSelectModifier = "ctrlCmd" | "alt";
+
+export interface TreeCommandBehavior {
readonly expandMode: TreeExpandMode;
+ readonly multiSelect: boolean;
+ readonly multiSelectModifier: TreeMultiSelectModifier;
+}
+
+/** The modifier keys a gesture carries, as a DOM event reports them. */
+export interface TreeModifiers {
+ readonly ctrlKey: boolean;
+ readonly metaKey: boolean;
+ readonly altKey: boolean;
+ readonly shiftKey: boolean;
+}
+
+interface SelectOptions {
+ /** Adds to or removes from the selection instead of replacing it. */
+ readonly toggle: boolean;
+ /** Selects from the anchor through this row. */
+ readonly range: boolean;
+ /** Whether selected rows hidden under a collapsed branch survive. */
+ readonly preserveHidden: boolean;
}
type RowCommand = {
@@ -19,26 +41,42 @@ type RowCommand = {
export type TreeCommand =
| RowCommand<"focus">
- | RowCommand<"move", { readonly offset: -1 | 1 }>
- | RowCommand<"select">
+ /** Selects the row's sibling group, widening to its parent once full. */
+ | RowCommand<"selectScope">
+ | RowCommand<
+ "move",
+ {
+ readonly offset: -1 | 1;
+ /** A viewport's worth of rows rather than one. */
+ readonly page: boolean;
+ /** Extends the selection to the row moved to. */
+ readonly extend: boolean;
+ }
+ >
+ | RowCommand<"select", SelectOptions>
| RowCommand<"toggle", { readonly recursive: boolean }>
+ | RowCommand<"typeahead", { readonly key: string }>
| {
readonly type: "dismiss";
readonly clearSelection: boolean;
readonly clearFocus: boolean;
};
-export interface PointerCommandInput extends TreeCommandBehavior {
+interface CommandInput extends TreeCommandBehavior {
readonly row: TreeRowModel;
+ readonly modifiers: TreeModifiers;
+}
+
+export interface PointerCommandInput extends CommandInput {
+ /** A pinned row selects without the expand-on-click its body would get. */
+ readonly source: "row" | "sticky";
readonly onTwistie: boolean;
/** `MouseEvent.detail`, so 2 on a double click. */
readonly detail: number;
- readonly altKey: boolean;
}
-export interface KeyboardCommandInput extends TreeCommandBehavior {
+export interface KeyboardCommandInput extends CommandInput {
readonly key: string;
- readonly row: TreeRowModel;
readonly visibleRows: readonly TreeRowModel[];
/** Whether a control inside the row, not the row, has focus. */
readonly fromAction: boolean;
@@ -61,29 +99,83 @@ const NAVIGATION_KEYS: ReadonlySet = new Set([
"ArrowUp",
"ArrowLeft",
"ArrowRight",
+ "PageDown",
+ "PageUp",
"Home",
"End",
]);
const focusCommand = (id: string): TreeCommand => ({ type: "focus", id });
-const selectCommand = (id: string): TreeCommand => ({ type: "select", id });
+const selectCommand = (
+ id: string,
+ options: Partial = {},
+): TreeCommand => ({
+ type: "select",
+ id,
+ toggle: false,
+ range: false,
+ preserveHidden: true,
+ ...options,
+});
const toggleCommand = (id: string, recursive = false): TreeCommand => ({
type: "toggle",
id,
recursive,
});
+/** Whether the gesture adds to the selection rather than replacing it. */
+export function isSelectionModifier(
+ modifiers: TreeModifiers,
+ behavior: TreeCommandBehavior,
+): boolean {
+ if (!behavior.multiSelect) {
+ return false;
+ }
+ return behavior.multiSelectModifier === "alt"
+ ? modifiers.altKey
+ : modifiers.ctrlKey || modifiers.metaKey;
+}
+
+/** Whether the gesture is about selection at all, ranges included. */
+export function isSelectionGesture(
+ modifiers: TreeModifiers,
+ behavior: TreeCommandBehavior,
+): boolean {
+ return (
+ isSelectionModifier(modifiers, behavior) ||
+ (behavior.multiSelect && modifiers.shiftKey)
+ );
+}
+
/** The commands a click on `row` means, twistie clicks included. */
export function pointerCommands(
input: PointerCommandInput,
): readonly TreeCommand[] {
- const { row, expandMode, detail } = input;
+ const { row, source, expandMode, detail, modifiers } = input;
const id = row.node.id;
- const toggle = toggleCommand(id, input.altKey);
+
+ if (isSelectionGesture(modifiers, input)) {
+ // Hidden rows drop out: a selection the user cannot see cannot be judged.
+ const select = selectCommand(id, {
+ toggle: isSelectionModifier(modifiers, input),
+ range: modifiers.shiftKey,
+ preserveHidden: false,
+ });
+ return source === "sticky" ? [select] : [focusCommand(id), select];
+ }
+
+ // Alt expands recursively unless it is the selection modifier.
+ const toggle = toggleCommand(
+ id,
+ modifiers.altKey && input.multiSelectModifier !== "alt",
+ );
if (input.onTwistie) {
- return [focusCommand(id), toggle];
+ return source === "sticky"
+ ? [focusCommand(id), selectCommand(id), toggle]
+ : [focusCommand(id), toggle];
}
const togglesBody =
+ source === "row" &&
row.expanded !== undefined &&
(expandMode === "singleClick" ? detail <= 1 : detail === 2);
return togglesBody
@@ -93,7 +185,7 @@ export function pointerCommands(
/** The commands a key press means, plus who keeps the event afterwards. */
export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome {
- const { key, row, visibleRows } = input;
+ const { key, row, visibleRows, modifiers } = input;
const id = row.node.id;
// A key pressed inside a row action belongs to it, unless it navigates.
if (input.fromAction && !NAVIGATION_KEYS.has(key)) {
@@ -111,13 +203,34 @@ export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome {
preventDefault,
focusRowElementId: input.fromAction ? id : undefined,
});
+ const selectionModifier = isSelectionModifier(modifiers, input);
+
+ // Ctrl/Cmd+A, which native scopes to the sibling group before widening.
+ if (
+ selectionModifier &&
+ !modifiers.shiftKey &&
+ key.toLocaleLowerCase() === "a"
+ ) {
+ return outcome([{ type: "selectScope", id }]);
+ }
switch (key) {
case "ArrowDown":
case "ArrowUp":
+ case "PageDown":
+ case "PageUp": {
+ const page = key === "PageDown" || key === "PageUp";
+ const offset = key === "ArrowDown" || key === "PageDown" ? 1 : -1;
return outcome([
- { type: "move", id, offset: key === "ArrowDown" ? 1 : -1 },
+ {
+ type: "move",
+ id,
+ offset,
+ page,
+ extend: !page && input.multiSelect && modifiers.shiftKey,
+ },
]);
+ }
case "Home":
case "End": {
const target = key === "Home" ? visibleRows[0] : visibleRows.at(-1);
@@ -144,18 +257,21 @@ export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome {
return outcome(parent ? [focusCommand(parent)] : NO_COMMANDS);
}
case "Enter": {
+ // Ctrl+Shift+Enter toggles this row and leaves the rest selected.
+ if (selectionModifier && modifiers.shiftKey) {
+ return outcome([selectCommand(id, { toggle: true })]);
+ }
+ const select = selectCommand(id, { toggle: selectionModifier });
const alsoToggles =
row.expanded !== undefined && input.expandMode === "singleClick";
- return outcome(
- alsoToggles
- ? [selectCommand(id), toggleCommand(id)]
- : [selectCommand(id)],
- );
+ return outcome(alsoToggles ? [select, toggleCommand(id)] : [select]);
}
case " ":
// A leaf has nothing to toggle, so Space selects it instead.
return outcome([
- row.expanded === undefined ? selectCommand(id) : toggleCommand(id),
+ row.expanded === undefined
+ ? selectCommand(id, { toggle: selectionModifier })
+ : toggleCommand(id),
]);
case "Escape": {
const clearSelection = input.selectedCount > 0;
@@ -165,8 +281,19 @@ export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome {
clearSelection || input.hasFocusedRow,
);
}
- default:
- // Anything the tree does not handle stays with the host, Tab included.
- return outcome(NO_COMMANDS, false);
+ default: {
+ // A bare printable key types ahead; anything else is the host's.
+ const typesAhead =
+ key.length === 1 &&
+ !modifiers.ctrlKey &&
+ !modifiers.metaKey &&
+ !modifiers.altKey;
+ return outcome(
+ typesAhead
+ ? [{ type: "typeahead", id, key: key.toLocaleLowerCase() }]
+ : NO_COMMANDS,
+ typesAhead,
+ );
+ }
}
}
diff --git a/packages/ui/src/components/Tree/treeTransition.ts b/packages/ui/src/components/Tree/treeTransition.ts
index e728ee48b..42be5cb2f 100644
--- a/packages/ui/src/components/Tree/treeTransition.ts
+++ b/packages/ui/src/components/Tree/treeTransition.ts
@@ -1,13 +1,17 @@
/**
- * The interaction state props cannot hold: focus, the tab stop, and container
- * focus. `deriveTreeInteractionView` reads it against the current model and
- * resolves what the rows render; `transitionTree` folds commands into it.
+ * The interaction state props cannot hold: focus, the tab stop, the selection
+ * anchor, the type-ahead buffer, and container focus. `deriveTreeInteractionView`
+ * reads it against the current model and resolves what the rows render;
+ * `transitionTree` folds commands into it.
*/
import { parentId, type TreeModel, type TreeRowModel } from "./treeModel";
import type { TreeCommand } from "./treePolicy";
+/** How long a type-ahead query keeps collecting keys, as in the native list. */
+const TYPE_QUERY_MS = 800;
+
/** The focused row, with its ancestors to fall back on if it disappears. */
interface FocusTarget {
readonly id: string;
@@ -20,7 +24,13 @@ export interface TreeInteractionState {
readonly tabTargetId?: string;
/** The selection whose tab stop the user already moved away from. */
readonly dismissedSelectionKey?: string;
+ /** The selection the anchor belongs to; a new one from props resets it. */
+ readonly anchorKey: string;
+ /** Where a range selection measures from. */
+ readonly anchorId?: string;
readonly hasDomFocus: boolean;
+ readonly typeQuery?: string;
+ readonly typeExpires?: number;
}
/** What the rows render from, derived fresh on every render. */
@@ -29,6 +39,7 @@ interface TreeInteractionView {
readonly controlledKey: string;
readonly selectedIds: ReadonlySet;
readonly focusedId: string | undefined;
+ readonly anchorId: string | undefined;
readonly guideOwnerIds: ReadonlySet;
readonly tabStopId: string | undefined;
}
@@ -37,6 +48,10 @@ interface TransitionInput {
readonly model: TreeModel;
readonly controlledIds: readonly string[];
readonly expandedIds: readonly string[];
+ readonly multiSelect: boolean;
+ /** Rows a page key should travel, measured against the scroller. */
+ readonly pageOffset?: number;
+ readonly now: number;
}
interface TreeTransition {
@@ -57,8 +72,14 @@ const focusTarget = (row: TreeRowModel): FocusTarget => ({
pathIds: row.pathIds,
});
-export function initialTreeInteractionState(): TreeInteractionState {
- return { hasDomFocus: false };
+export function initialTreeInteractionState(
+ controlledIds: readonly string[],
+): TreeInteractionState {
+ return {
+ anchorKey: selectionKey(controlledIds),
+ anchorId: controlledIds[0],
+ hasDomFocus: false,
+ };
}
/**
@@ -132,6 +153,10 @@ export function deriveTreeInteractionView(
controlledKey,
selectedIds,
focusedId,
+ anchorId:
+ nextState.anchorKey === controlledKey
+ ? nextState.anchorId
+ : controlledIds[0],
guideOwnerIds: activeGuideOwners(
visibleRows,
selectedIds,
@@ -174,6 +199,108 @@ export function rowFocused(
};
}
+/**
+ * The native range: the run of selected rows around the anchor is released
+ * first, so shrinking a range back over itself deselects what it passes.
+ */
+function selectionRange(
+ visibleRows: readonly TreeRowModel[],
+ selectedIds: ReadonlySet,
+ anchorId: string,
+ targetId: string,
+): Set | undefined {
+ const rowIds = visibleRows.map((row) => row.node.id);
+ const anchor = rowIds.indexOf(anchorId);
+ const target = rowIds.indexOf(targetId);
+ if (anchor < 0 || target < 0) {
+ return undefined;
+ }
+ const ids = new Set(selectedIds);
+ let start = anchor;
+ let end = anchor;
+ while (start > 0 && ids.has(rowIds[start - 1] ?? "")) {
+ start--;
+ }
+ while (end < rowIds.length - 1 && ids.has(rowIds[end + 1] ?? "")) {
+ end++;
+ }
+ for (const id of rowIds.slice(start, end + 1)) {
+ ids.delete(id);
+ }
+ for (const id of rowIds.slice(
+ Math.min(anchor, target),
+ Math.max(anchor, target) + 1,
+ )) {
+ ids.add(id);
+ }
+ return ids;
+}
+
+interface SelectionResult {
+ readonly ids: ReadonlySet;
+ readonly anchorId: string;
+}
+
+/** The selection a `select` command produces, and the anchor it leaves. */
+function selectRow(
+ model: TreeModel,
+ selectedIds: ReadonlySet,
+ anchorId: string | undefined,
+ multiSelect: boolean,
+ row: TreeRowModel,
+ options: { toggle: boolean; range: boolean; preserveHidden: boolean },
+): SelectionResult {
+ const id = row.node.id;
+ if (!multiSelect) {
+ return { ids: new Set([id]), anchorId: id };
+ }
+ const ids = new Set(
+ options.preserveHidden
+ ? selectedIds
+ : [...selectedIds].filter((selectedId) =>
+ model.visibleIds.has(selectedId),
+ ),
+ );
+ if (options.range && anchorId) {
+ const rangeIds = selectionRange(model.visibleRows, ids, anchorId, id);
+ if (rangeIds) {
+ return { ids: rangeIds, anchorId };
+ }
+ }
+ if (options.toggle && ids.delete(id)) {
+ return { ids, anchorId: id };
+ }
+ if (!options.toggle) {
+ ids.clear();
+ }
+ ids.add(id);
+ return { ids, anchorId: id };
+}
+
+/**
+ * `list.selectAll` on a tree: the row's sibling group, widening to include the
+ * parent once that whole group is already selected.
+ */
+function scopedSelection(
+ model: TreeModel,
+ selectedIds: ReadonlySet,
+ row: TreeRowModel,
+): Set {
+ const scopeId = parentId(row);
+ const scoped = model.rows.filter(
+ (candidate) => scopeId === undefined || candidate.pathIds.includes(scopeId),
+ );
+ const ids = new Set(scoped.map((candidate) => candidate.node.id));
+ const scope = scopeId ? model.rowsById.get(scopeId) : undefined;
+ if (
+ scope &&
+ scoped.every((candidate) => selectedIds.has(candidate.node.id))
+ ) {
+ ids.add(scope.node.id);
+ }
+ return ids;
+}
+
function togglingBranches(
row: TreeRowModel,
model: TreeModel,
@@ -215,6 +342,41 @@ function toggleExpansion(
];
}
+/**
+ * Prefix first, then a fuzzy subsequence, as the native list does. A repeated
+ * single key walks the rows starting with it instead of matching the run.
+ */
+function typeaheadMatch(
+ visibleRows: readonly TreeRowModel[],
+ query: string,
+ current: TreeRowModel,
+): TreeRowModel | undefined {
+ const repeated =
+ query.length > 1 && [...query].every((key) => key === query[0]);
+ const value = (repeated ? query[0] : query)?.toLocaleLowerCase() ?? "";
+ const from =
+ query.length === 1 || repeated
+ ? visibleRows.indexOf(current) + 1
+ : visibleRows.indexOf(current);
+ const ordered = visibleRows.map(
+ (_, offset) => visibleRows[(from + offset) % visibleRows.length],
+ );
+ const fuzzy = (row: TreeRowModel): boolean => {
+ let index = 0;
+ for (const character of row.textValue.toLocaleLowerCase()) {
+ if (character === value[index] && ++index === value.length) {
+ return true;
+ }
+ }
+ return false;
+ };
+ return (
+ ordered.find((row) =>
+ row?.textValue.toLocaleLowerCase().startsWith(value),
+ ) ?? ordered.find((row) => row && fuzzy(row))
+ );
+}
+
export function transitionTree(
state: TreeInteractionState,
commands: readonly TreeCommand[],
@@ -223,17 +385,27 @@ export function transitionTree(
const { model } = input;
const view = deriveTreeInteractionView(state, model, input.controlledIds);
let nextState = view.state;
+ let selectedIds = view.selectedIds;
let currentKey = view.controlledKey;
+ let anchorId = view.anchorId;
let selection: readonly string[] | undefined;
let expandedIds: readonly string[] | undefined;
let focusTree = false;
- const select = (ids: ReadonlySet): void => {
+ const setAnchor = (id: string | undefined): void => {
+ anchorId = id;
+ nextState = { ...nextState, anchorKey: currentKey, anchorId: id };
+ };
+ const select = (ids: ReadonlySet, nextAnchor?: string): void => {
selection = model.rows
.filter((row) => ids.has(row.node.id))
.map((row) => row.node.id);
+ selectedIds = new Set(selection);
currentKey = selectionKey(selection);
nextState = { ...nextState, dismissedSelectionKey: currentKey };
+ if (nextAnchor !== undefined) {
+ setAnchor(nextAnchor);
+ }
};
const focus = (row: TreeRowModel | undefined): void => {
if (!row || !model.visibleIds.has(row.node.id)) {
@@ -251,7 +423,20 @@ export function transitionTree(
break;
case "select":
if (row) {
- select(new Set([row.node.id]));
+ const result = selectRow(
+ model,
+ selectedIds,
+ anchorId,
+ input.multiSelect,
+ row,
+ command,
+ );
+ select(result.ids, result.anchorId);
+ }
+ break;
+ case "selectScope":
+ if (row) {
+ select(scopedSelection(model, selectedIds, row));
}
break;
case "move": {
@@ -259,8 +444,29 @@ export function transitionTree(
break;
}
const rows = model.visibleRows;
- const index = rows.indexOf(row) + command.offset;
- focus(rows[Math.min(Math.max(index, 0), rows.length - 1)]);
+ const offset = command.page
+ ? (input.pageOffset ?? command.offset)
+ : command.offset;
+ const index = rows.indexOf(row) + offset;
+ const target = rows[Math.min(Math.max(index, 0), rows.length - 1)];
+ if (!target) {
+ break;
+ }
+ if (command.extend) {
+ const rangeAnchor = anchorId ?? row.node.id;
+ const ids = selectionRange(
+ rows,
+ selectedIds,
+ rangeAnchor,
+ target.node.id,
+ );
+ if (ids) {
+ select(ids, rangeAnchor);
+ }
+ } else {
+ setAnchor(target.node.id);
+ }
+ focus(target);
break;
}
case "toggle":
@@ -273,6 +479,22 @@ export function transitionTree(
);
}
break;
+ case "typeahead": {
+ if (!row) {
+ break;
+ }
+ const query =
+ nextState.typeQuery && input.now < (nextState.typeExpires ?? 0)
+ ? nextState.typeQuery + command.key
+ : command.key;
+ nextState = {
+ ...nextState,
+ typeQuery: query,
+ typeExpires: input.now + TYPE_QUERY_MS,
+ };
+ focus(typeaheadMatch(model.visibleRows, query, row));
+ break;
+ }
case "dismiss":
if (command.clearSelection) {
select(new Set());
@@ -282,6 +504,7 @@ export function transitionTree(
focusTree = true;
}
currentKey = NO_SELECTION_KEY;
+ setAnchor(undefined);
break;
}
}
diff --git a/packages/ui/src/components/Tree/useTreeAdapter.ts b/packages/ui/src/components/Tree/useTreeAdapter.ts
index e40564579..83ff4cbef 100644
--- a/packages/ui/src/components/Tree/useTreeAdapter.ts
+++ b/packages/ui/src/components/Tree/useTreeAdapter.ts
@@ -1,12 +1,25 @@
import { type KeyboardEvent, type MouseEvent, useMemo, useState } from "react";
-import { closestRow, nestedInteractiveTarget } from "./rowDom";
-import { createTreeModel, type TreeNode, type TreeRowModel } from "./treeModel";
import {
+ closestRow,
+ hitTwistie,
+ nestedInteractiveTarget,
+ scrollableAncestor,
+} from "./rowDom";
+import {
+ createTreeModel,
+ ROW_HEIGHT_PX,
+ type TreeNode,
+ type TreeRowModel,
+} from "./treeModel";
+import {
+ isSelectionGesture,
keyboardCommands,
pointerCommands,
type TreeCommand,
+ type TreeCommandBehavior,
type TreeExpandMode,
+ type TreeMultiSelectModifier,
} from "./treePolicy";
import {
deriveTreeInteractionView,
@@ -20,16 +33,29 @@ import {
const NO_IDS: readonly string[] = [];
const NO_GUIDES = "";
-export interface SelectionProps {
- readonly selectedItemId?: string;
- readonly onSelectedItemChange?: (itemId: string | undefined) => void;
-}
+/** Single selection, or multi-selection, never a mix of the two APIs. */
+export type SelectionProps =
+ | {
+ readonly multiSelect?: false;
+ readonly selectedItemId?: string;
+ readonly onSelectedItemChange?: (itemId: string | undefined) => void;
+ readonly selectedItemIds?: never;
+ readonly onSelectedItemsChange?: never;
+ }
+ | {
+ readonly multiSelect: true;
+ readonly selectedItemIds?: readonly string[];
+ readonly onSelectedItemsChange?: (itemIds: readonly string[]) => void;
+ readonly selectedItemId?: never;
+ readonly onSelectedItemChange?: never;
+ };
-interface AdapterOptions extends SelectionProps {
+interface AdapterOptions {
readonly nodes: readonly TreeNode[];
readonly expandedIds: readonly string[];
readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void;
readonly expandMode: TreeExpandMode;
+ readonly multiSelectModifier: TreeMultiSelectModifier;
readonly onKeyDown?: (event: KeyboardEvent) => void;
readonly treeRef: React.RefObject;
}
@@ -41,20 +67,21 @@ function rowElement(tree: HTMLElement | null, id: string): HTMLElement | null {
);
}
-function hitTwistie(row: TreeRowModel, target: EventTarget): boolean {
- return (
- row.expanded !== undefined &&
- target instanceof Element &&
- target.closest(".ui-tree-item__chevron") !== null
- );
+function controlledIds(selection: SelectionProps): readonly string[] {
+ if (selection.multiSelect) {
+ return selection.selectedItemIds ?? NO_IDS;
+ }
+ return selection.selectedItemId === undefined
+ ? NO_IDS
+ : [selection.selectedItemId];
}
/**
* Where the pure modules meet React and the DOM. Events arrive delegated from
* the container, which leaves rows as memoized presentation.
*/
-export function useTreeAdapter(options: AdapterOptions) {
- const { nodes, expandedIds, expandMode, treeRef } = options;
+export function useTreeAdapter(options: AdapterOptions & SelectionProps) {
+ const { nodes, expandedIds, treeRef } = options;
// Explicit: memoized rows compare against these row objects, and a consumer
// of the published package may not run the React Compiler.
const model = useMemo(
@@ -62,27 +89,75 @@ export function useTreeAdapter(options: AdapterOptions) {
[nodes, expandedIds],
);
const { visibleRows, rowsById } = model;
- const selectedItemIds =
- options.selectedItemId === undefined ? NO_IDS : [options.selectedItemId];
- const [state, setState] = useState(
- initialTreeInteractionState,
+ const selected = controlledIds(options);
+ const [state, setState] = useState(() =>
+ initialTreeInteractionState(selected),
);
- const view = deriveTreeInteractionView(state, model, selectedItemIds);
+ const view = deriveTreeInteractionView(state, model, selected);
// Identity, not value: the view returns this same state unless the data
// moved, and then the reconciled one renders instead.
if (view.state !== state) {
setState(view.state);
}
+ const behavior: TreeCommandBehavior = {
+ expandMode: options.expandMode,
+ multiSelect: Boolean(options.multiSelect),
+ multiSelectModifier: options.multiSelectModifier,
+ };
+
+ /**
+ * How far a page key travels: to the far edge of the viewport, or a whole
+ * viewport once the focused row is already sitting on it.
+ */
+ const pageOffset = (row: TreeRowModel, direction: 1 | -1): number => {
+ const tree = treeRef.current;
+ const scroller = tree ? scrollableAncestor(tree) : undefined;
+ if (!tree || !scroller) {
+ return direction;
+ }
+ const viewport = scroller.getBoundingClientRect();
+ if (viewport.height > 0) {
+ const inView = [
+ ...tree.querySelectorAll("[data-tree-id]"),
+ ].filter((element) => {
+ const bounds = element.getBoundingClientRect();
+ return bounds.bottom > viewport.top && bounds.top < viewport.bottom;
+ });
+ const edge = direction === 1 ? inView.at(-1) : inView[0];
+ const edgeId = edge?.dataset.treeId;
+ const edgeRow = edgeId ? rowsById.get(edgeId) : undefined;
+ const offset = edgeRow
+ ? visibleRows.indexOf(edgeRow) - visibleRows.indexOf(row)
+ : 0;
+ if (offset !== 0) {
+ return offset;
+ }
+ scroller.scrollBy?.(0, direction * scroller.clientHeight);
+ }
+ return (
+ direction * Math.max(1, Math.floor(scroller.clientHeight / ROW_HEIGHT_PX))
+ );
+ };
const dispatch = (commands: readonly TreeCommand[]): void => {
+ const move = commands.find((command) => command.type === "move");
+ const moved = move ? rowsById.get(move.id) : undefined;
const result = transitionTree(state, commands, {
model,
- controlledIds: selectedItemIds,
+ controlledIds: selected,
expandedIds,
+ multiSelect: behavior.multiSelect,
+ pageOffset:
+ move?.page && moved ? pageOffset(moved, move.offset) : undefined,
+ now: Date.now(),
});
setState(result.state);
if (result.selection) {
- options.onSelectedItemChange?.(result.selection[0]);
+ if (options.multiSelect) {
+ options.onSelectedItemsChange?.(result.selection);
+ } else {
+ options.onSelectedItemChange?.(result.selection[0]);
+ }
}
if (result.expandedIds) {
options.onExpandedIdsChange?.(result.expandedIds);
@@ -103,9 +178,30 @@ export function useTreeAdapter(options: AdapterOptions) {
if (row && target === closestRow(target)) {
setState((current) => rowFocused(current, row, view.controlledKey));
}
- const entered = row ?? rowsById.get(view.tabStopId ?? "");
+ // Only an entry with no focus target adopts a row: a focus mark the same
+ // gesture just cleared must not come back when focus returns here.
+ const entered = view.state.focusTarget
+ ? undefined
+ : (row ?? rowsById.get(view.tabStopId ?? ""));
setState((current) => treeFocusChanged(current, true, entered));
};
+ const onPointer = (
+ row: TreeRowModel,
+ event: MouseEvent,
+ onTwistie: boolean,
+ source: "row" | "sticky",
+ ): void => {
+ dispatch(
+ pointerCommands({
+ ...behavior,
+ row,
+ source,
+ onTwistie,
+ detail: event.detail,
+ modifiers: event,
+ }),
+ );
+ };
const onClick = (event: MouseEvent): void => {
const element = closestRow(event.target);
const row = rowFor(event.target);
@@ -120,15 +216,7 @@ export function useTreeAdapter(options: AdapterOptions) {
) {
return;
}
- dispatch(
- pointerCommands({
- expandMode,
- row,
- onTwistie: hitTwistie(row, event.target),
- detail: event.detail,
- altKey: event.altKey,
- }),
- );
+ onPointer(row, event, hitTwistie(row, event.target), "row");
};
const onKeyDown = (event: KeyboardEvent): void => {
options.onKeyDown?.(event);
@@ -148,7 +236,7 @@ export function useTreeAdapter(options: AdapterOptions) {
event.currentTarget,
);
const result = keyboardCommands({
- expandMode,
+ ...behavior,
key: event.key,
row,
visibleRows,
@@ -157,6 +245,7 @@ export function useTreeAdapter(options: AdapterOptions) {
interactive.dataset.treeId === undefined,
selectedCount: view.selectedIds.size,
hasFocusedRow: view.focusedId !== undefined,
+ modifiers: event,
});
if (result.focusRowElementId) {
rowElement(treeRef.current, result.focusRowElementId)?.focus();
@@ -181,9 +270,14 @@ export function useTreeAdapter(options: AdapterOptions) {
.map((id) => (view.guideOwnerIds.has(id) ? "1" : "0"))
.join(""),
dispatch,
+ isSelectionGesture: (event: MouseEvent) =>
+ isSelectionGesture(event, behavior),
onFocusIn,
onBlurOut: () => setState((current) => treeFocusChanged(current, false)),
onClick,
+ onPointer,
onKeyDown,
};
}
+
+export type TreeAdapter = ReturnType;
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
index ffb5b035a..0e4046a7b 100644
--- a/packages/ui/src/tokens.css
+++ b/packages/ui/src/tokens.css
@@ -201,6 +201,15 @@
--vscode-tree-indentGuidesStroke,
color-mix(in srgb, currentColor 40%, transparent)
);
+ /* Pinned rows paint over what scrolls beneath them. */
+ --ui-tree-sticky-background: var(
+ --vscode-sideBarStickyScroll-background,
+ var(--ui-background)
+ );
+ --ui-tree-sticky-shadow: var(
+ --vscode-sideBarStickyScroll-shadow,
+ transparent
+ );
/* Menus */
--ui-menu-background: var(--vscode-menu-background);
diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx
index e111a279e..b85c426fe 100644
--- a/packages/ui/storybook/Tree.demo.tsx
+++ b/packages/ui/storybook/Tree.demo.tsx
@@ -4,6 +4,8 @@ import { Tree, type TreeProps } from "../src/components/Tree/Tree";
import type { TreeNode } from "../src/components/Tree/treeModel";
+const NO_IDS: readonly string[] = [];
+
/** Every branch id, so a demo tree starts fully open unless told otherwise. */
function branchIds(nodes: readonly TreeNode[]): readonly string[] {
return nodes.flatMap((node) =>
@@ -11,25 +13,44 @@ function branchIds(nodes: readonly TreeNode[]): readonly string[] {
);
}
+type DistributiveOmit = T extends unknown
+ ? Omit>
+ : never;
+
+export type TreeDemoProps = DistributiveOmit<
+ TreeProps,
+ "onSelectedItemChange" | "onSelectedItemsChange"
+>;
+
/** Holds the selection and expansion state a controlled `Tree` expects. */
export function TreeDemo({
+ multiSelect,
selectedItemId,
+ selectedItemIds,
expandedIds,
...treeProps
-}: Omit<
- TreeProps,
- "onSelectedItemChange" | "onExpandedIdsChange"
->): React.JSX.Element {
- const [selected, setSelected] = useState(selectedItemId);
+}: TreeDemoProps): React.JSX.Element {
+ const [selectedId, setSelectedId] = useState(selectedItemId);
+ const [selectedIds, setSelectedIds] = useState(selectedItemIds ?? NO_IDS);
const [expanded, setExpanded] = useState(
() => expandedIds ?? branchIds(treeProps.nodes),
);
+ const selection = multiSelect
+ ? ({
+ multiSelect: true,
+ selectedItemIds: selectedIds,
+ onSelectedItemsChange: setSelectedIds,
+ } as const)
+ : ({
+ multiSelect: false,
+ selectedItemId: selectedId,
+ onSelectedItemChange: setSelectedId,
+ } as const);
return (
diff --git a/test/webview/ui/tree.keyboard.test.tsx b/test/webview/ui/tree.keyboard.test.tsx
index 0d39ada9a..3d9744818 100644
--- a/test/webview/ui/tree.keyboard.test.tsx
+++ b/test/webview/ui/tree.keyboard.test.tsx
@@ -1,5 +1,7 @@
-import { fireEvent, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Tree, type TreeNode, type TreeProps } from "@repo/ui";
import {
BASIC_NODES,
@@ -15,8 +17,6 @@ import {
tree,
} from "./treeTestHelpers";
-import type { TreeNode, TreeProps } from "@repo/ui";
-
/** Two branches and a leaf whose rich label holds a live control. */
const NAV_NODES: readonly TreeNode[] = [
{
@@ -65,9 +65,9 @@ describe("Tree keyboard navigation", () => {
it("expands, enters, leaves, and collapses a branch", () => {
navTree();
- press("ArrowRight", "Beta");
+ press("ArrowRight", { from: "Beta" });
expect(rowNames()).toContain("Blue");
- press("ArrowRight", "Beta");
+ press("ArrowRight", { from: "Beta" });
expect(activeRow()).toBe("Blue");
press("ArrowLeft");
expect(activeRow()).toBe("Beta");
@@ -77,20 +77,20 @@ describe("Tree keyboard navigation", () => {
it("selects with Enter and toggles with Space", () => {
navTree({ expandedIds: [] });
- press("Enter", "Beta");
+ press("Enter", { from: "Beta" });
expect(selectedRows()).toEqual(["Beta"]);
expect(expandedRows()).toEqual(["Beta"]);
- press(" ", "Alpha");
+ press(" ", { from: "Alpha" });
expect(expandedRows()).toEqual(["Alpha", "Beta"]);
expect(selectedRows()).toEqual(["Beta"]);
// A leaf has nothing to toggle, so Space selects it.
- press(" ", "Bravo");
+ press(" ", { from: "Bravo" });
expect(selectedRows()).toEqual(["Bravo"]);
});
it("only selects on Enter under doubleClick", () => {
navTree({ expandedIds: [], expandMode: "doubleClick" });
- press("Enter", "Beta");
+ press("Enter", { from: "Beta" });
expect(selectedRows()).toEqual(["Beta"]);
expect(expandedRows()).toEqual([]);
});
@@ -146,4 +146,86 @@ describe("Tree keyboard navigation", () => {
press("ArrowDown");
expect(activeRow()).toBe("One");
});
+ it("moves by viewport pages, clamped at either end", () => {
+ render(
+
+ ({
+ id: `row-${index}`,
+ label: `Row ${index}`,
+ }))}
+ />
+
,
+ );
+ // jsdom lays nothing out, so a page is the scroller's height in rows.
+ Object.defineProperty(screen.getByTestId("scroller"), "clientHeight", {
+ value: 5 * 22,
+ });
+ for (const [key, active] of [
+ ["PageDown", "Row 5"],
+ ["PageDown", "Row 10"],
+ ["PageDown", "Row 11"],
+ ["PageUp", "Row 6"],
+ ] as const) {
+ press(key);
+ expect(activeRow()).toBe(active);
+ }
+ });
+
+ describe("type-ahead", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it("matches the labels the rows currently carry", () => {
+ const view = renderTree({
+ "aria-label": "Names",
+ nodes: [
+ { id: "alpha", label: "Alpha" },
+ { id: "cedar", label: "Amber" },
+ ],
+ });
+ view.update({
+ nodes: [
+ { id: "alpha", label: "Alpha" },
+ { id: "cedar", label: "Cedar" },
+ ],
+ });
+ press("c");
+ expect(activeRow()).toBe("Cedar");
+ });
+
+ it("walks the matches when the same key repeats", () => {
+ navTree();
+ for (const active of ["Beta", "Bravo", "Beta"]) {
+ press("b");
+ expect(activeRow()).toBe(active);
+ }
+ });
+
+ it("buffers keys into one query until it expires", () => {
+ navTree();
+ press("a");
+ expect(activeRow()).toBe("Apricot");
+ press("m");
+ expect(activeRow()).toBe("Amber");
+ void act(() => vi.advanceTimersByTime(800));
+ press("a");
+ expect(activeRow()).toBe("Alpha");
+ });
+
+ it("keeps a longer query on the row it already matched", () => {
+ renderTree({
+ "aria-label": "Prefixes",
+ nodes: [
+ { id: "amber", label: "Amber" },
+ { id: "amethyst", label: "Amethyst" },
+ ],
+ });
+ press("a");
+ expect(activeRow()).toBe("Amethyst");
+ press("m");
+ expect(activeRow()).toBe("Amethyst");
+ });
+ });
});
diff --git a/test/webview/ui/tree.rows.test.tsx b/test/webview/ui/tree.rows.test.tsx
index 614160a07..d17fe79f2 100644
--- a/test/webview/ui/tree.rows.test.tsx
+++ b/test/webview/ui/tree.rows.test.tsx
@@ -54,7 +54,7 @@ describe("Tree rows", () => {
expect(
row("Lazy").querySelector(".ui-tree-item__chevron > .ui-icon"),
).toHaveClass("codicon-chevron-right");
- press("ArrowRight", "Lazy");
+ press("ArrowRight", { from: "Lazy" });
expect(row("Lazy")).toHaveAttribute("aria-expanded", "true");
expect(emitted.expandedIds.at(-1)).toEqual(["lazy"]);
});
diff --git a/test/webview/ui/tree.selection.test.tsx b/test/webview/ui/tree.selection.test.tsx
new file mode 100644
index 000000000..39800bfa5
--- /dev/null
+++ b/test/webview/ui/tree.selection.test.tsx
@@ -0,0 +1,209 @@
+import { act, fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ BASIC_NODES,
+ activeGuides,
+ activeRow,
+ clickRow,
+ clickTwistie,
+ press,
+ renderStatefulTree,
+ renderTree,
+ row,
+ selectedRows,
+ tree,
+} from "./treeTestHelpers";
+
+import type { TreeNode } from "@repo/ui";
+
+const MULTI_NODES: readonly TreeNode[] = ["One", "Two", "Three", "Four"].map(
+ (label) => ({ id: label.toLowerCase(), label }),
+);
+const multiTree = (selectedItemIds: readonly string[] = ["one"]) =>
+ renderStatefulTree({
+ "aria-label": "Multi",
+ multiSelect: true,
+ nodes: MULTI_NODES,
+ selectedItemIds,
+ });
+
+describe("Tree multi-select", () => {
+ it("toggles, replaces, and keeps the active row on the last one touched", () => {
+ const { emitted } = multiTree();
+ expect(tree()).toHaveAttribute("aria-multiselectable", "true");
+ expect(selectedRows()).toEqual(["One"]);
+ clickRow("Three", { ctrlKey: true });
+ expect(selectedRows()).toEqual(["One", "Three"]);
+ clickRow("One", { metaKey: true });
+ expect(selectedRows()).toEqual(["Three"]);
+ clickRow("Four");
+ expect(emitted.selectedItemIds.at(-1)).toEqual(["four"]);
+ expect(selectedRows()).toEqual(["Four"]);
+ clickRow("Two", { ctrlKey: true });
+ expect(activeRow()).toBe("Two");
+ });
+
+ it("extends and shrinks anchored ranges with clicks and arrows", () => {
+ multiTree();
+ clickRow("Two");
+ clickRow("Four", { shiftKey: true });
+ expect(selectedRows()).toEqual(["Two", "Three", "Four"]);
+ clickRow("Three", { shiftKey: true });
+ expect(selectedRows()).toEqual(["Two", "Three"]);
+ press("ArrowDown", { shiftKey: true });
+ expect(activeRow()).toBe("Four");
+ expect(selectedRows()).toEqual(["Two", "Three", "Four"]);
+ press("ArrowUp", { shiftKey: true });
+ expect(selectedRows()).toEqual(["Two", "Three"]);
+ });
+
+ it("starts a range from the controlled selection", () => {
+ multiTree();
+ clickRow("Three", { shiftKey: true });
+ expect(selectedRows()).toEqual(["One", "Two", "Three"]);
+ });
+
+ it("keeps the anchor when the controlled selection only reorders", () => {
+ const onSelectedItemsChange = vi.fn();
+ const view = renderTree({
+ "aria-label": "Ordered",
+ multiSelect: true,
+ nodes: MULTI_NODES,
+ selectedItemIds: ["one", "three"],
+ onSelectedItemsChange,
+ });
+ view.update({ selectedItemIds: ["three", "one"] });
+ clickRow("Four", { shiftKey: true });
+ expect(onSelectedItemsChange).toHaveBeenLastCalledWith([
+ "one",
+ "two",
+ "three",
+ "four",
+ ]);
+ });
+
+ it("leaves Shift+Home and Shift+End as plain navigation", () => {
+ multiTree();
+ clickRow("Two");
+ press("End", { shiftKey: true });
+ expect(activeRow()).toBe("Four");
+ expect(selectedRows()).toEqual(["Two"]);
+ press("Home", { shiftKey: true });
+ expect(activeRow()).toBe("One");
+ expect(selectedRows()).toEqual(["Two"]);
+ });
+
+ it("uses the configured modifier for keyboard toggles", () => {
+ const onSelectedItemsChange = vi.fn();
+ renderTree({
+ "aria-label": "Alt selection",
+ multiSelect: true,
+ multiSelectModifier: "alt",
+ nodes: MULTI_NODES.slice(0, 2),
+ selectedItemIds: ["one"],
+ onSelectedItemsChange,
+ });
+ // Ctrl is not the modifier here, so Enter replaces the selection.
+ press("Enter", { from: "Two", ctrlKey: true, shiftKey: true });
+ expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["two"]);
+ press("Enter", { from: "Two", altKey: true, shiftKey: true });
+ expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["one", "two"]);
+ });
+
+ it("gives a selection modifier precedence over expansion", () => {
+ const { emitted } = renderStatefulTree({
+ "aria-label": "Modifier",
+ multiSelect: true,
+ selectedItemIds: [],
+ nodes: BASIC_NODES,
+ expandedIds: ["parent"],
+ });
+ clickRow("Parent", { ctrlKey: true });
+ clickRow("Parent", { shiftKey: true });
+ clickTwistie("Parent", { ctrlKey: true });
+ expect(emitted.expandedIds).toEqual([]);
+ });
+
+ it("scopes Ctrl+A to the sibling group before widening to the parent", () => {
+ const scoped = renderStatefulTree({
+ "aria-label": "Scoped",
+ multiSelect: true,
+ selectedItemIds: [],
+ expandedIds: ["parent"],
+ nodes: [
+ {
+ id: "parent",
+ label: "Parent",
+ children: [
+ { id: "one", label: "One" },
+ { id: "three", label: "Three" },
+ ],
+ },
+ { id: "outside", label: "Outside" },
+ ],
+ });
+ act(() => row("One").focus());
+ press("a", { from: "One", ctrlKey: true });
+ expect(selectedRows()).toEqual(["One", "Three"]);
+ press("a", { from: "One", ctrlKey: true });
+ expect(selectedRows()).toEqual(["Parent", "One", "Three"]);
+ act(() => row("Parent").focus());
+ press("a", { from: "Parent", ctrlKey: true });
+ expect(selectedRows()).toEqual(["Parent", "One", "Three", "Outside"]);
+ scoped.unmount();
+ // Ctrl+Shift+A is not select-all, so the host keeps it.
+ multiTree();
+ expect(press("A", { from: "One", ctrlKey: true, shiftKey: true })).toBe(
+ true,
+ );
+ expect(selectedRows()).toEqual(["One"]);
+ });
+
+ it("clears a multi-selection and its focus mark with Escape", () => {
+ multiTree(["one", "two"]);
+ act(() => row("One").focus());
+ expect(press("Escape")).toBe(false);
+ expect(selectedRows()).toEqual([]);
+ expect(row("One")).toHaveClass("ui-tree-item--focused");
+ // The focus mark outlives the first Escape only past one selected row.
+ expect(press("Escape")).toBe(false);
+ expect(row("One")).not.toHaveClass("ui-tree-item--focused");
+ expect(press("Escape")).toBe(true);
+ });
+
+ it("lights a guide for every selected row", () => {
+ renderTree({
+ "aria-label": "Guides",
+ multiSelect: true,
+ selectedItemIds: ["a", "b"],
+ expandedIds: ["parent"],
+ nodes: [
+ {
+ id: "parent",
+ label: "Parent",
+ children: [
+ { id: "a", label: "A" },
+ { id: "b", label: "B" },
+ ],
+ },
+ ],
+ });
+ expect(activeGuides("A")).toEqual([true]);
+ expect(activeGuides("B")).toEqual([true]);
+ });
+
+ it("ignores selection modifiers without multiSelect", () => {
+ const { emitted } = renderStatefulTree({
+ "aria-label": "Single",
+ selectedItemId: "one",
+ nodes: MULTI_NODES.slice(0, 2),
+ });
+ expect(screen.getByRole("tree")).not.toHaveAttribute(
+ "aria-multiselectable",
+ );
+ fireEvent.click(row("Two"), { ctrlKey: true });
+ expect(emitted.selectedItemId.at(-1)).toBe("two");
+ expect(selectedRows()).toEqual(["Two"]);
+ });
+});
diff --git a/test/webview/ui/tree.sticky.test.tsx b/test/webview/ui/tree.sticky.test.tsx
new file mode 100644
index 000000000..49aab8e0c
--- /dev/null
+++ b/test/webview/ui/tree.sticky.test.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Tree } from "@repo/ui";
+
+import { BASIC_NODES } from "./treeTestHelpers";
+
+describe("Tree sticky scroll", () => {
+ it("renders an empty sticky anchor before scrolling", () => {
+ render(
+ ,
+ );
+ expect(document.querySelector(".ui-tree-sticky")).not.toBeNull();
+ expect(document.querySelector(".ui-tree-sticky__rows")).toBeNull();
+ });
+ it("preserves pinned pointer, focus, and accessibility behavior", () => {
+ const onExpandedIdsChange = vi.fn();
+ const onSelectedItemChange = vi.fn();
+ render(
+
+ ({
+ id: `file-${index}`,
+ label: `file-${index}`,
+ })),
+ },
+ ],
+ },
+ ]}
+ expandedIds={["alpha", "src"]}
+ onExpandedIdsChange={onExpandedIdsChange}
+ onSelectedItemChange={onSelectedItemChange}
+ />
+
,
+ );
+ const scroller = screen.getByTestId("scroller");
+ Object.defineProperty(scroller, "clientHeight", { value: 10 * 22 });
+ const widget = document.querySelector(".ui-tree-sticky");
+ if (!widget?.parentElement) throw new Error("Expected the sticky widget.");
+ widget.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
+ widget.parentElement.getBoundingClientRect = () =>
+ ({ top: -66 }) as DOMRect;
+ Object.assign(scroller, { scrollBy: vi.fn() });
+ fireEvent.scroll(scroller);
+ const pinned = [
+ ...document.querySelectorAll(".ui-tree-sticky .ui-tree-item"),
+ ];
+ expect(pinned.map((row) => row.textContent)).toEqual(["alpha", "src"]);
+ expect(document.querySelector(".ui-tree-sticky__shadow")).not.toBeNull();
+ expect(widget).not.toHaveAttribute("aria-hidden");
+ expect(widget).toHaveAttribute("tabindex", "0");
+ expect(pinned[0]).toHaveAttribute("role", "treeitem");
+ expect(pinned[0]).toHaveAccessibleName("alpha");
+ expect(pinned[0]).toHaveAttribute("aria-level", "1");
+ expect(pinned[0]).toHaveAttribute("aria-posinset", "1");
+ expect(pinned[0]).toHaveAttribute("aria-setsize", "1");
+ expect(pinned[0]).toHaveAttribute("aria-selected", "false");
+ expect(pinned[1]).toHaveAttribute("aria-expanded", "true");
+ const twistie = pinned[1]?.querySelector(".ui-tree-item__chevron");
+ expect(twistie).not.toBeNull();
+ fireEvent.click(twistie!);
+ expect(onExpandedIdsChange).toHaveBeenCalledWith(["alpha"]);
+ expect(onSelectedItemChange).toHaveBeenCalledOnce();
+ expect(onSelectedItemChange).toHaveBeenCalledWith("src");
+ onSelectedItemChange.mockClear();
+ fireEvent.click(pinned[0]);
+ expect(onSelectedItemChange).toHaveBeenCalledOnce();
+ expect(onSelectedItemChange).toHaveBeenCalledWith("alpha");
+ expect(onExpandedIdsChange).toHaveBeenCalledOnce();
+ const realAlpha = document.querySelector(
+ '[data-tree-id="alpha"]',
+ );
+ expect(screen.getByRole("tree")).toHaveAttribute(
+ "aria-activedescendant",
+ realAlpha?.id,
+ );
+ expect(document.activeElement).toBe(screen.getByRole("tree"));
+ vi.mocked(scroller.scrollBy).mockClear();
+ fireEvent.click(pinned[0], { ctrlKey: true });
+ expect(scroller.scrollBy).toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/webview/ui/treeController.test.tsx b/test/webview/ui/treeController.test.tsx
new file mode 100644
index 000000000..cd6f43928
--- /dev/null
+++ b/test/webview/ui/treeController.test.tsx
@@ -0,0 +1,49 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useRef, useState } from "react";
+import { describe, expect, it } from "vitest";
+
+import { useTreeAdapter } from "@repo/ui/components/Tree/useTreeAdapter";
+
+function ControlledTreeController(): React.JSX.Element {
+ const [selectedItemIds, setSelectedItemIds] = useState([
+ "one",
+ ]);
+ const treeRef = useRef(null);
+ const adapter = useTreeAdapter({
+ nodes: [
+ { id: "one", label: "One" },
+ { id: "two", label: "Two" },
+ { id: "three", label: "Three" },
+ ],
+ expandedIds: [],
+ expandMode: "singleClick",
+ multiSelect: true,
+ multiSelectModifier: "ctrlCmd",
+ selectedItemIds,
+ onSelectedItemsChange: setSelectedItemIds,
+ treeRef,
+ });
+
+ return (
+
+
+
+ {adapter.model.visibleRows.map((row) => (
+
+ ))}
+
+ );
+}
+
+describe("useTreeAdapter", () => {
+ it("keeps the Shift+Arrow target as the tab target after the selection echo", () => {
+ render();
+ const one = document.querySelector('[data-tree-id="one"]');
+ if (!one) throw new Error("Expected the first row.");
+
+ fireEvent.keyDown(one, { key: "ArrowDown", shiftKey: true });
+
+ expect(screen.getByTestId("selection")).toHaveTextContent("one,two");
+ expect(screen.getByTestId("tab-stop")).toHaveTextContent("two");
+ });
+});
diff --git a/test/webview/ui/treePolicy.test.ts b/test/webview/ui/treePolicy.test.ts
index 3fe4f92cd..19705f365 100644
--- a/test/webview/ui/treePolicy.test.ts
+++ b/test/webview/ui/treePolicy.test.ts
@@ -11,6 +11,7 @@ import {
pointerCommands,
type KeyboardCommandInput,
type PointerCommandInput,
+ type TreeModifiers,
} from "@repo/ui/components/Tree/treePolicy";
const NODES: readonly TreeNode[] = [
@@ -29,46 +30,101 @@ const row = (id: string, from: TreeModel = model): TreeRowModel => {
return found;
};
+const NO_MODIFIERS: TreeModifiers = {
+ ctrlKey: false,
+ metaKey: false,
+ altKey: false,
+ shiftKey: false,
+};
+/** The modifiers a gesture holds down, by name. */
+const held = (...pressed: Array): TreeModifiers => ({
+ ...NO_MODIFIERS,
+ ...Object.fromEntries(pressed.map((key) => [key, true])),
+});
+
const pointer = (overrides: Partial = {}) =>
pointerCommands({
expandMode: "singleClick",
+ multiSelect: false,
+ multiSelectModifier: "ctrlCmd",
row: row("parent"),
+ source: "row",
onTwistie: false,
detail: 1,
- altKey: false,
+ modifiers: NO_MODIFIERS,
...overrides,
});
const keyboard = (overrides: Partial = {}) =>
keyboardCommands({
expandMode: "singleClick",
+ multiSelect: false,
+ multiSelectModifier: "ctrlCmd",
key: "ArrowDown",
row: row("parent"),
visibleRows: model.visibleRows,
fromAction: false,
selectedCount: 0,
hasFocusedRow: false,
+ modifiers: NO_MODIFIERS,
...overrides,
});
const FOCUS_PARENT = { type: "focus", id: "parent" };
-const SELECT_PARENT = { type: "select", id: "parent" };
const TOGGLE_PARENT = { type: "toggle", id: "parent", recursive: false };
+/** Selects replace the selection and keep hidden rows unless told otherwise. */
+const select = (
+ id: string,
+ options: { toggle?: boolean; range?: boolean; preserveHidden?: boolean } = {},
+) => ({
+ type: "select",
+ id,
+ toggle: false,
+ range: false,
+ preserveHidden: true,
+ ...options,
+});
describe("pointerCommands", () => {
it("focuses and selects before expanding, so a click cannot reorder them", () => {
- expect(pointer()).toEqual([FOCUS_PARENT, SELECT_PARENT, TOGGLE_PARENT]);
+ expect(pointer()).toEqual([
+ { type: "focus", id: "parent" },
+ {
+ type: "select",
+ id: "parent",
+ toggle: false,
+ range: false,
+ preserveHidden: true,
+ },
+ { type: "toggle", id: "parent", recursive: false },
+ ]);
});
it("keeps a twistie click off the selection, and Alt recursive", () => {
- expect(pointer({ onTwistie: true, detail: 2, altKey: true })).toEqual([
+ expect(
+ pointer({ onTwistie: true, detail: 2, modifiers: held("altKey") }),
+ ).toEqual([
FOCUS_PARENT,
{ type: "toggle", id: "parent", recursive: true },
]);
});
+ it("leaves Alt to selection when it is the selection modifier", () => {
+ expect(
+ pointer({
+ onTwistie: true,
+ multiSelect: true,
+ multiSelectModifier: "alt",
+ modifiers: held("altKey"),
+ }),
+ ).toEqual([
+ FOCUS_PARENT,
+ select("parent", { toggle: true, preserveHidden: false }),
+ ]);
+ });
+
it.each([
- [1, [FOCUS_PARENT, SELECT_PARENT]],
- [2, [FOCUS_PARENT, SELECT_PARENT, TOGGLE_PARENT]],
+ [1, [FOCUS_PARENT, select("parent")]],
+ [2, [FOCUS_PARENT, select("parent"), TOGGLE_PARENT]],
])("expands on click %i under doubleClick", (detail, commands) => {
expect(pointer({ expandMode: "doubleClick", detail })).toEqual(commands);
});
@@ -76,23 +132,79 @@ describe("pointerCommands", () => {
it("leaves a leaf nothing to expand", () => {
expect(pointer({ row: row("last") })).toEqual([
{ type: "focus", id: "last" },
- { type: "select", id: "last" },
+ select("last"),
+ ]);
+ });
+
+ it("gives a selection gesture precedence over twistie expansion", () => {
+ expect(
+ pointer({
+ multiSelect: true,
+ onTwistie: true,
+ modifiers: held("ctrlKey", "shiftKey"),
+ }),
+ ).toEqual([
+ FOCUS_PARENT,
+ select("parent", { toggle: true, range: true, preserveHidden: false }),
+ ]);
+ });
+
+ it("selects from a pinned row without expanding it, twistie aside", () => {
+ expect(pointer({ source: "sticky" })).toEqual([
+ FOCUS_PARENT,
+ select("parent"),
+ ]);
+ expect(pointer({ source: "sticky", onTwistie: true })).toEqual([
+ FOCUS_PARENT,
+ select("parent"),
+ TOGGLE_PARENT,
]);
+ // A selection gesture on a pinned row never moves focus to it.
+ expect(
+ pointer({
+ source: "sticky",
+ multiSelect: true,
+ modifiers: held("shiftKey"),
+ }),
+ ).toEqual([select("parent", { range: true, preserveHidden: false })]);
});
});
describe("keyboardCommands", () => {
it.each([
- ["ArrowDown", 1],
- ["ArrowUp", -1],
- ] as const)("moves the active row on %s", (key, offset) => {
+ ["ArrowDown", 1, false],
+ ["ArrowUp", -1, false],
+ ["PageDown", 1, true],
+ ["PageUp", -1, true],
+ ] as const)("moves the active row on %s", (key, offset, page) => {
expect(keyboard({ key })).toEqual({
- commands: [{ type: "move", id: "parent", offset }],
+ commands: [{ type: "move", id: "parent", offset, page, extend: false }],
preventDefault: true,
focusRowElementId: undefined,
});
});
+ it("extends the selection with Shift, but never by the page", () => {
+ expect(
+ keyboard({ multiSelect: true, modifiers: held("shiftKey") }).commands,
+ ).toEqual([
+ { type: "move", id: "parent", offset: 1, page: false, extend: true },
+ ]);
+ expect(
+ keyboard({
+ key: "PageDown",
+ multiSelect: true,
+ modifiers: held("shiftKey"),
+ }).commands,
+ ).toEqual([
+ { type: "move", id: "parent", offset: 1, page: true, extend: false },
+ ]);
+ // Shift alone extends nothing without multi-selection.
+ expect(keyboard({ modifiers: held("shiftKey") }).commands).toEqual([
+ { type: "move", id: "parent", offset: 1, page: false, extend: false },
+ ]);
+ });
+
it.each([
["Home", "parent"],
["End", "last"],
@@ -119,18 +231,40 @@ describe("keyboardCommands", () => {
it("keeps selection and expansion apart on Enter and Space", () => {
expect(keyboard({ key: "Enter" }).commands).toEqual([
- SELECT_PARENT,
+ select("parent"),
TOGGLE_PARENT,
]);
expect(
keyboard({ key: "Enter", expandMode: "doubleClick" }).commands,
- ).toEqual([SELECT_PARENT]);
+ ).toEqual([select("parent")]);
expect(keyboard({ key: " " }).commands).toEqual([TOGGLE_PARENT]);
expect(keyboard({ key: " ", row: row("child") }).commands).toEqual([
- { type: "select", id: "child" },
+ select("child"),
]);
});
+ it("adds to the selection with the selection modifier held", () => {
+ expect(
+ keyboard({
+ key: "Enter",
+ multiSelect: true,
+ modifiers: held("ctrlKey", "shiftKey"),
+ }).commands,
+ ).toEqual([select("parent", { toggle: true })]);
+ expect(
+ keyboard({
+ key: " ",
+ row: row("child"),
+ multiSelect: true,
+ modifiers: held("metaKey"),
+ }).commands,
+ ).toEqual([select("child", { toggle: true })]);
+ expect(
+ keyboard({ key: "a", multiSelect: true, modifiers: held("ctrlKey") })
+ .commands,
+ ).toEqual([{ type: "selectScope", id: "parent" }]);
+ });
+
it.each([
[0, false, false, false],
[1, false, true, false],
@@ -147,7 +281,16 @@ describe("keyboardCommands", () => {
},
);
- it("leaves unclaimed keys, Tab included, to the host", () => {
+ it("types ahead on a bare printable key, and leaves the rest to the host", () => {
+ expect(keyboard({ key: "B" })).toMatchObject({
+ commands: [{ type: "typeahead", id: "parent", key: "b" }],
+ preventDefault: true,
+ });
+ expect(keyboard({ key: "b", modifiers: held("ctrlKey") })).toEqual({
+ commands: [],
+ preventDefault: false,
+ focusRowElementId: undefined,
+ });
expect(keyboard({ key: "Tab" })).toEqual({
commands: [],
preventDefault: false,
@@ -162,7 +305,9 @@ describe("keyboardCommands", () => {
focusRowElementId: undefined,
});
expect(keyboard({ key: "ArrowDown", fromAction: true })).toMatchObject({
- commands: [{ type: "move", id: "parent", offset: 1 }],
+ commands: [
+ { type: "move", id: "parent", offset: 1, page: false, extend: false },
+ ],
preventDefault: true,
focusRowElementId: "parent",
});
diff --git a/test/webview/ui/treeStickyState.test.ts b/test/webview/ui/treeStickyState.test.ts
new file mode 100644
index 000000000..b14e470a0
--- /dev/null
+++ b/test/webview/ui/treeStickyState.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import { computeStickyState } from "@repo/ui/components/Tree/sticky/stickyState";
+import {
+ createTreeModel,
+ ROW_HEIGHT_PX,
+ type TreeNode,
+} from "@repo/ui/components/Tree/treeModel";
+const leaves = (prefix: string, count: number): TreeNode[] =>
+ Array.from({ length: count }, (_, index) => ({
+ id: `${prefix}/${index}`,
+ label: `${prefix}/${index}`,
+ }));
+// Row indices: 0 a, 1-3 a/*, 4 b, 5-7 b/*, 8 c, 9-13 c/*, 14 z.
+const ROWS = createTreeModel(
+ [
+ {
+ id: "a",
+ label: "a",
+ children: [
+ ...leaves("a", 3),
+ {
+ id: "b",
+ label: "b",
+ children: [
+ ...leaves("b", 3),
+ { id: "c", label: "c", children: leaves("c", 5) },
+ ],
+ },
+ ],
+ },
+ { id: "z", label: "z" },
+ ],
+ new Set(["a", "b", "c"]),
+).visibleRows;
+const px = (rows: number): number => rows * ROW_HEIGHT_PX;
+const VIEWPORT = px(10);
+describe("computeStickyState", () => {
+ it.each([
+ ["before scrolling", 0, VIEWPORT, 7, []],
+ ["without a viewport", px(2), 0, 7, []],
+ ["in the first subtree", px(1), VIEWPORT, 7, ["a"]],
+ ["at the deepest subtree", px(9), VIEWPORT, 7, ["a", "b", "c"]],
+ ["at the item cap", px(9), VIEWPORT, 2, ["a", "b"]],
+ ["at 40% of the viewport", px(9), px(1.5) / 0.4, 7, ["a"]],
+ ["past every branch", px(14), VIEWPORT, 7, []],
+ ] as const)(
+ "pins the expected chain %s",
+ (_case, scrollTop, height, cap, ids) => {
+ expect(computeStickyState(ROWS, scrollTop, height, cap).ids).toEqual(ids);
+ },
+ );
+ it("pushes the widget out as the last pinned subtree ends", () => {
+ const state = computeStickyState(ROWS, px(12), VIEWPORT, 7);
+ expect(state.ids).toEqual(["a", "b", "c"]);
+ expect(state.pushOffset).toBe(px(14) - (px(12) + px(3)));
+ });
+});
diff --git a/test/webview/ui/treeTestHelpers.tsx b/test/webview/ui/treeTestHelpers.tsx
index a22090052..310f93609 100644
--- a/test/webview/ui/treeTestHelpers.tsx
+++ b/test/webview/ui/treeTestHelpers.tsx
@@ -3,6 +3,16 @@ import { useState } from "react";
import { Tree, type TreeNode, type TreeProps } from "@repo/ui";
+/**
+ * Tree props as a test writes them. `TreeProps` is a union over the selection
+ * APIs, and a spread does not keep track of which side it is on, so the harness
+ * re-asserts it at the one point it renders.
+ */
+export type TreeTestProps = Partial & {
+ readonly nodes: readonly TreeNode[];
+};
+const asTreeProps = (props: object): TreeProps => props as TreeProps;
+
/** A branch of two plus a leaf: depth, sibling order, and hideable rows. */
export const BASIC_NODES: readonly TreeNode[] = [
{
@@ -52,8 +62,10 @@ export const expandedRows = (): string[] =>
* Sends a key to the container, where DOM focus lives; `from` targets a row, as
* a click leaves it. Returns false when the tree claimed the key.
*/
-export const press = (key: string, from?: string): boolean =>
- fireEvent.keyDown(from ? row(from) : tree(), { key });
+export const press = (
+ key: string,
+ { from, ...init }: { from?: string } & KeyboardEventInit = {},
+): boolean => fireEvent.keyDown(from ? row(from) : tree(), { key, ...init });
export const clickRow = (name: string, init?: MouseEventInit): void => {
fireEvent.click(row(name), init);
@@ -75,36 +87,60 @@ export const activeGuides = (name: string): boolean[] =>
);
/** A fully controlled Tree, for tests that drive the props themselves. */
-export function renderTree(props: TreeProps) {
- const view = render();
+export function renderTree(props: TreeTestProps) {
+ const view = render();
return {
...view,
/** Re-renders with props changed, as a consumer's state would. */
- update: (next: Partial): void =>
- view.rerender(),
+ update: (next: Partial): void =>
+ view.rerender(),
};
}
+interface Recorder {
+ readonly selectedItemId: Array;
+ readonly selectedItemIds: Array;
+ readonly expandedIds: Array;
+}
+
function StatefulTree({
- onSelectedItemChange,
- onExpandedIdsChange,
- ...props
-}: TreeProps): React.JSX.Element {
- const [selectedItemId, setSelectedItemId] = useState(props.selectedItemId);
+ props,
+ record,
+}: {
+ props: TreeTestProps;
+ record: Recorder;
+}): React.JSX.Element {
+ const [selectedId, setSelectedId] = useState(props.selectedItemId);
+ const [selectedIds, setSelectedIds] = useState(props.selectedItemIds ?? []);
const [expandedIds, setExpandedIds] = useState(props.expandedIds);
+ const selection = props.multiSelect
+ ? {
+ multiSelect: true,
+ selectedItemIds: selectedIds,
+ onSelectedItemsChange: (ids: readonly string[]) => {
+ record.selectedItemIds.push(ids);
+ setSelectedIds(ids);
+ },
+ }
+ : {
+ multiSelect: false,
+ selectedItemId: selectedId,
+ onSelectedItemChange: (id: string | undefined) => {
+ record.selectedItemId.push(id);
+ setSelectedId(id);
+ },
+ };
return (
{
- onSelectedItemChange?.(id);
- setSelectedItemId(id);
- }}
- expandedIds={expandedIds}
- onExpandedIdsChange={(ids) => {
- onExpandedIdsChange?.(ids);
- setExpandedIds(ids);
- }}
+ {...asTreeProps({
+ ...props,
+ ...selection,
+ expandedIds,
+ onExpandedIdsChange: (ids: readonly string[]) => {
+ record.expandedIds.push(ids);
+ setExpandedIds(ids);
+ },
+ })}
/>
);
}
@@ -113,15 +149,12 @@ function StatefulTree({
* A Tree that keeps its own state, so a gesture's result lands in the DOM.
* `emitted` records what it reported to its consumer, newest last.
*/
-export function renderStatefulTree(props: TreeProps) {
- const selection: Array = [];
- const expandedIds: Array = [];
- const view = render(
- selection.push(id)}
- onExpandedIdsChange={(ids) => expandedIds.push(ids)}
- />,
- );
- return { ...view, emitted: { selection, expandedIds } };
+export function renderStatefulTree(props: TreeTestProps) {
+ const emitted: Recorder = {
+ selectedItemId: [],
+ selectedItemIds: [],
+ expandedIds: [],
+ };
+ const view = render();
+ return { ...view, emitted };
}
diff --git a/test/webview/ui/treeTransition.test.ts b/test/webview/ui/treeTransition.test.ts
index e41f6eb55..259fbdb1a 100644
--- a/test/webview/ui/treeTransition.test.ts
+++ b/test/webview/ui/treeTransition.test.ts
@@ -37,7 +37,7 @@ function focusRow(
model: TreeModel,
controlledIds: readonly string[] = [],
): TreeInteractionState {
- const state = initialTreeInteractionState();
+ const state = initialTreeInteractionState(controlledIds);
const { controlledKey } = deriveTreeInteractionView(
state,
model,
@@ -59,12 +59,16 @@ const transition = (
overrides: {
expandedIds?: readonly string[];
controlledIds?: readonly string[];
+ multiSelect?: boolean;
+ now?: number;
} = {},
) =>
transitionTree(state, commands, {
model,
controlledIds: overrides.controlledIds ?? [],
expandedIds: overrides.expandedIds ?? ["parent"],
+ multiSelect: overrides.multiSelect ?? false,
+ now: overrides.now ?? 0,
});
describe("deriveTreeInteractionView", () => {
@@ -96,12 +100,24 @@ describe("deriveTreeInteractionView", () => {
it("gives an unclaimed selection the tab stop, until focus claims it", () => {
const state = focusRow("parent", OPEN);
expect(view(state, OPEN, ["last"]).tabStopId).toBe("last");
- const claimed = transition(state, [{ type: "select", id: "last" }], OPEN);
+ const claimed = transition(
+ state,
+ [
+ {
+ type: "select",
+ id: "last",
+ toggle: false,
+ range: false,
+ preserveHidden: true,
+ },
+ ],
+ OPEN,
+ );
expect(view(claimed.state, OPEN, ["last"]).tabStopId).toBe("parent");
});
it("draws guides down to the selected row, and the focused one when in focus", () => {
- const selected = view(initialTreeInteractionState(), OPEN, ["child"]);
+ const selected = view(initialTreeInteractionState([]), OPEN, ["child"]);
expect([...selected.guideOwnerIds]).toEqual(["parent"]);
const blurred = view(focusRow("child", OPEN), OPEN);
expect([...blurred.guideOwnerIds]).toEqual([]);
@@ -135,13 +151,13 @@ describe("transitionTree", () => {
const state = focusRow("parent", OPEN);
const down = transition(
state,
- [{ type: "move", id: "parent", offset: 1 }],
+ [{ type: "move", id: "parent", offset: 1, page: false, extend: false }],
OPEN,
);
expect(down.state.focusTarget?.id).toBe("child");
const up = transition(
state,
- [{ type: "move", id: "parent", offset: -1 }],
+ [{ type: "move", id: "parent", offset: -1, page: false, extend: false }],
OPEN,
);
expect(up.state.focusTarget?.id).toBe("parent");
@@ -149,7 +165,7 @@ describe("transitionTree", () => {
it("emits expansion in tree order, keeping ids the data does not have yet", () => {
const expanded = transition(
- initialTreeInteractionState(),
+ initialTreeInteractionState([]),
[{ type: "toggle", id: "parent", recursive: false }],
CLOSED,
{ expandedIds: ["ghost"] },
@@ -172,7 +188,7 @@ describe("transitionTree", () => {
new Set(["one"]),
);
const expanded = transition(
- initialTreeInteractionState(),
+ initialTreeInteractionState([]),
[{ type: "toggle", id: "root", recursive: true }],
nested,
{ expandedIds: ["one"] },
@@ -180,3 +196,140 @@ describe("transitionTree", () => {
expect(expanded.expandedIds).toEqual(["root", "one", "two"]);
});
});
+
+describe("multi-selection", () => {
+ const withSelection = (ids: readonly string[]) => ({
+ controlledIds: ids,
+ multiSelect: true,
+ });
+
+ it("keeps the anchor when a controlled selection only reorders", () => {
+ const state = initialTreeInteractionState(["child", "last"]);
+ expect(view(state, OPEN, ["last", "child"]).anchorId).toBe("child");
+ });
+
+ it("adds to and removes from the selection when toggling", () => {
+ const added = transition(
+ initialTreeInteractionState(["child"]),
+ [
+ {
+ type: "select",
+ id: "last",
+ toggle: true,
+ range: false,
+ preserveHidden: true,
+ },
+ ],
+ OPEN,
+ withSelection(["child"]),
+ );
+ expect(added.selection).toEqual(["child", "last"]);
+ const removed = transition(
+ initialTreeInteractionState(["child", "last"]),
+ [
+ {
+ type: "select",
+ id: "last",
+ toggle: true,
+ range: false,
+ preserveHidden: true,
+ },
+ ],
+ OPEN,
+ withSelection(["child", "last"]),
+ );
+ expect(removed.selection).toEqual(["child"]);
+ });
+
+ it("selects the range from the anchor, in tree order", () => {
+ const anchored = focusRow("parent", OPEN, ["parent"]);
+ const ranged = transition(
+ anchored,
+ [
+ {
+ type: "select",
+ id: "last",
+ toggle: false,
+ range: true,
+ preserveHidden: false,
+ },
+ ],
+ OPEN,
+ withSelection(["parent"]),
+ );
+ expect(ranged.selection).toEqual(["parent", "child", "last"]);
+ });
+
+ it("extends the selection as a Shift move travels", () => {
+ const extended = transition(
+ initialTreeInteractionState(["parent"]),
+ [{ type: "move", id: "parent", offset: 1, page: false, extend: true }],
+ OPEN,
+ withSelection(["parent"]),
+ );
+ expect(extended.selection).toEqual(["parent", "child"]);
+ expect(extended.state.focusTarget?.id).toBe("child");
+ });
+
+ it("moves a page by the offset the scroller measured", () => {
+ const paged = transition(
+ initialTreeInteractionState([]),
+ [{ type: "move", id: "parent", offset: 1, page: true, extend: false }],
+ OPEN,
+ );
+ expect(paged.state.focusTarget?.id).toBe("child");
+ });
+
+ it("scopes select-all to the sibling group, then widens to the parent", () => {
+ const group = transition(
+ initialTreeInteractionState([]),
+ [{ type: "selectScope", id: "child" }],
+ OPEN,
+ withSelection([]),
+ );
+ expect(group.selection).toEqual(["child"]);
+ const widened = transition(
+ initialTreeInteractionState(["child"]),
+ [{ type: "selectScope", id: "child" }],
+ OPEN,
+ withSelection(["child"]),
+ );
+ expect(widened.selection).toEqual(["parent", "child"]);
+ });
+
+ it("resets the anchor on dismiss", () => {
+ const dismissed = transition(
+ focusRow("child", OPEN, ["child"]),
+ [{ type: "dismiss", clearSelection: true, clearFocus: false }],
+ OPEN,
+ { controlledIds: ["child"] },
+ );
+ expect(dismissed.state.anchorId).toBeUndefined();
+ });
+
+ it("buffers type-ahead keys until the query expires", () => {
+ const first = transition(
+ initialTreeInteractionState([]),
+ [{ type: "typeahead", id: "parent", key: "l" }],
+ OPEN,
+ { now: 1000 },
+ );
+ expect(first.state.focusTarget?.id).toBe("last");
+ expect(first.state.typeQuery).toBe("l");
+ // Within the window the keys join into one query; after it they do not.
+ const joined = transition(
+ first.state,
+ [{ type: "typeahead", id: "last", key: "a" }],
+ OPEN,
+ { now: 1100 },
+ );
+ expect(joined.state.typeQuery).toBe("la");
+ const expired = transition(
+ first.state,
+ [{ type: "typeahead", id: "last", key: "a" }],
+ OPEN,
+ { now: 9000 },
+ );
+ expect(expired.state.typeQuery).toBe("a");
+ });
+});