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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,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
107 changes: 99 additions & 8 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,99 @@ 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` and `TreeItem` form a declarative hierarchy; a row's children are its
child rows:

```tsx
const [selectedItemId, setSelectedItemId] = useState("src");
const [expanded, setExpanded] = useState(true);

<Tree
aria-label="Explorer"
variant="explorer"
selectedItemId={selectedItemId}
onSelectedItemChange={setSelectedItemId}
>
<TreeItem
itemId="src"
label="src"
expanded={expanded}
onExpandedChange={setExpanded}
>
<TreeItem itemId="tree" label="Tree.tsx" icon="symbol-class" />
</TreeItem>
</Tree>;
```

`itemId` carries selection and registry identity. `label` is the row content:
a string also supplies the accessible name and the case-insensitive, buffered
type-ahead key, so matching never depends on rendered DOM text; a `ReactNode`
label must pass `textValue` for those, which the types enforce. `icon` renders
a codicon ahead of the label. `aria-label` or `aria-labelledby` override the
accessible name.

`expanded` is what makes a row a branch: it adds the twistie and lets the row
nest child rows, including a branch whose children have not loaded yet. Only a
branch may have children, and passing them without `expanded` throws. Because
children are always child rows and never row content, wrapper components,
fragments, and arrays all work. `Tree` controls selection, each `TreeItem`
controls its own expansion, neither defaults, and there is no multi-selection.

Arrow Up/Down, Home, End, and type-ahead move focus through visible enabled
rows. Arrow Right expands a branch or enters it; Arrow Left collapses it or
returns to the parent. Enter and Space select the focused row and toggle a
branch. Clicking a row does both; clicking the twistie only toggles, leaving
selection in place like the native tree. Interactive content in the trailing
`action` slot is isolated from selection and expansion.

`multiSelect` swaps the singular selection props for `selectedItemIds` and
`onSelectedItemsChange` and marks the tree `aria-multiselectable`. Ctrl/Cmd
click toggles a row, Shift click and Shift arrows extend from the anchor (the
last row selected without Shift), and Ctrl/Cmd+A takes every visible enabled
row. Ranges follow tree order and skip disabled and collapsed rows.

`stickyScroll` pins the ancestors of the topmost visible row against the
nearest scrolling ancestor, like VS Code's tree sticky scroll; pass a number
to cap how many levels pin at once. Pinning is `position: sticky` on the
branch rows themselves, so the browser does the push-out and there is no
scroll listener. Webviews receive no `workbench.tree.*` settings, so to honor
the user's own configuration the host has to read them and pass them in:

```ts
const tree = vscode.workspace.getConfiguration("workbench.tree");
const stickyScroll =
tree.get<boolean>("enableStickyScroll") &&
(tree.get<number>("stickyScrollMaxItemCount") ?? 7);
```

Navigation order, visibility, and hierarchy are read back from the rendered
rows, so reordering or reparenting needs no extra wiring. Collapsing a branch
unmounts its children, so cost tracks what is open rather than the size of the
tree: a 100k-node tree browsed a folder at a time mounts in ~350ms and keeps
keystrokes under a millisecond. The suite is not virtualized, so the limit is
rows open _at once_ — around 10k is comfortable, 50k degrades, and 100k
expanded at once needs a virtualized tree instead.

Rows are 22px tall and keep the VS Code twistie gutter, matching trees whose
branch rows render icons. For file trees whose folders render without icons —
the native Explorer default — `variant="explorer"` collapses that gutter on
leaf rows so file icons align with branch twisties; don't combine it with
branch icons, which pulls leaf icons out of alignment with branch content.
Indent guides appear on hover, with the focused and selected ancestor paths
always lit. The package's intentional Modern default insets rows 4px with 4px
corners and keyboard-only focus outlines; `data-ui-style="stable"` on the
document root makes them edge-to-edge and square, restoring VS Code's current
stable focus behavior.

## Overlays

Expand Down Expand Up @@ -76,10 +163,12 @@ until the exit animation ends. High contrast, `forced-colors`, and
- Overlay shadows are darker than native in dark themes: menus in VS Code
use `shadow-lg`, which webviews cannot read, so the closest available
`widget.shadow` stands in.
- Sticky rows have no drop shadow. VS Code draws one under its sticky
widget; CSS cannot tell a pinned row from an unpinned one, and a scroll
listener would undo the point of doing this without JavaScript.
- 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 +186,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.
167 changes: 167 additions & 0 deletions packages/ui/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
.ui-tree {
--ui-tree-indent-size: 8px;
--ui-tree-row-height: 22px;
width: 100%;
min-width: 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;
}

/* Pinned below its pinned ancestors; the subtree's end pushes it out. */
.ui-tree-item--sticky > .ui-tree-item__row {
position: sticky;
top: calc((var(--ui-tree-level) - 1) * var(--ui-tree-row-height));
z-index: calc(100 - var(--ui-tree-level));
--ui-tree-row-background: var(--ui-tree-sticky-background);
}

.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:focus > .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;
}
}
19 changes: 19 additions & 0 deletions packages/ui/src/components/Tree/Tree.modern.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row {
margin-inline: var(--ui-spacing-40);
border-radius: var(--ui-radius-small);
}

:where(:root:not([data-ui-style="stable"]))
.ui-tree--focused
.ui-tree-item:focus-visible
> .ui-tree-item__row {
outline: 1px solid var(--ui-list-focus-outline);
outline-offset: -1px;
}

:where(:root:not([data-ui-style="stable"]))
.ui-tree--focused
.ui-tree-item[aria-selected="true"]:focus-visible
> .ui-tree-item__row {
outline-color: var(--ui-list-focus-and-selection-outline);
}
14 changes: 14 additions & 0 deletions packages/ui/src/components/Tree/Tree.stable.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
:where(:root[data-ui-style="stable"])
.ui-tree--focused
.ui-tree-item:focus
> .ui-tree-item__row {
outline: 1px solid var(--ui-list-focus-outline);
outline-offset: -1px;
}

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