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
82 changes: 73 additions & 9 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,77 @@ 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`, `TreeItem`, and `TreeGroup` form a declarative hierarchy:

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

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

`itemId` and `textValue` are required. The ID provides stable selection and
registry identity; the text value supplies the fallback accessible name and
drives case-insensitive, buffered type-ahead without depending on rendered DOM
text. Keep it aligned with the visible label unless you provide an explicit
`aria-label` or `aria-labelledby`. Selection is controlled by `Tree`, and each
branch's expansion is controlled by its `TreeItem`. The suite intentionally does
not provide default state or multi-selection.

Arrow Up/Down, Home, End, and type-ahead move focus through visible enabled
items. Arrow Right expands a branch or enters it; Arrow Left collapses a branch
or returns to its parent. Enter and Space select the focused item and toggle a
branch. Clicking a row selects it and toggles a branch. Interactive content in
the trailing `action` slot is isolated from tree selection and expansion.

Normal compound usage derives logical order from rendered DOM position.
Consumers with externally managed ordering or focus can pass `getItemIndex` and
`focusItem` to `Tree`, so mounted registrations carry a logical index and an
item-focused callback. These hooks coordinate mounted rows only; a virtualized
consumer remains responsible for window boundaries, offscreen item navigation,
and rendering focused items.

Tree rows are 22px tall. By default every row keeps the VS Code twistie
gutter, matching trees whose branch rows render icons. For file trees whose
folders render without icons — the native Explorer default — use
`variant="explorer"`: leaf rows collapse the unused gutter so file icons align
with branch twisties. Don't combine the explorer variant with branch icons, or
leaf icons sit on the indent guides.
The package's intentional Modern default uses 4px side
insets, 4px corner radii, and keyboard-only focus outlines. Setting
`data-ui-style="stable"` on the document root makes rows edge-to-edge and square
and restores VS Code's current stable focus behavior. `TreeIndentGuide` is
available for custom row content; the compound tree also renders hierarchy
guides on hover and highlights active or selected ancestor paths.

## Overlays

Expand Down Expand Up @@ -79,7 +144,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 @@ -96,5 +160,5 @@ the only runtime dependencies are the Radix overlay primitives and
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.
`#codicons`, `#storybook`, `#tree-context`). These resolve only inside this
package and ship with it, so they survive a standalone NPM split.
3 changes: 2 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"imports": {
"#cx": "./src/cx.ts",
"#codicons": "./src/codicons.ts",
"#storybook": "./src/storybook.ts"
"#storybook": "./src/storybook.ts",
"#tree-context": "./src/treeContext.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
Expand Down
169 changes: 169 additions & 0 deletions packages/ui/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
.ui-tree {
--ui-tree-indent-size: 8px;
box-sizing: border-box;
width: 100%;
min-width: 0;
}

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

.ui-tree-item__row {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
height: 22px;
padding-inline-end: var(--ui-spacing-120);
cursor: pointer;
user-select: 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);
}

.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-selected="true"] > .ui-tree-item__row {
outline: 1px dotted var(--ui-list-selection-outline);
outline-offset: -1px;
}

.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;
}

.ui-tree-item__indent-slot {
position: relative;
width: var(--ui-tree-indent-size);
flex: none;
}

.ui-tree-item__indent-slot::after {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
border-inline-start: 1px solid var(--ui-tree-indent-guide-inactive);
content: "";
opacity: 0;
}

.ui-tree:hover .ui-tree-item__indent-slot::after,
.ui-tree-item__indent-slot--active::after {
opacity: 1;
}

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

.ui-tree-item__chevron {
display: flex;
align-items: center;
justify-content: center;
box-sizing: content-box;
width: 16px;
height: 22px;
padding-inline-end: 6px;
flex: none;
transform: translateX(3px);
}

.ui-tree--explorer
.ui-tree-item:not([aria-expanded])
> .ui-tree-item__row
> .ui-tree-item__chevron {
width: 0;
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: 22px;
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;
}

.ui-tree-indent-guide {
display: inline-block;
box-sizing: border-box;
width: var(--ui-tree-indent-size, 8px);
height: 22px;
border-inline-start: 1px solid var(--ui-tree-indent-guide-inactive);
}

.ui-tree-indent-guide--active {
border-inline-start-color: var(--ui-tree-indent-guide-active);
}

@media (prefers-reduced-motion: no-preference) {
.ui-tree-item__indent-slot::after {
transition: opacity 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-item__indent-slot::after,
.ui-tree-indent-guide {
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