Skip to content

Commit 73371ed

Browse files
committed
feat(ui): add an accessible VS Code-parity Tree
A controlled Tree that follows current VS Code workbench behavior: every visible node renders as a flat `treeitem` row with declared aria-level, posinset, and setsize, while keyboard navigation keeps DOM focus on the container and names the active row with `aria-activedescendant`. Focus and selection stay independent, as they do natively. Arrow keys, Home, and End move the active row; Arrow Right and Left walk into and out of branches; Enter, Space, and the twistie follow VS Code's split between selecting and expanding, under either expand mode. Rows are 22px with the native twistie gutter and indent guides, and `variant="explorer"` aligns leaf icons with branch twisties for icon-less file trees. The model, the input policy, and the interaction transitions are pure modules; `useTreeAdapter` is the only place React state and the DOM meet. Pointer, focus, and key events are delegated from the container, which leaves rows as memoized presentation, so a keystroke re-renders the two rows it touched instead of every row. The flat projection leaves room for windowing later. Closes #1037
1 parent 3449c8b commit 73371ed

30 files changed

Lines changed: 2697 additions & 17 deletions

.storybook/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type { StorybookConfig } from "@storybook/react-vite";
66

77
const config: StorybookConfig = {
88
stories: ["../packages/*/src/**/*.stories.@(ts|tsx)"],
9-
addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
9+
addons: [
10+
"@storybook/addon-a11y",
11+
"@storybook/addon-docs",
12+
"storybook-addon-pseudo-states",
13+
],
1014
framework: {
1115
name: "@storybook/react-vite",
1216
options: {},

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ Non-negotiables:
9494
- Extension panels must call **both** `buildCommandHandlers` and
9595
`buildRequestHandlers` (empty `{}` is fine). This gives a compile error
9696
when anyone adds an action to the API without a matching handler.
97+
- Every webview and Storybook build runs the React Compiler, so components
98+
and hooks must follow the rules of React: no reading or writing a ref
99+
during render, no mutating props, state, or anything already rendered,
100+
and hooks called unconditionally. A component that breaks them is skipped
101+
silently and loses its memoization. Parameter defaults that read another
102+
prop (`focused = adapter?.focusedId === row.node.id`) are the usual
103+
culprit; put those defaults in the body. `useMemo` and `useCallback` are
104+
rarely needed, and when kept they must list every dependency, or
105+
`react-hooks/preserve-manual-memoization` fails the lint.
97106

98107
## Code Style
99108

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,7 @@
813813
"@tanstack/react-query": "catalog:",
814814
"@testing-library/jest-dom": "^7.0.1",
815815
"@testing-library/react": "^16.3.2",
816+
"@testing-library/user-event": "catalog:",
816817
"@tsconfig/node22": "^22.0.6",
817818
"@types/mocha": "^10.0.10",
818819
"@types/node": "^22.20.1",
@@ -856,6 +857,7 @@
856857
"react": "catalog:",
857858
"react-dom": "catalog:",
858859
"storybook": "catalog:",
860+
"storybook-addon-pseudo-states": "catalog:",
859861
"typescript": "catalog:",
860862
"typescript-eslint": "^8.67.0",
861863
"utf-8-validate": "^6.0.6",

packages/ui/README.md

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ Its stable separation boundary is the public root exports, no monorepo runtime
77
imports, and component CSS using only semantic `--ui-*` tokens. A future package
88
build can emit those same entry points without API changes.
99

10+
Consumers compile these components with the React Compiler, so they follow the
11+
rules of React and lean on it for memoization. A component that breaks the
12+
rules is skipped silently rather than reported, which for a list or a tree
13+
costs a re-render per row, so check with the compiler and not only the linter.
14+
1015
## CSS
1116

1217
Import the semantic token mapping and codicon assets once in each real webview
@@ -38,12 +43,89 @@ Every component forwards `className` and `style` to its root element, and
3843
default rules use single-class specificity, so a consumer class imported
3944
after the library overrides any default (width, height, spacing).
4045

41-
Where VS Code's stable rendering and its Modern UI preview
42-
(`workbench.experimental.modernUI`) diverge, components follow Modern UI,
43-
and new components should too. Webviews get no signal for the setting, so
44-
the default cannot follow the host. Until the design settles,
45-
`data-ui-style="stable"` on the document root restores the stable-parity
46-
menu motion; Storybook's "UI style" toolbar switch toggles it live.
46+
VS Code currently uses its stable UI by default; Modern UI remains behind the
47+
experimental `workbench.experimental.modernUI` setting. `@repo/ui`
48+
intentionally uses Modern UI as its package default because webviews receive no
49+
host signal for that setting. The divergence is isolated: set
50+
`data-ui-style="stable"` on the document root to restore stable row geometry,
51+
focus behavior, and menu motion. Storybook's "UI style" toolbar switch toggles
52+
that override live.
53+
54+
## Tree
55+
56+
`Tree` is controlled: `nodes` describe the hierarchy, `expandedIds` controls
57+
branches, and `selectedItemId` controls selection. Each
58+
visible node renders as a flat `treeitem`, while normal keyboard navigation
59+
keeps DOM focus on the `tree` container and identifies the active row with
60+
`aria-activedescendant`. Focus and selection are independent.
61+
62+
```tsx
63+
const [selectedItemId, setSelectedItemId] = useState("src");
64+
const [expandedIds, setExpandedIds] = useState<readonly string[]>(["src"]);
65+
66+
<Tree
67+
aria-label="Explorer"
68+
variant="explorer"
69+
nodes={[
70+
{
71+
id: "src",
72+
label: "src",
73+
children: [{ id: "tree", label: "Tree.tsx", icon: "symbol-class" }],
74+
},
75+
{ id: "readme", label: "README.md", icon: "markdown" },
76+
]}
77+
expandedIds={expandedIds}
78+
onExpandedIdsChange={setExpandedIds}
79+
selectedItemId={selectedItemId}
80+
onSelectedItemChange={setSelectedItemId}
81+
/>;
82+
```
83+
84+
Ids must be unique across the whole tree, and a duplicate throws. A string
85+
`label` is also the accessible name; a rich label must provide `textValue`. `children` marks a branch, including an empty array for a branch
86+
whose children are still loading. `icon`, `action`, and `className` customize
87+
the row. Actions stay live on plain hover, as in the native list, and are
88+
isolated from row selection and expansion.
89+
90+
Arrow Up/Down, Home, and End move the active row through visible rows. Arrow Right
91+
expands a branch or enters it; Arrow Left collapses it or moves to its parent.
92+
93+
`expandMode="singleClick"` is the default: clicking a branch selects
94+
and toggles it, and Enter does the same. With `expandMode="doubleClick"`, a
95+
single click or Enter only selects and a double click toggles expansion. Space
96+
toggles a branch without selecting it, or selects a leaf. A normal-row twistie
97+
toggles without changing selection. Alt-click recursively toggles descendant
98+
branches.
99+
100+
Escape clears selection, then the active focus mark. Once neither remains,
101+
Escape is left to the host. The root `onKeyDown` runs first, so a host
102+
can intercept shortcuts with `preventDefault()`.
103+
104+
```mermaid
105+
flowchart LR
106+
accTitle: Tree architecture
107+
accDescr: Data and input flow through the pure Tree modules into the React and DOM adapter.
108+
109+
Props[Nodes and controlled props] --> Model[treeModel.ts]
110+
Events[Pointer and keyboard events] --> Policy[treePolicy.ts]
111+
Policy --> Commands[Tree commands]
112+
Model --> Transition[treeTransition.ts]
113+
Commands --> Transition
114+
Transition --> Adapter[useTreeAdapter.ts]
115+
Adapter --> Rows[Tree.tsx and TreeRow.tsx]
116+
```
117+
118+
The model, policy, and transitions stay pure. The adapter owns React and DOM
119+
integration. The flat visible model supports future windowing, but the Tree is
120+
not currently virtualized.
121+
122+
Rows are 22px tall and keep the VS Code twistie gutter. For Explorer-style file
123+
trees whose branches have no icons, `variant="explorer"` aligns leaf icons with
124+
branch twisties; do not combine it with branch icons. Indent guides appear on
125+
hover, selected ancestor paths stay active, and the focused path is active only
126+
while the tree has focus. The package default uses inset Modern UI rows;
127+
`data-ui-style="stable"` restores edge-to-edge square rows and stable focus
128+
styling.
47129

48130
## Overlays
49131

@@ -79,7 +161,6 @@ until the exit animation ends. High contrast, `forced-colors`, and
79161
- Keybinding hints show the contributed defaults the consumer passes, not
80162
user remaps: VS Code exposes no API for extensions to resolve a command's
81163
effective keybinding.
82-
- List/selection-row tokens are deferred to the Tree suite (#1037).
83164

84165
## Codicons
85166

@@ -97,4 +178,6 @@ declared CSS exports.
97178

98179
Shared internals are reached through `package.json` subpath imports (`#cx`,
99180
`#codicons`, `#storybook`). These resolve only inside this package and ship
100-
with it, so they survive a standalone NPM split.
181+
with it, so they survive a standalone NPM split. Component families keep
182+
their own internals (contexts, stores) inside their folder and import them
183+
relatively, so a family can lift out wholesale.

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"imports": {
2020
"#cx": "./src/cx.ts",
2121
"#codicons": "./src/codicons.ts",
22+
"#ref": "./src/ref.ts",
2223
"#storybook": "./src/storybook.ts"
2324
},
2425
"scripts": {

packages/ui/src/components/SearchInput/SearchInput.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type ChangeEvent, type ComponentProps, useRef } from "react";
22

33
import { cx } from "#cx";
4+
import { setForwardedRef } from "#ref";
45

56
import "../control.css";
67
import { Icon } from "../Icon/Icon";
@@ -54,12 +55,7 @@ export function SearchInput({
5455
// Track the node for clear-and-refocus, honoring the consumer ref
5556
ref={(node) => {
5657
inputRef.current = node;
57-
if (typeof ref === "function") {
58-
return ref(node);
59-
}
60-
if (ref) {
61-
ref.current = node;
62-
}
58+
setForwardedRef(ref, node);
6359
}}
6460
type="search"
6561
value={value}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Row state reads from the ARIA the rows already declare. The `:not()` on hover
3+
* mirrors native's `.monaco-list-row:hover:not(.selected):not(.focused)`: a
4+
* selected or focused row keeps its own paint.
5+
*/
6+
7+
.ui-tree {
8+
--ui-tree-indent-size: 8px;
9+
--ui-tree-row-height: 22px;
10+
width: 100%;
11+
min-width: 0;
12+
outline: 0;
13+
}
14+
15+
.ui-tree-item {
16+
outline: 0;
17+
}
18+
19+
.ui-tree-item__row {
20+
position: relative;
21+
display: flex;
22+
align-items: center;
23+
height: var(--ui-tree-row-height);
24+
padding-inline-end: var(--ui-spacing-120);
25+
background: transparent;
26+
cursor: pointer;
27+
user-select: none;
28+
}
29+
30+
.ui-tree-item:not([aria-selected="true"], .ui-tree-item--focused)
31+
> .ui-tree-item__row:hover {
32+
color: var(--ui-list-hover-foreground);
33+
background: var(--ui-list-hover-background);
34+
outline: 1px dashed var(--ui-list-hover-outline);
35+
outline-offset: -1px;
36+
}
37+
38+
.ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
39+
color: var(--ui-list-inactive-selection-foreground);
40+
background: var(--ui-list-inactive-selection-background);
41+
outline: 1px dotted var(--ui-list-selection-outline);
42+
outline-offset: -1px;
43+
}
44+
45+
.ui-tree--focused .ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
46+
color: var(--ui-list-active-selection-foreground);
47+
background: var(--ui-list-active-selection-background);
48+
}
49+
50+
/* The native list's inactive focus outline: kept while the tree is blurred. */
51+
.ui-tree:not(.ui-tree--focused) .ui-tree-item--focused > .ui-tree-item__row {
52+
outline: 1px dotted var(--ui-list-inactive-focus-outline);
53+
outline-offset: -1px;
54+
}
55+
56+
.ui-tree--focused .ui-tree-item--focused > .ui-tree-item__row {
57+
outline: 1px solid var(--ui-list-focus-outline);
58+
outline-offset: -1px;
59+
}
60+
61+
.ui-tree--focused
62+
.ui-tree-item--focused[aria-selected="true"]
63+
> .ui-tree-item__row {
64+
outline-color: var(--ui-list-focus-and-selection-outline);
65+
}
66+
67+
.ui-tree-item__indent {
68+
position: absolute;
69+
inset-block: 0;
70+
inset-inline-start: calc(2 * var(--ui-tree-indent-size));
71+
display: flex;
72+
pointer-events: none;
73+
}
74+
75+
/* One guide per ancestor, like the native tree's .indent-guide. */
76+
.ui-tree-item__indent-slot {
77+
box-sizing: border-box;
78+
width: var(--ui-tree-indent-size);
79+
flex: none;
80+
border-inline-start: 1px solid transparent;
81+
}
82+
83+
/* Never overlapping selectors, so neither can override the other. */
84+
.ui-tree-item__indent-slot--active {
85+
border-inline-start-color: var(--ui-tree-indent-guide-active);
86+
}
87+
88+
.ui-tree:hover
89+
.ui-tree-item__indent-slot:not(.ui-tree-item__indent-slot--active) {
90+
border-inline-start-color: var(--ui-tree-indent-guide-inactive);
91+
}
92+
93+
.ui-tree-item__chevron {
94+
display: flex;
95+
align-items: center;
96+
justify-content: center;
97+
width: 16px;
98+
height: var(--ui-tree-row-height);
99+
padding-inline-start: calc(var(--ui-tree-level) * var(--ui-tree-indent-size));
100+
padding-inline-end: 6px;
101+
flex: none;
102+
transform: translateX(3px);
103+
}
104+
105+
.ui-tree-item__chevron:dir(rtl) {
106+
transform: translateX(-3px);
107+
}
108+
109+
.ui-tree-item__chevron > .ui-icon {
110+
width: 10px;
111+
font-size: 10px;
112+
}
113+
114+
/* Keep 3px so leaf icons clear the innermost guide and line up with twisties. */
115+
.ui-tree--explorer
116+
.ui-tree-item:not([aria-expanded])
117+
> .ui-tree-item__row
118+
> .ui-tree-item__chevron {
119+
width: 3px;
120+
padding-inline-end: 0;
121+
visibility: hidden;
122+
}
123+
124+
.ui-tree-item__content {
125+
display: flex;
126+
align-items: center;
127+
min-width: 0;
128+
flex: 1;
129+
line-height: var(--ui-tree-row-height);
130+
overflow: hidden;
131+
white-space: nowrap;
132+
}
133+
134+
.ui-tree-item__content > .ui-icon {
135+
margin-inline-end: var(--ui-spacing-60);
136+
flex: none;
137+
}
138+
139+
.ui-tree-item__action {
140+
display: none;
141+
align-items: center;
142+
align-self: stretch;
143+
flex: none;
144+
gap: 2px;
145+
}
146+
147+
.ui-tree-item:is([aria-selected="true"], .ui-tree-item--focused)
148+
> .ui-tree-item__row
149+
.ui-tree-item__action,
150+
.ui-tree-item__row:is(:hover, :focus-within) .ui-tree-item__action {
151+
display: inline-flex;
152+
}
153+
154+
/* Modern UI insets the rows; data-ui-style="stable" keeps them edge to edge. */
155+
:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row {
156+
margin-inline: var(--ui-spacing-40);
157+
border-radius: var(--ui-radius-small);
158+
}
159+
160+
@media (prefers-reduced-motion: no-preference) {
161+
.ui-tree-item__indent-slot {
162+
transition: border-color 100ms linear;
163+
}
164+
}
165+
166+
@media (forced-colors: active) {
167+
.ui-tree-item:not([aria-selected="true"]) > .ui-tree-item__row:hover,
168+
.ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
169+
color: HighlightText;
170+
background: Highlight;
171+
}
172+
173+
.ui-tree:hover .ui-tree-item__indent-slot,
174+
.ui-tree-item__indent-slot--active {
175+
border-color: CanvasText;
176+
}
177+
}

0 commit comments

Comments
 (0)