diff --git a/packages/ui/README.md b/packages/ui/README.md index ec7936934..94cca3049 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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); + + + + src + + + Tree.tsx + + + +; +``` + +`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 @@ -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 @@ -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. diff --git a/packages/ui/package.json b/packages/ui/package.json index a2ea86665..f45a2446a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css new file mode 100644 index 000000000..358f9462b --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -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; + } +} diff --git a/packages/ui/src/components/Tree/Tree.modern.css b/packages/ui/src/components/Tree/Tree.modern.css new file mode 100644 index 000000000..b0d07e292 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.modern.css @@ -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); +} diff --git a/packages/ui/src/components/Tree/Tree.stable.css b/packages/ui/src/components/Tree/Tree.stable.css new file mode 100644 index 000000000..d5f4de92c --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stable.css @@ -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); +} diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx new file mode 100644 index 000000000..6f82fe01b --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { expect, userEvent, within } from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Icon } from "../Icon/Icon"; +import { IconButton } from "../IconButton/IconButton"; +import { TreeGroup } from "../TreeGroup/TreeGroup"; +import { TreeItem } from "../TreeItem/TreeItem"; + +import { Tree } from "./Tree"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TreeStates = (): React.JSX.Element => { + const [selectedItemId, setSelectedItemId] = useState("components"); + const [sourceExpanded, setSourceExpanded] = useState(true); + const [componentsExpanded, setComponentsExpanded] = useState(true); + + // The native default Explorer: branch rows render without icons, so the + // explorer variant aligns leaf file icons with the branch twisties. + return ( + + + src + + + components + + } + > + + Tree.tsx + + + + Tree.css + + + + + + tests + + + + + + README.md + + + ); +}; + +const meta: Meta = { + title: "UI/Tree", + component: TreeStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +const exerciseTree = async ({ + canvasElement, +}: { + canvasElement: HTMLElement; +}): Promise => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("treeitem", { name: "components" }), + ).toHaveAttribute("aria-selected", "true"); + + // Selection reveals the trailing action without relying on :hover or + // :focus, which need a focused window and a real pointer. + const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await userEvent.click(treeItem); + await expect(treeItem).toHaveAttribute("aria-selected", "true"); + await userEvent.click(canvas.getByRole("button", { name: "Close Tree.tsx" })); + await expect(treeItem).toHaveAttribute("aria-selected", "true"); + + await userEvent.click(canvas.getByRole("treeitem", { name: "README.md" })); + await expect( + canvas.getByRole("treeitem", { name: "README.md" }), + ).toHaveAttribute("aria-selected", "true"); +}; + +export const States: Story = { play: exerciseTree }; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, + play: exerciseTree, +}; + +export const Nested: Story = {}; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx new file mode 100644 index 000000000..3d0a8c9c1 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,120 @@ +import { + type ComponentPropsWithRef, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { cx } from "#cx"; +import { TreeContext, TreeHierarchyContext } from "#tree-context"; + +import { TreeStore } from "../../treeStore"; + +import "./Tree.css"; +import "./Tree.modern.css"; +import "./Tree.stable.css"; + +const ROOT_HIERARCHY = { level: 1, pathItemIds: [] } as const; + +function focusBelongsToTree( + tree: HTMLElement, + target: EventTarget | null, +): boolean { + return target instanceof Element && target.closest(".ui-tree") === tree; +} + +export interface TreeProps extends Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" +> { + /** + * "explorer" collapses the unused twistie gutter on leaf rows so file + * icons align with branch twisties, like the native Explorer whose + * folders render without icons. Keep "default" when branch rows render + * icons, or leaf icons sit on the indent guides. + */ + variant?: "default" | "explorer"; + selectedItemId?: string; + onSelectedItemChange?: (itemId: string) => void; + getItemIndex?: (itemId: string) => number | undefined; + focusItem?: (itemId: string) => void; +} + +/** A controlled, single-selection tree with native VS Code keyboard behavior. */ +export function Tree({ + variant = "default", + selectedItemId, + onSelectedItemChange, + getItemIndex, + focusItem, + className, + children, + onBlur, + onFocus, + onKeyDown, + ...props +}: TreeProps): React.JSX.Element { + const [store] = useState( + () => + new TreeStore(selectedItemId, { + onSelectedItemChange, + getItemIndex, + focusItem, + }), + ); + const [hasDomFocus, setHasDomFocus] = useState(false); + + useLayoutEffect(() => { + store.setConfiguration(selectedItemId, { + onSelectedItemChange, + getItemIndex, + focusItem, + }); + }, [focusItem, getItemIndex, onSelectedItemChange, selectedItemId, store]); + + useLayoutEffect(() => store.flushPendingChanges()); + useEffect(() => () => store.dispose(), [store]); + + return ( + + +
{ + onFocus?.(event); + if ( + !event.defaultPrevented && + focusBelongsToTree(event.currentTarget, event.target) + ) { + setHasDomFocus(true); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !focusBelongsToTree(event.currentTarget, event.relatedTarget) + ) { + setHasDomFocus(false); + } + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented) { + store.onKeyDown(event); + } + }} + > + {children} +
+
+
+ ); +} diff --git a/packages/ui/src/components/TreeGroup/TreeGroup.stories.tsx b/packages/ui/src/components/TreeGroup/TreeGroup.stories.tsx new file mode 100644 index 000000000..38ccc6788 --- /dev/null +++ b/packages/ui/src/components/TreeGroup/TreeGroup.stories.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Tree } from "../Tree/Tree"; +import { TreeItem } from "../TreeItem/TreeItem"; + +import { TreeGroup } from "./TreeGroup"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TreeGroupStates = (): React.JSX.Element => { + const [expanded, setExpanded] = useState(true); + + return ( + + + Grouped items + + + First child + + + Second child + + + + + ); +}; + +const meta: Meta = { + title: "UI/TreeGroup", + component: TreeGroupStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; diff --git a/packages/ui/src/components/TreeGroup/TreeGroup.tsx b/packages/ui/src/components/TreeGroup/TreeGroup.tsx new file mode 100644 index 000000000..926997fb3 --- /dev/null +++ b/packages/ui/src/components/TreeGroup/TreeGroup.tsx @@ -0,0 +1,26 @@ +import { cx } from "#cx"; +import { useTreeItemContext } from "#tree-context"; + +import type { ComponentPropsWithRef } from "react"; + +export type TreeGroupProps = Omit, "role">; + +/** The child-item container for its nearest parent TreeItem. */ +export function TreeGroup({ + className, + children, + ...props +}: TreeGroupProps): React.JSX.Element { + const { expanded } = useTreeItemContext(); + + return ( + + ); +} diff --git a/packages/ui/src/components/TreeIndentGuide/TreeIndentGuide.stories.tsx b/packages/ui/src/components/TreeIndentGuide/TreeIndentGuide.stories.tsx new file mode 100644 index 000000000..1f63a40dc --- /dev/null +++ b/packages/ui/src/components/TreeIndentGuide/TreeIndentGuide.stories.tsx @@ -0,0 +1,33 @@ +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeIndentGuide } from "./TreeIndentGuide"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TreeIndentGuideStates = (): React.JSX.Element => ( +
+ + Inactive + + + Active + +
+); + +const meta: Meta = { + title: "UI/TreeIndentGuide", + component: TreeIndentGuideStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; diff --git a/packages/ui/src/components/TreeIndentGuide/TreeIndentGuide.tsx b/packages/ui/src/components/TreeIndentGuide/TreeIndentGuide.tsx new file mode 100644 index 000000000..a3314fc5b --- /dev/null +++ b/packages/ui/src/components/TreeIndentGuide/TreeIndentGuide.tsx @@ -0,0 +1,31 @@ +import { cx } from "#cx"; + +import "../Tree/Tree.css"; + +import type { ComponentPropsWithRef } from "react"; + +export interface TreeIndentGuideProps extends Omit< + ComponentPropsWithRef<"span">, + "aria-hidden" +> { + active?: boolean; +} + +/** A decorative tree hierarchy guide for custom row content. */ +export function TreeIndentGuide({ + active = false, + className, + ...props +}: TreeIndentGuideProps): React.JSX.Element { + return ( +