Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -87,20 +87,46 @@ 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
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
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions packages/ui/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions packages/ui/src/components/Tree/Tree.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<div
data-testid="scroller"
style={{ height: "140px", overflow: "auto", width: "280px" }}
ref={(scroller) => {
if (scroller) scroller.scrollTop = 143;
}}
>
<TreeDemo
aria-label="Sticky explorer"
variant="explorer"
stickyScroll
nodes={DEEP_FILES}
/>
</div>
),
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({
Expand Down
32 changes: 27 additions & 5 deletions packages/ui/src/components/Tree/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,37 @@ 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<
ComponentPropsWithRef<"div">,
"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 {
Expand All @@ -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,
Expand All @@ -52,13 +63,16 @@ export function Tree({
}: TreeProps): React.JSX.Element {
const treeRef = useRef<HTMLDivElement>(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,
});
Expand All @@ -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",
Expand Down Expand Up @@ -102,6 +117,13 @@ export function Tree({
onClick={adapter.onClick}
onKeyDown={adapter.onKeyDown}
>
{stickyScroll ? (
<StickyScroll
maxCount={stickyScroll === true ? DEFAULT_STICKY_COUNT : stickyScroll}
adapter={adapter}
treeRef={treeRef}
/>
) : null}
{adapter.model.visibleRows.map((row) => (
<TreeRow
key={row.node.id}
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/components/Tree/TreeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface TreeRowProps {
readonly selected?: boolean;
/** One character per ancestor, `1` where the indent guide is active. */
readonly guideFlags?: string;
/** Positions a pinned copy inside the sticky widget. */
readonly style?: CSSProperties;
}

/** Pure presentation: props compare by value, so `memo` skips untouched rows. */
Expand All @@ -23,6 +25,7 @@ export const TreeRow = memo(function TreeRow({
focused = false,
selected = false,
guideFlags = "",
style,
}: TreeRowProps): React.JSX.Element {
const { node, expanded } = row;
const level = row.pathIds.length + 1;
Expand All @@ -43,7 +46,7 @@ export const TreeRow = memo(function TreeRow({
focused && "ui-tree-item--focused",
node.className,
)}
style={{ "--ui-tree-level": level } as CSSProperties}
style={{ ...style, "--ui-tree-level": level } as CSSProperties}
>
<div className="ui-tree-item__row">
<span className="ui-tree-item__indent" aria-hidden="true">
Expand Down
28 changes: 28 additions & 0 deletions packages/ui/src/components/Tree/rowDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>("[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;
}
Loading