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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,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 @@ -844,6 +845,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
123 changes: 115 additions & 8 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,118 @@ 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 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.

```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 are isolated from row selection and expansion.

Arrow Up/Down, Home, End, PageUp/PageDown, and buffered prefix/fuzzy typing
move the active row through visible rows, including disabled rows. Arrow Right
expands a branch or enters it; Arrow Left collapses it or moves to its parent.
Disabled rows have `aria-disabled`, remain keyboard-navigation targets, and
cannot be selected or expanded.

`expandMode="singleClick"` is the default: clicking an enabled 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 unless Alt is configured as the multi-selection modifier.

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 enabled 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
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]
Adapter --> Sticky[StickyScroll.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 +185,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 +202,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.
212 changes: 212 additions & 0 deletions packages/ui/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
.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;
}

/* 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-disabled="true"]):not([aria-selected="true"])
> .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[aria-disabled="true"] > .ui-tree-item__row {
color: var(--ui-disabled-foreground, currentColor);
cursor: default;
}

.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-disabled="true"]):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