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
6 changes: 5 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
stories: ["../packages/*/src/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
addons: [
"@storybook/addon-a11y",
"@storybook/addon-docs",
"storybook-addon-pseudo-states",
],
framework: {
name: "@storybook/react-vite",
options: {},
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ Non-negotiables:
- Extension panels must call **both** `buildCommandHandlers` and
`buildRequestHandlers` (empty `{}` is fine). This gives a compile error
when anyone adds an action to the API without a matching handler.
- Every webview and Storybook build runs the React Compiler, so components
and hooks must follow the rules of React: no reading or writing a ref
during render, no mutating props, state, or anything already rendered,
and hooks called unconditionally. A component that breaks them is skipped
silently and loses its memoization. Parameter defaults that read another
prop (`focused = adapter?.focusedId === row.node.id`) are the usual
culprit; put those defaults in the body. `useMemo` and `useCallback` are
rarely needed, and when kept they must list every dependency, or
`react-hooks/preserve-manual-memoization` fails the lint.

## Code Style

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@
"@tanstack/react-query": "catalog:",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "catalog:",
"@tsconfig/node22": "^22.0.5",
"@types/mocha": "^10.0.10",
"@types/node": "^22.20.1",
Expand Down Expand Up @@ -856,6 +857,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"storybook": "catalog:",
"storybook-addon-pseudo-states": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "^8.66.0",
"utf-8-validate": "^6.0.6",
Expand Down
100 changes: 92 additions & 8 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Its stable separation boundary is the public root exports, no monorepo runtime
imports, and component CSS using only semantic `--ui-*` tokens. A future package
build can emit those same entry points without API changes.

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

## CSS

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

Where VS Code's stable rendering and its Modern UI preview
(`workbench.experimental.modernUI`) diverge, components follow Modern UI,
and new components should too. Webviews get no signal for the setting, so
the default cannot follow the host. Until the design settles,
`data-ui-style="stable"` on the document root restores the stable-parity
menu motion; Storybook's "UI style" toolbar switch toggles it live.
VS Code currently uses its stable UI by default; Modern UI remains behind the
experimental `workbench.experimental.modernUI` setting. `@repo/ui`
intentionally uses Modern UI as its package default because webviews receive no
host signal for that setting. The divergence is isolated: set
`data-ui-style="stable"` on the document root to restore stable row geometry,
focus behavior, and menu motion. Storybook's "UI style" toolbar switch toggles
that override live.

## Tree

`Tree` is controlled: `nodes` describe the hierarchy, `expandedIds` controls
branches, and `selectedItemId` controls 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.

```tsx
const [selectedItemId, setSelectedItemId] = useState("src");
const [expandedIds, setExpandedIds] = useState<readonly string[]>(["src"]);

<Tree
aria-label="Explorer"
variant="explorer"
nodes={[
{
id: "src",
label: "src",
children: [{ id: "tree", label: "Tree.tsx", icon: "symbol-class" }],
},
{ id: "readme", label: "README.md", icon: "markdown" },
]}
expandedIds={expandedIds}
onExpandedIdsChange={setExpandedIds}
selectedItemId={selectedItemId}
onSelectedItemChange={setSelectedItemId}
/>;
```

Ids must be unique across the whole tree. A string `label` is also the
accessible name and type-navigation value; a rich label must provide
`textValue`. `children` marks a branch, including an empty array for a branch
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
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.

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
can intercept shortcuts with `preventDefault()`.

```mermaid
flowchart LR
accTitle: Tree architecture
accDescr: Data and input flow through the pure Tree modules into the React and DOM adapter.

Props[Nodes and controlled props] --> Model[treeModel.ts]
Events[Pointer and keyboard events] --> Policy[treePolicy.ts]
Policy --> Commands[Tree commands]
Model --> Transition[treeTransition.ts]
Commands --> Transition
Transition --> Adapter[useTreeAdapter.ts]
Adapter --> Rows[Tree.tsx and TreeRow.tsx]
```

The model, policy, and transitions stay pure. The adapter owns React and DOM
integration. The flat visible model supports future windowing, but the Tree is
not currently virtualized.

Rows are 22px tall and keep the VS Code twistie gutter. For Explorer-style file
trees whose branches have no icons, `variant="explorer"` aligns leaf icons with
branch twisties; do not combine it with branch icons. Indent guides appear on
hover, selected ancestor paths stay active, and the focused path is active only
while the tree has focus. The package default uses inset Modern UI rows;
`data-ui-style="stable"` restores edge-to-edge square rows and stable focus
styling.

## Overlays

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

## Codicons

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

Shared internals are reached through `package.json` subpath imports (`#cx`,
`#codicons`, `#storybook`). These resolve only inside this package and ship
with it, so they survive a standalone NPM split.
with it, so they survive a standalone NPM split. Component families keep
their own internals (contexts, stores) inside their folder and import them
relatively, so a family can lift out wholesale.
171 changes: 171 additions & 0 deletions packages/ui/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
.ui-tree {
--ui-tree-indent-size: 8px;
--ui-tree-row-height: 22px;
width: 100%;
min-width: 0;
outline: 0;
}

.ui-tree-item {
outline: 0;
}

.ui-tree-item__row {
position: relative;
display: flex;
align-items: center;
height: var(--ui-tree-row-height);
padding-inline-end: var(--ui-spacing-120);
background: var(--ui-tree-row-background, transparent);
cursor: pointer;
user-select: none;
}

/* Native skips hover on selected and focused rows, keeping their outlines. */
.ui-tree-item:not([aria-selected="true"]):not(.ui-tree-item--focused)
> .ui-tree-item__row:hover {
color: var(--ui-list-hover-foreground);
background: var(--ui-list-hover-background);
outline: 1px dashed var(--ui-list-hover-outline);
outline-offset: -1px;
}

.ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
color: var(--ui-list-inactive-selection-foreground);
background: var(--ui-list-inactive-selection-background);
outline: 1px dotted var(--ui-list-selection-outline);
outline-offset: -1px;
}

.ui-tree--focused .ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
color: var(--ui-list-active-selection-foreground);
background: var(--ui-list-active-selection-background);
}

.ui-tree-item__indent {
position: absolute;
inset-block: 0;
inset-inline-start: calc(2 * var(--ui-tree-indent-size));
display: flex;
pointer-events: none;
}

/* The native list's inactive focus outline: kept while the tree is blurred. */
.ui-tree:not(.ui-tree--focused) .ui-tree-item--focused > .ui-tree-item__row {
outline: 1px dotted var(--ui-list-inactive-focus-outline);
outline-offset: -1px;
}

/* One guide per ancestor, like the native tree's .indent-guide. */
.ui-tree-item__indent-slot {
box-sizing: border-box;
width: var(--ui-tree-indent-size);
flex: none;
border-inline-start: 1px solid transparent;
}

/* Never overlapping selectors, so neither can override the other. */
.ui-tree-item__indent-slot--active {
border-inline-start-color: var(--ui-tree-indent-guide-active);
}

.ui-tree:hover
.ui-tree-item__indent-slot:not(.ui-tree-item__indent-slot--active) {
border-inline-start-color: var(--ui-tree-indent-guide-inactive);
}

.ui-tree-item__chevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: var(--ui-tree-row-height);
padding-inline-start: calc(var(--ui-tree-level) * var(--ui-tree-indent-size));
padding-inline-end: 6px;
flex: none;
transform: translateX(3px);
}

.ui-tree-item__chevron:dir(rtl) {
transform: translateX(-3px);
}

/* Keep 3px so leaf icons clear the innermost guide and line up with twisties. */
.ui-tree--explorer
.ui-tree-item:not([aria-expanded])
> .ui-tree-item__row
> .ui-tree-item__chevron {
width: 3px;
padding-inline-end: 0;
visibility: hidden;
}

.ui-tree-item__chevron > .ui-icon {
width: 10px;
font-size: 10px;
}

.ui-tree-item__content {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
line-height: var(--ui-tree-row-height);
overflow: hidden;
white-space: nowrap;
}

.ui-tree-item__content > .ui-icon {
margin-inline-end: var(--ui-spacing-60);
flex: none;
}

.ui-tree-item__action {
display: none;
align-items: center;
align-self: stretch;
flex: none;
gap: 2px;
}

.ui-tree-item[aria-selected="true"] > .ui-tree-item__row .ui-tree-item__action,
.ui-tree-item__row:hover .ui-tree-item__action,
.ui-tree-item--focused > .ui-tree-item__row .ui-tree-item__action,
.ui-tree-item__row:focus-within .ui-tree-item__action {
display: inline-flex;
}

@media (prefers-reduced-motion: no-preference) {
.ui-tree-item__indent-slot {
transition: border-color 100ms linear;
}
}

@media (forced-colors: active) {
.ui-tree-item:not([aria-selected="true"]) > .ui-tree-item__row:hover,
.ui-tree-item[aria-selected="true"] > .ui-tree-item__row {
color: HighlightText;
background: Highlight;
}

.ui-tree:hover .ui-tree-item__indent-slot,
.ui-tree-item__indent-slot--active {
border-color: CanvasText;
}
}

:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row {
margin-inline: var(--ui-spacing-40);
border-radius: var(--ui-radius-small);
}

.ui-tree--focused .ui-tree-item--focused > .ui-tree-item__row {
outline: 1px solid var(--ui-list-focus-outline);
outline-offset: -1px;
}

.ui-tree--focused
.ui-tree-item--focused[aria-selected="true"]
> .ui-tree-item__row {
outline-color: var(--ui-list-focus-and-selection-outline);
}
Loading