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
81 changes: 73 additions & 8 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,76 @@ 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`. A `TreeGroup` must be a
direct child of its `TreeItem` (arrays and fragments are fine); wrapping it in
another component hides it from branch detection and throws. 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; clicking the twistie
only toggles, leaving selection in place like the native tree. Interactive
content in the trailing `action` slot is isolated from tree selection and
expansion.

Logical navigation order derives from rendered DOM position, so reordering
rows needs no extra wiring.

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; the
collapsed gutter pulls leaf icons out of alignment with branch content.
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. The tree renders
hierarchy guides on hover and highlights active or selected ancestor paths.

## Overlays

Expand Down Expand Up @@ -79,7 +143,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 +160,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.
164 changes: 164 additions & 0 deletions packages/ui/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
.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);
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;
}

.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-item__chevron:dir(rtl) {
transform: translateX(-3px);
}

/* Keep 3px of the collapsed gutter so leaf icons clear the innermost
indent guide and line up with the translateX'd branch 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: 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;
}

/* Collapse wins over consumer display values on the group. */
.ui-tree-group[hidden] {
display: none !important;
}

@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 {
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