diff --git a/.storybook/main.ts b/.storybook/main.ts index 5b262100d..54f71a684 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -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: {}, diff --git a/package.json b/package.json index 660003403..2d8c339e4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui/README.md b/packages/ui/README.md index ec7936934..2b5414390 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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); + + + + + +; +``` + +`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("enableStickyScroll") && + (tree.get("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 @@ -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 @@ -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. diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css new file mode 100644 index 000000000..c85decb03 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -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; + } +} 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..be6353472 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,242 @@ +import { expect, fireEvent, userEvent, within } from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +// The native default Explorer: branch rows render without icons, so the +// explorer variant aligns leaf file icons with the branch twisties. +const FILES: readonly TreeDemoNode[] = [ + { + id: "source", + label: "src", + children: [ + { + id: "components", + label: "components", + children: [ + { + id: "tree", + label: "Tree.tsx", + icon: "symbol-class", + action: { icon: "close", label: "Close Tree.tsx" }, + }, + { id: "styles", label: "Tree.css", icon: "symbol-color" }, + ], + }, + { id: "tests", label: "tests", icon: "beaker", disabled: true }, + ], + }, + { id: "readme", label: "README.md", icon: "markdown" }, +]; + +const TreeStates = (): React.JSX.Element => ( + +); + +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); + const selectedItem = (): HTMLElement => + canvas.getByRole("treeitem", { name: "components" }); + await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + + // Click the trailing action while another row owns selection to prove + // action clicks never select their host row. The button is display:none + // until its row is hovered, selected, or focused, so query it hidden; + // the synthetic click still dispatches and bubbles. + const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await userEvent.click( + canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), + ); + await expect(selectedItem()).toHaveAttribute("aria-selected", "true"); + await expect(treeItem).toHaveAttribute("aria-selected", "false"); + + await userEvent.click(treeItem); + await expect(treeItem).toHaveAttribute("aria-selected", "true"); + + const readme = canvas.getByRole("treeitem", { name: "README.md" }); + await userEvent.click(readme); + await expect(readme).toHaveAttribute("aria-selected", "true"); +}; + +export const States: Story = { play: exerciseTree }; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, + play: exerciseTree, +}; + +const STORE_FILES: readonly TreeDemoNode[] = [ + { id: "TreeStore.ts", label: "TreeStore.ts", icon: "symbol-class" }, + { id: "context.ts", label: "context.ts", icon: "symbol-interface" }, + { + id: "Tree.tsx", + label: "Tree.tsx", + icon: "symbol-class", + className: "story-hover", + action: { icon: "close", label: "Close Tree.tsx" }, + }, +]; + +const NESTED_FILES: readonly TreeDemoNode[] = [ + ...["src", "components", "Tree", "store"].reduceRight< + readonly TreeDemoNode[] + >((children, label) => [{ id: label, label, children }], STORE_FILES), + { id: "README.md", label: "README.md", icon: "markdown" }, +]; + +const DEEP_FILES: readonly TreeDemoNode[] = ["alpha", "beta"].map((branch) => ({ + id: branch, + label: branch, + children: [ + { + id: `${branch}/src`, + label: "src", + children: Array.from({ length: 12 }, (_, index) => ({ + id: `${branch}/src/file-${index}`, + label: `file-${index}.ts`, + icon: "symbol-class" as const, + })), + }, + ], +})); + +export const StickyScroll: Story = { + render: () => ( +
{ + if (scroller) { + scroller.scrollTop = 143; + } + }} + > + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("treeitem", { name: "alpha" })).toHaveClass( + "ui-tree-item--sticky", + ); + // The scroll, not the pinned offsets: headless Chrome paints no frame + // during play, so sticky positions never settle here. The snapshot is + // what proves they pin. + await expect(canvas.getByTestId("scroller").scrollTop).toBeGreaterThan(0); + }, +}; + +export const MultiSelect: Story = { + // Synthetic events do not move DOM focus, so force the outline. + parameters: { + pseudo: { + focus: ['[aria-label="README.md"]'], + focusVisible: ['[aria-label="README.md"]'], + }, + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A held modifier does not carry across separate userEvent calls. + await fireEvent.click(canvas.getByRole("treeitem", { name: "README.md" }), { + ctrlKey: true, + }); + await expect( + canvas.getByRole("treeitem", { name: "README.md" }), + ).toHaveAttribute("aria-selected", "true"); + await expect( + canvas.getByRole("treeitem", { name: "Tree.tsx" }), + ).toHaveAttribute("aria-selected", "true"); + // A real focus call, since React listens for focusin, which the + // synthetic focus event does not bubble. + canvas.getByRole("treeitem", { name: "README.md" }).focus(); + }, +}; + +/** Focus is its own state: the outline moves without changing selection. */ +export const Focused: Story = { + parameters: { + pseudo: { + focus: ['[aria-label="Tree.css"]'], + focusVisible: ['[aria-label="Tree.css"]'], + }, + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + canvas.getByRole("treeitem", { name: "Tree.css" }).focus(); + await expect( + canvas.getByRole("treeitem", { name: "Tree.css" }), + ).toHaveAttribute("aria-selected", "false"); + }, +}; + +export const Nested: Story = { + render: () => ( + + ), + // Real Tree.css hover, forced by the pseudo-states addon: the hovered tree + // reveals the faint indent guides next to the active guide of the selection. + parameters: { + pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const deepLeaf = canvas.getByRole("treeitem", { name: "TreeStore.ts" }); + await expect(deepLeaf).toHaveAttribute("aria-level", "5"); + + // Focus makes the selection render active. + await userEvent.click(deepLeaf); + await expect(deepLeaf).toHaveAttribute("aria-selected", "true"); + }, +}; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx new file mode 100644 index 000000000..cae80be84 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,152 @@ +import { + type ComponentPropsWithRef, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { cx } from "#cx"; + +import { + TreeContext, + TreeHierarchyContext, + type TreeHierarchyContextValue, +} from "./context"; +import "./Tree.css"; +import "./Tree.modern.css"; +import "./Tree.stable.css"; +import { TreeStore } from "./TreeStore"; + +/** VS Code's workbench.tree.stickyScrollMaxItemCount default. */ +const DEFAULT_STICKY_LEVELS = 7; + +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 twistie gutter on leaf rows so file icons align + * with branch twisties, like the native Explorer whose folders render + * without icons. Combining it with branch icons misaligns leaf icons. + */ + variant?: "default" | "explorer"; + selectedItemId?: string; + onSelectedItemChange?: (itemId: string) => void; + /** Ctrl/Cmd click toggles a row and Shift click extends from the anchor. */ + multiSelect?: boolean; + selectedItemIds?: readonly string[]; + onSelectedItemsChange?: (itemIds: readonly string[]) => void; + /** + * Pins ancestors of the topmost visible row against the nearest scrolling + * ancestor, like VS Code. A number caps how many levels pin at once. + */ + stickyScroll?: boolean | number; +} + +/** A controlled, single-selection tree with native VS Code keyboard behavior. */ +export function Tree({ + variant = "default", + selectedItemId, + onSelectedItemChange, + multiSelect = false, + selectedItemIds, + onSelectedItemsChange, + stickyScroll = false, + className, + children, + onBlur, + onFocus, + onKeyDown, + ref, + ...props +}: TreeProps): React.JSX.Element { + const selection = multiSelect + ? (selectedItemIds ?? []) + : selectedItemId === undefined + ? [] + : [selectedItemId]; + const [store] = useState(() => new TreeStore(selection)); + const [hasDomFocus, setHasDomFocus] = useState(false); + const rootHierarchy: TreeHierarchyContextValue = { + level: 1, + pathItemIds: [], + stickyLevels: + stickyScroll === true ? DEFAULT_STICKY_LEVELS : Number(stickyScroll), + }; + + // Any commit can add, remove, reorder, or reveal rows. + useLayoutEffect(() => { + store.setConfiguration( + selection, + multiSelect + ? onSelectedItemsChange + : ([itemId]) => { + if (itemId !== undefined) { + onSelectedItemChange?.(itemId); + } + }, + multiSelect, + ); + store.reconcile(); + }); + useEffect(() => () => store.dispose(), [store]); + + return ( + + +
{ + store.setRoot(element); + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }} + role="tree" + aria-multiselectable={multiSelect || undefined} + className={cx( + "ui-tree", + variant === "explorer" && "ui-tree--explorer", + hasDomFocus && "ui-tree--focused", + className, + )} + onFocus={(event) => { + 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/Tree/TreeItem.stories.tsx b/packages/ui/src/components/Tree/TreeItem.stories.tsx new file mode 100644 index 000000000..d1f4860ec --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.stories.tsx @@ -0,0 +1,51 @@ +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo, type TreeDemoNode } from "../../../storybook/Tree.demo"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const ITEM_STATES: readonly TreeDemoNode[] = [ + { id: "plain", label: "Plain item", icon: "file" }, + { + id: "selected", + label: "Selected branch", + icon: "folder-opened", + children: [{ id: "child", label: "Child item" }], + }, + { + id: "collapsed", + label: "Collapsed branch", + icon: "folder", + collapsed: true, + children: [{ id: "hidden", label: "Hidden item" }], + }, + { + id: "action", + label: "Item with action", + action: { icon: "trash", label: "Delete item" }, + }, + { id: "disabled", label: "Disabled item", disabled: true }, +]; + +const TreeItemStates = (): React.JSX.Element => ( + +); + +const meta: Meta = { + title: "UI/TreeItem", + component: TreeItemStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, +}; diff --git a/packages/ui/src/components/Tree/TreeItem.tsx b/packages/ui/src/components/Tree/TreeItem.tsx new file mode 100644 index 000000000..577f2d871 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.tsx @@ -0,0 +1,231 @@ +import { + type ComponentPropsWithRef, + type CSSProperties, + type MouseEvent as ReactMouseEvent, + type ReactNode, + use, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import { TreeHierarchyContext, useTreeContext } from "./context"; +import { INTERACTIVE_SELECTOR } from "./TreeStore"; + +import type { CodiconName } from "#codicons"; + +/** A string label doubles as the text value; a rich label must supply one. */ +type TreeItemLabel = + | { label: string; textValue?: string } + | { label: ReactNode; textValue: string }; + +export type TreeItemProps = Omit< + ComponentPropsWithRef<"div">, + "children" | "id" | "role" | "onSelect" +> & + TreeItemLabel & { + itemId: string; + icon?: CodiconName; + disabled?: boolean; + /** Controls expansion, and makes the row a branch. */ + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + /** Child rows; only a branch can have them. */ + children?: ReactNode; + action?: ReactNode; + }; + +function eventBelongsToRow( + event: { currentTarget: HTMLElement; target: EventTarget | null }, + row: HTMLElement | null, +): boolean { + return ( + event.target === event.currentTarget || + (event.target instanceof Node && row?.contains(event.target) === true) + ); +} + +function isNestedInteractiveTarget( + event: ReactMouseEvent, +): boolean { + const target = event.target; + if (!(target instanceof Element) || target === event.currentTarget) { + return false; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + return ( + interactiveTarget !== null && + interactiveTarget !== event.currentTarget && + event.currentTarget.contains(interactiveTarget) + ); +} + +/** A controlled tree row. Passing `expanded` makes it a branch that nests rows. */ +export function TreeItem({ + itemId, + label, + textValue, + icon, + expanded, + onExpandedChange, + children, + action, + disabled = false, + className, + style, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + onClick, + onFocus, + ref, + ...props +}: TreeItemProps): React.JSX.Element { + const store = useTreeContext(); + const hierarchy = use(TreeHierarchyContext); + const internalRef = useRef(null); + const rowRef = useRef(null); + const chevronRef = useRef(null); + const isBranch = expanded !== undefined; + const isSticky = isBranch && hierarchy.level <= hierarchy.stickyLevels; + if (!isBranch && children) { + throw new Error( + `TreeItem "${itemId}" has child rows, so it is a branch: pass expanded and onExpandedChange to control it.`, + ); + } + + const rowTextValue = textValue ?? (typeof label === "string" ? label : ""); + const setExpanded = isBranch ? onExpandedChange : undefined; + const { + guideOwnerIds, + focused: isFocusedRow, + selected: isSelected, + tabIndex, + } = useSyncExternalStore( + store.subscribe, + () => store.getItemSnapshot(itemId), + () => store.getItemSnapshot(itemId), + ); + + useLayoutEffect(() => { + const element = internalRef.current; + return element ? store.registerItem(itemId, element) : undefined; + }, [itemId, store]); + + useLayoutEffect(() => { + store.updateItem(itemId, { + textValue: rowTextValue, + parentId: hierarchy.parentItemId, + setExpanded, + }); + }, [hierarchy.parentItemId, itemId, rowTextValue, setExpanded, store]); + + const groupHierarchy = { + level: hierarchy.level + 1, + parentItemId: itemId, + pathItemIds: [...hierarchy.pathItemIds, itemId], + stickyLevels: hierarchy.stickyLevels, + }; + + return ( +
{ + internalRef.current = element; + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }} + role="treeitem" + aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? rowTextValue)} + aria-labelledby={ariaLabelledBy} + aria-level={hierarchy.level} + aria-selected={isSelected} + aria-disabled={disabled || undefined} + aria-expanded={expanded} + tabIndex={tabIndex} + className={cx( + "ui-tree-item", + isSticky && "ui-tree-item--sticky", + isFocusedRow && "ui-tree-item--focused", + className, + )} + style={{ "--ui-tree-level": hierarchy.level, ...style } as CSSProperties} + onFocus={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onFocus?.(event); + if (!event.defaultPrevented && event.target === event.currentTarget) { + store.onItemFocus(itemId); + } + }} + onClick={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onClick?.(event); + if ( + event.defaultPrevented || + disabled || + isNestedInteractiveTarget(event) + ) { + return; + } + // Twistie clicks toggle without moving selection, like the native tree. + const onTwistie = + isBranch && + event.target instanceof Node && + chevronRef.current?.contains(event.target) === true; + if (!onTwistie) { + store.requestSelection(itemId, { + toggle: event.ctrlKey || event.metaKey, + range: event.shiftKey, + }); + } + setExpanded?.(!expanded); + }} + > +
+
+ {expanded && children ? ( + +
+ {children} +
+
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeStore.ts b/packages/ui/src/components/Tree/TreeStore.ts new file mode 100644 index 000000000..1c631029e --- /dev/null +++ b/packages/ui/src/components/Tree/TreeStore.ts @@ -0,0 +1,589 @@ +import type { KeyboardEvent } from "react"; + +export const INTERACTIVE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[contenteditable]:not([contenteditable='false'])", + "[role='button']", + "[role='checkbox']", + "[role='combobox']", + "[role='link']", + "[role='menuitem']", + "[role='option']", + "[role='radio']", + "[role='slider']", + "[role='spinbutton']", + "[role='switch']", + "[role='textbox']", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +/** Matching this gives visibility and tree order without mirroring the DOM. */ +const VISIBLE_ROW_SELECTOR = + '[role="treeitem"]:not([hidden]):not([hidden] *):not([aria-disabled="true"])'; + +const TYPE_AHEAD_TIMEOUT_MS = 500; + +/** Repeating one character cycles matches instead of growing the query. */ +function nextTypeAheadQuery(buffer: string, character: string): string { + const isRepeat = + buffer.length > 0 && [...buffer].every((value) => value === character); + return isRepeat ? character : `${buffer}${character}`; +} + +function findTypeAheadMatch( + items: readonly TreeStoreItem[], + fromIndex: number, + query: string, +): TreeStoreItem | undefined { + for (let offset = 0; offset < items.length; offset++) { + const item = items[(fromIndex + offset) % items.length]; + if (item?.textValue.toLocaleLowerCase().startsWith(query)) { + return item; + } + } + return undefined; +} + +function isDisabled(element: HTMLElement): boolean { + return element.getAttribute("aria-disabled") === "true"; +} + +/** null on leaves. */ +function readExpanded(element: HTMLElement): boolean | null { + const expanded = element.getAttribute("aria-expanded"); + return expanded === null ? null : expanded === "true"; +} + +/** Only what the DOM cannot answer; parentId outlives the row's removal. */ +export interface TreeStoreItem { + readonly id: string; + readonly element: HTMLElement; + textValue: string; + parentId?: string; + setExpanded?: (expanded: boolean) => void; +} + +export type TreeStoreItemUpdate = Omit; + +/** Ctrl/Cmd toggles a row, Shift extends from the anchor, like VS Code. */ +interface SelectionModifiers { + toggle?: boolean; + range?: boolean; +} + +export interface TreeItemSnapshot { + tabIndex: 0 | -1; + selected: boolean; + /** Outlives losing DOM focus, like the native list's focused row. */ + focused: boolean; + /** Tree-wide; a row filters its own ancestor path. */ + guideOwnerIds: ReadonlySet; +} + +/** + * Registry and interaction engine for one tree: roving tab stop, focus and + * selection reconciliation, and VS Code keyboard behavior over rows that + * register themselves as they mount. + */ +export class TreeStore { + private readonly items = new Map(); + private readonly itemsByElement = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly itemSnapshots = new Map(); + private root: HTMLElement | undefined; + private tabStopId: string | undefined; + private removedTabStopAncestorIds: readonly string[] = []; + private focusedElement: HTMLElement | undefined; + private selectedIds: ReadonlySet = new Set(); + private anchorId: string | undefined; + private pendingSelectedId: string | undefined; + private multiSelect = false; + private onSelectionChange: ((itemIds: readonly string[]) => void) | undefined; + private visibleItems: readonly TreeStoreItem[] = []; + private visibleItemsDirty = true; + private guideOwnerIds: ReadonlySet = new Set(); + private revision = 0; + private typeAheadBuffer = ""; + private typeAheadTimer: ReturnType | undefined; + + constructor(selectedIds: readonly string[] = []) { + this.selectedIds = new Set(selectedIds); + this.pendingSelectedId = selectedIds[0]; + } + + readonly subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange); + return () => this.listeners.delete(onChange); + }; + + readonly setRoot = (root: HTMLElement | null): void => { + this.root = root ?? undefined; + }; + + /** The DOM moved, so re-derive what depends on it. */ + readonly reconcile = (): void => { + this.visibleItemsDirty = true; + const tabStopChanged = this.reconcileTabStop(); + const focusChanged = this.reconcileFocusedItem(); + if (this.refreshGuideOwners() || tabStopChanged || focusChanged) { + this.publishChange(); + } + }; + + readonly setConfiguration = ( + selectedIds: readonly string[], + onSelectionChange?: (itemIds: readonly string[]) => void, + multiSelect = false, + ): void => { + this.onSelectionChange = onSelectionChange; + this.multiSelect = multiSelect; + const unchanged = + selectedIds.length === this.selectedIds.size && + selectedIds.every((id) => this.selectedIds.has(id)); + if (unchanged) { + return; + } + this.selectedIds = new Set(selectedIds); + this.pendingSelectedId = selectedIds[0]; + this.reconcilePendingSelection(); + this.refreshGuideOwners(); + this.publishChange(); + }; + + readonly dispose = (): void => { + clearTimeout(this.typeAheadTimer); + this.typeAheadTimer = undefined; + this.typeAheadBuffer = ""; + }; + + readonly requestSelection = ( + itemId: string, + { toggle, range }: SelectionModifiers = {}, + ): void => { + if (this.multiSelect && range && this.anchorId !== undefined) { + this.onSelectionChange?.(this.rangeIds(this.anchorId, itemId)); + return; + } + this.anchorId = itemId; + if (this.multiSelect && toggle) { + const next = new Set(this.selectedIds); + if (!next.delete(itemId)) { + next.add(itemId); + } + this.onSelectionChange?.([...next]); + return; + } + this.onSelectionChange?.([itemId]); + }; + + readonly registerItem = (id: string, element: HTMLElement): (() => void) => { + if (this.items.has(id)) { + throw new Error( + `Tree keyboard navigation item id "${id}" is already registered by another row. Item ids must be unique.`, + ); + } + const item: TreeStoreItem = { id, element, textValue: "" }; + this.items.set(id, item); + this.itemsByElement.set(element, item); + + return (): void => { + if (this.items.get(id) !== item) { + return; + } + if (id === this.tabStopId) { + // Capture ancestry before deletion; reconciliation needs it. + this.removedTabStopAncestorIds = this.ancestorIds(id); + } + this.items.delete(id); + this.itemsByElement.delete(element); + this.itemSnapshots.delete(id); + }; + }; + + readonly updateItem = (id: string, update: TreeStoreItemUpdate): void => { + const item = this.items.get(id); + if (item) { + // Full next state, not a patch: absent keys clear. + Object.assign(item, update); + } + }; + + readonly getItemSnapshot = (id: string): TreeItemSnapshot => { + const tabIndex: 0 | -1 = this.tabStopId === id ? 0 : -1; + const selected = this.selectedIds.has(id); + const focused = this.focusedItem?.id === id; + const previous = this.itemSnapshots.get(id); + if ( + previous?.tabIndex === tabIndex && + previous.selected === selected && + previous.focused === focused && + previous.guideOwnerIds === this.guideOwnerIds + ) { + return previous; + } + + const snapshot = { + tabIndex, + selected, + focused, + guideOwnerIds: this.guideOwnerIds, + }; + this.itemSnapshots.set(id, snapshot); + return snapshot; + }; + + readonly onItemFocus = (id: string): void => { + const item = this.items.get(id); + const canReceiveFocus = item !== undefined && this.isReachable(item); + if (canReceiveFocus) { + // The user took over; the initial selection no longer claims the + // tab stop when its branch is revealed later. + this.pendingSelectedId = undefined; + } + const focusChanged = this.setFocusedElement( + canReceiveFocus ? item.element : undefined, + ); + const tabStopChanged = canReceiveFocus + ? this.setTabStopValue(item.id) + : false; + if (this.refreshGuideOwners() || focusChanged || tabStopChanged) { + this.publishChange(); + } + }; + + readonly onKeyDown = (event: KeyboardEvent): void => { + if (this.isInteractiveTarget(event)) { + return; + } + const visibleItems = this.getVisibleItems(); + if ( + this.multiSelect && + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === "a" + ) { + this.onSelectionChange?.(visibleItems.map((item) => item.id)); + event.preventDefault(); + return; + } + const currentItem = + this.findEventItem(event) ?? + this.focusedItem ?? + this.getItem(this.tabStopId) ?? + visibleItems[0]; + if (!currentItem) { + return; + } + + // A disabled or hidden row is absent from visibleItems, so take its + // neighbors from where it would sit in tree order. + const currentIndex = visibleItems.indexOf(currentItem); + const nextIndex = + currentIndex === -1 + ? this.findInsertionIndex(currentItem, visibleItems) + : currentIndex + 1; + const previousIndex = + currentIndex === -1 ? nextIndex - 1 : currentIndex - 1; + const disabled = isDisabled(currentItem.element); + const expanded = readExpanded(currentItem.element); + let handled = true; + + switch (event.key) { + case "ArrowDown": + this.focusItem(visibleItems[nextIndex], event.shiftKey); + break; + case "ArrowUp": + this.focusItem(visibleItems[previousIndex], event.shiftKey); + break; + case "Home": + this.focusItem(visibleItems[0]); + break; + case "End": + this.focusItem(visibleItems.at(-1)); + break; + case "ArrowRight": + if (disabled) { + break; + } + if (expanded === false && currentItem.setExpanded) { + currentItem.setExpanded(true); + } else if (expanded === true) { + // Descendants directly follow their branch in tree order. + const firstChild = visibleItems[nextIndex]; + if (firstChild && currentItem.element.contains(firstChild.element)) { + this.focusItem(firstChild); + } + } + break; + case "ArrowLeft": + if (disabled) { + break; + } + if (expanded === true && currentItem.setExpanded) { + currentItem.setExpanded(false); + } else { + this.focusItem( + this.findReachableAncestor(this.ancestorIds(currentItem.id)), + ); + } + break; + case "Enter": + case " ": + if (disabled) { + break; + } + this.requestSelection(currentItem.id, { + toggle: event.ctrlKey || event.metaKey, + }); + if (expanded !== null) { + currentItem.setExpanded?.(!expanded); + } + break; + default: + handled = false; + } + + if (handled) { + event.preventDefault(); + return; + } + + if ( + event.key.length !== 1 || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + this.typeAheadBuffer = nextTypeAheadQuery( + this.typeAheadBuffer, + event.key.toLocaleLowerCase(), + ); + clearTimeout(this.typeAheadTimer); + this.typeAheadTimer = setTimeout(() => { + this.typeAheadBuffer = ""; + this.typeAheadTimer = undefined; + }, TYPE_AHEAD_TIMEOUT_MS); + + this.focusItem( + findTypeAheadMatch(visibleItems, nextIndex, this.typeAheadBuffer), + ); + event.preventDefault(); + }; + + private get focusedItem(): TreeStoreItem | undefined { + return this.focusedElement === undefined + ? undefined + : this.itemsByElement.get(this.focusedElement); + } + + /** Every visible enabled row between two ids, in tree order. */ + private rangeIds(fromId: string, toId: string): readonly string[] { + const visibleItems = this.getVisibleItems(); + const from = visibleItems.findIndex((item) => item.id === fromId); + const to = visibleItems.findIndex((item) => item.id === toId); + if (from === -1 || to === -1) { + return [toId]; + } + return visibleItems + .slice(Math.min(from, to), Math.max(from, to) + 1) + .map((item) => item.id); + } + + private getItem(id: string | undefined): TreeStoreItem | undefined { + return id === undefined ? undefined : this.items.get(id); + } + + private isReachable(item: TreeStoreItem): boolean { + return item.element.matches(VISIBLE_ROW_SELECTOR); + } + + private getVisibleItems(): readonly TreeStoreItem[] { + if (this.visibleItemsDirty) { + const rows = + this.root?.querySelectorAll(VISIBLE_ROW_SELECTOR) ?? []; + this.visibleItems = [...rows] + .map((row) => this.itemsByElement.get(row)) + .filter((item): item is TreeStoreItem => item !== undefined); + this.visibleItemsDirty = false; + } + return this.visibleItems; + } + + private findInsertionIndex( + item: TreeStoreItem, + visibleItems: readonly TreeStoreItem[], + ): number { + const index = visibleItems.findIndex( + (visible) => + (item.element.compareDocumentPosition(visible.element) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0, + ); + return index === -1 ? visibleItems.length : index; + } + + private findReachableAncestor( + ancestorIds: readonly string[], + ): TreeStoreItem | undefined { + for (const ancestorId of ancestorIds) { + const ancestor = this.items.get(ancestorId); + if (ancestor && this.isReachable(ancestor)) { + return ancestor; + } + } + return undefined; + } + + /** Ancestor ids nearest first. The React tree makes parent links acyclic. */ + private ancestorIds(id: string): readonly string[] { + const ancestorIds: string[] = []; + let parentId = this.items.get(id)?.parentId; + while (parentId !== undefined) { + ancestorIds.push(parentId); + parentId = this.items.get(parentId)?.parentId; + } + return ancestorIds; + } + + private findEventItem( + event: KeyboardEvent, + ): TreeStoreItem | undefined { + const row = + event.target instanceof Element + ? event.target.closest('[role="treeitem"]') + : null; + return row === null ? undefined : this.itemsByElement.get(row); + } + + private isInteractiveTarget(event: KeyboardEvent): boolean { + const target = event.target; + if (!(target instanceof Element)) { + return false; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + if ( + interactiveTarget === null || + interactiveTarget === event.currentTarget || + !event.currentTarget.contains(interactiveTarget) + ) { + return false; + } + // Row elements are the navigation surface, not embedded controls. + return !( + interactiveTarget instanceof HTMLElement && + this.itemsByElement.has(interactiveTarget) + ); + } + + private guideOwnerId(item: TreeStoreItem): string | undefined { + return readExpanded(item.element) === true ? item.id : item.parentId; + } + + private refreshGuideOwners(): boolean { + const ownerIds = new Set(); + for (const id of [this.focusedItem?.id, ...this.selectedIds]) { + const item = this.getItem(id); + const ownerId = item && this.guideOwnerId(item); + if (ownerId !== undefined) { + ownerIds.add(ownerId); + } + } + const unchanged = + ownerIds.size === this.guideOwnerIds.size && + [...ownerIds].every((id) => this.guideOwnerIds.has(id)); + if (unchanged) { + return false; + } + this.guideOwnerIds = ownerIds; + return true; + } + + private reconcilePendingSelection(): boolean { + const pendingSelectedId = this.pendingSelectedId; + if (pendingSelectedId === undefined) { + return false; + } + const selectedItem = this.items.get(pendingSelectedId); + if (!selectedItem) { + return false; + } + if (!this.isReachable(selectedItem)) { + // Hidden keeps the claim until the branch reveals it; disabled drops it. + if (isDisabled(selectedItem.element)) { + this.pendingSelectedId = undefined; + } + return false; + } + this.pendingSelectedId = undefined; + return this.setTabStopValue(pendingSelectedId); + } + + private reconcileTabStop(): boolean { + if (this.reconcilePendingSelection()) { + return true; + } + + const currentItem = this.getItem(this.tabStopId); + if (currentItem && this.isReachable(currentItem)) { + return false; + } + + // An unregistered tab stop is gone from the map; use the captured path. + const ancestor = this.findReachableAncestor( + currentItem + ? this.ancestorIds(currentItem.id) + : this.removedTabStopAncestorIds, + ); + return this.setTabStopValue(ancestor?.id ?? this.getVisibleItems()[0]?.id); + } + + private reconcileFocusedItem(): boolean { + const focusedItem = this.focusedItem; + if (focusedItem && this.isReachable(focusedItem)) { + return false; + } + return this.setFocusedElement(undefined); + } + + private setTabStopValue(id: string | undefined): boolean { + if (this.tabStopId === id) { + return false; + } + this.tabStopId = id; + return true; + } + + private setFocusedElement(element: HTMLElement | undefined): boolean { + if (this.focusedElement === element) { + return false; + } + this.focusedElement = element; + return true; + } + + private focusItem(item: TreeStoreItem | undefined, extend = false): void { + if (!item) { + return; + } + if (extend && this.multiSelect) { + this.requestSelection(item.id, { range: true }); + } + const tabStopChanged = this.setTabStopValue(item.id); + const revisionBeforeFocus = this.revision; + item.element.focus(); + if (tabStopChanged && this.revision === revisionBeforeFocus) { + this.publishChange(); + } + } + + private publishChange(): void { + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } +} diff --git a/packages/ui/src/components/Tree/context.ts b/packages/ui/src/components/Tree/context.ts new file mode 100644 index 000000000..345392717 --- /dev/null +++ b/packages/ui/src/components/Tree/context.ts @@ -0,0 +1,26 @@ +import { createContext, use } from "react"; + +import type { TreeStore } from "./TreeStore"; + +export interface TreeHierarchyContextValue { + level: number; + parentItemId?: string; + pathItemIds: readonly string[]; + /** Levels that pin on scroll; 0 disables sticky scroll. */ + stickyLevels: number; +} + +export const TreeContext = createContext(undefined); +export const TreeHierarchyContext = createContext({ + level: 1, + pathItemIds: [], + stickyLevels: 0, +}); + +export function useTreeContext(): TreeStore { + const context = use(TreeContext); + if (!context) { + throw new Error("Tree components must be rendered inside Tree."); + } + return context; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0..cd64e1ade 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,4 +72,6 @@ export { TooltipProvider, type TooltipProviderProps, } from "./components/Tooltip/Tooltip"; +export { Tree, type TreeProps } from "./components/Tree/Tree"; +export { TreeItem, type TreeItemProps } from "./components/Tree/TreeItem"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index e4345fa09..da5161606 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -147,11 +147,64 @@ --ui-radius-circle: var(--vscode-cornerRadius-circle, 9999px); /* Spacing, VS Code's scale (baseSizes.ts); names are px times ten */ + --ui-spacing-40: var(--vscode-spacing-size40, 4px); --ui-spacing-60: var(--vscode-spacing-size60, 6px); --ui-spacing-120: var(--vscode-spacing-size120, 12px); --ui-spacing-160: var(--vscode-spacing-size160, 16px); --ui-spacing-240: var(--vscode-spacing-size240, 24px); + /* Lists and trees */ + --ui-list-hover-background: var(--vscode-list-hoverBackground, transparent); + --ui-list-hover-foreground: var( + --vscode-list-hoverForeground, + var(--ui-foreground) + ); + --ui-list-active-selection-background: var( + --vscode-list-activeSelectionBackground, + var(--ui-list-hover-background) + ); + --ui-list-active-selection-foreground: var( + --vscode-list-activeSelectionForeground, + var(--ui-foreground) + ); + --ui-list-inactive-selection-background: var( + --vscode-list-inactiveSelectionBackground, + var(--ui-list-active-selection-background) + ); + --ui-list-inactive-selection-foreground: var( + --vscode-list-inactiveSelectionForeground, + var(--ui-foreground) + ); + --ui-list-focus-outline: var( + --vscode-list-focusOutline, + var(--ui-focus-border) + ); + --ui-list-selection-outline: var(--vscode-list-selectionOutline, transparent); + --ui-list-inactive-focus-outline: var( + --vscode-list-inactiveFocusOutline, + transparent + ); + --ui-list-hover-outline: var(--vscode-list-hoverOutline, transparent); + --ui-list-focus-and-selection-outline: var( + --vscode-list-focusAndSelectionOutline, + var(--vscode-list-selectionOutline, var(--ui-list-focus-outline)) + ); + /* Outside a webview, approximate the native guides (inactive is the + active stroke at 40%) instead of disappearing. */ + --ui-tree-indent-guide-inactive: var( + --vscode-tree-inactiveIndentGuidesStroke, + color-mix(in srgb, currentColor 16%, transparent) + ); + --ui-tree-indent-guide-active: var( + --vscode-tree-indentGuidesStroke, + color-mix(in srgb, currentColor 40%, transparent) + ); + /* Pinned rows paint over what scrolls beneath them. */ + --ui-tree-sticky-background: var( + --vscode-sideBarStickyScroll-background, + var(--ui-background) + ); + /* Menus */ --ui-menu-background: var(--vscode-menu-background); --ui-menu-foreground: var(--vscode-menu-foreground); diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx new file mode 100644 index 000000000..cf34552dc --- /dev/null +++ b/packages/ui/storybook/Tree.demo.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; + +import { IconButton } from "../src/components/IconButton/IconButton"; +import { Tree, type TreeProps } from "../src/components/Tree/Tree"; +import { TreeItem } from "../src/components/Tree/TreeItem"; + +import type { CodiconName } from "#codicons"; + +export interface TreeDemoNode { + id: string; + label: string; + icon?: CodiconName; + action?: { icon: CodiconName; label: string }; + disabled?: boolean; + className?: string; + /** Branches start expanded unless this says otherwise. */ + collapsed?: boolean; + children?: readonly TreeDemoNode[]; +} + +export interface TreeDemoProps extends Omit< + TreeProps, + "children" | "onSelectedItemChange" | "onSelectedItemsChange" +> { + nodes: readonly TreeDemoNode[]; +} + +function initialCollapsedIds(nodes: readonly TreeDemoNode[]): Set { + const collapsedIds = new Set(); + const visit = (node: TreeDemoNode): void => { + if (node.collapsed) { + collapsedIds.add(node.id); + } + node.children?.forEach(visit); + }; + nodes.forEach(visit); + return collapsedIds; +} + +/** Wraps a node tree in the selection and expansion state a Tree expects. */ +export function TreeDemo({ + nodes, + multiSelect, + selectedItemId: initialSelectedItemId, + selectedItemIds: initialSelectedItemIds, + ...treeProps +}: TreeDemoProps): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState(initialSelectedItemId); + const [selectedItemIds, setSelectedItemIds] = useState( + initialSelectedItemIds ?? [], + ); + const [collapsedIds, setCollapsedIds] = useState(() => + initialCollapsedIds(nodes), + ); + + const setExpanded = (itemId: string, expanded: boolean): void => { + setCollapsedIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.delete(itemId); + } else { + next.add(itemId); + } + return next; + }); + }; + + const renderNodes = ( + siblings: readonly TreeDemoNode[], + ): React.JSX.Element[] => + siblings.map((node) => ( + setExpanded(node.id, expanded)) + } + action={ + node.action && ( + + ) + } + > + {node.children && renderNodes(node.children)} + + )); + + const selection = multiSelect + ? { + multiSelect: true, + selectedItemIds, + onSelectedItemsChange: setSelectedItemIds, + } + : { selectedItemId, onSelectedItemChange: setSelectedItemId }; + + return ( + + {renderNodes(nodes)} + + ); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index de3f039b9..d8416421b 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "resolveJsonModule": true }, - "include": ["src", "storybook.preview.ts"] + "include": ["src", "storybook", "storybook.preview.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b6827e3..721bf5543 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ catalogs: storybook: specifier: ^10.5.7 version: 10.5.7 + storybook-addon-pseudo-states: + specifier: ^10.5.7 + version: 10.5.7 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -299,6 +302,9 @@ importers: storybook: specifier: 'catalog:' version: 10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) + storybook-addon-pseudo-states: + specifier: 'catalog:' + version: 10.5.7(storybook@10.5.7) typescript: specifier: 'catalog:' version: 6.0.3 @@ -4978,6 +4984,11 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + storybook-addon-pseudo-states@10.5.7: + resolution: {integrity: sha512-ZX8duQTWmIzI/z6T/evmcQN5QTeCTJS+OKgoKDNFzuc97C3PPORan5RaNSMXJiZJo5uUTIAELjr+BNV0VX7cmw==} + peerDependencies: + storybook: ^10.5.7 + storybook@10.5.7: resolution: {integrity: sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==} hasBin: true @@ -10554,6 +10565,10 @@ snapshots: stdin-discarder@0.2.2: {} + storybook-addon-pseudo-states@10.5.7(storybook@10.5.7): + dependencies: + storybook: 10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) + storybook@10.5.7(@types/react@19.2.18)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6): dependencies: '@storybook/global': 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1346e857..12273baaa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,7 @@ catalog: react: ^19.2.8 react-dom: ^19.2.8 storybook: ^10.5.7 + storybook-addon-pseudo-states: ^10.5.7 typescript: ^6.0.3 vite: ^8.2.1 diff --git a/test/webview/ui/tree.test.tsx b/test/webview/ui/tree.test.tsx new file mode 100644 index 000000000..ea63a1094 --- /dev/null +++ b/test/webview/ui/tree.test.tsx @@ -0,0 +1,905 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createRef, Fragment, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tree, TreeItem } from "@repo/ui"; + +const ACTIVE_GUIDE = "ui-tree-item__indent-slot--active"; + +const treeItem = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + +const guideSlots = (name: string): Element[] => [ + ...treeItem(name).querySelectorAll(".ui-tree-item__indent-slot"), +]; + +/** A branch with an enabled and a disabled child, plus a root-level sibling. */ +function ControlledTree({ + onSelectedItemChange = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelectedItemChange?: (itemId: string) => void; + onExpandedChange?: (expanded: boolean) => void; +}): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState("child"); + const [expanded, setExpanded] = useState(true); + + return ( + { + onSelectedItemChange(itemId); + setSelectedItemId(itemId); + }} + > + { + onExpandedChange(nextExpanded); + setExpanded(nextExpanded); + }} + > + + + + + + ); +} + +/** Two branches whose labels share prefixes, for arrow keys and type-ahead. */ +function NavTree({ + onSelect = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelect?: (itemId: string) => void; + onExpandedChange?: (itemId: string, expanded: boolean) => void; +}): React.JSX.Element { + const [expandedIds, setExpandedIds] = useState(() => new Set(["alpha"])); + const branch = ( + itemId: string, + ): Pick< + React.ComponentProps, + "expanded" | "onExpandedChange" + > => ({ + expanded: expandedIds.has(itemId), + onExpandedChange: (nextExpanded: boolean) => { + onExpandedChange(itemId, nextExpanded); + setExpandedIds((current) => { + const next = new Set(current); + if (nextExpanded) { + next.add(itemId); + } else { + next.delete(itemId); + } + return next; + }); + }, + }); + + return ( + + + + + + + + + + + Bravo + + + } + textValue="Bravo" + /> + + ); +} + +const revealTree = (expanded: boolean): React.JSX.Element => ( + + + + + + +); + +describe("Tree", () => { + it("forwards tree semantics, className, style, and ref", () => { + const ref = createRef(); + render( + + + , + ); + + const tree = screen.getByRole("tree", { name: "Explorer" }); + expect(tree).toHaveClass("ui-tree", "ui-tree--explorer", "custom-tree"); + expect(tree).toHaveStyle({ width: "240px" }); + expect(ref.current).toBe(tree); + }); + + it("exposes levels, selection, disabled state, groups, and branch expansion", () => { + render(); + + const parent = treeItem("Parent"); + const child = treeItem("Child"); + expect(parent).toHaveAttribute("aria-level", "1"); + expect(parent).toHaveAttribute("aria-expanded", "true"); + expect(parent).toHaveAttribute("aria-selected", "false"); + expect(child).toHaveAttribute("aria-level", "2"); + expect(child).toHaveAttribute("aria-selected", "true"); + expect(child).not.toHaveAttribute("aria-expanded"); + expect(treeItem("Disabled")).toHaveAttribute("aria-disabled", "true"); + + const group = screen.getByRole("group"); + expect(group).not.toHaveAttribute("hidden"); + expect(group.closest('[role="treeitem"]')).toBe(parent); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("keeps exactly one visible enabled item in the tab order", () => { + render(); + + const tabStops = screen + .getAllByRole("treeitem") + .filter((item) => item.tabIndex === 0); + expect(tabStops).toEqual([treeItem("Child")]); + expect(treeItem("Disabled")).toHaveAttribute("tabindex", "-1"); + }); + + it("hands the tab stop to a selection revealed by expansion", () => { + const { rerender } = render(revealTree(false)); + expect(treeItem("Top")).toHaveAttribute("tabindex", "0"); + + rerender(revealTree(true)); + expect(treeItem("Child")).toHaveAttribute("tabindex", "0"); + }); + + it("keeps the tab stop with the user once they focus another row", () => { + const { rerender } = render(revealTree(false)); + act(() => treeItem("Parent").focus()); + + rerender(revealTree(true)); + expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); + expect(treeItem("Child")).toHaveAttribute("tabindex", "-1"); + }); + + it("moves the tab stop to an ancestor when its row unmounts", async () => { + const renderTree = (showLeaf: boolean): React.JSX.Element => ( + + + + {showLeaf && } + + + ); + const { rerender } = render(renderTree(true)); + act(() => treeItem("Leaf").focus()); + + rerender(renderTree(false)); + await act(() => Promise.resolve()); + expect(treeItem("Parent")).toHaveAttribute("tabindex", "0"); + }); + + it("leaves keys to interactive elements rendered outside rows", () => { + render( + + + + , + ); + + const input = screen.getByRole("textbox", { name: "New file" }); + act(() => input.focus()); + const arrowNotPrevented = fireEvent.keyDown(input, { key: "ArrowDown" }); + const typeAheadNotPrevented = fireEvent.keyDown(input, { key: "a" }); + + expect(document.activeElement).toBe(input); + expect(arrowNotPrevented).toBe(true); + expect(typeAheadNotPrevented).toBe(true); + }); + + it("derives indent guide owners from focus and controlled selection", () => { + const renderTree = (selectedItemId?: string): React.JSX.Element => ( + + {["Alpha", "Beta"].map((branch) => ( + + + + ))} + + ); + const { rerender } = render(renderTree()); + + expect(treeItem("Alpha")).toHaveAttribute("tabindex", "0"); + expect(guideSlots("Alpha leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Beta leaf")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Beta leaf").focus()); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + + rerender(renderTree("Alpha leaf")); + expect(treeItem("Alpha leaf")).toHaveAttribute("tabindex", "0"); + expect(treeItem("Beta leaf")).toHaveAttribute("tabindex", "-1"); + expect(guideSlots("Alpha leaf")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("Beta leaf")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("uses only the expanded focused branch as its indent guide owner", () => { + render( + + + + + + + , + ); + + act(() => treeItem("Branch").focus()); + const slots = guideSlots("Leaf"); + expect(slots).toHaveLength(2); + expect(slots[0]).not.toHaveClass(ACTIVE_GUIDE); + expect(slots[1]).toHaveClass(ACTIVE_GUIDE); + }); + + it("clears hidden, disabled, and unmounted focused guide owners", async () => { + const renderTree = ({ + expanded = true, + disabled = false, + showChild = true, + }: { + expanded?: boolean; + disabled?: boolean; + showChild?: boolean; + }): React.JSX.Element => ( + + + {showChild && ( + + )} + + + ); + const { rerender } = render(renderTree({})); + + act(() => treeItem("Child").focus()); + expect(guideSlots("Child")[0]).toHaveClass(ACTIVE_GUIDE); + rerender(renderTree({ expanded: false })); + rerender(renderTree({ expanded: true })); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Child").focus()); + rerender(renderTree({ disabled: true })); + rerender(renderTree({})); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + + act(() => treeItem("Child").focus()); + rerender(renderTree({ showChild: false })); + await act(() => Promise.resolve()); + rerender(renderTree({})); + expect(guideSlots("Child")[0]).not.toHaveClass(ACTIVE_GUIDE); + }); + + it("updates controlled selection before the rerender is observable", () => { + const renderTree = (selectedItemId: string): React.JSX.Element => ( + + + + + ); + const { rerender } = render(renderTree("first")); + + rerender(renderTree("second")); + expect(treeItem("First")).toHaveAttribute("aria-selected", "false"); + expect(treeItem("Second")).toHaveAttribute("aria-selected", "true"); + }); + + it("keeps marking the focused row after the tree loses focus", () => { + render(); + const child = treeItem("Child"); + + act(() => child.focus()); + expect(child).toHaveClass("ui-tree-item--focused"); + expect(treeItem("Parent")).not.toHaveClass("ui-tree-item--focused"); + + // Blur drops the tree's class, not the row's; that gap is the + // inactive focus outline. + fireEvent.blur(child, { relatedTarget: document.body }); + expect(child).toHaveClass("ui-tree-item--focused"); + expect(screen.getByRole("tree")).not.toHaveClass("ui-tree--focused"); + }); + + it("uses focus from this tree only for active selection colors", () => { + render( + <> + + + + + + + , + ); + + const firstTree = screen.getByRole("tree", { name: "First" }); + const secondTree = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(treeItem("First item")); + expect(firstTree).toHaveClass("ui-tree--focused"); + expect(secondTree).not.toHaveClass("ui-tree--focused"); + + fireEvent.blur(treeItem("First item"), { + relatedTarget: treeItem("Second item"), + }); + fireEvent.focus(treeItem("Second item")); + expect(firstTree).not.toHaveClass("ui-tree--focused"); + expect(secondTree).toHaveClass("ui-tree--focused"); + }); +}); + +describe("TreeItem", () => { + it("names rows from the label unless the consumer overrides it", () => { + render( + + Custom labelled item + + Rich item} + textValue="Rich item" + /> + + + , + ); + + expect(treeItem("Plain item")).toBeInTheDocument(); + expect(treeItem("Rich item")).toBeInTheDocument(); + expect(treeItem("Custom labelled item")).toBeInTheDocument(); + expect(treeItem("Custom label")).toBeInTheDocument(); + expect( + treeItem("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), + ).toHaveClass("codicon-file"); + }); + + it("accepts child rows from fragments, arrays, and wrapper components", () => { + const WrappedRows = (): React.JSX.Element => ( + <> + + + ); + render( + + + + + {[]} + + + , + ); + + expect(treeItem("Branch")).toHaveAttribute("aria-expanded", "true"); + expect(treeItem("Wrapped")).toHaveAttribute("aria-level", "2"); + expect(treeItem("Listed")).toHaveAttribute("aria-level", "2"); + }); + + it("rejects child rows on a row that is not a branch", () => { + expect(() => + render( + + + + + , + ), + ).toThrow(/has child rows, so it is a branch/); + }); + + it("shows a twistie for a branch whose children are not loaded yet", () => { + const onExpandedChange = vi.fn(); + render( + + + , + ); + + const lazy = treeItem("Lazy"); + expect(lazy).toHaveAttribute("aria-expanded", "false"); + expect(lazy.querySelector(".ui-tree-item__chevron > .ui-icon")).toHaveClass( + "codicon-chevron-right", + ); + fireEvent.keyDown(lazy, { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + + it("pins branch rows down to the sticky scroll limit", () => { + const renderTree = (stickyScroll: boolean): React.JSX.Element => ( + + + + + + + + + + ); + const { rerender } = render(renderTree(false)); + expect(treeItem("One")).not.toHaveClass("ui-tree-item--sticky"); + + rerender(renderTree(true)); + expect(treeItem("One")).toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Two")).toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Three")).not.toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Leaf")).not.toHaveClass("ui-tree-item--sticky"); + expect(treeItem("Three").style.getPropertyValue("--ui-tree-level")).toBe( + "3", + ); + }); + + it("reports controlled selection and expansion from a row click", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + , + ); + + fireEvent.click(treeItem("Parent")); + expect(onSelectedItemChange).toHaveBeenCalledWith("parent"); + expect(onExpandedChange).toHaveBeenCalledWith(false); + // Collapsing unmounts the subtree, so a mostly-closed tree only + // renders what is open. + expect(screen.queryByRole("group", { hidden: true })).toBeNull(); + expect( + screen.queryByRole("treeitem", { name: "Child", hidden: true }), + ).toBeNull(); + }); + + it("toggles a branch from its twistie without changing selection", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + const onClick = vi.fn(); + render( + + + + + , + ); + + const chevron = treeItem("Branch").querySelector(".ui-tree-item__chevron"); + if (!chevron) { + throw new Error("Expected a branch twistie."); + } + fireEvent.click(chevron); + + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(onClick).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("isolates a trailing action from tree selection and expansion", () => { + const onAction = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + Delete + + } + > + + + , + ); + + const action = screen.getByRole("button", { name: "Delete" }); + expect(treeItem("Branch")).toHaveAccessibleName("Branch"); + expect(action.parentElement).toHaveClass("ui-tree-item__action"); + + fireEvent.click(action); + expect(onAction).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("keeps parent row handlers isolated from descendant treeitems", () => { + const onParentClick = vi.fn(); + const onParentFocus = vi.fn(); + const onChildClick = vi.fn(); + const onChildFocus = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + + + , + ); + + const child = treeItem("Child"); + const childContent = child.querySelector(".ui-tree-item__content"); + if (!childContent) { + throw new Error("Expected child row content."); + } + fireEvent.click(childContent); + expect(onChildClick).toHaveBeenCalledOnce(); + expect(onParentClick).not.toHaveBeenCalled(); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + + fireEvent.focus(child); + expect(onChildFocus).toHaveBeenCalledOnce(); + expect(onParentFocus).not.toHaveBeenCalled(); + + onSelectedItemChange.mockClear(); + fireEvent.keyDown(child, { key: "Enter" }); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("does not activate a disabled row that receives programmatic focus", () => { + const onSelectedItemChange = vi.fn(); + render(); + const disabled = treeItem("Disabled"); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "Enter" }); + fireEvent.keyDown(disabled, { key: " " }); + + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("forwards className, style, and ref, and marks the selected row", () => { + const ref = createRef(); + render( + + Selected action} + /> + + , + ); + + const selected = treeItem("Selected"); + expect(selected).toHaveClass("ui-tree-item", "custom-item"); + // Selection is what keeps the action slot visible, per Tree.css. + expect(selected).toHaveAttribute("aria-selected", "true"); + expect( + screen.getByRole("button", { name: "Selected action" }).parentElement, + ).toHaveClass("ui-tree-item__action"); + expect(selected.style.color).toBe("red"); + expect(selected.firstElementChild).toHaveClass("ui-tree-item__row"); + expect(ref.current).toBe(selected); + expect(treeItem("Plain")).toHaveAttribute("aria-selected", "false"); + }); +}); + +describe("Tree multi-select", () => { + const MultiTree = ({ + onSelectedItemsChange, + }: { + onSelectedItemsChange: (itemIds: readonly string[]) => void; + }): React.JSX.Element => { + const [selectedItemIds, setSelectedItemIds] = useState([ + "one", + ]); + return ( + { + onSelectedItemsChange(itemIds); + setSelectedItemIds(itemIds); + }} + > + {["One", "Two", "Three", "Four"].map((label) => ( + + ))} + + ); + }; + + const selectedNames = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-selected") === "true") + .map((item) => item.getAttribute("aria-label") ?? ""); + + it("marks the tree multi-selectable and reflects every selected row", () => { + render(); + + expect(screen.getByRole("tree")).toHaveAttribute( + "aria-multiselectable", + "true", + ); + expect(selectedNames()).toEqual(["One"]); + }); + + it("toggles with Ctrl and replaces without it", () => { + const onSelectedItemsChange = vi.fn(); + render(); + + fireEvent.click(treeItem("Three"), { ctrlKey: true }); + expect(selectedNames()).toEqual(["One", "Three"]); + fireEvent.click(treeItem("One"), { metaKey: true }); + expect(selectedNames()).toEqual(["Three"]); + + fireEvent.click(treeItem("Four")); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["four"]); + expect(selectedNames()).toEqual(["Four"]); + }); + + it("extends from the anchor with Shift click and Shift arrows", () => { + render(); + + fireEvent.click(treeItem("Two")); + fireEvent.click(treeItem("Four"), { shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + + // The anchor stays put, so shrinking the range works too. + fireEvent.click(treeItem("Three"), { shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three"]); + + fireEvent.keyDown(treeItem("Three"), { key: "ArrowDown", shiftKey: true }); + expect(selectedNames()).toEqual(["Two", "Three", "Four"]); + expect(document.activeElement).toBe(treeItem("Four")); + }); + + it("selects every visible row with Ctrl+A", () => { + render(); + + fireEvent.keyDown(treeItem("One"), { key: "a", ctrlKey: true }); + expect(selectedNames()).toEqual(["One", "Two", "Three", "Four"]); + }); + + it("lights the ancestor guide for every selected row", () => { + render( + + + + + + , + ); + + expect(guideSlots("A")[0]).toHaveClass(ACTIVE_GUIDE); + expect(guideSlots("B")[0]).toHaveClass(ACTIVE_GUIDE); + }); + + it("ignores the modifiers when multi-select is off", () => { + const onSelectedItemChange = vi.fn(); + render( + + + + , + ); + + expect(screen.getByRole("tree")).not.toHaveAttribute( + "aria-multiselectable", + ); + fireEvent.click(treeItem("Two"), { ctrlKey: true }); + expect(onSelectedItemChange).toHaveBeenCalledWith("two"); + }); +}); + +describe("Tree keyboard navigation", () => { + it("moves through visible enabled items with arrows, Home, and End", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Apricot")); + fireEvent.keyDown(treeItem("Apricot"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Amber")); + fireEvent.keyDown(treeItem("Amber"), { key: "End" }); + expect(document.activeElement).toBe(treeItem("Bravo")); + fireEvent.keyDown(treeItem("Bravo"), { key: "Home" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + fireEvent.keyDown(treeItem("Alpha"), { key: "ArrowUp" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + + it("expands a branch, enters it, collapses, and returns to the parent", () => { + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowRight" }); + expect(document.activeElement).toBe(treeItem("Blue")); + fireEvent.keyDown(treeItem("Blue"), { key: "ArrowLeft" }); + expect(document.activeElement).toBe(treeItem("Beta")); + fireEvent.keyDown(treeItem("Beta"), { key: "ArrowLeft" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("selects and toggles a branch with Enter and Space", () => { + const onSelect = vi.fn(); + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(treeItem("Beta"), { key: "Enter" }); + expect(onSelect).toHaveBeenLastCalledWith("beta"); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(treeItem("Beta"), { key: " " }); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("steps from a focused disabled row to its enabled neighbors", () => { + render(); + const disabled = treeItem("Disabled"); + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Apricot")); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowUp" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + + it("ignores keys from interactive content nested in a row", () => { + const onSelect = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Action" }), { + key: "Enter", + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("follows DOM order after rows reorder without item updates", () => { + const renderPair = (reversed: boolean): React.JSX.Element => { + const rows = ["One", "Two"].map((label) => ( + + )); + return ( + {reversed ? rows.reverse() : rows} + ); + }; + const { rerender } = render(renderPair(false)); + fireEvent.keyDown(treeItem("One"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("Two")); + + rerender(renderPair(true)); + fireEvent.keyDown(treeItem("Two"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(treeItem("One")); + }); + + it("rejects duplicate item ids across rows", () => { + expect(() => + render( + + + + , + ), + ).toThrow(/already registered by another row/i); + }); + + it("finds renamed items by type-ahead without re-registering", () => { + const renderNames = (label: string): React.JSX.Element => ( + + + + + ); + const { rerender } = render(renderNames("Amber")); + rerender(renderNames("Cedar")); + + fireEvent.keyDown(treeItem("Alpha"), { key: "c" }); + expect(document.activeElement).toBe(treeItem("Cedar")); + }); + + describe("type-ahead", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("matches case-insensitively, wraps, and cycles repeated characters", () => { + render(); + fireEvent.keyDown(treeItem("Amber"), { key: "B" }); + expect(document.activeElement).toBe(treeItem("Beta")); + fireEvent.keyDown(treeItem("Beta"), { key: "b" }); + expect(document.activeElement).toBe(treeItem("Bravo")); + fireEvent.keyDown(treeItem("Bravo"), { key: "b" }); + expect(document.activeElement).toBe(treeItem("Beta")); + }); + + it("buffers characters and clears the buffer after the timeout", () => { + render(); + fireEvent.keyDown(treeItem("Alpha"), { key: "a" }); + fireEvent.keyDown(treeItem("Apricot"), { key: "m" }); + expect(document.activeElement).toBe(treeItem("Amber")); + + act(() => { + vi.advanceTimersByTime(500); + }); + fireEvent.keyDown(treeItem("Amber"), { key: "a" }); + expect(document.activeElement).toBe(treeItem("Alpha")); + }); + }); +});