Skip to content

Commit 85c00af

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. The flat projection leaves room for windowing later. Closes #1037
1 parent 04503bc commit 85c00af

27 files changed

Lines changed: 2553 additions & 11 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.5",
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.66.0",
861863
"utf-8-validate": "^6.0.6",

packages/ui/README.md

Lines changed: 92 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,90 @@ 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. A string `label` is also the
85+
accessible name and type-navigation value; a rich label must provide
86+
`textValue`. `children` marks a branch, including an empty array for a branch
87+
whose children are still loading. `icon`, `action`, and `className` customize
88+
the row. Actions stay live on plain hover, as in the native list, and are
89+
isolated from row selection and expansion.
90+
91+
Arrow Up/Down, Home, and End move the active row through visible rows. Arrow Right
92+
expands a branch or enters it; Arrow Left collapses it or moves to its parent.
93+
94+
`expandMode="singleClick"` is the default: clicking a branch selects
95+
and toggles it, and Enter does the same. With `expandMode="doubleClick"`, a
96+
single click or Enter only selects and a double click toggles expansion. Space
97+
toggles a branch without selecting it, or selects a leaf. A normal-row twistie
98+
toggles without changing selection. Alt-click recursively toggles descendant
99+
branches.
100+
101+
Escape clears selection, then the active focus mark. Once neither remains,
102+
Escape is left to the host. The root `onKeyDown` runs first, so a host
103+
can intercept shortcuts with `preventDefault()`.
104+
105+
```mermaid
106+
flowchart LR
107+
accTitle: Tree architecture
108+
accDescr: Data and input flow through the pure Tree modules into the React and DOM adapter.
109+
110+
Props[Nodes and controlled props] --> Model[treeModel.ts]
111+
Events[Pointer and keyboard events] --> Policy[treePolicy.ts]
112+
Policy --> Commands[Tree commands]
113+
Model --> Transition[treeTransition.ts]
114+
Commands --> Transition
115+
Transition --> Adapter[useTreeAdapter.ts]
116+
Adapter --> Rows[Tree.tsx and TreeRow.tsx]
117+
```
118+
119+
The model, policy, and transitions stay pure. The adapter owns React and DOM
120+
integration. The flat visible model supports future windowing, but the Tree is
121+
not currently virtualized.
122+
123+
Rows are 22px tall and keep the VS Code twistie gutter. For Explorer-style file
124+
trees whose branches have no icons, `variant="explorer"` aligns leaf icons with
125+
branch twisties; do not combine it with branch icons. Indent guides appear on
126+
hover, selected ancestor paths stay active, and the focused path is active only
127+
while the tree has focus. The package default uses inset Modern UI rows;
128+
`data-ui-style="stable"` restores edge-to-edge square rows and stable focus
129+
styling.
47130

48131
## Overlays
49132

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

84166
## Codicons
85167

@@ -97,4 +179,6 @@ declared CSS exports.
97179

98180
Shared internals are reached through `package.json` subpath imports (`#cx`,
99181
`#codicons`, `#storybook`). These resolve only inside this package and ship
100-
with it, so they survive a standalone NPM split.
182+
with it, so they survive a standalone NPM split. Component families keep
183+
their own internals (contexts, stores) inside their folder and import them
184+
relatively, so a family can lift out wholesale.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
.ui-tree {
2+
--ui-tree-indent-size: 8px;
3+
--ui-tree-row-height: 22px;
4+
width: 100%;
5+
min-width: 0;
6+
outline: 0;
7+
}
8+
9+
.ui-tree-item {
10+
outline: 0;
11+
}
12+
13+
.ui-tree-item__row {
14+
position: relative;
15+
display: flex;
16+
align-items: center;
17+
height: var(--ui-tree-row-height);
18+
padding-inline-end: var(--ui-spacing-120);
19+
background: var(--ui-tree-row-background, transparent);
20+
cursor: pointer;
21+
user-select: none;
22+
}
23+
24+
/* Native skips hover on selected and focused rows, keeping their outlines. */
25+
.ui-tree-item:not([aria-selected="true"]):not(.ui-tree-item--focused)
26+
> .ui-tree-item__row:hover {
27+
color: var(--ui-list-hover-foreground);
28+
background: var(--ui-list-hover-background);
29+
outline: 1px dashed var(--ui-list-hover-outline);
30+
outline-offset: -1px;
31+
}
32+
33+
.ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
34+
color: var(--ui-list-inactive-selection-foreground);
35+
background: var(--ui-list-inactive-selection-background);
36+
outline: 1px dotted var(--ui-list-selection-outline);
37+
outline-offset: -1px;
38+
}
39+
40+
.ui-tree--focused .ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
41+
color: var(--ui-list-active-selection-foreground);
42+
background: var(--ui-list-active-selection-background);
43+
}
44+
45+
.ui-tree-item__indent {
46+
position: absolute;
47+
inset-block: 0;
48+
inset-inline-start: calc(2 * var(--ui-tree-indent-size));
49+
display: flex;
50+
pointer-events: none;
51+
}
52+
53+
/* The native list's inactive focus outline: kept while the tree is blurred. */
54+
.ui-tree:not(.ui-tree--focused) .ui-tree-item--focused > .ui-tree-item__row {
55+
outline: 1px dotted var(--ui-list-inactive-focus-outline);
56+
outline-offset: -1px;
57+
}
58+
59+
/* One guide per ancestor, like the native tree's .indent-guide. */
60+
.ui-tree-item__indent-slot {
61+
box-sizing: border-box;
62+
width: var(--ui-tree-indent-size);
63+
flex: none;
64+
border-inline-start: 1px solid transparent;
65+
}
66+
67+
/* Never overlapping selectors, so neither can override the other. */
68+
.ui-tree-item__indent-slot--active {
69+
border-inline-start-color: var(--ui-tree-indent-guide-active);
70+
}
71+
72+
.ui-tree:hover
73+
.ui-tree-item__indent-slot:not(.ui-tree-item__indent-slot--active) {
74+
border-inline-start-color: var(--ui-tree-indent-guide-inactive);
75+
}
76+
77+
.ui-tree-item__chevron {
78+
display: flex;
79+
align-items: center;
80+
justify-content: center;
81+
width: 16px;
82+
height: var(--ui-tree-row-height);
83+
padding-inline-start: calc(var(--ui-tree-level) * var(--ui-tree-indent-size));
84+
padding-inline-end: 6px;
85+
flex: none;
86+
transform: translateX(3px);
87+
}
88+
89+
.ui-tree-item__chevron:dir(rtl) {
90+
transform: translateX(-3px);
91+
}
92+
93+
/* Keep 3px so leaf icons clear the innermost guide and line up with twisties. */
94+
.ui-tree--explorer
95+
.ui-tree-item:not([aria-expanded])
96+
> .ui-tree-item__row
97+
> .ui-tree-item__chevron {
98+
width: 3px;
99+
padding-inline-end: 0;
100+
visibility: hidden;
101+
}
102+
103+
.ui-tree-item__chevron > .ui-icon {
104+
width: 10px;
105+
font-size: 10px;
106+
}
107+
108+
.ui-tree-item__content {
109+
display: flex;
110+
align-items: center;
111+
min-width: 0;
112+
flex: 1;
113+
line-height: var(--ui-tree-row-height);
114+
overflow: hidden;
115+
white-space: nowrap;
116+
}
117+
118+
.ui-tree-item__content > .ui-icon {
119+
margin-inline-end: var(--ui-spacing-60);
120+
flex: none;
121+
}
122+
123+
.ui-tree-item__action {
124+
display: none;
125+
align-items: center;
126+
align-self: stretch;
127+
flex: none;
128+
gap: 2px;
129+
}
130+
131+
.ui-tree-item[aria-selected="true"] > .ui-tree-item__row .ui-tree-item__action,
132+
.ui-tree-item__row:hover .ui-tree-item__action,
133+
.ui-tree-item--focused > .ui-tree-item__row .ui-tree-item__action,
134+
.ui-tree-item__row:focus-within .ui-tree-item__action {
135+
display: inline-flex;
136+
}
137+
138+
@media (prefers-reduced-motion: no-preference) {
139+
.ui-tree-item__indent-slot {
140+
transition: border-color 100ms linear;
141+
}
142+
}
143+
144+
@media (forced-colors: active) {
145+
.ui-tree-item:not([aria-selected="true"]) > .ui-tree-item__row:hover,
146+
.ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
147+
color: HighlightText;
148+
background: Highlight;
149+
}
150+
151+
.ui-tree:hover .ui-tree-item__indent-slot,
152+
.ui-tree-item__indent-slot--active {
153+
border-color: CanvasText;
154+
}
155+
}
156+
157+
:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row {
158+
margin-inline: var(--ui-spacing-40);
159+
border-radius: var(--ui-radius-small);
160+
}
161+
162+
.ui-tree--focused .ui-tree-item--focused > .ui-tree-item__row {
163+
outline: 1px solid var(--ui-list-focus-outline);
164+
outline-offset: -1px;
165+
}
166+
167+
.ui-tree--focused
168+
.ui-tree-item--focused[aria-selected="true"]
169+
> .ui-tree-item__row {
170+
outline-color: var(--ui-list-focus-and-selection-outline);
171+
}

0 commit comments

Comments
 (0)