diff --git a/.storybook/main.ts b/.storybook/main.ts index 5b262100d4..54f71a684c 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/AGENTS.md b/AGENTS.md index e40c60cedd..ac0a27720c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,15 @@ Non-negotiables: - Extension panels must call **both** `buildCommandHandlers` and `buildRequestHandlers` (empty `{}` is fine). This gives a compile error when anyone adds an action to the API without a matching handler. +- Every webview and Storybook build runs the React Compiler, so components + and hooks must follow the rules of React: no reading or writing a ref + during render, no mutating props, state, or anything already rendered, + and hooks called unconditionally. A component that breaks them is skipped + silently and loses its memoization. Parameter defaults that read another + prop (`focused = adapter?.focusedId === row.node.id`) are the usual + culprit; put those defaults in the body. `useMemo` and `useCallback` are + rarely needed, and when kept they must list every dependency, or + `react-hooks/preserve-manual-memoization` fails the lint. ## Code Style diff --git a/package.json b/package.json index 6de3d32ecc..e91f9bfd9c 100644 --- a/package.json +++ b/package.json @@ -813,6 +813,7 @@ "@tanstack/react-query": "catalog:", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "catalog:", "@tsconfig/node22": "^22.0.6", "@types/mocha": "^10.0.10", "@types/node": "^22.20.1", @@ -856,6 +857,7 @@ "react": "catalog:", "react-dom": "catalog:", "storybook": "catalog:", + "storybook-addon-pseudo-states": "catalog:", "typescript": "catalog:", "typescript-eslint": "^8.67.0", "utf-8-validate": "^6.0.6", diff --git a/packages/ui/README.md b/packages/ui/README.md index ec79369344..7e1b8c741c 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -7,6 +7,11 @@ Its stable separation boundary is the public root exports, no monorepo runtime imports, and component CSS using only semantic `--ui-*` tokens. A future package build can emit those same entry points without API changes. +Consumers compile these components with the React Compiler, so they follow the +rules of React and lean on it for memoization. A component that breaks the +rules is skipped silently rather than reported, which for a list or a tree +costs a re-render per row, so check with the compiler and not only the linter. + ## CSS Import the semantic token mapping and codicon assets once in each real webview @@ -38,12 +43,126 @@ 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` is controlled: `nodes` describe the hierarchy, `expandedIds` controls +branches, and the single- or multi-selection props control selection. Each +visible node renders as a flat `treeitem`, while normal keyboard navigation +keeps DOM focus on the `tree` container and identifies the active row with +`aria-activedescendant`. Focus and selection are independent. + +```tsx +const [selectedItemId, setSelectedItemId] = useState("src"); +const [expandedIds, setExpandedIds] = useState(["src"]); + +; +``` + +Ids must be unique across the whole tree, and a duplicate throws. A string +`label` is also the accessible name; a rich label must provide `textValue`. `children` marks a branch, including an empty array for a branch +whose children are still loading. `icon`, `action`, and `className` customize +the row. Actions stay live on plain hover, as in the native list, and are +isolated from row selection and expansion. + +Arrow Up/Down, Home, End, PageUp/PageDown, and buffered prefix/fuzzy typing +move the active row through visible rows. Arrow Right +expands a branch or enters it; Arrow Left collapses it or moves to its parent. + +`expandMode="singleClick"` is the default: clicking a branch selects +and toggles it, and Enter does the same. With `expandMode="doubleClick"`, a +single click or Enter only selects and a double click toggles expansion. Space +toggles a branch without selecting it, or selects a leaf. A normal-row twistie +toggles without changing selection. Alt-click recursively toggles descendant +branches unless Alt is configured as the multi-selection modifier. + +Escape clears selection. It also clears the active focus mark when the tree has +at most one selected row; after a larger multi-selection, a second Escape +clears the remaining focus mark. Once neither selection nor a focus mark +remains, Escape is left to the host. The root `onKeyDown` runs first, so a host +can intercept shortcuts with `preventDefault()`. + +`multiSelect` uses `selectedItemIds` and `onSelectedItemsChange` and sets +`aria-multiselectable`. `multiSelectModifier` chooses the toggle modifier: +`"ctrlCmd"` (the default) uses Ctrl/Cmd and `"alt"` uses Alt. Shift-click and +Shift+Arrow extend from the selection anchor; modifier clicks take precedence +over expansion. Ctrl/Cmd+A selects the visible rows in the active sibling +scope. + +`stickyScroll` pins ancestors against the nearest scrolling ancestor. `true` +uses a maximum of seven pinned rows; a number supplies the maximum, and the +widget is also capped at 40% of the viewport. The pinned region is a separate +tab stop: Arrow Up/Down move among pinned ancestors, Arrow Down/Right from the +deepest row enters its first visible child, Enter reveals, focuses, and selects +the real row, Arrow Left reveals and focuses it and collapses an expanded +branch, and Space only reveals and focuses it. A plain pointer click reveals, +focuses, and selects; a pinned twistie additionally toggles the branch. +Selection-modifier clicks update selection without revealing the real row. + +Webviews do not receive `workbench.tree.*` settings automatically. Consumers +that mirror native sticky-scroll preferences must read +`workbench.tree.enableStickyScroll` and +`workbench.tree.stickyScrollMaxItemCount` in the extension host and send the +values to the webview. + +```mermaid +flowchart LR + accTitle: Tree architecture + accDescr: Data and input flow through the pure Tree modules into the React and DOM adapter. + + Props[Nodes and controlled props] --> Model[treeModel.ts] + Events[Pointer and keyboard events] --> Policy[treePolicy.ts] + Policy --> Commands[Tree commands] + Model --> Transition[treeTransition.ts] + Commands --> Transition + Transition --> Adapter[useTreeAdapter.ts] + Adapter --> Rows[Tree.tsx and TreeRow.tsx] + Adapter --> Sticky[StickyScroll.tsx] + Rows --> Hover[TreeHover.tsx] +``` + +The model, policy, and transitions stay pure. The adapter owns React and DOM +integration. The flat visible model supports future windowing, but the Tree is +not currently virtualized. + +Rows are 22px tall and keep the VS Code twistie gutter. For Explorer-style file +trees whose branches have no icons, `variant="explorer"` aligns leaf icons with +branch twisties; do not combine it with branch icons. Indent guides appear on +hover, selected ancestor paths stay active, and the focused path is active only +while the tree has focus. The package default uses inset Modern UI rows; +`data-ui-style="stable"` restores edge-to-edge square rows and stable focus +styling. + +Labels hover with the node's text value, so truncated rows stay readable. +Set `tooltip` for richer content or `null` to opt out. One bubble serves the +whole tree, as in the native list: an invisible anchor moves to whatever the +pointer reaches, taking its x from the cursor and its y from the target's box, +the way a native hover placed at the mouse does. Each new target waits out the +show delay, except within a row's action bar, where crossing between buttons is +instant, the exception native grants a dense cluster of targets. Ctrl+K Ctrl+I opens the focused +row's hover with no delay at all, and moving the focus closes it. ## Overlays @@ -62,7 +181,18 @@ tooltips. `TooltipProvider` ancestor. Mount one provider per app so that a pointer moving between nearby triggers skips the show delay, like native hovers. The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`, -and tooltips stop growing at half the window height. +and tooltips stop growing at half the window height. Components that own +their hovers fall back to a private provider when the app has none, so +`Tree` rows and `IconButton` work unwrapped. A private provider keeps its own +skip-delay, though, so an app with several of them makes every hover wait out +the full delay; mount one provider and they share it. `IconButton` hints with +its label like a native action bar item; pass `tooltip` to say something else, +or `null` for a button that stays quiet. + +`HoverDelegateScope` hands every `Tooltip` inside it to one shared bubble +instead of a bubble each, the way a VS Code list serves its rows and their +action bars from a single hover widget. `Tree` uses it, which is also what +lets one place decide when a hover is instant rather than delayed. Overlay content is portalled to `body`, inherits webview typography from there, and shares the `.ui-overlay` base for stacking, border, shadow, @@ -79,7 +209,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 @@ -97,4 +226,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/package.json b/packages/ui/package.json index a2ea866658..673456aac4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "imports": { "#cx": "./src/cx.ts", "#codicons": "./src/codicons.ts", + "#ref": "./src/ref.ts", "#storybook": "./src/storybook.ts" }, "scripts": { @@ -27,6 +28,7 @@ "dependencies": { "@radix-ui/react-context-menu": "^2.3.7", "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-tooltip": "^1.2.16", "@vscode/codicons": "catalog:" }, diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx index e5d308661a..27e2f877ad 100644 --- a/packages/ui/src/components/IconButton/IconButton.tsx +++ b/packages/ui/src/components/IconButton/IconButton.tsx @@ -1,9 +1,10 @@ -import { type ComponentProps } from "react"; +import { type ComponentProps, type ReactNode } from "react"; import { cx } from "#cx"; import "../control.css"; import { Icon } from "../Icon/Icon"; +import { Tooltip, TooltipScope } from "../Tooltip/Tooltip"; import "./IconButton.css"; @@ -15,18 +16,20 @@ export interface IconButtonProps extends Omit< > { icon: CodiconName; label: string; + /** Hover content; defaults to the label, `null` opts out. */ + tooltip?: ReactNode; } -/* No default title: native toolbar buttons hint with the styled hover - widget, not the browser box. Wrap in Tooltip for that. */ +/* Hints through the hover widget, never the browser title box. */ export function IconButton({ icon, label, + tooltip = label, className, type = "button", ...props }: IconButtonProps): React.JSX.Element { - return ( + const button = ( ); + if (!tooltip) return button; + return ( + + {button} + + ); } diff --git a/packages/ui/src/components/SearchInput/SearchInput.tsx b/packages/ui/src/components/SearchInput/SearchInput.tsx index 154ffaefce..4231863f6f 100644 --- a/packages/ui/src/components/SearchInput/SearchInput.tsx +++ b/packages/ui/src/components/SearchInput/SearchInput.tsx @@ -1,6 +1,7 @@ import { type ChangeEvent, type ComponentProps, useRef } from "react"; import { cx } from "#cx"; +import { setForwardedRef } from "#ref"; import "../control.css"; import { Icon } from "../Icon/Icon"; @@ -54,12 +55,7 @@ export function SearchInput({ // Track the node for clear-and-refocus, honoring the consumer ref ref={(node) => { inputRef.current = node; - if (typeof ref === "function") { - return ref(node); - } - if (ref) { - ref.current = node; - } + setForwardedRef(ref, node); }} type="search" value={value} diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx index 68a285c53a..99b593bdc8 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -1,4 +1,13 @@ +import { Slot } from "@radix-ui/react-slot"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { + createContext, + use, + type ComponentProps, + type ComponentPropsWithRef, + type PointerEvent, + type ReactNode, +} from "react"; import { cx } from "#cx"; @@ -6,21 +15,75 @@ import "../overlay.css"; import "./Tooltip.css"; -import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react"; - export type TooltipProviderProps = ComponentProps< typeof TooltipPrimitive.Provider >; +/** VS Code's `workbench.hover.delay`. */ +const DEFAULT_DELAY_MS = 500; + /** * App-level tooltip context; `Tooltip` throws without one. Sharing a single * provider lets a pointer moving between nearby triggers skip the show delay, - * like native hovers. The default delay is VS Code's `workbench.hover.delay`. + * like native hovers. */ -export function TooltipProvider( - props: TooltipProviderProps, -): React.JSX.Element { - return ; +export function TooltipProvider({ + delayDuration = DEFAULT_DELAY_MS, + ...props +}: TooltipProviderProps): React.JSX.Element { + return ( + + + + ); +} + +const TooltipContext = createContext(null); + +/** Owns tooltips without forcing a provider on consumers; defers to any app-level one. */ +export function TooltipScope({ children }: { children: ReactNode }): ReactNode { + return use(TooltipContext) === null ? ( + {children} + ) : ( + children + ); +} + +/** The surrounding provider's show delay, for surfaces that time their own. */ +export function useTooltipDelay(): number { + return use(TooltipContext) ?? DEFAULT_DELAY_MS; +} + +export interface HoverTarget { + readonly content: ReactNode; + readonly element: HTMLElement; +} + +/** `immediate` skips the show delay. */ +export type HoverDelegate = ( + target: HoverTarget | undefined, + immediate?: boolean, +) => void; + +const HoverDelegateContext = createContext( + undefined, +); + +/** + * Hands every `Tooltip` inside to one shared bubble, the way a VS Code list + * serves its rows and their action bars from a single hover widget. Pass + * `undefined` to hand them back. + */ +export function HoverDelegateScope({ + delegate, + children, +}: { + delegate: HoverDelegate | undefined; + children: ReactNode; +}): React.JSX.Element { + return ( + {children} + ); } export interface TooltipProps extends Omit< @@ -30,6 +93,8 @@ export interface TooltipProps extends Omit< content: ReactNode; /** The trigger element; must accept a forwarded ref (asChild). */ children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } /** Hover bubble matching the native hover widget; requires a `TooltipProvider` ancestor. */ @@ -37,15 +102,32 @@ export function Tooltip({ content, children, className, + open, + onOpenChange, ...props }: TooltipProps): React.JSX.Element { + const delegate = use(HoverDelegateContext); + if (delegate) { + return ( + ) => + delegate({ content, element: event.currentTarget }) + } + onPointerLeave={() => delegate(undefined)} + > + {children} + + ); + } return ( - + {children} .ui-tree-item { + position: absolute; + inset-inline: 0; + background: var(--ui-tree-sticky-background); +} + +/* Pinned copies show indentation, never guide rails, like the native widget; + without this the tree-wide hover rule lights them up. */ +.ui-tree-sticky .ui-tree-item__indent { + display: none; +} + +.ui-tree-item:not([aria-selected="true"], .ui-tree-item--focused) + > .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); +} + +/* 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; +} + +.ui-tree--focused .ui-tree-item--focused > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +.ui-tree--focused + .ui-tree-item--focused[aria-selected="true"] + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} + +.ui-tree-item__indent { + position: absolute; + inset-block: 0; + inset-inline-start: calc(2 * var(--ui-tree-indent-size)); + display: flex; + pointer-events: none; +} + +/* 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); +} + +.ui-tree-item__chevron > .ui-icon { + width: 10px; + font-size: 10px; +} + +/* 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__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:is([aria-selected="true"], .ui-tree-item--focused) + > .ui-tree-item__row + .ui-tree-item__action, +.ui-tree-item__row:is(:hover, :focus-within) .ui-tree-item__action { + display: inline-flex; +} + +/* Modern UI insets the rows; data-ui-style="stable" keeps them edge to edge. */ +:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { + margin-inline: var(--ui-spacing-40); + border-radius: var(--ui-radius-small); +} + +@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-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; + } +} + +/* The shared hover anchors here instead of to each row's label. */ +.ui-tree-hover-anchor { + position: absolute; + pointer-events: none; + opacity: 0; +} 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 0000000000..326a97a00a --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,363 @@ +import { + expect, + fireEvent, + screen, + userEvent, + waitFor, + within, +} from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { TreeDemo } from "../../../storybook/Tree.demo"; +import { IconButton } from "../IconButton/IconButton"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import type { CodiconName } from "#codicons"; + +import type { TreeProps } from "./Tree"; +import type { TreeNode } from "./treeModel"; + +interface NodeOptions { + readonly label?: string; + readonly icon?: CodiconName; + readonly action?: React.ReactNode; + readonly className?: string; +} +const node = (id: string, options: NodeOptions = {}): TreeNode => ({ + id, + label: id, + ...options, +}); +const branch = ( + id: string, + children: readonly TreeNode[], + options: NodeOptions = {}, +): TreeNode => ({ ...node(id, options), children }); +const closeAction = (name: string): React.ReactNode => ( + +); + +/** Branch rows have no icons, so explorer aligns leaf icons with twisties. */ +const FILES: readonly TreeNode[] = [ + branch( + "source", + [ + branch("components", [ + node("tree", { + label: "Tree.tsx", + icon: "symbol-class", + action: closeAction("Tree.tsx"), + }), + node("styles", { label: "Tree.css", icon: "symbol-color" }), + ]), + node("tests", { icon: "beaker" }), + ], + { label: "src" }, + ), + node("readme", { label: "README.md", icon: "markdown" }), +]; + +const tree = (props: TreeProps): React.JSX.Element => ( + +); +const TreeStates = (): React.JSX.Element => + tree({ + "aria-label": "Explorer", + nodes: FILES, + selectedItemId: "components", + variant: "explorer", + }); +const meta: Meta = { + title: "UI/Tree", + component: TreeStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +const exerciseTree: NonNullable = async ({ canvasElement }) => { + const canvas = within(canvasElement); + const selected = canvas.getByRole("treeitem", { name: "components" }); + const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await expect(selected).toHaveAttribute("aria-selected", "true"); + await userEvent.click( + canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), + ); + await expect(selected).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 ROW_STATES: readonly TreeNode[] = [ + node("plain", { label: "Plain item", icon: "file" }), + branch("selected", [node("child", { label: "Child item" })], { + label: "Selected branch", + icon: "folder-opened", + }), + branch("collapsed", [node("hidden", { label: "Hidden item" })], { + label: "Collapsed branch", + icon: "folder", + }), + node("action", { + label: "Item with action", + className: "story-row-action", + action: , + }), +]; +export const RowStates: Story = { + parameters: { + pseudo: { hover: [".story-row-action > .ui-tree-item__row"] }, + }, + render: () => + tree({ + "aria-label": "Tree row states", + nodes: ROW_STATES, + selectedItemId: "selected", + expandedIds: ["selected"], + }), +}; +export const RowStatesStable: Story = { + ...RowStates, + globals: { uiStyle: "stable" }, +}; + +/** Two deep branches, so a short scroller always has ancestors to pin. */ +const DEEP_FILES: readonly TreeNode[] = ["alpha", "beta"].map((name) => + branch(name, [ + branch( + `${name}/src`, + Array.from({ length: 12 }, (_, index) => + node(`${name}/src/file-${index}`, { + label: `file-${index}.ts`, + icon: "symbol-class", + }), + ), + { label: "src" }, + ), + ]), +); + +export const StickyScroll: Story = { + render: () => ( +
{ + if (scroller) scroller.scrollTop = 143; + }} + > + +
+ ), + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelector(".ui-tree-sticky__rows"), + ).not.toBeNull(), + ); + await expect( + within(canvasElement).getByTestId("scroller").scrollTop, + ).toBeGreaterThan(0); + }, +}; + +export const MultiSelect: Story = { + render: () => + tree({ + "aria-label": "Multi-select explorer", + nodes: FILES, + variant: "explorer", + multiSelect: true, + selectedItemIds: ["tree", "styles"], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const treeElement = canvas.getByRole("tree"); + const readme = canvas.getByRole("treeitem", { name: "README.md" }); + await fireEvent.click(readme, { ctrlKey: true }); + await expect(readme).toHaveAttribute("aria-selected", "true"); + await expect( + canvas.getByRole("treeitem", { name: "Tree.tsx" }), + ).toHaveAttribute("aria-selected", "true"); + await expect(canvasElement.ownerDocument.activeElement).toBe(treeElement); + await expect(treeElement).toHaveAttribute( + "aria-activedescendant", + readme.id, + ); + }, +}; + +export const Focused: Story = { + render: () => + tree({ + "aria-label": "Focused explorer", + nodes: FILES, + selectedItemId: "tree", + variant: "explorer", + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const treeElement = canvas.getByRole("tree"); + const styles = canvas.getByRole("treeitem", { name: "Tree.css" }); + treeElement.focus(); + await waitFor(() => expect(treeElement).toHaveClass("ui-tree--focused")); + await fireEvent.keyDown(treeElement, { key: "ArrowDown" }); + await expect(canvasElement.ownerDocument.activeElement).toBe(treeElement); + await expect(treeElement).toHaveAttribute( + "aria-activedescendant", + styles.id, + ); + await expect(styles).toHaveAttribute("aria-selected", "false"); + }, +}; + +const NESTED_FILES: readonly TreeNode[] = [ + branch("src", [ + branch("components", [ + branch("Tree", [ + node("Tree.tsx", { + icon: "symbol-class", + className: "story-hover", + action: closeAction("Tree.tsx"), + }), + node("TreeRow.tsx", { icon: "symbol-class" }), + node("useTreeAdapter.ts", { icon: "symbol-method" }), + branch("sticky", [node("StickyScroll.tsx", { icon: "symbol-class" })]), + ]), + ]), + ]), + node("README.md", { icon: "markdown" }), +]; +export const Nested: Story = { + render: () => + tree({ + "aria-label": "Nested explorer", + nodes: NESTED_FILES, + selectedItemId: "StickyScroll.tsx", + variant: "explorer", + }), + parameters: { + pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, + }, + play: async ({ canvasElement }) => { + const deepLeaf = within(canvasElement).getByRole("treeitem", { + name: "StickyScroll.tsx", + }); + await expect(deepLeaf).toHaveAttribute("aria-level", "5"); + await userEvent.click(deepLeaf); + await expect(deepLeaf).toHaveAttribute("aria-selected", "true"); + }, +}; + +const LONG_NAME = "a-really-long-component-name-that-truncates.tsx"; +const HOVER_FILES: readonly TreeNode[] = [ + branch( + "hover-src", + [ + node("hover-index", { label: "index.ts", icon: "symbol-method" }), + node("hover-long", { label: LONG_NAME, icon: "symbol-class" }), + node("hover-actions", { + label: "Workspace", + icon: "vm", + action: ( + <> + + + + ), + }), + ], + { label: "src" }, + ), +]; + +/** Leaves room above the rows, where a hover sits. */ +const hoverTree = (ariaLabel: string, selectedItemId?: string) => ( +
+ {tree({ + "aria-label": ariaLabel, + nodes: HOVER_FILES, + selectedItemId, + })} +
+); + +/** The bubble takes its x from the cursor, so give the pointer a real one. */ +const hoverAt = async (element: Element): Promise => { + // An action bar is revealed by CSS, which lands a frame after the render. + await waitFor(() => + expect(element.getBoundingClientRect().width).toBeGreaterThan(0), + ); + const bounds = element.getBoundingClientRect(); + await userEvent.hover(element); + await fireEvent.pointerMove(element, { + clientX: bounds.left + 24, + clientY: bounds.top + bounds.height / 2, + }); +}; + +/** The bubble is portalled, so it lands outside the story canvas. */ +const expectBubble = async (content: string): Promise => { + const bubble = await screen.findByRole("tooltip"); + await waitFor(() => expect(bubble).toHaveTextContent(content)); +}; + +export const Hover: Story = { + render: () => hoverTree("Hovered label"), + play: async ({ canvasElement }) => { + const hovered = within(canvasElement).getByRole("treeitem", { + name: LONG_NAME, + }); + await hoverAt(hovered.getElementsByClassName("ui-tree-item__content")[0]); + await expectBubble(LONG_NAME); + }, +}; + +export const HoverOnAction: Story = { + // Selected so CSS reveals the action bar, which a synthetic hover cannot. + render: () => hoverTree("Hovered action", "hover-actions"), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await hoverAt( + canvas.getByRole("button", { name: "Start workspace", hidden: true }), + ); + await expectBubble("Start workspace"); + // Crossing the same action bar swaps the bubble without a second delay. + await hoverAt( + canvas.getByRole("button", { name: "Workspace settings", hidden: true }), + ); + await expectBubble("Workspace settings"); + }, +}; + +export const HoverByKeyboard: Story = { + render: () => hoverTree("Keyboard hover"), + play: async ({ canvasElement }) => { + const treeElement = within(canvasElement).getByRole("tree"); + treeElement.focus(); + await waitFor(() => expect(treeElement).toHaveClass("ui-tree--focused")); + await fireEvent.keyDown(treeElement, { key: "ArrowDown" }); + await fireEvent.keyDown(treeElement, { key: "ArrowDown" }); + // VS Code binds list.showHover to this chord. + await fireEvent.keyDown(treeElement, { key: "k", ctrlKey: true }); + await fireEvent.keyDown(treeElement, { key: "i", ctrlKey: true }); + await expectBubble(LONG_NAME); + }, +}; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx new file mode 100644 index 0000000000..eef0bc6cbe --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,150 @@ +import { type ComponentPropsWithRef, useId, useRef } from "react"; + +import { cx } from "#cx"; +import { setForwardedRef } from "#ref"; + +import { TooltipScope } from "../Tooltip/Tooltip"; + +import { StickyScroll } from "./sticky/StickyScroll"; +import "./Tree.css"; +import { TreeHover, type TreeHoverControl } from "./TreeHover"; +import { TreeRow } from "./TreeRow"; +import { useTreeAdapter, type SelectionProps } from "./useTreeAdapter"; + +import type { TreeNode } from "./treeModel"; +import type { TreeExpandMode, TreeMultiSelectModifier } from "./treePolicy"; + +/** VS Code's `workbench.tree.stickyScrollMaxItemCount` default. */ +const DEFAULT_STICKY_COUNT = 7; +const NO_IDS: readonly string[] = []; + +/** The tree's own props; everything else lands on the container element. */ +interface TreeOwnProps { + readonly nodes: readonly TreeNode[]; + readonly expandedIds?: readonly string[]; + readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void; + /** `explorer` aligns leaf icons with branch twisties, as VS Code does. */ + readonly variant?: "default" | "explorer"; + readonly expandMode?: TreeExpandMode; + readonly multiSelectModifier?: TreeMultiSelectModifier; + /** Pins ancestors while scrolling; a number caps how many. */ + readonly stickyScroll?: boolean | number; +} + +type TreeContainerProps = Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" | "children" | keyof TreeOwnProps +>; + +export type TreeProps = TreeOwnProps & SelectionProps & TreeContainerProps; + +/** Whether the focus or blur target is inside the tree rather than a portal. */ +function ownsTarget(tree: HTMLElement, target: EventTarget | null): boolean { + return target instanceof Node && tree.contains(target); +} + +/** A controlled tree following the current VS Code workbench behavior. */ +export function Tree({ + nodes, + expandedIds = NO_IDS, + onExpandedIdsChange, + multiSelect, + selectedItemId, + onSelectedItemChange, + selectedItemIds, + onSelectedItemsChange, + variant = "default", + expandMode = "singleClick", + multiSelectModifier = "ctrlCmd", + stickyScroll = false, + className, + onFocus, + onBlur, + onKeyDown, + ref, + ...containerProps +}: TreeProps): React.JSX.Element { + const treeRef = useRef(null); + const hoverRef: TreeHoverControl = useRef(undefined); + const treeDomId = useId(); + const selection: SelectionProps = multiSelect + ? { multiSelect: true, selectedItemIds, onSelectedItemsChange } + : { multiSelect: false, selectedItemId, onSelectedItemChange }; + const adapter = useTreeAdapter({ + ...selection, + nodes, + expandedIds, + onExpandedIdsChange, + expandMode, + multiSelectModifier, + onKeyDown, + treeRef, + hoverControl: hoverRef, + }); + + return ( + +
{ + treeRef.current = element; + setForwardedRef(ref, element); + }} + role="tree" + tabIndex={0} + aria-activedescendant={ + adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined + } + aria-multiselectable={multiSelect ? true : undefined} + className={cx( + "ui-tree", + variant === "explorer" && "ui-tree--explorer", + adapter.hasDomFocus && "ui-tree--focused", + className, + )} + onFocus={(event) => { + onFocus?.(event); + if ( + !event.defaultPrevented && + ownsTarget(event.currentTarget, event.target) + ) { + adapter.onFocusIn(event.target); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !ownsTarget(event.currentTarget, event.relatedTarget) + ) { + adapter.onBlurOut(); + } + }} + onClick={adapter.onClick} + onKeyDown={adapter.onKeyDown} + > + + {stickyScroll ? ( + + ) : null} + {adapter.model.visibleRows.map((row) => ( + + ))} + +
+
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeHover.tsx b/packages/ui/src/components/Tree/TreeHover.tsx new file mode 100644 index 0000000000..61502fe56e --- /dev/null +++ b/packages/ui/src/components/Tree/TreeHover.tsx @@ -0,0 +1,156 @@ +import { + useEffect, + useImperativeHandle, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; + +import { + HoverDelegateScope, + Tooltip, + useTooltipDelay, + type HoverDelegate, + type HoverTarget, +} from "../Tooltip/Tooltip"; + +const GRACE_MS = 100; + +/** Native reopens with no delay this soon after hiding, and only for a dense + cluster of targets such as an action bar. */ +const INSTANT_MS = 200; +const DENSE_CLUSTER = ".ui-tree-item__action"; + +/** setupCustomHover offsets a cursor-placed hover by this much. */ +const CURSOR_OFFSET_PX = 10; + +const ROW = ".ui-tree-item"; + +export type TreeHoverControl = RefObject; + +interface Shown extends HoverTarget { + readonly top: number; + readonly left: number; + readonly width: number; + readonly height: number; + readonly align: "center" | "start"; +} + +/** + * One hover for the whole tree, like the native list's shared widget: rows and + * anything inside them report the element under the pointer, and an invisible + * anchor moves to it. + */ +export function TreeHover({ + children, + treeRef, + controlRef, +}: { + children: ReactNode; + treeRef: RefObject; + controlRef: TreeHoverControl; +}): React.JSX.Element { + const delay = useTooltipDelay(); + const [shown, setShown] = useState(); + const openRef = useRef(false); + const hiddenAtRef = useRef(0); + const clusterRef = useRef(null); + const pointerXRef = useRef(undefined); + const timerRef = useRef>(undefined); + + const hide = (): void => { + clearTimeout(timerRef.current); + if (openRef.current) hiddenAtRef.current = Date.now(); + openRef.current = false; + setShown(undefined); + }; + + // Native centers on an action bar button and follows the cursor along a row, + // measuring the row so taller content cannot push the bubble off it. + const show = (target: HoverTarget, atPointer = true): void => { + const tree = treeRef.current; + if (!tree) return; + const cluster = target.element.closest(DENSE_CLUSTER); + const box = cluster + ? target.element + : (target.element.closest(ROW) ?? target.element); + const bounds = tree.getBoundingClientRect(); + const rect = box.getBoundingClientRect(); + const cursorX = cluster || !atPointer ? undefined : pointerXRef.current; + openRef.current = true; + clusterRef.current = cluster; + setShown({ + ...target, + top: rect.top - bounds.top, + left: + (cursorX === undefined ? rect.left : cursorX + CURSOR_OFFSET_PX) - + bounds.left, + width: cursorX === undefined ? rect.width : 0, + height: rect.height, + align: cursorX === undefined ? "center" : "start", + }); + }; + + const setTarget: HoverDelegate = (target, immediate = false) => { + clearTimeout(timerRef.current); + if (!target?.content) { + timerRef.current = setTimeout(hide, GRACE_MS); + return; + } + const cluster = target.element.closest(DENSE_CLUSTER); + const recent = + openRef.current || Date.now() - hiddenAtRef.current < INSTANT_MS; + if (immediate || (recent && cluster && cluster === clusterRef.current)) { + show(target, !immediate); + return; + } + hide(); + timerRef.current = setTimeout(() => show(target), delay); + }; + + useImperativeHandle(controlRef, () => setTarget, [setTarget]); + useEffect(() => () => clearTimeout(timerRef.current), []); + + useEffect(() => { + const tree = treeRef.current; + if (!tree) return; + const track = (event: PointerEvent): void => { + pointerXRef.current = event.clientX; + }; + tree.addEventListener("pointermove", track, { passive: true }); + return () => tree.removeEventListener("pointermove", track); + }, [treeRef]); + + return ( + + {children} + {/* Outside the scope, or the bubble would delegate to itself. */} + + {shown ? ( + { + if (!open) hide(); + }} + onPointerEnter={() => clearTimeout(timerRef.current)} + onPointerLeave={() => setTarget(undefined)} + > + + ) : null} + + + ); +} diff --git a/packages/ui/src/components/Tree/TreeRow.tsx b/packages/ui/src/components/Tree/TreeRow.tsx new file mode 100644 index 0000000000..0b4832cada --- /dev/null +++ b/packages/ui/src/components/Tree/TreeRow.tsx @@ -0,0 +1,86 @@ +import { type CSSProperties, memo } from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; +import { Tooltip } from "../Tooltip/Tooltip"; + +import { rowTooltip, type TreeRowModel } from "./treeModel"; + +interface TreeRowProps { + readonly row: TreeRowModel; + /** Left out by rows rendered outside the tree, which only present. */ + readonly id?: string; + readonly focused?: boolean; + readonly selected?: boolean; + /** One character per ancestor, `1` where the indent guide is active. */ + readonly guideFlags?: string; + /** Positions a pinned copy inside the sticky widget. */ + readonly style?: CSSProperties; +} + +/** Pure presentation: props compare by value, so `memo` skips untouched rows. */ +export const TreeRow = memo(function TreeRow({ + row, + id, + focused = false, + selected = false, + guideFlags = "", + style, +}: TreeRowProps): React.JSX.Element { + const { node, expanded } = row; + const level = row.pathIds.length + 1; + const tooltip = rowTooltip(row); + // Native hangs the hover off the label, not the whole row. + const label = ( + + {node.icon ? : null} + {typeof node.label === "string" ? {node.label} : node.label} + + ); + return ( +
+
+
+
+ ); +}); diff --git a/packages/ui/src/components/Tree/rowDom.ts b/packages/ui/src/components/Tree/rowDom.ts new file mode 100644 index 0000000000..3b101c68fc --- /dev/null +++ b/packages/ui/src/components/Tree/rowDom.ts @@ -0,0 +1,66 @@ +/** The DOM reads the data model cannot answer: what an event actually hit. */ + +/** + * Anything focusable in a row owns its own clicks and keys. `[tabindex]` covers + * the interactive ARIA roles: a role nothing can focus is one nothing can use. + */ +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[contenteditable]:not([contenteditable='false'])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +/** The focusable element the event hit, unless that element is `container`. */ +export function nestedInteractiveTarget( + target: EventTarget | null, + container: HTMLElement, +): Element | null { + if (!(target instanceof Element) || target === container) { + return null; + } + const focusable = target.closest(FOCUSABLE_SELECTOR); + return focusable !== null && + focusable !== container && + container.contains(focusable) + ? focusable + : null; +} + +/** Whether a click landed on the twistie rather than the row body. */ +export function hitTwistie( + row: { readonly expanded: boolean | undefined }, + target: EventTarget | null, +): boolean { + return ( + row.expanded !== undefined && + target instanceof Element && + target.closest(".ui-tree-item__chevron") !== null + ); +} + +/** The row element the event hit, if any. */ +export function closestRow(target: EventTarget | null): HTMLElement | null { + return target instanceof Element + ? target.closest("[data-tree-id]") + : null; +} + +export function scrollableAncestor( + element: HTMLElement, +): HTMLElement | undefined { + for ( + let parent = element.parentElement; + parent !== null; + parent = parent.parentElement + ) { + const { overflowY } = getComputedStyle(parent); + if (overflowY === "auto" || overflowY === "scroll") { + return parent; + } + } + return undefined; +} diff --git a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx new file mode 100644 index 0000000000..05490aba2e --- /dev/null +++ b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx @@ -0,0 +1,205 @@ +import { + type RefObject, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { hitTwistie, scrollableAncestor } from "../rowDom"; +import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; +import { TreeRow } from "../TreeRow"; + +import { computeStickyState, NO_STICKY, type StickyState } from "./stickyState"; + +import type { TreeAdapter } from "../useTreeAdapter"; + +function useStickyState( + rows: readonly TreeRowModel[], + maxCount: number, + widgetRef: RefObject, +): StickyState { + const snapshotRef = useRef(NO_STICKY); + const subscribe = (notify: () => void): (() => void) => { + const tree = widgetRef.current?.parentElement; + const scroller = tree ? scrollableAncestor(tree) : undefined; + if (!scroller) return () => undefined; + scroller.addEventListener("scroll", notify, { passive: true }); + const observer = + typeof ResizeObserver === "undefined" + ? undefined + : new ResizeObserver(notify); + observer?.observe(scroller); + return () => { + scroller.removeEventListener("scroll", notify); + observer?.disconnect(); + }; + }; + const getSnapshot = (): StickyState => { + const widget = widgetRef.current; + const tree = widget?.parentElement; + if (!widget || !tree) return NO_STICKY; + const next = computeStickyState( + rows, + widget.getBoundingClientRect().top - tree.getBoundingClientRect().top, + scrollableAncestor(tree)?.clientHeight ?? 0, + maxCount, + ); + const current = snapshotRef.current; + if ( + current.pushOffset !== next.pushOffset || + current.ids.length !== next.ids.length || + current.ids.some((id, index) => id !== next.ids[index]) + ) + snapshotRef.current = next; + return snapshotRef.current; + }; + return useSyncExternalStore(subscribe, getSnapshot, () => NO_STICKY); +} + +export function StickyScroll({ + maxCount, + adapter, + treeRef, +}: { + maxCount: number; + adapter: TreeAdapter; + treeRef: React.RefObject; +}): React.JSX.Element { + const { visibleRows, rowsById } = adapter.model; + const widgetRef = useRef(null); + const state = useStickyState(visibleRows, maxCount, widgetRef); + const pinnedRows = state.ids + .map((id) => rowsById.get(id)) + .filter((row) => row !== undefined); + const pinnedHeight = pinnedRows.length * ROW_HEIGHT_PX + state.pushOffset; + const [requestedIndex, setRequestedIndex] = useState(0); + const focusedIndex = Math.max( + 0, + Math.min(requestedIndex, pinnedRows.length - 1), + ); + + useEffect(() => { + if ( + pinnedRows.length === 0 && + widgetRef.current?.contains(document.activeElement) + ) { + treeRef.current?.focus(); + } + }, [pinnedRows.length, treeRef]); + + const reveal = (row: TreeRowModel, index: number): void => { + const widget = widgetRef.current; + const tree = widget?.parentElement; + if (!widget || !tree) return; + scrollableAncestor(tree)?.scrollBy( + 0, + visibleRows.indexOf(row) * ROW_HEIGHT_PX - + index * ROW_HEIGHT_PX - + (widget.getBoundingClientRect().top - tree.getBoundingClientRect().top), + ); + }; + const revealAndDispatch = ( + row: TreeRowModel, + commands: Parameters[0], + ): void => { + reveal(row, focusedIndex); + adapter.dispatch(commands); + }; + + return ( +
0 ? 0 : -1} + onFocus={(event) => { + if (event.target === event.currentTarget) + setRequestedIndex(focusedIndex); + }} + onKeyDown={(event) => { + const row = pinnedRows[focusedIndex]; + if (!row) return; + if (event.key === "ArrowUp") + setRequestedIndex(Math.max(0, focusedIndex - 1)); + else if (event.key === "ArrowDown" || event.key === "ArrowRight") { + if (pinnedRows[focusedIndex + 1]) setRequestedIndex(focusedIndex + 1); + else { + const child = visibleRows[visibleRows.indexOf(row) + 1]; + if (child?.pathIds.includes(row.node.id)) { + adapter.dispatch([{ type: "focus", id: child.node.id }]); + } + } + } else if (event.key === "Enter") { + revealAndDispatch(row, [ + { type: "focus", id: row.node.id }, + { + type: "select", + id: row.node.id, + toggle: false, + range: false, + preserveHidden: true, + }, + ]); + } else if (event.key === "ArrowLeft") { + revealAndDispatch(row, [ + { type: "focus", id: row.node.id }, + ...(row.expanded + ? [ + { + type: "toggle" as const, + id: row.node.id, + recursive: false, + }, + ] + : []), + ]); + } else if (event.key === " ") { + revealAndDispatch(row, [{ type: "focus", id: row.node.id }]); + } else return; + event.preventDefault(); + event.stopPropagation(); + }} + > + {pinnedRows.length > 0 ? ( + <> +
{ + const index = [...event.currentTarget.children].findIndex( + (child) => child.contains(event.target as Node), + ); + const row = pinnedRows[index]; + if (!row) return; + if (!adapter.isSelectionGesture(event)) reveal(row, index); + adapter.onPointer( + row, + event, + hitTwistie(row, event.target), + "sticky", + ); + }} + > + {pinnedRows.map((row, index) => ( + + ))} +
+
+ + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/Tree/sticky/stickyState.ts b/packages/ui/src/components/Tree/sticky/stickyState.ts new file mode 100644 index 0000000000..a180208547 --- /dev/null +++ b/packages/ui/src/components/Tree/sticky/stickyState.ts @@ -0,0 +1,72 @@ +import { ROW_HEIGHT_PX, type TreeRowModel } from "../treeModel"; + +/** VS Code caps the sticky widget at 40% of the viewport. */ +const MAX_VIEWPORT_RATIO = 0.4; + +export interface StickyState { + /** Ids of the pinned ancestor chain, outermost first. */ + readonly ids: readonly string[]; + /** Upward shift in px while the last pinned subtree scrolls out. */ + readonly pushOffset: number; +} + +export const NO_STICKY: StickyState = { ids: [], pushOffset: 0 }; + +/** + * The ancestor chain to pin, like VS Code's findStickyState: the ancestors + * of the topmost row not covered by the widget, capped by `maxCount` and by + * viewport share. Pinned rows cover rows below, which can deepen the chain, + * so grow to a fixpoint. + */ +export function computeStickyState( + rows: readonly TreeRowModel[], + scrolledPx: number, + viewportPx: number, + maxCount: number, +): StickyState { + const cap = Math.min( + maxCount, + Math.floor((viewportPx * MAX_VIEWPORT_RATIO) / ROW_HEIGHT_PX), + ); + if (scrolledPx <= 0 || cap <= 0) { + return NO_STICKY; + } + const topIndex = Math.floor(scrolledPx / ROW_HEIGHT_PX); + let count = 0; + let chain: readonly string[] = []; + for (;;) { + const rowChain = rows[topIndex + count]?.pathIds ?? []; + const next = Math.min(rowChain.length, cap); + if (next <= count) { + break; + } + count = next; + chain = rowChain; + } + const ids = chain.slice(0, count); + if (ids.length === 0) { + return NO_STICKY; + } + return { ids, pushOffset: pushOffset(rows, scrolledPx, ids) }; +} + +/** How far the widget shifts up as the last pinned subtree ends. */ +function pushOffset( + rows: readonly TreeRowModel[], + scrolledPx: number, + ids: readonly string[], +): number { + const lastId = ids.at(-1); + let endIndex = -1; + rows.forEach((row, index) => { + if (row.node.id === lastId || row.pathIds.includes(lastId ?? "")) { + endIndex = index; + } + }); + if (endIndex === -1) { + return 0; + } + const subtreeBottom = (endIndex + 1) * ROW_HEIGHT_PX; + const widgetBottom = scrolledPx + ids.length * ROW_HEIGHT_PX; + return Math.min(0, subtreeBottom - widgetBottom); +} diff --git a/packages/ui/src/components/Tree/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts new file mode 100644 index 0000000000..9850ab3094 --- /dev/null +++ b/packages/ui/src/components/Tree/treeModel.ts @@ -0,0 +1,105 @@ +import type { ReactNode } from "react"; + +import type { CodiconName } from "#codicons"; + +/** VS Code's tree row height; Tree.css --ui-tree-row-height must match. */ +export const ROW_HEIGHT_PX = 22; + +/** A string label doubles as the text value; a rich label must supply one. */ +type TreeNodeLabel = + | { readonly label: string; readonly textValue?: string } + | { readonly label: ReactNode; readonly textValue: string }; + +/** One node of tree data. `children` marks a branch, `[]` one still loading. */ +export type TreeNode = TreeNodeLabel & { + readonly id: string; + readonly icon?: CodiconName; + /** Hover content; defaults to the text value, `null` opts out. */ + readonly tooltip?: ReactNode; + readonly action?: ReactNode; + readonly className?: string; + readonly children?: readonly TreeNode[]; +}; + +export interface TreeRowModel { + readonly node: TreeNode; + /** Ancestor ids, outermost first; the ARIA level is one past its length. */ + readonly pathIds: readonly string[]; + /** Flat rows have no group element, so each declares its own set. */ + readonly posInSet: number; + readonly setSize: number; + readonly textValue: string; + /** undefined on leaves. */ + readonly expanded: boolean | undefined; +} + +export interface TreeModel { + /** Rows under expanded ancestors only, in render order. */ + readonly visibleRows: readonly TreeRowModel[]; + /** Every row, hidden ones included. */ + readonly rows: readonly TreeRowModel[]; + readonly rowsById: ReadonlyMap; + readonly visibleIds: ReadonlySet; +} + +/** The row's hover content; empty when the node opts out. */ +export function rowTooltip(row: TreeRowModel): ReactNode { + const { tooltip } = row.node; + return tooltip === undefined ? row.textValue : tooltip; +} + +export function parentId(row: TreeRowModel): string | undefined { + return row.pathIds.at(-1); +} + +/** Ids are unique tree wide, as in VS Code, so a duplicate throws. */ +export function createTreeModel( + nodes: readonly TreeNode[], + expandedIds: ReadonlySet, +): TreeModel { + const visibleRows: TreeRowModel[] = []; + const rows: TreeRowModel[] = []; + const rowsById = new Map(); + + const visit = ( + siblings: readonly TreeNode[], + pathIds: readonly string[], + visible: boolean, + ): void => { + siblings.forEach((node, index) => { + if (rowsById.has(node.id)) { + throw new Error(`Tree node id "${node.id}" must be unique.`); + } + const expanded = node.children ? expandedIds.has(node.id) : undefined; + const row: TreeRowModel = { + node, + pathIds, + posInSet: index + 1, + setSize: siblings.length, + textValue: + node.textValue ?? (typeof node.label === "string" ? node.label : ""), + expanded, + }; + rows.push(row); + rowsById.set(node.id, row); + if (visible) { + visibleRows.push(row); + } + if (node.children) { + visit( + node.children, + [...pathIds, node.id], + visible && expanded === true, + ); + } + }); + }; + + visit(nodes, [], true); + return { + visibleRows, + rows, + rowsById, + visibleIds: new Set(visibleRows.map((row) => row.node.id)), + }; +} diff --git a/packages/ui/src/components/Tree/treePolicy.ts b/packages/ui/src/components/Tree/treePolicy.ts new file mode 100644 index 0000000000..1bd7d55fcb --- /dev/null +++ b/packages/ui/src/components/Tree/treePolicy.ts @@ -0,0 +1,299 @@ +/** + * The VS Code key and pointer bindings, as the commands a gesture means for a + * row. "Policy" because it decides intent only: `treeTransition.ts` applies it. + */ + +import { parentId, type TreeRowModel } from "./treeModel"; + +/** Mirrors `workbench.tree.expandMode`, values included. */ +export type TreeExpandMode = "singleClick" | "doubleClick"; + +/** Mirrors `workbench.list.multiSelectModifier`, values included. */ +export type TreeMultiSelectModifier = "ctrlCmd" | "alt"; + +export interface TreeCommandBehavior { + readonly expandMode: TreeExpandMode; + readonly multiSelect: boolean; + readonly multiSelectModifier: TreeMultiSelectModifier; +} + +/** The modifier keys a gesture carries, as a DOM event reports them. */ +export interface TreeModifiers { + readonly ctrlKey: boolean; + readonly metaKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +interface SelectOptions { + /** Adds to or removes from the selection instead of replacing it. */ + readonly toggle: boolean; + /** Selects from the anchor through this row. */ + readonly range: boolean; + /** Whether selected rows hidden under a collapsed branch survive. */ + readonly preserveHidden: boolean; +} + +type RowCommand = { + readonly type: Type; + readonly id: string; +} & Options; + +export type TreeCommand = + | RowCommand<"focus"> + /** Selects the row's sibling group, widening to its parent once full. */ + | RowCommand<"selectScope"> + | RowCommand< + "move", + { + readonly offset: -1 | 1; + /** A viewport's worth of rows rather than one. */ + readonly page: boolean; + /** Extends the selection to the row moved to. */ + readonly extend: boolean; + } + > + | RowCommand<"select", SelectOptions> + | RowCommand<"toggle", { readonly recursive: boolean }> + | RowCommand<"typeahead", { readonly key: string }> + | { + readonly type: "dismiss"; + readonly clearSelection: boolean; + readonly clearFocus: boolean; + }; + +interface CommandInput extends TreeCommandBehavior { + readonly row: TreeRowModel; + readonly modifiers: TreeModifiers; +} + +export interface PointerCommandInput extends CommandInput { + /** A pinned row selects without the expand-on-click its body would get. */ + readonly source: "row" | "sticky"; + readonly onTwistie: boolean; + /** `MouseEvent.detail`, so 2 on a double click. */ + readonly detail: number; +} + +export interface KeyboardCommandInput extends CommandInput { + readonly key: string; + readonly visibleRows: readonly TreeRowModel[]; + /** Whether a control inside the row, not the row, has focus. */ + readonly fromAction: boolean; + readonly selectedCount: number; + readonly hasFocusedRow: boolean; +} + +interface KeyboardOutcome { + readonly commands: readonly TreeCommand[]; + readonly preventDefault: boolean; + /** Set when a row action navigates, so the row takes focus back. */ + readonly focusRowElementId: string | undefined; +} + +const NO_COMMANDS: readonly TreeCommand[] = []; + +/** The keys the tree claims even while a row action has focus. */ +const NAVIGATION_KEYS: ReadonlySet = new Set([ + "ArrowDown", + "ArrowUp", + "ArrowLeft", + "ArrowRight", + "PageDown", + "PageUp", + "Home", + "End", +]); + +const focusCommand = (id: string): TreeCommand => ({ type: "focus", id }); +const selectCommand = ( + id: string, + options: Partial = {}, +): TreeCommand => ({ + type: "select", + id, + toggle: false, + range: false, + preserveHidden: true, + ...options, +}); +const toggleCommand = (id: string, recursive = false): TreeCommand => ({ + type: "toggle", + id, + recursive, +}); + +/** Whether the gesture adds to the selection rather than replacing it. */ +export function isSelectionModifier( + modifiers: TreeModifiers, + behavior: TreeCommandBehavior, +): boolean { + if (!behavior.multiSelect) { + return false; + } + return behavior.multiSelectModifier === "alt" + ? modifiers.altKey + : modifiers.ctrlKey || modifiers.metaKey; +} + +/** Whether the gesture is about selection at all, ranges included. */ +export function isSelectionGesture( + modifiers: TreeModifiers, + behavior: TreeCommandBehavior, +): boolean { + return ( + isSelectionModifier(modifiers, behavior) || + (behavior.multiSelect && modifiers.shiftKey) + ); +} + +/** The commands a click on `row` means, twistie clicks included. */ +export function pointerCommands( + input: PointerCommandInput, +): readonly TreeCommand[] { + const { row, source, expandMode, detail, modifiers } = input; + const id = row.node.id; + + if (isSelectionGesture(modifiers, input)) { + // Hidden rows drop out: a selection the user cannot see cannot be judged. + const select = selectCommand(id, { + toggle: isSelectionModifier(modifiers, input), + range: modifiers.shiftKey, + preserveHidden: false, + }); + return source === "sticky" ? [select] : [focusCommand(id), select]; + } + + // Alt expands recursively unless it is the selection modifier. + const toggle = toggleCommand( + id, + modifiers.altKey && input.multiSelectModifier !== "alt", + ); + if (input.onTwistie) { + return source === "sticky" + ? [focusCommand(id), selectCommand(id), toggle] + : [focusCommand(id), toggle]; + } + const togglesBody = + source === "row" && + row.expanded !== undefined && + (expandMode === "singleClick" ? detail <= 1 : detail === 2); + return togglesBody + ? [focusCommand(id), selectCommand(id), toggle] + : [focusCommand(id), selectCommand(id)]; +} + +/** The commands a key press means, plus who keeps the event afterwards. */ +export function keyboardCommands(input: KeyboardCommandInput): KeyboardOutcome { + const { key, row, visibleRows, modifiers } = input; + const id = row.node.id; + // A key pressed inside a row action belongs to it, unless it navigates. + if (input.fromAction && !NAVIGATION_KEYS.has(key)) { + return { + commands: NO_COMMANDS, + preventDefault: false, + focusRowElementId: undefined, + }; + } + const outcome = ( + commands: readonly TreeCommand[], + preventDefault = true, + ): KeyboardOutcome => ({ + commands, + preventDefault, + focusRowElementId: input.fromAction ? id : undefined, + }); + const selectionModifier = isSelectionModifier(modifiers, input); + + // Ctrl/Cmd+A, which native scopes to the sibling group before widening. + if ( + selectionModifier && + !modifiers.shiftKey && + key.toLocaleLowerCase() === "a" + ) { + return outcome([{ type: "selectScope", id }]); + } + + switch (key) { + case "ArrowDown": + case "ArrowUp": + case "PageDown": + case "PageUp": { + const page = key === "PageDown" || key === "PageUp"; + const offset = key === "ArrowDown" || key === "PageDown" ? 1 : -1; + return outcome([ + { + type: "move", + id, + offset, + page, + extend: !page && input.multiSelect && modifiers.shiftKey, + }, + ]); + } + case "Home": + case "End": { + const target = key === "Home" ? visibleRows[0] : visibleRows.at(-1); + return outcome(target ? [focusCommand(target.node.id)] : NO_COMMANDS); + } + case "ArrowRight": { + if (row.expanded === false) { + return outcome([toggleCommand(id)]); + } + const child = row.expanded + ? visibleRows[visibleRows.indexOf(row) + 1] + : undefined; + return outcome( + child?.pathIds.includes(id) + ? [focusCommand(child.node.id)] + : NO_COMMANDS, + ); + } + case "ArrowLeft": { + if (row.expanded === true) { + return outcome([toggleCommand(id)]); + } + const parent = parentId(row); + return outcome(parent ? [focusCommand(parent)] : NO_COMMANDS); + } + case "Enter": { + // Ctrl+Shift+Enter toggles this row and leaves the rest selected. + if (selectionModifier && modifiers.shiftKey) { + return outcome([selectCommand(id, { toggle: true })]); + } + const select = selectCommand(id, { toggle: selectionModifier }); + const alsoToggles = + row.expanded !== undefined && input.expandMode === "singleClick"; + return outcome(alsoToggles ? [select, toggleCommand(id)] : [select]); + } + case " ": + // A leaf has nothing to toggle, so Space selects it instead. + return outcome([ + row.expanded === undefined + ? selectCommand(id, { toggle: selectionModifier }) + : toggleCommand(id), + ]); + case "Escape": { + const clearSelection = input.selectedCount > 0; + const clearFocus = input.selectedCount <= 1 && input.hasFocusedRow; + return outcome( + [{ type: "dismiss", clearSelection, clearFocus }], + clearSelection || input.hasFocusedRow, + ); + } + default: { + // A bare printable key types ahead; anything else is the host's. + const typesAhead = + key.length === 1 && + !modifiers.ctrlKey && + !modifiers.metaKey && + !modifiers.altKey; + return outcome( + typesAhead + ? [{ type: "typeahead", id, key: key.toLocaleLowerCase() }] + : NO_COMMANDS, + typesAhead, + ); + } + } +} diff --git a/packages/ui/src/components/Tree/treeTransition.ts b/packages/ui/src/components/Tree/treeTransition.ts new file mode 100644 index 0000000000..42be5cb2ff --- /dev/null +++ b/packages/ui/src/components/Tree/treeTransition.ts @@ -0,0 +1,512 @@ +/** + * The interaction state props cannot hold: focus, the tab stop, the selection + * anchor, the type-ahead buffer, and container focus. `deriveTreeInteractionView` + * reads it against the current model and resolves what the rows render; + * `transitionTree` folds commands into it. + */ + +import { parentId, type TreeModel, type TreeRowModel } from "./treeModel"; + +import type { TreeCommand } from "./treePolicy"; + +/** How long a type-ahead query keeps collecting keys, as in the native list. */ +const TYPE_QUERY_MS = 800; + +/** The focused row, with its ancestors to fall back on if it disappears. */ +interface FocusTarget { + readonly id: string; + readonly pathIds: readonly string[]; +} + +export interface TreeInteractionState { + readonly focusTarget?: FocusTarget; + /** The row Tab returns to, which outlives a row leaving the viewport. */ + readonly tabTargetId?: string; + /** The selection whose tab stop the user already moved away from. */ + readonly dismissedSelectionKey?: string; + /** The selection the anchor belongs to; a new one from props resets it. */ + readonly anchorKey: string; + /** Where a range selection measures from. */ + readonly anchorId?: string; + readonly hasDomFocus: boolean; + readonly typeQuery?: string; + readonly typeExpires?: number; +} + +/** What the rows render from, derived fresh on every render. */ +interface TreeInteractionView { + readonly state: TreeInteractionState; + readonly controlledKey: string; + readonly selectedIds: ReadonlySet; + readonly focusedId: string | undefined; + readonly anchorId: string | undefined; + readonly guideOwnerIds: ReadonlySet; + readonly tabStopId: string | undefined; +} + +interface TransitionInput { + readonly model: TreeModel; + readonly controlledIds: readonly string[]; + readonly expandedIds: readonly string[]; + readonly multiSelect: boolean; + /** Rows a page key should travel, measured against the scroller. */ + readonly pageOffset?: number; + readonly now: number; +} + +interface TreeTransition { + readonly state: TreeInteractionState; + /** Set only when the commands changed it, since selection is controlled. */ + readonly selection?: readonly string[]; + readonly expandedIds?: readonly string[]; + readonly focusTree: boolean; +} + +/** Selections compare by value: the ids arrive fresh in props each render. */ +const selectionKey = (ids: readonly string[]): string => + JSON.stringify([...new Set(ids)].sort()); +const NO_SELECTION_KEY = selectionKey([]); + +const focusTarget = (row: TreeRowModel): FocusTarget => ({ + id: row.node.id, + pathIds: row.pathIds, +}); + +export function initialTreeInteractionState( + controlledIds: readonly string[], +): TreeInteractionState { + return { + anchorKey: selectionKey(controlledIds), + anchorId: controlledIds[0], + hasDomFocus: false, + }; +} + +/** + * Points the state at rows the model still has, returning it unchanged when it + * already does; callers compare by identity to spot data moving under them. + */ +function reconcile( + state: TreeInteractionState, + model: TreeModel, +): TreeInteractionState { + const { rowsById, visibleIds } = model; + if (state.focusTarget && !rowsById.has(state.focusTarget.id)) { + const fallbackId = state.focusTarget.pathIds.findLast((id) => + visibleIds.has(id), + ); + const fallback = fallbackId ? rowsById.get(fallbackId) : undefined; + return { + ...state, + focusTarget: fallback ? focusTarget(fallback) : undefined, + tabTargetId: fallbackId, + }; + } + if (state.tabTargetId && !rowsById.has(state.tabTargetId)) { + return { ...state, tabTargetId: undefined }; + } + return state; +} + +/** The guides VS Code draws solid: the paths down to selection and focus. */ +function activeGuideOwners( + visibleRows: readonly TreeRowModel[], + selectedIds: ReadonlySet, + focusedId: string | undefined, +): ReadonlySet { + const owners = new Set(); + for (const row of visibleRows) { + if (!selectedIds.has(row.node.id) && focusedId !== row.node.id) { + continue; + } + const ownerId = row.expanded ? row.node.id : parentId(row); + if (ownerId) { + owners.add(ownerId); + } + } + return owners; +} + +export function deriveTreeInteractionView( + state: TreeInteractionState, + model: TreeModel, + controlledIds: readonly string[], +): TreeInteractionView { + const { visibleRows, rowsById, visibleIds } = model; + const nextState = reconcile(state, model); + const { focusTarget: focus, tabTargetId } = nextState; + const focusedId = focus && visibleIds.has(focus.id) ? focus.id : undefined; + // Focus kept out of view holds the tab stop, so Tab cannot move the user. + const hiddenFocus = + focus !== undefined && !focusedId && rowsById.has(focus.id); + const selectedIds = new Set(controlledIds); + const controlledKey = selectionKey(controlledIds); + const claimedSelection = + nextState.dismissedSelectionKey === controlledKey + ? undefined + : visibleRows.find((row) => selectedIds.has(row.node.id))?.node.id; + const tabTarget = + tabTargetId && visibleIds.has(tabTargetId) ? tabTargetId : undefined; + + return { + state: nextState, + controlledKey, + selectedIds, + focusedId, + anchorId: + nextState.anchorKey === controlledKey + ? nextState.anchorId + : controlledIds[0], + guideOwnerIds: activeGuideOwners( + visibleRows, + selectedIds, + nextState.hasDomFocus ? focusedId : undefined, + ), + tabStopId: + claimedSelection ?? + tabTarget ?? + (hiddenFocus ? undefined : visibleRows[0]?.node.id), + }; +} + +/** Adopts `row` as the focused row on first entry, never after. */ +export function treeFocusChanged( + state: TreeInteractionState, + focused: boolean, + row?: TreeRowModel, +): TreeInteractionState { + if (!focused) { + return state.hasDomFocus ? { ...state, hasDomFocus: false } : state; + } + return { + ...state, + focusTarget: state.focusTarget ?? (row ? focusTarget(row) : undefined), + hasDomFocus: true, + }; +} + +/** Focus moved to `row`, which also becomes the tab stop from now on. */ +export function rowFocused( + state: TreeInteractionState, + row: TreeRowModel, + controlledKey: string, +): TreeInteractionState { + return { + ...state, + focusTarget: focusTarget(row), + tabTargetId: row.node.id, + dismissedSelectionKey: controlledKey, + }; +} + +/** + * The native range: the run of selected rows around the anchor is released + * first, so shrinking a range back over itself deselects what it passes. + */ +function selectionRange( + visibleRows: readonly TreeRowModel[], + selectedIds: ReadonlySet, + anchorId: string, + targetId: string, +): Set | undefined { + const rowIds = visibleRows.map((row) => row.node.id); + const anchor = rowIds.indexOf(anchorId); + const target = rowIds.indexOf(targetId); + if (anchor < 0 || target < 0) { + return undefined; + } + const ids = new Set(selectedIds); + let start = anchor; + let end = anchor; + while (start > 0 && ids.has(rowIds[start - 1] ?? "")) { + start--; + } + while (end < rowIds.length - 1 && ids.has(rowIds[end + 1] ?? "")) { + end++; + } + for (const id of rowIds.slice(start, end + 1)) { + ids.delete(id); + } + for (const id of rowIds.slice( + Math.min(anchor, target), + Math.max(anchor, target) + 1, + )) { + ids.add(id); + } + return ids; +} + +interface SelectionResult { + readonly ids: ReadonlySet; + readonly anchorId: string; +} + +/** The selection a `select` command produces, and the anchor it leaves. */ +function selectRow( + model: TreeModel, + selectedIds: ReadonlySet, + anchorId: string | undefined, + multiSelect: boolean, + row: TreeRowModel, + options: { toggle: boolean; range: boolean; preserveHidden: boolean }, +): SelectionResult { + const id = row.node.id; + if (!multiSelect) { + return { ids: new Set([id]), anchorId: id }; + } + const ids = new Set( + options.preserveHidden + ? selectedIds + : [...selectedIds].filter((selectedId) => + model.visibleIds.has(selectedId), + ), + ); + if (options.range && anchorId) { + const rangeIds = selectionRange(model.visibleRows, ids, anchorId, id); + if (rangeIds) { + return { ids: rangeIds, anchorId }; + } + } + if (options.toggle && ids.delete(id)) { + return { ids, anchorId: id }; + } + if (!options.toggle) { + ids.clear(); + } + ids.add(id); + return { ids, anchorId: id }; +} + +/** + * `list.selectAll` on a tree: the row's sibling group, widening to include the + * parent once that whole group is already selected. + */ +function scopedSelection( + model: TreeModel, + selectedIds: ReadonlySet, + row: TreeRowModel, +): Set { + const scopeId = parentId(row); + const scoped = model.rows.filter( + (candidate) => scopeId === undefined || candidate.pathIds.includes(scopeId), + ); + const ids = new Set(scoped.map((candidate) => candidate.node.id)); + const scope = scopeId ? model.rowsById.get(scopeId) : undefined; + if ( + scope && + scoped.every((candidate) => selectedIds.has(candidate.node.id)) + ) { + ids.add(scope.node.id); + } + return ids; +} + +function togglingBranches( + row: TreeRowModel, + model: TreeModel, + recursive: boolean, +): readonly TreeRowModel[] { + if (!recursive) { + return [row]; + } + return model.rows.filter( + (candidate) => + candidate.node.children !== undefined && + (candidate === row || candidate.pathIds.includes(row.node.id)), + ); +} + +/** + * Expansion is data, so the ids come back in tree order. Ids the data does not + * have are kept, so a branch that loads later reopens. + */ +function toggleExpansion( + row: TreeRowModel, + model: TreeModel, + expandedIds: readonly string[], + recursive: boolean, +): readonly string[] { + const next = new Set(expandedIds); + for (const branch of togglingBranches(row, model, recursive)) { + if (row.expanded) { + next.delete(branch.node.id); + } else { + next.add(branch.node.id); + } + } + return [ + ...model.rows + .filter((candidate) => next.has(candidate.node.id)) + .map((candidate) => candidate.node.id), + ...[...next].filter((id) => !model.rowsById.has(id)), + ]; +} + +/** + * Prefix first, then a fuzzy subsequence, as the native list does. A repeated + * single key walks the rows starting with it instead of matching the run. + */ +function typeaheadMatch( + visibleRows: readonly TreeRowModel[], + query: string, + current: TreeRowModel, +): TreeRowModel | undefined { + const repeated = + query.length > 1 && [...query].every((key) => key === query[0]); + const value = (repeated ? query[0] : query)?.toLocaleLowerCase() ?? ""; + const from = + query.length === 1 || repeated + ? visibleRows.indexOf(current) + 1 + : visibleRows.indexOf(current); + const ordered = visibleRows.map( + (_, offset) => visibleRows[(from + offset) % visibleRows.length], + ); + const fuzzy = (row: TreeRowModel): boolean => { + let index = 0; + for (const character of row.textValue.toLocaleLowerCase()) { + if (character === value[index] && ++index === value.length) { + return true; + } + } + return false; + }; + return ( + ordered.find((row) => + row?.textValue.toLocaleLowerCase().startsWith(value), + ) ?? ordered.find((row) => row && fuzzy(row)) + ); +} + +export function transitionTree( + state: TreeInteractionState, + commands: readonly TreeCommand[], + input: TransitionInput, +): TreeTransition { + const { model } = input; + const view = deriveTreeInteractionView(state, model, input.controlledIds); + let nextState = view.state; + let selectedIds = view.selectedIds; + let currentKey = view.controlledKey; + let anchorId = view.anchorId; + let selection: readonly string[] | undefined; + let expandedIds: readonly string[] | undefined; + let focusTree = false; + + const setAnchor = (id: string | undefined): void => { + anchorId = id; + nextState = { ...nextState, anchorKey: currentKey, anchorId: id }; + }; + const select = (ids: ReadonlySet, nextAnchor?: string): void => { + selection = model.rows + .filter((row) => ids.has(row.node.id)) + .map((row) => row.node.id); + selectedIds = new Set(selection); + currentKey = selectionKey(selection); + nextState = { ...nextState, dismissedSelectionKey: currentKey }; + if (nextAnchor !== undefined) { + setAnchor(nextAnchor); + } + }; + const focus = (row: TreeRowModel | undefined): void => { + if (!row || !model.visibleIds.has(row.node.id)) { + return; + } + nextState = rowFocused(nextState, row, currentKey); + focusTree = true; + }; + + for (const command of commands) { + const row = "id" in command ? model.rowsById.get(command.id) : undefined; + switch (command.type) { + case "focus": + focus(row); + break; + case "select": + if (row) { + const result = selectRow( + model, + selectedIds, + anchorId, + input.multiSelect, + row, + command, + ); + select(result.ids, result.anchorId); + } + break; + case "selectScope": + if (row) { + select(scopedSelection(model, selectedIds, row)); + } + break; + case "move": { + if (!row) { + break; + } + const rows = model.visibleRows; + const offset = command.page + ? (input.pageOffset ?? command.offset) + : command.offset; + const index = rows.indexOf(row) + offset; + const target = rows[Math.min(Math.max(index, 0), rows.length - 1)]; + if (!target) { + break; + } + if (command.extend) { + const rangeAnchor = anchorId ?? row.node.id; + const ids = selectionRange( + rows, + selectedIds, + rangeAnchor, + target.node.id, + ); + if (ids) { + select(ids, rangeAnchor); + } + } else { + setAnchor(target.node.id); + } + focus(target); + break; + } + case "toggle": + if (row?.expanded !== undefined) { + expandedIds = toggleExpansion( + row, + model, + expandedIds ?? input.expandedIds, + command.recursive, + ); + } + break; + case "typeahead": { + if (!row) { + break; + } + const query = + nextState.typeQuery && input.now < (nextState.typeExpires ?? 0) + ? nextState.typeQuery + command.key + : command.key; + nextState = { + ...nextState, + typeQuery: query, + typeExpires: input.now + TYPE_QUERY_MS, + }; + focus(typeaheadMatch(model.visibleRows, query, row)); + break; + } + case "dismiss": + if (command.clearSelection) { + select(new Set()); + } + if (command.clearFocus) { + nextState = { ...nextState, focusTarget: undefined }; + focusTree = true; + } + currentKey = NO_SELECTION_KEY; + setAnchor(undefined); + break; + } + } + return { state: nextState, selection, expandedIds, focusTree }; +} diff --git a/packages/ui/src/components/Tree/useTreeAdapter.ts b/packages/ui/src/components/Tree/useTreeAdapter.ts new file mode 100644 index 0000000000..af66f89106 --- /dev/null +++ b/packages/ui/src/components/Tree/useTreeAdapter.ts @@ -0,0 +1,339 @@ +import { + type KeyboardEvent, + type MouseEvent, + useMemo, + useRef, + useState, +} from "react"; + +import { + closestRow, + hitTwistie, + nestedInteractiveTarget, + scrollableAncestor, +} from "./rowDom"; +import { + createTreeModel, + ROW_HEIGHT_PX, + rowTooltip, + type TreeNode, + type TreeRowModel, +} from "./treeModel"; +import { + isSelectionGesture, + keyboardCommands, + pointerCommands, + type TreeCommand, + type TreeCommandBehavior, + type TreeExpandMode, + type TreeMultiSelectModifier, +} from "./treePolicy"; +import { + deriveTreeInteractionView, + initialTreeInteractionState, + rowFocused, + transitionTree, + treeFocusChanged, + type TreeInteractionState, +} from "./treeTransition"; + +import type { TreeHoverControl } from "./TreeHover"; + +const NO_IDS: readonly string[] = []; +const NO_GUIDES = ""; +const MODIFIER_KEYS: ReadonlySet = new Set([ + "Alt", + "Control", + "Meta", + "Shift", +]); + +/** Single selection, or multi-selection, never a mix of the two APIs. */ +export type SelectionProps = + | { + readonly multiSelect?: false; + readonly selectedItemId?: string; + readonly onSelectedItemChange?: (itemId: string | undefined) => void; + readonly selectedItemIds?: never; + readonly onSelectedItemsChange?: never; + } + | { + readonly multiSelect: true; + readonly selectedItemIds?: readonly string[]; + readonly onSelectedItemsChange?: (itemIds: readonly string[]) => void; + readonly selectedItemId?: never; + readonly onSelectedItemChange?: never; + }; + +interface AdapterOptions { + readonly nodes: readonly TreeNode[]; + readonly expandedIds: readonly string[]; + readonly onExpandedIdsChange?: (expandedIds: readonly string[]) => void; + readonly expandMode: TreeExpandMode; + readonly multiSelectModifier: TreeMultiSelectModifier; + readonly onKeyDown?: (event: KeyboardEvent) => void; + readonly treeRef: React.RefObject; + readonly hoverControl?: TreeHoverControl; +} + +function rowElement(tree: HTMLElement | null, id: string): HTMLElement | null { + return ( + tree?.querySelector(`[data-tree-id="${CSS.escape(id)}"]`) ?? + null + ); +} + +function controlledIds(selection: SelectionProps): readonly string[] { + if (selection.multiSelect) { + return selection.selectedItemIds ?? NO_IDS; + } + return selection.selectedItemId === undefined + ? NO_IDS + : [selection.selectedItemId]; +} + +/** + * Where the pure modules meet React and the DOM. Events arrive delegated from + * the container, which leaves rows as memoized presentation. + */ +export function useTreeAdapter(options: AdapterOptions & SelectionProps) { + const { nodes, expandedIds, treeRef } = options; + // Explicit: memoized rows compare against these row objects, and a consumer + // of the published package may not run the React Compiler. + const model = useMemo( + () => createTreeModel(nodes, new Set(expandedIds)), + [nodes, expandedIds], + ); + const { visibleRows, rowsById } = model; + const selected = controlledIds(options); + const chordRef = useRef(false); + const [state, setState] = useState(() => + initialTreeInteractionState(selected), + ); + const view = deriveTreeInteractionView(state, model, selected); + // Identity, not value: the view returns this same state unless the data + // moved, and then the reconciled one renders instead. + if (view.state !== state) { + setState(view.state); + } + const behavior: TreeCommandBehavior = { + expandMode: options.expandMode, + multiSelect: Boolean(options.multiSelect), + multiSelectModifier: options.multiSelectModifier, + }; + + /** + * How far a page key travels: to the far edge of the viewport, or a whole + * viewport once the focused row is already sitting on it. + */ + const pageOffset = (row: TreeRowModel, direction: 1 | -1): number => { + const tree = treeRef.current; + const scroller = tree ? scrollableAncestor(tree) : undefined; + if (!tree || !scroller) { + return direction; + } + const viewport = scroller.getBoundingClientRect(); + if (viewport.height > 0) { + const inView = [ + ...tree.querySelectorAll("[data-tree-id]"), + ].filter((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.bottom > viewport.top && bounds.top < viewport.bottom; + }); + const edge = direction === 1 ? inView.at(-1) : inView[0]; + const edgeId = edge?.dataset.treeId; + const edgeRow = edgeId ? rowsById.get(edgeId) : undefined; + const offset = edgeRow + ? visibleRows.indexOf(edgeRow) - visibleRows.indexOf(row) + : 0; + if (offset !== 0) { + return offset; + } + scroller.scrollBy?.(0, direction * scroller.clientHeight); + } + return ( + direction * Math.max(1, Math.floor(scroller.clientHeight / ROW_HEIGHT_PX)) + ); + }; + + const dispatch = (commands: readonly TreeCommand[]): void => { + const move = commands.find((command) => command.type === "move"); + const moved = move ? rowsById.get(move.id) : undefined; + const result = transitionTree(state, commands, { + model, + controlledIds: selected, + expandedIds, + multiSelect: behavior.multiSelect, + pageOffset: + move?.page && moved ? pageOffset(moved, move.offset) : undefined, + now: Date.now(), + }); + setState(result.state); + if (result.selection) { + if (options.multiSelect) { + options.onSelectedItemsChange?.(result.selection); + } else { + options.onSelectedItemChange?.(result.selection[0]); + } + } + if (result.expandedIds) { + options.onExpandedIdsChange?.(result.expandedIds); + } + if (result.focusTree) { + treeRef.current?.focus(); + } + }; + + const rowFor = (target: EventTarget | null): TreeRowModel | undefined => { + const id = closestRow(target)?.dataset.treeId; + return id ? rowsById.get(id) : undefined; + }; + const onFocusIn = (target: EventTarget | null): void => { + const row = rowFor(target); + // A row focused in its own right becomes the focus target; entering the + // container only adopts one. + if (row && target === closestRow(target)) { + setState((current) => rowFocused(current, row, view.controlledKey)); + } + // Only an entry with no focus target adopts a row: a focus mark the same + // gesture just cleared must not come back when focus returns here. + const entered = view.state.focusTarget + ? undefined + : (row ?? rowsById.get(view.tabStopId ?? "")); + setState((current) => treeFocusChanged(current, true, entered)); + }; + const onPointer = ( + row: TreeRowModel, + event: MouseEvent, + onTwistie: boolean, + source: "row" | "sticky", + ): void => { + dispatch( + pointerCommands({ + ...behavior, + row, + source, + onTwistie, + detail: event.detail, + modifiers: event, + }), + ); + }; + const onClick = (event: MouseEvent): void => { + const element = closestRow(event.target); + const row = rowFor(event.target); + if (!row || !element) { + return; + } + // Focusable content and the action bar handle their own clicks. + if ( + nestedInteractiveTarget(event.target, element) || + (event.target instanceof Element && + event.target.closest(".ui-tree-item__action")) + ) { + return; + } + onPointer(row, event, hitTwistie(row, event.target), "row"); + }; + /** VS Code binds `list.showHover` to the Ctrl+K Ctrl+I chord. */ + const showHoverChord = ( + event: KeyboardEvent, + ): "pending" | "show" | undefined => { + const held = (event.ctrlKey || event.metaKey) && !event.altKey; + const key = held ? event.key.toLowerCase() : ""; + const armed = chordRef.current; + chordRef.current = !armed && key === "k"; + if (chordRef.current) { + return "pending"; + } + return armed && key === "i" ? "show" : undefined; + }; + const showHover = (row: TreeRowModel | undefined): void => { + const element = row + ? rowElement(treeRef.current, row.node.id)?.querySelector( + ".ui-tree-item__content", + ) + : undefined; + options.hoverControl?.current?.( + row && element ? { content: rowTooltip(row), element } : undefined, + true, + ); + }; + const onKeyDown = (event: KeyboardEvent): void => { + options.onKeyDown?.(event); + if (event.defaultPrevented) { + return; + } + const row = + rowFor(event.target) ?? + (view.focusedId ? rowsById.get(view.focusedId) : undefined) ?? + (view.tabStopId ? rowsById.get(view.tabStopId) : undefined) ?? + visibleRows[0]; + if (!row) { + return; + } + // A hover the keyboard opened stays only until the next real key. + if (!MODIFIER_KEYS.has(event.key)) { + const chord = showHoverChord(event); + if (chord) { + if (chord === "show") { + showHover(row); + } + event.preventDefault(); + return; + } + showHover(undefined); + } + const interactive = nestedInteractiveTarget( + event.target, + event.currentTarget, + ); + const result = keyboardCommands({ + ...behavior, + key: event.key, + row, + visibleRows, + fromAction: + interactive instanceof HTMLElement && + interactive.dataset.treeId === undefined, + selectedCount: view.selectedIds.size, + hasFocusedRow: view.focusedId !== undefined, + modifiers: event, + }); + if (result.focusRowElementId) { + rowElement(treeRef.current, result.focusRowElementId)?.focus(); + } + dispatch(result.commands); + if (result.preventDefault) { + event.preventDefault(); + } + }; + + return { + model, + focusedId: view.focusedId, + tabStopId: view.tabStopId, + hasDomFocus: view.state.hasDomFocus, + selectedIds: view.selectedIds, + /** One character per ancestor, `1` where its guide is active. */ + guideFlags: (row: TreeRowModel): string => + view.guideOwnerIds.size === 0 + ? NO_GUIDES + : row.pathIds + .map((id) => (view.guideOwnerIds.has(id) ? "1" : "0")) + .join(""), + dispatch, + isSelectionGesture: (event: MouseEvent) => + isSelectionGesture(event, behavior), + onFocusIn, + onBlurOut: () => { + showHover(undefined); + setState((current) => treeFocusChanged(current, false)); + }, + onClick, + onPointer, + onKeyDown, + }; +} + +export type TreeAdapter = ReturnType; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0a..2724e330bb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -67,9 +67,14 @@ export { type KeybindingPlatform, } from "./keybinding"; export { + type HoverDelegate, + HoverDelegateScope, + type HoverTarget, Tooltip, type TooltipProps, TooltipProvider, type TooltipProviderProps, } from "./components/Tooltip/Tooltip"; +export { Tree, type TreeProps } from "./components/Tree/Tree"; +export type { TreeNode } from "./components/Tree/treeModel"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/ref.ts b/packages/ui/src/ref.ts new file mode 100644 index 0000000000..1ceeaca75b --- /dev/null +++ b/packages/ui/src/ref.ts @@ -0,0 +1,16 @@ +import type { Ref } from "react"; + +/** + * Hands a node to a consumer's `ref` prop, whichever form it takes, so a + * component can keep its own ref to a node it also forwards. + */ +export function setForwardedRef( + ref: Ref | undefined, + value: T | null, +): void { + if (typeof ref === "function") { + ref(value); + } else if (ref) { + ref.current = value; + } +} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index e4345fa09f..0e4046a7bf 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -147,11 +147,70 @@ --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) + ); + /* No list.selectionOutline or list.hoverOutline color exists; native feeds + both from activeContrastBorder. */ + --ui-list-selection-outline: var(--vscode-contrastActiveBorder, transparent); + --ui-list-inactive-focus-outline: var( + --vscode-list-inactiveFocusOutline, + transparent + ); + --ui-list-hover-outline: var(--vscode-contrastActiveBorder, transparent); + --ui-list-focus-and-selection-outline: var( + --vscode-list-focusAndSelectionOutline, + var(--vscode-contrastActiveBorder, 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) + ); + --ui-tree-sticky-shadow: var( + --vscode-sideBarStickyScroll-shadow, + transparent + ); + /* Menus */ --ui-menu-background: var(--vscode-menu-background); --ui-menu-foreground: var(--vscode-menu-foreground); diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index d37c2067f4..8b8a3d9ba9 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -9,6 +9,7 @@ import { VscodeToolbarButton, } from "@vscode-elements/react-elements"; import { useState } from "react"; +import { expect, waitFor } from "storybook/test"; import { Button } from "./components/Button/Button"; import { @@ -177,23 +178,33 @@ const Parity = (): React.JSX.Element => (
); -/* The reference menu renders inline; ours is a real portalled DropdownMenu, - so the play function opens it under its trigger. */ +/* The reference menu renders inline with no trigger, so ours hangs off a + collapsed one and both start at the same height. */ const MenuParity = (): React.JSX.Element => (
+ Ours + VS Code Elements - - - - + + Start workspace Open logs @@ -230,5 +241,15 @@ export const Menu: Story = { render: () => , play: async ({ canvasElement }) => { await openMenu(canvasElement, "Menu"); + const reference = canvasElement.querySelector("vscode-context-menu"); + await expect(reference).not.toBeNull(); + // Opening our portalled menu clicks outside the reference menu. Reopen + // it after that click so Pixel always captures both implementations. + reference?.setAttribute("show", ""); + await waitFor(() => + expect( + reference?.shadowRoot?.querySelector(".context-menu"), + ).not.toBeNull(), + ); }, }; diff --git a/packages/ui/storybook/Tree.demo.tsx b/packages/ui/storybook/Tree.demo.tsx new file mode 100644 index 0000000000..b85c426fe2 --- /dev/null +++ b/packages/ui/storybook/Tree.demo.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; + +import { Tree, type TreeProps } from "../src/components/Tree/Tree"; + +import type { TreeNode } from "../src/components/Tree/treeModel"; + +const NO_IDS: readonly string[] = []; + +/** Every branch id, so a demo tree starts fully open unless told otherwise. */ +function branchIds(nodes: readonly TreeNode[]): readonly string[] { + return nodes.flatMap((node) => + node.children ? [node.id, ...branchIds(node.children)] : [], + ); +} + +type DistributiveOmit = T extends unknown + ? Omit> + : never; + +export type TreeDemoProps = DistributiveOmit< + TreeProps, + "onSelectedItemChange" | "onSelectedItemsChange" +>; + +/** Holds the selection and expansion state a controlled `Tree` expects. */ +export function TreeDemo({ + multiSelect, + selectedItemId, + selectedItemIds, + expandedIds, + ...treeProps +}: TreeDemoProps): React.JSX.Element { + const [selectedId, setSelectedId] = useState(selectedItemId); + const [selectedIds, setSelectedIds] = useState(selectedItemIds ?? NO_IDS); + const [expanded, setExpanded] = useState( + () => expandedIds ?? branchIds(treeProps.nodes), + ); + const selection = multiSelect + ? ({ + multiSelect: true, + selectedItemIds: selectedIds, + onSelectedItemsChange: setSelectedIds, + } as const) + : ({ + multiSelect: false, + selectedItemId: selectedId, + onSelectedItemChange: setSelectedId, + } as const); + + return ( + + ); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index de3f039b95..d8416421b9 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/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts index f6d8728e28..7a47393c61 100644 --- a/packages/webview-shared/createWebviewConfig.ts +++ b/packages/webview-shared/createWebviewConfig.ts @@ -45,6 +45,11 @@ export function createWebviewConfig( resolve: { alias: { "@repo/webview-shared": resolve(dirname, "../webview-shared/src"), + // @repo/ui ships TypeScript source and its package-internal + // subpath imports; bundling it needs the same direct resolution + "@repo/ui": resolve(dirname, "../ui/src"), + "#cx": resolve(dirname, "../ui/src/cx.ts"), + "#codicons": resolve(dirname, "../ui/src/codicons.ts"), }, }, }); diff --git a/packages/workspaces/package.json b/packages/workspaces/package.json index d7a3b34591..f64d783f00 100644 --- a/packages/workspaces/package.json +++ b/packages/workspaces/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@repo/shared": "workspace:*", + "@repo/ui": "workspace:*", "@repo/webview-shared": "workspace:*", "@tanstack/react-query": "catalog:", "@vscode-elements/react-elements": "catalog:", diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx index abed211177..b8cdff9266 100644 --- a/packages/workspaces/src/App.tsx +++ b/packages/workspaces/src/App.tsx @@ -1,3 +1,7 @@ +import { MOCK_WORKSPACES } from "./mockData"; +import { WorkspacesPanel } from "./WorkspacesPanel"; + export default function App() { - return
TODO
; + // Prototype: mock data only; IPC arrives with the real provider wiring. + return ; } diff --git a/packages/workspaces/src/WorkspaceFilterSelect.tsx b/packages/workspaces/src/WorkspaceFilterSelect.tsx new file mode 100644 index 0000000000..37c871786b --- /dev/null +++ b/packages/workspaces/src/WorkspaceFilterSelect.tsx @@ -0,0 +1,60 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + Icon, +} from "@repo/ui"; + +/** Which workspace set the panel lists. */ +export type WorkspaceFilter = "mine" | "all" | "shared"; + +const FILTER_LABELS: Record = { + mine: "Mine", + all: "All", + shared: "Shared", +}; + +export interface WorkspaceFilterSelectProps { + /** "Shared" is hidden unless the signed-in user is an owner. */ + isOwner?: boolean; + value: WorkspaceFilter; + onChange: (filter: WorkspaceFilter) => void; +} + +export function WorkspaceFilterSelect({ + isOwner = false, + value, + onChange, +}: WorkspaceFilterSelectProps): React.JSX.Element { + return ( + + + + + + onChange(filter as WorkspaceFilter)} + > + + {FILTER_LABELS.mine} + + + {FILTER_LABELS.all} + + {isOwner ? ( + + {FILTER_LABELS.shared} + + ) : null} + + + + ); +} diff --git a/packages/workspaces/src/WorkspacesPanel.css b/packages/workspaces/src/WorkspacesPanel.css new file mode 100644 index 0000000000..2516ae1487 --- /dev/null +++ b/packages/workspaces/src/WorkspacesPanel.css @@ -0,0 +1,50 @@ +.workspaces-panel { + display: flex; + flex-direction: column; + height: 100vh; +} + +.workspaces-panel__toolbar { + display: flex; + gap: var(--ui-spacing-40); + align-items: center; + padding: var(--ui-spacing-40); +} + +.workspaces-panel__toolbar .ui-search-input { + flex: 1; + min-width: 0; +} + +.workspaces-panel__filter { + gap: var(--ui-spacing-40); + flex: none; +} + +.workspaces-panel__tree { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.workspaces-panel__row-label { + display: inline-flex; + align-items: center; + gap: var(--ui-spacing-60); + min-width: 0; +} + +.workspaces-panel__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspaces-panel__owner { + color: var(--ui-description-foreground); + flex: none; +} + +.workspaces-panel__highlight { + color: var(--vscode-list-highlightForeground, var(--ui-link-foreground)); +} diff --git a/packages/workspaces/src/WorkspacesPanel.tsx b/packages/workspaces/src/WorkspacesPanel.tsx new file mode 100644 index 0000000000..80aece3a97 --- /dev/null +++ b/packages/workspaces/src/WorkspacesPanel.tsx @@ -0,0 +1,146 @@ +import { + EmptyState, + ErrorState, + LoadingState, + SearchInput, + Tree, + type TreeNode, +} from "@repo/ui"; +import { useMemo, useState } from "react"; + +import { workspaceNode } from "./rows"; +import { + WorkspaceFilterSelect, + type WorkspaceFilter, +} from "./WorkspaceFilterSelect"; +import "./WorkspacesPanel.css"; + +import type { MockWorkspaceEntry } from "./mockData"; + +export interface WorkspacesPanelProps { + readonly workspaces: readonly MockWorkspaceEntry[]; + /** Gates the "Shared" filter option, like `coder.isOwner`. */ + readonly isOwner?: boolean; + readonly state?: "ready" | "loading" | "error"; + readonly onRetry?: () => void; +} + +function filterEntries( + entries: readonly MockWorkspaceEntry[], + filter: WorkspaceFilter, + query: string, +): readonly MockWorkspaceEntry[] { + const lowered = query.trim().toLocaleLowerCase(); + return entries.filter((entry) => { + if (filter === "mine" && entry.owner !== "me") return false; + if (filter === "shared" && !entry.shared) return false; + if (lowered === "") return true; + const haystack = [ + entry.workspace.name, + entry.workspace.owner_name, + entry.workspace.template_display_name, + ...entry.agents.map((agent) => agent.name), + ] + .join(" ") + .toLocaleLowerCase(); + return haystack.includes(lowered); + }); +} + +/** Expands workspaces and agents, leaving leaf sections collapsed. */ +function initialExpandedIds(nodes: readonly TreeNode[]): readonly string[] { + const ids: string[] = []; + const visit = (node: TreeNode, depth: number): void => { + if (node.children && depth < 2) { + ids.push(node.id); + } + node.children?.forEach((child) => visit(child, depth + 1)); + }; + nodes.forEach((node) => visit(node, 0)); + return ids; +} + +/** Prototype panel: toolbar, filtered tree, and the loading/error/empty states. */ +export function WorkspacesPanel({ + workspaces, + isOwner = false, + state = "ready", + onRetry, +}: WorkspacesPanelProps): React.JSX.Element { + const [filter, setFilter] = useState("mine"); + const [query, setQuery] = useState(""); + const [selectedItemId, setSelectedItemId] = useState(); + + const nodes = useMemo( + () => + filterEntries(workspaces, filter, query).map((entry) => + workspaceNode(entry, filter !== "mine", query), + ), + [workspaces, filter, query], + ); + const [expandedIds, setExpandedIds] = useState(() => + initialExpandedIds(nodes), + ); + + let body: React.JSX.Element; + if (state === "loading") { + body = ; + } else if (state === "error") { + body = ( + + ); + } else if (workspaces.length === 0) { + body = ( + + ); + } else if (nodes.length === 0) { + body = ( + + ); + } else { + body = ( +
+ +
+ ); + } + + return ( +
+
+ + +
+ {body} +
+ ); +} diff --git a/packages/workspaces/src/index.css b/packages/workspaces/src/index.css index 8f414f586f..19de1895bb 100644 --- a/packages/workspaces/src/index.css +++ b/packages/workspaces/src/index.css @@ -1 +1,14 @@ -/* TODO */ +/* UI library theme tokens and codicon font */ +@import "@repo/ui/codicon.css"; +@import "@repo/ui/tokens.css"; + +body { + margin: 0; + padding: 0; + font-family: var(--ui-font-family); + font-size: var(--ui-font-size); + font-weight: var(--ui-font-weight-regular); + color: var(--ui-foreground); + background: var(--ui-background); + overflow: hidden; +} diff --git a/packages/workspaces/src/index.tsx b/packages/workspaces/src/index.tsx index e6bb115928..176fe4bb83 100644 --- a/packages/workspaces/src/index.tsx +++ b/packages/workspaces/src/index.tsx @@ -1,3 +1,4 @@ +import { TooltipProvider } from "@repo/ui"; import { ErrorBoundary } from "@repo/webview-shared/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; @@ -19,7 +20,9 @@ createRoot(root).render( - + + + , diff --git a/packages/workspaces/src/mockData.ts b/packages/workspaces/src/mockData.ts new file mode 100644 index 0000000000..bb4acb8cdd --- /dev/null +++ b/packages/workspaces/src/mockData.ts @@ -0,0 +1,278 @@ +import type { + Workspace, + WorkspaceAgent, + WorkspaceAgentMetadata, + WorkspaceApp, + WorkspaceAppStatus, + WorkspaceBuild, +} from "coder/site/src/api/typesGenerated"; + +/** A workspace with its agents and per-agent metadata for the prototype. */ +export interface MockWorkspaceEntry { + readonly workspace: Workspace; + readonly agents: readonly WorkspaceAgent[]; + readonly metadata: ReadonlyMap; + readonly owner: "me" | "other"; + readonly shared: boolean; +} + +/* Local fixtures instead of @repo/mocks: the mocks package is restricted to + tests and stories, and the panel only needs a few fixed objects. */ + +const mockBuild = ( + overrides: Partial = {}, +): WorkspaceBuild => ({ + id: "build-1", + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", + workspace_id: "workspace-1", + workspace_name: "dev", + workspace_owner_id: "owner-1", + workspace_owner_name: "testuser", + template_version_id: "version-1", + template_version_name: "v1", + build_number: 1, + transition: "start", + initiator_id: "owner-1", + initiator_name: "testuser", + job: { + id: "job-1", + created_at: "2026-08-01T00:00:00Z", + status: "succeeded", + file_id: "file-1", + tags: {}, + queue_position: 0, + queue_size: 0, + organization_id: "org-1", + initiator_id: "owner-1", + input: {}, + type: "workspace_build", + metadata: { + template_version_name: "v1", + template_id: "template-1", + template_name: "devcontainer", + template_display_name: "Dev Container", + template_icon: "/icon.svg", + }, + logs_overflowed: false, + }, + reason: "initiator", + resources: [], + status: "running", + daily_cost: 0, + template_version_preset_id: null, + ...overrides, +}); + +const mockWorkspace = (overrides: Partial = {}): Workspace => ({ + id: "workspace-1", + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", + owner_id: "owner-1", + owner_name: "testuser", + owner_avatar_url: "", + organization_id: "org-1", + organization_name: "test-org", + template_id: "template-1", + template_name: "devcontainer", + template_display_name: "Dev Container", + template_icon: "/icon.svg", + template_allow_user_cancel_workspace_jobs: true, + template_active_version_id: "version-1", + template_require_active_version: false, + template_use_classic_parameter_flow: false, + latest_build: mockBuild(), + latest_app_status: null, + outdated: false, + name: "dev", + last_used_at: "2026-08-13T00:00:00Z", + deleting_at: null, + dormant_at: null, + health: { healthy: true, failing_agents: [] }, + automatic_updates: "never", + allow_renames: false, + favorite: false, + next_start_at: null, + is_prebuild: false, + ...overrides, +}); + +const mockAgent = ( + overrides: Partial = {}, +): WorkspaceAgent => ({ + id: "agent-1", + parent_id: null, + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", + status: "connected", + lifecycle_state: "ready", + name: "main", + resource_id: "resource-1", + architecture: "amd64", + environment_variables: {}, + operating_system: "linux", + logs_length: 0, + logs_overflowed: false, + version: "2.25.0", + api_version: "1.0", + apps: [], + connection_timeout_seconds: 120, + troubleshooting_url: "", + subsystems: [], + health: { healthy: true }, + display_apps: [], + log_sources: [], + scripts: [], + startup_script_behavior: "non-blocking", + ...overrides, +}); + +const mockApp = (overrides: Partial = {}): WorkspaceApp => ({ + id: "app-1", + external: false, + slug: "app-1", + subdomain: false, + sharing_level: "owner", + health: "healthy", + hidden: false, + open_in: "tab", + statuses: [], + ...overrides, +}); + +const mockStatus = ( + overrides: Partial = {}, +): WorkspaceAppStatus => ({ + id: "status-1", + created_at: "2026-08-13T10:00:00Z", + workspace_id: "workspace-1", + agent_id: "agent-1", + app_id: "app-1", + state: "idle", + message: "Idle", + uri: "", + icon: "", + needs_user_attention: false, + ...overrides, +}); + +const mockMetadata = ( + key: string, + displayName: string, + value: string, + collectedAt: string, +): WorkspaceAgentMetadata => ({ + description: { + display_name: displayName, + key, + script: `echo ${value}`, + interval: 10, + timeout: 1, + }, + result: { collected_at: collectedAt, age: 12, value, error: "" }, +}); + +/** + * Mock deployment data: two own workspaces (one running with app statuses and + * metadata, one stopped), one shared running workspace, and one workspace from + * another owner. + */ +export const MOCK_WORKSPACES: readonly MockWorkspaceEntry[] = [ + { + workspace: mockWorkspace({ id: "workspace-dev", name: "dev" }), + agents: [ + mockAgent({ + id: "agent-dev", + apps: [ + mockApp({ + id: "vscode", + slug: "vscode", + display_name: "VS Code Desktop", + }), + mockApp({ + id: "ci", + slug: "ci", + display_name: "CI Watcher", + statuses: [ + mockStatus({ + id: "status-ci-running", + app_id: "ci", + state: "working", + message: "Building packages/ui", + }), + mockStatus({ + id: "status-ci-failed", + app_id: "ci", + state: "failure", + message: "Type check failed in treePolicy.ts", + needs_user_attention: true, + }), + ], + }), + ], + }), + ], + metadata: new Map([ + [ + "agent-dev", + [ + mockMetadata("cpu", "CPU Usage", "23%", "2026-08-13T13:58:00Z"), + mockMetadata( + "branch", + "Git Branch", + "feat/ui-tree-suite", + "2026-08-13T13:55:00Z", + ), + ], + ], + ]), + owner: "me", + shared: false, + }, + { + workspace: mockWorkspace({ + id: "workspace-staging", + name: "staging", + template_name: "kubernetes", + template_display_name: "Kubernetes", + latest_build: mockBuild({ status: "stopped" }), + }), + agents: [ + mockAgent({ + id: "agent-staging", + status: "disconnected", + lifecycle_state: "off", + }), + ], + metadata: new Map(), + owner: "me", + shared: false, + }, + { + workspace: mockWorkspace({ + id: "workspace-shared-review", + name: "code-review", + owner_id: "owner-2", + owner_name: "priya", + shared_with: [], + }), + agents: [mockAgent({ id: "agent-review", name: "review" })], + metadata: new Map(), + owner: "other", + shared: true, + }, + { + workspace: mockWorkspace({ + id: "workspace-ci-pool", + name: "ci-pool", + owner_id: "owner-3", + owner_name: "marcus", + template_name: "ci", + template_display_name: "CI Runner", + }), + agents: [mockAgent({ id: "agent-ci", name: "runner" })], + metadata: new Map(), + owner: "other", + shared: false, + }, +]; diff --git a/packages/workspaces/src/rows.tsx b/packages/workspaces/src/rows.tsx new file mode 100644 index 0000000000..54b99b6f55 --- /dev/null +++ b/packages/workspaces/src/rows.tsx @@ -0,0 +1,227 @@ +import { + IconButton, + StatusPill, + Tooltip, + type TreeNode, + type StatusPillTone, + type CodiconName, +} from "@repo/ui"; +import { formatDistanceToNow } from "date-fns"; + +import type { + Workspace, + WorkspaceAgent, + WorkspaceAgentMetadata, + WorkspaceAppStatus, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +import type { MockWorkspaceEntry } from "./mockData"; + +const WORKSPACE_STATUS_PILLS: Record< + WorkspaceStatus, + { icon: CodiconName; tone: StatusPillTone } +> = { + running: { icon: "play", tone: "success" }, + starting: { icon: "loading", tone: "info" }, + stopped: { icon: "pass", tone: "neutral" }, + failed: { icon: "error", tone: "danger" }, + pending: { icon: "history", tone: "info" }, + canceling: { icon: "loading", tone: "warning" }, + canceled: { icon: "debug-stop", tone: "neutral" }, + deleting: { icon: "loading", tone: "warning" }, + deleted: { icon: "archive", tone: "neutral" }, + stopping: { icon: "loading", tone: "warning" }, +}; + +const AGENT_STATUS_PILLS: Record< + WorkspaceAgent["status"], + { icon: CodiconName; tone: StatusPillTone } +> = { + connected: { icon: "pass", tone: "success" }, + connecting: { icon: "loading", tone: "info" }, + disconnected: { icon: "alert", tone: "warning" }, + timeout: { icon: "alert", tone: "danger" }, +}; + +const APP_STATUS_PILLS: Record< + WorkspaceAppStatus["state"], + { icon: CodiconName; tone: StatusPillTone } +> = { + complete: { icon: "pass", tone: "success" }, + failure: { icon: "error", tone: "danger" }, + idle: { icon: "circle-filled", tone: "neutral" }, + working: { icon: "loading", tone: "info" }, +}; + +function statusPill( + pill: { icon: CodiconName; tone: StatusPillTone }, + label: string, +): React.JSX.Element { + return ( + + {label} + + ); +} + +/** Marks the first case-insensitive match of the search query, like the native views. */ +function highlight(text: string, query: string): React.ReactNode { + const lowered = query.trim().toLocaleLowerCase(); + if (lowered === "") return text; + const index = text.toLocaleLowerCase().indexOf(lowered); + if (index === -1) return text; + return ( + <> + {text.slice(0, index)} + + {text.slice(index, index + lowered.length)} + + {text.slice(index + lowered.length)} + + ); +} + +/** The workspace branch row: name, owner, status pill, and hover actions. */ +export function workspaceNode( + entry: MockWorkspaceEntry, + showOwner: boolean, + query: string, +): TreeNode { + const { workspace } = entry; + const status = workspace.latest_build.status; + const textValue = showOwner + ? `${workspace.name} (${workspace.owner_name})` + : workspace.name; + return { + id: workspace.id, + label: ( + + + {highlight(workspace.name, query)} + + {showOwner ? ( + + {highlight(workspace.owner_name, query)} + + ) : null} + {statusPill(WORKSPACE_STATUS_PILLS[status], status)} + + ), + textValue, + icon: "window", + action: ( + <> + + + + + ), + children: entry.agents.map((agent) => agentNode(entry, agent, query)), + }; +} + +/** The agent row: name, connection pill, hover actions, and inline sections. */ +export function agentNode( + entry: MockWorkspaceEntry, + agent: WorkspaceAgent, + query: string, +): TreeNode { + const running = entry.workspace.latest_build.status === "running"; + const pill = running + ? statusPill(AGENT_STATUS_PILLS[agent.status], agent.status) + : statusPill({ icon: "pass", tone: "neutral" }, "offline"); + const sections = [ + appStatusSection(entry.workspace, agent), + metadataSection(agent.id, entry.metadata.get(agent.id)), + ].filter((section): section is TreeNode => section !== undefined); + return { + id: agent.id, + label: ( + + + {highlight(agent.name, query)} + + {pill} + + ), + textValue: agent.name, + icon: "server", + action: ( + <> + + + + ), + children: sections.length > 0 ? sections : undefined, + }; +} + +/** App statuses inline under their agent; nothing when no app reports any. */ +export function appStatusSection( + workspace: Workspace, + agent: WorkspaceAgent, +): TreeNode | undefined { + const statuses = agent.apps.flatMap((app) => + app.statuses.map((status) => ({ app, status })), + ); + if (statuses.length === 0) return undefined; + return { + id: `${agent.id}/app-statuses`, + label: "App Statuses", + children: statuses.map(({ app, status }) => ({ + id: status.id, + label: ( + + {statusPill(APP_STATUS_PILLS[status.state], status.state)} + + {app.display_name ?? app.slug} + + {status.message} + + ), + textValue: `${app.display_name ?? app.slug}: ${status.message}`, + })), + }; +} + +/** Agent metadata inline under their agent; values carry a collected-at tooltip. */ +export function metadataSection( + agentId: string, + metadata: readonly WorkspaceAgentMetadata[] | undefined, +): TreeNode | undefined { + if (!metadata || metadata.length === 0) return undefined; + return { + id: `${agentId}/metadata`, + label: "Agent Metadata", + children: metadata.map((entry) => ({ + id: `${agentId}/metadata/${entry.description.key}`, + label: ( + + + {entry.description.display_name} + + + Collected{" "} + {formatDistanceToNow(new Date(entry.result.collected_at), { + addSuffix: true, + })} + + } + > + {entry.result.value} + + + ), + textValue: `${entry.description.display_name}: ${entry.result.value}`, + })), + }; +} diff --git a/packages/workspaces/tsconfig.json b/packages/workspaces/tsconfig.json index 27059a9803..2cc370cb52 100644 --- a/packages/workspaces/tsconfig.json +++ b/packages/workspaces/tsconfig.json @@ -1,8 +1,10 @@ { "extends": "../tsconfig.packages.json", "compilerOptions": { + "resolveJsonModule": true, "paths": { "@repo/shared": ["../shared/src"], + "@repo/ui": ["../ui/src"], "@repo/webview-shared": ["../webview-shared/src"] } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cf91b0392..b362a0d514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ catalogs: '@tanstack/react-query': specifier: ^5.102.2 version: 5.102.2 + '@testing-library/user-event': + specifier: ^14.6.3 + version: 14.6.6 '@types/react': specifier: ^19.2.18 version: 19.2.18 @@ -58,6 +61,9 @@ catalogs: storybook: specifier: ^10.5.10 version: 10.5.10 + storybook-addon-pseudo-states: + specifier: ^10.5.10 + version: 10.5.10 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -170,6 +176,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8) + '@testing-library/user-event': + specifier: 'catalog:' + version: 14.6.6(@testing-library/dom@10.4.1) '@tsconfig/node22': specifier: ^22.0.6 version: 22.0.6 @@ -299,6 +308,9 @@ importers: storybook: specifier: 'catalog:' version: 10.5.10(@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.10(storybook@10.5.10) typescript: specifier: 'catalog:' version: 6.0.3 @@ -448,6 +460,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.24 version: 2.1.24(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-tooltip': specifier: ^1.2.16 version: 1.2.16(@types/react-dom@19.2.5)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8) @@ -507,6 +522,9 @@ importers: '@repo/shared': specifier: workspace:* version: link:../shared + '@repo/ui': + specifier: workspace:* + version: link:../ui '@repo/webview-shared': specifier: workspace:* version: link:../webview-shared @@ -4862,6 +4880,11 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + storybook-addon-pseudo-states@10.5.10: + resolution: {integrity: sha512-gBBGK0EouiWtYDvRBXLzMM8GSKERRsV+UvGFBJpVoYiq7DwsZqQARnvFitJhyWsZCZuxzHFsxOxt/HiTb5i4VQ==} + peerDependencies: + storybook: ^10.5.10 + storybook@10.5.10: resolution: {integrity: sha512-Rz8k9ejFHsi7lbtJTaxZlhCUz4GkbJIKEoKDjXeLfr/ZhXip73E6keKxW0KH8iGeKiCqHAbJCV4YIQrxTOLiig==} hasBin: true @@ -10266,6 +10289,10 @@ snapshots: stdin-discarder@0.2.2: {} + storybook-addon-pseudo-states@10.5.10(storybook@10.5.10): + dependencies: + storybook: 10.5.10(@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.10(@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 839ba3e17d..eaa4bb6fca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ catalog: "@storybook/addon-docs": ^10.5.10 "@storybook/react-vite": ^10.5.10 "@tanstack/react-query": ^5.102.2 + "@testing-library/user-event": ^14.6.3 "@types/react": ^19.2.18 "@types/react-dom": ^19.2.5 "@types/vscode-webview": ^1.57.5 @@ -19,6 +20,7 @@ catalog: react: ^19.2.8 react-dom: ^19.2.8 storybook: ^10.5.10 + storybook-addon-pseudo-states: ^10.5.10 typescript: ^6.0.3 vite: ^8.2.2 diff --git a/src/extension.ts b/src/extension.ts index e5733b0bb2..a3610ff546 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -297,7 +297,7 @@ async function doActivate( const workspacesPanelEnabled = vscode.workspace .getConfiguration("coder") - .get("experimental.workspacesPanel", false); + .get("experimental.workspacesPanel", true); contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled); diff --git a/test/tsconfig.json b/test/tsconfig.json index 23b4b0a00e..228f0fcbe4 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -15,6 +15,7 @@ "@repo/tasks/*": ["../packages/tasks/src/*"], "@repo/ui": ["../packages/ui/src/index.ts"], "@repo/ui/*": ["../packages/ui/src/*"], + "@repo/workspaces/*": ["../packages/workspaces/src/*"], "@repo/netcheck/*": ["../packages/netcheck/src/*"], "@repo/speedtest/*": ["../packages/speedtest/src/*"] } diff --git a/test/webview/ui/components.test.tsx b/test/webview/ui/components.test.tsx index b27cdac4bc..db98e2e48b 100644 --- a/test/webview/ui/components.test.tsx +++ b/test/webview/ui/components.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { createRef, useState } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -11,6 +12,7 @@ import { SearchInput, Spinner, StatusPill, + TooltipProvider, } from "@repo/ui"; import { qs } from "../helpers"; @@ -47,6 +49,24 @@ describe("IconButton", () => { fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(onClick).toHaveBeenCalledOnce(); }); + it("hovers with the label unless opted out", async () => { + const hoverText = async ( + tooltip?: string | null, + ): Promise => { + const view = render( + + + , + ); + await userEvent.hover(screen.getByRole("button")); + const text = screen.queryByRole("tooltip")?.textContent ?? undefined; + view.unmount(); + return text; + }; + expect(await hoverText()).toBe("Refresh"); + expect(await hoverText("Reload the list")).toBe("Reload the list"); + expect(await hoverText(null)).toBeUndefined(); + }); }); describe("Spinner", () => { diff --git a/test/webview/ui/tree.core.test.tsx b/test/webview/ui/tree.core.test.tsx new file mode 100644 index 0000000000..4d371d8964 --- /dev/null +++ b/test/webview/ui/tree.core.test.tsx @@ -0,0 +1,202 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; + +import { Tree, type TreeNode } from "@repo/ui"; + +import { + BASIC_NODES, + activeGuides, + activeRow, + clickRow, + press, + renderTree, + row, + rowNames, + selectedRows, + tree, +} from "./treeTestHelpers"; + +/** The ARIA a flat row declares for itself, there being no groups. */ +const semantics = (name: string): Record => { + const item = row(name); + return { + level: item.getAttribute("aria-level"), + posInSet: item.getAttribute("aria-posinset"), + setSize: item.getAttribute("aria-setsize"), + expanded: item.getAttribute("aria-expanded"), + tabIndex: item.getAttribute("tabindex"), + }; +}; + +describe("Tree", () => { + it("forwards container props and declares flat row semantics", () => { + const ref = createRef(); + renderTree({ + "aria-label": "Explorer", + variant: "explorer", + className: "custom-tree", + style: { width: "240px" }, + ref, + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + const container = screen.getByRole("tree", { name: "Explorer" }); + expect(container).toHaveClass( + "ui-tree", + "ui-tree--explorer", + "custom-tree", + ); + expect(container).toHaveStyle({ width: "240px" }); + expect(container).toHaveAttribute("tabindex", "0"); + expect(ref.current).toBe(container); + expect(rowNames()).toEqual(["Parent", "Child", "Sibling", "Last"]); + expect(semantics("Parent")).toEqual({ + level: "1", + posInSet: "1", + setSize: "2", + expanded: "true", + tabIndex: "-1", + }); + expect(semantics("Sibling")).toEqual({ + level: "2", + posInSet: "2", + setSize: "2", + expanded: null, + tabIndex: "-1", + }); + expect(semantics("Last")).toEqual({ + level: "1", + posInSet: "2", + setSize: "2", + expanded: null, + tabIndex: "-1", + }); + expect(screen.queryByRole("group")).toBeNull(); + }); + + it("keeps DOM focus on the container while the active row moves", () => { + renderTree({ + "aria-label": "Files", + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + act(() => tree().focus()); + expect(activeRow()).toBe("Parent"); + press("ArrowDown"); + expect(activeRow()).toBe("Child"); + expect(document.activeElement).toBe(tree()); + }); + + it("keeps focus through a collapse, a reveal, and a removal", () => { + const withChild: readonly TreeNode[] = [ + { id: "top", label: "Top" }, + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + ]; + const view = renderTree({ + "aria-label": "Reveal", + nodes: withChild, + expandedIds: ["parent"], + }); + clickRow("Child"); + expect(activeRow()).toBe("Child"); + view.update({ expandedIds: [] }); + expect(activeRow()).toBeUndefined(); + view.update({ expandedIds: ["parent"] }); + expect(activeRow()).toBe("Child"); + view.update({ + nodes: [withChild[0], { id: "parent", label: "Parent", children: [] }], + }); + expect(activeRow()).toBe("Parent"); + }); + + it("draws indent guides for the selected row, and the focused one in focus", () => { + const nodes: readonly TreeNode[] = ["Alpha", "Beta"].map((branch) => ({ + id: branch, + label: branch, + children: [{ id: `${branch} leaf`, label: `${branch} leaf` }], + })); + const view = renderTree({ + "aria-label": "Guides", + nodes, + expandedIds: ["Alpha", "Beta"], + }); + expect(activeGuides("Alpha leaf")).toEqual([false]); + clickRow("Beta leaf"); + expect(activeGuides("Beta leaf")).toEqual([true]); + view.update({ selectedItemId: "Alpha leaf" }); + expect(activeGuides("Alpha leaf")).toEqual([true]); + fireEvent.blur(row("Beta leaf"), { relatedTarget: document.body }); + expect(activeGuides("Beta leaf")).toEqual([false]); + expect(activeGuides("Alpha leaf")).toEqual([true]); + }); + + it("activates only the guide of the branch a row belongs to", () => { + renderTree({ + "aria-label": "Nested guides", + nodes: [ + { + id: "root", + label: "Root", + children: [ + { + id: "branch", + label: "Branch", + children: [{ id: "leaf", label: "Leaf" }], + }, + ], + }, + ], + expandedIds: ["root", "branch"], + }); + clickRow("Branch"); + expect(activeGuides("Leaf")).toEqual([false, true]); + }); + + it("follows controlled selection and keeps the focus mark while blurred", () => { + const view = renderTree({ + "aria-label": "Selection", + nodes: BASIC_NODES, + expandedIds: ["parent"], + selectedItemId: "child", + }); + expect(selectedRows()).toEqual(["Child"]); + view.update({ selectedItemId: "last" }); + expect(selectedRows()).toEqual(["Last"]); + act(() => row("Child").focus()); + expect(row("Child")).toHaveClass("ui-tree-item--focused"); + fireEvent.blur(row("Child"), { relatedTarget: document.body }); + expect(row("Child")).toHaveClass("ui-tree-item--focused"); + expect(tree()).not.toHaveClass("ui-tree--focused"); + }); + + it("scopes the focused styling to the tree the user is in", () => { + render( + <> + + + , + ); + const first = screen.getByRole("tree", { name: "First" }); + const second = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(row("First item")); + expect(first).toHaveClass("ui-tree--focused"); + expect(second).not.toHaveClass("ui-tree--focused"); + fireEvent.blur(row("First item"), { relatedTarget: row("Second item") }); + fireEvent.focus(row("Second item")); + expect(first).not.toHaveClass("ui-tree--focused"); + expect(second).toHaveClass("ui-tree--focused"); + }); +}); diff --git a/test/webview/ui/tree.keyboard.test.tsx b/test/webview/ui/tree.keyboard.test.tsx new file mode 100644 index 0000000000..a08dc6ae5b --- /dev/null +++ b/test/webview/ui/tree.keyboard.test.tsx @@ -0,0 +1,259 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tree, TooltipProvider, type TreeNode, type TreeProps } from "@repo/ui"; + +import { + BASIC_NODES, + activeRow, + clickRow, + expandedRows, + press, + renderStatefulTree, + renderTree, + row, + rowNames, + selectedRows, + tree, +} from "./treeTestHelpers"; + +/** Two branches and a leaf whose rich label holds a live control. */ +const NAV_NODES: readonly TreeNode[] = [ + { + id: "alpha", + label: "Alpha", + children: [ + { id: "apricot", label: "Apricot" }, + { id: "amber", label: "Amber" }, + ], + }, + { id: "beta", label: "Beta", children: [{ id: "blue", label: "Blue" }] }, + { + id: "bravo", + label: ( + <> + Bravo + + + ), + textValue: "Bravo", + }, +]; + +const navTree = (props: Partial = {}) => + renderStatefulTree({ + "aria-label": "Navigation", + nodes: NAV_NODES, + expandedIds: ["alpha"], + ...props, + }); + +describe("Tree keyboard navigation", () => { + it("moves the active row with arrows, Home, and End", () => { + navTree(); + for (const [key, active] of [ + ["ArrowDown", "Apricot"], + ["ArrowDown", "Amber"], + ["End", "Bravo"], + ["Home", "Alpha"], + ["ArrowUp", "Alpha"], + ] as const) { + press(key); + expect(activeRow()).toBe(active); + } + }); + + it("expands, enters, leaves, and collapses a branch", () => { + navTree(); + press("ArrowRight", { from: "Beta" }); + expect(rowNames()).toContain("Blue"); + press("ArrowRight", { from: "Beta" }); + expect(activeRow()).toBe("Blue"); + press("ArrowLeft"); + expect(activeRow()).toBe("Beta"); + press("ArrowLeft"); + expect(rowNames()).not.toContain("Blue"); + }); + + it("selects with Enter and toggles with Space", () => { + navTree({ expandedIds: [] }); + press("Enter", { from: "Beta" }); + expect(selectedRows()).toEqual(["Beta"]); + expect(expandedRows()).toEqual(["Beta"]); + press(" ", { from: "Alpha" }); + expect(expandedRows()).toEqual(["Alpha", "Beta"]); + expect(selectedRows()).toEqual(["Beta"]); + // A leaf has nothing to toggle, so Space selects it. + press(" ", { from: "Bravo" }); + expect(selectedRows()).toEqual(["Bravo"]); + }); + + it("only selects on Enter under doubleClick", () => { + navTree({ expandedIds: [], expandMode: "doubleClick" }); + press("Enter", { from: "Beta" }); + expect(selectedRows()).toEqual(["Beta"]); + expect(expandedRows()).toEqual([]); + }); + + it("clears selection, then the focus mark, before yielding Escape", () => { + renderStatefulTree({ + "aria-label": "Escape", + nodes: BASIC_NODES, + expandedIds: ["parent"], + selectedItemId: "child", + }); + clickRow("Child"); + expect(press("Escape")).toBe(false); + expect(selectedRows()).toEqual([]); + expect(row("Child")).not.toHaveClass("ui-tree-item--focused"); + // Nothing left to clear, so the host gets the key. + expect(press("Escape")).toBe(true); + }); + + it("lets the host claim keys first", () => { + const captured: string[] = []; + navTree({ + onKeyDown: (event) => { + if (event.ctrlKey && event.key === "c") { + captured.push(event.key); + event.preventDefault(); + } + }, + }); + clickRow("Alpha"); + fireEvent.keyDown(tree(), { key: "c", ctrlKey: true }); + expect(captured).toEqual(["c"]); + // The tree never saw the key: nothing moved, nothing changed. + expect(activeRow()).toBe("Alpha"); + expect(selectedRows()).toEqual(["Alpha"]); + }); + + it("leaves a control's own keys alone and takes navigation back", () => { + navTree(); + const action = screen.getByRole("button", { name: "Action" }); + fireEvent.keyDown(action, { key: "Enter" }); + expect(selectedRows()).toEqual([]); + fireEvent.keyDown(action, { key: "ArrowRight" }); + expect(document.activeElement).toBe(row("Bravo")); + }); + + it("navigates the current order after the data reorders", () => { + const nodes = ["One", "Two"].map((label) => ({ id: label, label })); + const view = renderTree({ "aria-label": "Reorder", nodes }); + press("ArrowDown"); + expect(activeRow()).toBe("Two"); + view.update({ nodes: [...nodes].reverse() }); + press("ArrowDown"); + expect(activeRow()).toBe("One"); + }); + it("moves by viewport pages, clamped at either end", () => { + render( +
+ ({ + id: `row-${index}`, + label: `Row ${index}`, + }))} + /> +
, + ); + // jsdom lays nothing out, so a page is the scroller's height in rows. + Object.defineProperty(screen.getByTestId("scroller"), "clientHeight", { + value: 5 * 22, + }); + for (const [key, active] of [ + ["PageDown", "Row 5"], + ["PageDown", "Row 10"], + ["PageDown", "Row 11"], + ["PageUp", "Row 6"], + ] as const) { + press(key); + expect(activeRow()).toBe(active); + } + }); + + it("opens the focused row's hover on the show-hover chord", async () => { + render( + + + , + ); + act(() => tree().focus()); + press("k", { ctrlKey: true }); + expect(screen.queryByRole("tooltip")).toBeNull(); + press("i", { ctrlKey: true }); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Alpha"); + // Any other key puts it away again. + press("ArrowDown"); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + }); + + describe("type-ahead", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("matches the labels the rows currently carry", () => { + const view = renderTree({ + "aria-label": "Names", + nodes: [ + { id: "alpha", label: "Alpha" }, + { id: "cedar", label: "Amber" }, + ], + }); + view.update({ + nodes: [ + { id: "alpha", label: "Alpha" }, + { id: "cedar", label: "Cedar" }, + ], + }); + press("c"); + expect(activeRow()).toBe("Cedar"); + }); + + it("walks the matches when the same key repeats", () => { + navTree(); + for (const active of ["Beta", "Bravo", "Beta"]) { + press("b"); + expect(activeRow()).toBe(active); + } + }); + + it("buffers keys into one query until it expires", () => { + navTree(); + press("a"); + expect(activeRow()).toBe("Apricot"); + press("m"); + expect(activeRow()).toBe("Amber"); + void act(() => vi.advanceTimersByTime(800)); + press("a"); + expect(activeRow()).toBe("Alpha"); + }); + + it("keeps a longer query on the row it already matched", () => { + renderTree({ + "aria-label": "Prefixes", + nodes: [ + { id: "amber", label: "Amber" }, + { id: "amethyst", label: "Amethyst" }, + ], + }); + press("a"); + expect(activeRow()).toBe("Amethyst"); + press("m"); + expect(activeRow()).toBe("Amethyst"); + }); + }); +}); diff --git a/test/webview/ui/tree.rows.test.tsx b/test/webview/ui/tree.rows.test.tsx new file mode 100644 index 0000000000..3eedeb9088 --- /dev/null +++ b/test/webview/ui/tree.rows.test.tsx @@ -0,0 +1,238 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { IconButton, Tree, TooltipProvider, type TreeNode } from "@repo/ui"; + +import { + BASIC_NODES, + clickRow, + clickTwistie, + press, + renderStatefulTree, + renderTree, + row, + rowNames, + selectedRows, + tree, +} from "./treeTestHelpers"; + +/** Native hangs a row's hover off its label, so tests point at that. */ +const label = (name: string): Element => + row(name).getElementsByClassName("ui-tree-item__content")[0]; + +describe("Tree rows", () => { + it("renders icons, rich labels, class names, and an action slot", () => { + renderTree({ + "aria-label": "Rows", + selectedItemId: "selected", + nodes: [ + { id: "plain", label: "Plain item", icon: "file" }, + { id: "rich", label: Rich item, textValue: "Rich item" }, + { + id: "selected", + label: "Selected", + className: "custom-item", + action: , + }, + ], + }); + expect( + row("Plain item").querySelector(".ui-tree-item__content > .ui-icon"), + ).toHaveClass("codicon-file"); + expect(row("Rich item")).toContainHTML("Rich item"); + expect(row("Selected")).toHaveClass("ui-tree-item", "custom-item"); + expect(selectedRows()).toEqual(["Selected"]); + expect( + screen.getByRole("button", { name: "Selected action" }).parentElement, + ).toHaveClass("ui-tree-item__action"); + }); + + it("moves one hover between labels, defaulting to the text value", async () => { + render( + + Rich, textValue: "Rich item" }, + { id: "c", label: "Custom", tooltip: "The whole story" }, + { id: "d", label: "Quiet", tooltip: null }, + ]} + /> + , + ); + const user = userEvent.setup(); + for (const [name, text] of [ + ["Plain item", "Plain item"], + ["Rich item", "Rich item"], + ["Custom", "The whole story"], + ]) { + await user.hover(label(name)); + expect(await screen.findByRole("tooltip")).toHaveTextContent(text); + expect(screen.getAllByRole("tooltip")).toHaveLength(1); + } + await user.unhover(label("Custom")); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + await user.hover(label("Quiet")); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("waits for a new target but crosses an action bar at once", async () => { + render( + + + + + + ), + }, + ]} + /> + , + ); + fireEvent.pointerEnter(label("Workspace")); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Workspace"); + // A different kind of target, so the bubble hides and waits again. + fireEvent.pointerEnter( + screen.getByRole("button", { name: "Start workspace" }), + ); + expect(screen.queryByRole("tooltip")).toBeNull(); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Start workspace", + ); + // One dense action bar is close enough to skip the delay. + fireEvent.pointerEnter( + screen.getByRole("button", { name: "Workspace settings" }), + ); + expect(screen.getByRole("tooltip")).toHaveTextContent("Workspace settings"); + }); + + it("treats an empty children array as a branch that has not loaded", () => { + const { emitted } = renderStatefulTree({ + "aria-label": "Lazy", + nodes: [{ id: "lazy", label: "Lazy", children: [] }], + expandedIds: [], + }); + expect(row("Lazy")).toHaveAttribute("aria-expanded", "false"); + expect( + row("Lazy").querySelector(".ui-tree-item__chevron > .ui-icon"), + ).toHaveClass("codicon-chevron-right"); + press("ArrowRight", { from: "Lazy" }); + expect(row("Lazy")).toHaveAttribute("aria-expanded", "true"); + expect(emitted.expandedIds.at(-1)).toEqual(["lazy"]); + }); + + it("expands on a single click, and never from the twistie's selection", () => { + renderStatefulTree({ + "aria-label": "Single click", + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + clickRow("Parent"); + expect(selectedRows()).toEqual(["Parent"]); + expect(rowNames()).toEqual(["Parent", "Last"]); + clickTwistie("Parent"); + expect(rowNames()).toEqual(["Parent", "Child", "Sibling", "Last"]); + expect(selectedRows()).toEqual(["Parent"]); + }); + + it("waits for the second click under doubleClick", () => { + renderStatefulTree({ + "aria-label": "Double click", + nodes: BASIC_NODES, + expandedIds: ["parent"], + expandMode: "doubleClick", + }); + clickRow("Parent", { detail: 1 }); + expect(selectedRows()).toEqual(["Parent"]); + expect(rowNames()).toContain("Child"); + clickRow("Parent", { detail: 2 }); + expect(rowNames()).not.toContain("Child"); + }); + + it("expands every descendant branch on an Alt twistie click", () => { + renderStatefulTree({ + "aria-label": "Recursive", + nodes: [ + { + id: "root", + label: "Root", + children: [ + { + id: "one", + label: "One", + children: [{ id: "deep", label: "Deep" }], + }, + { id: "two", label: "Two", children: [] }, + ], + }, + ], + expandedIds: ["one"], + }); + clickTwistie("Root", { altKey: true }); + expect(rowNames()).toEqual(["Root", "One", "Deep", "Two"]); + }); + + it("keeps a row action live and out of the row's way", async () => { + const onAction = vi.fn(); + const { emitted } = renderStatefulTree({ + "aria-label": "Actions", + nodes: [ + { + ...BASIC_NODES[0], + action: ( + + ), + }, + ], + expandedIds: ["parent"], + }); + const action = screen.getByRole("button", { name: "Delete" }); + // Live before the row is ever touched, like a native action bar. + fireEvent.click(action); + expect(onAction).toHaveBeenCalledOnce(); + expect(selectedRows()).toEqual([]); + expect(emitted.expandedIds).toEqual([]); + const user = userEvent.setup(); + await user.tab(); + expect(document.activeElement).toBe(tree()); + await user.tab(); + expect(document.activeElement).toBe(action); + }); + + it("leaves a click on anything focusable in a row to that element", () => { + const nodes: readonly TreeNode[] = [ + { + id: "row", + textValue: "Row", + label: ( + <> + Row + Link + + + Widget + + + ), + }, + ]; + renderStatefulTree({ "aria-label": "Nested", nodes }); + for (const name of ["link", "textbox", "button"] as const) { + fireEvent.click(screen.getByRole(name)); + } + expect(selectedRows()).toEqual([]); + fireEvent.click(screen.getByTestId("text")); + expect(selectedRows()).toEqual(["Row"]); + }); +}); diff --git a/test/webview/ui/tree.selection.test.tsx b/test/webview/ui/tree.selection.test.tsx new file mode 100644 index 0000000000..39800bfa55 --- /dev/null +++ b/test/webview/ui/tree.selection.test.tsx @@ -0,0 +1,209 @@ +import { act, fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + BASIC_NODES, + activeGuides, + activeRow, + clickRow, + clickTwistie, + press, + renderStatefulTree, + renderTree, + row, + selectedRows, + tree, +} from "./treeTestHelpers"; + +import type { TreeNode } from "@repo/ui"; + +const MULTI_NODES: readonly TreeNode[] = ["One", "Two", "Three", "Four"].map( + (label) => ({ id: label.toLowerCase(), label }), +); +const multiTree = (selectedItemIds: readonly string[] = ["one"]) => + renderStatefulTree({ + "aria-label": "Multi", + multiSelect: true, + nodes: MULTI_NODES, + selectedItemIds, + }); + +describe("Tree multi-select", () => { + it("toggles, replaces, and keeps the active row on the last one touched", () => { + const { emitted } = multiTree(); + expect(tree()).toHaveAttribute("aria-multiselectable", "true"); + expect(selectedRows()).toEqual(["One"]); + clickRow("Three", { ctrlKey: true }); + expect(selectedRows()).toEqual(["One", "Three"]); + clickRow("One", { metaKey: true }); + expect(selectedRows()).toEqual(["Three"]); + clickRow("Four"); + expect(emitted.selectedItemIds.at(-1)).toEqual(["four"]); + expect(selectedRows()).toEqual(["Four"]); + clickRow("Two", { ctrlKey: true }); + expect(activeRow()).toBe("Two"); + }); + + it("extends and shrinks anchored ranges with clicks and arrows", () => { + multiTree(); + clickRow("Two"); + clickRow("Four", { shiftKey: true }); + expect(selectedRows()).toEqual(["Two", "Three", "Four"]); + clickRow("Three", { shiftKey: true }); + expect(selectedRows()).toEqual(["Two", "Three"]); + press("ArrowDown", { shiftKey: true }); + expect(activeRow()).toBe("Four"); + expect(selectedRows()).toEqual(["Two", "Three", "Four"]); + press("ArrowUp", { shiftKey: true }); + expect(selectedRows()).toEqual(["Two", "Three"]); + }); + + it("starts a range from the controlled selection", () => { + multiTree(); + clickRow("Three", { shiftKey: true }); + expect(selectedRows()).toEqual(["One", "Two", "Three"]); + }); + + it("keeps the anchor when the controlled selection only reorders", () => { + const onSelectedItemsChange = vi.fn(); + const view = renderTree({ + "aria-label": "Ordered", + multiSelect: true, + nodes: MULTI_NODES, + selectedItemIds: ["one", "three"], + onSelectedItemsChange, + }); + view.update({ selectedItemIds: ["three", "one"] }); + clickRow("Four", { shiftKey: true }); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith([ + "one", + "two", + "three", + "four", + ]); + }); + + it("leaves Shift+Home and Shift+End as plain navigation", () => { + multiTree(); + clickRow("Two"); + press("End", { shiftKey: true }); + expect(activeRow()).toBe("Four"); + expect(selectedRows()).toEqual(["Two"]); + press("Home", { shiftKey: true }); + expect(activeRow()).toBe("One"); + expect(selectedRows()).toEqual(["Two"]); + }); + + it("uses the configured modifier for keyboard toggles", () => { + const onSelectedItemsChange = vi.fn(); + renderTree({ + "aria-label": "Alt selection", + multiSelect: true, + multiSelectModifier: "alt", + nodes: MULTI_NODES.slice(0, 2), + selectedItemIds: ["one"], + onSelectedItemsChange, + }); + // Ctrl is not the modifier here, so Enter replaces the selection. + press("Enter", { from: "Two", ctrlKey: true, shiftKey: true }); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["two"]); + press("Enter", { from: "Two", altKey: true, shiftKey: true }); + expect(onSelectedItemsChange).toHaveBeenLastCalledWith(["one", "two"]); + }); + + it("gives a selection modifier precedence over expansion", () => { + const { emitted } = renderStatefulTree({ + "aria-label": "Modifier", + multiSelect: true, + selectedItemIds: [], + nodes: BASIC_NODES, + expandedIds: ["parent"], + }); + clickRow("Parent", { ctrlKey: true }); + clickRow("Parent", { shiftKey: true }); + clickTwistie("Parent", { ctrlKey: true }); + expect(emitted.expandedIds).toEqual([]); + }); + + it("scopes Ctrl+A to the sibling group before widening to the parent", () => { + const scoped = renderStatefulTree({ + "aria-label": "Scoped", + multiSelect: true, + selectedItemIds: [], + expandedIds: ["parent"], + nodes: [ + { + id: "parent", + label: "Parent", + children: [ + { id: "one", label: "One" }, + { id: "three", label: "Three" }, + ], + }, + { id: "outside", label: "Outside" }, + ], + }); + act(() => row("One").focus()); + press("a", { from: "One", ctrlKey: true }); + expect(selectedRows()).toEqual(["One", "Three"]); + press("a", { from: "One", ctrlKey: true }); + expect(selectedRows()).toEqual(["Parent", "One", "Three"]); + act(() => row("Parent").focus()); + press("a", { from: "Parent", ctrlKey: true }); + expect(selectedRows()).toEqual(["Parent", "One", "Three", "Outside"]); + scoped.unmount(); + // Ctrl+Shift+A is not select-all, so the host keeps it. + multiTree(); + expect(press("A", { from: "One", ctrlKey: true, shiftKey: true })).toBe( + true, + ); + expect(selectedRows()).toEqual(["One"]); + }); + + it("clears a multi-selection and its focus mark with Escape", () => { + multiTree(["one", "two"]); + act(() => row("One").focus()); + expect(press("Escape")).toBe(false); + expect(selectedRows()).toEqual([]); + expect(row("One")).toHaveClass("ui-tree-item--focused"); + // The focus mark outlives the first Escape only past one selected row. + expect(press("Escape")).toBe(false); + expect(row("One")).not.toHaveClass("ui-tree-item--focused"); + expect(press("Escape")).toBe(true); + }); + + it("lights a guide for every selected row", () => { + renderTree({ + "aria-label": "Guides", + multiSelect: true, + selectedItemIds: ["a", "b"], + expandedIds: ["parent"], + nodes: [ + { + id: "parent", + label: "Parent", + children: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ], + }, + ], + }); + expect(activeGuides("A")).toEqual([true]); + expect(activeGuides("B")).toEqual([true]); + }); + + it("ignores selection modifiers without multiSelect", () => { + const { emitted } = renderStatefulTree({ + "aria-label": "Single", + selectedItemId: "one", + nodes: MULTI_NODES.slice(0, 2), + }); + expect(screen.getByRole("tree")).not.toHaveAttribute( + "aria-multiselectable", + ); + fireEvent.click(row("Two"), { ctrlKey: true }); + expect(emitted.selectedItemId.at(-1)).toBe("two"); + expect(selectedRows()).toEqual(["Two"]); + }); +}); diff --git a/test/webview/ui/tree.sticky.test.tsx b/test/webview/ui/tree.sticky.test.tsx new file mode 100644 index 0000000000..49aab8e0cc --- /dev/null +++ b/test/webview/ui/tree.sticky.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { Tree } from "@repo/ui"; + +import { BASIC_NODES } from "./treeTestHelpers"; + +describe("Tree sticky scroll", () => { + it("renders an empty sticky anchor before scrolling", () => { + render( + , + ); + expect(document.querySelector(".ui-tree-sticky")).not.toBeNull(); + expect(document.querySelector(".ui-tree-sticky__rows")).toBeNull(); + }); + it("preserves pinned pointer, focus, and accessibility behavior", () => { + const onExpandedIdsChange = vi.fn(); + const onSelectedItemChange = vi.fn(); + render( +
+ ({ + id: `file-${index}`, + label: `file-${index}`, + })), + }, + ], + }, + ]} + expandedIds={["alpha", "src"]} + onExpandedIdsChange={onExpandedIdsChange} + onSelectedItemChange={onSelectedItemChange} + /> +
, + ); + const scroller = screen.getByTestId("scroller"); + Object.defineProperty(scroller, "clientHeight", { value: 10 * 22 }); + const widget = document.querySelector(".ui-tree-sticky"); + if (!widget?.parentElement) throw new Error("Expected the sticky widget."); + widget.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + widget.parentElement.getBoundingClientRect = () => + ({ top: -66 }) as DOMRect; + Object.assign(scroller, { scrollBy: vi.fn() }); + fireEvent.scroll(scroller); + const pinned = [ + ...document.querySelectorAll(".ui-tree-sticky .ui-tree-item"), + ]; + expect(pinned.map((row) => row.textContent)).toEqual(["alpha", "src"]); + expect(document.querySelector(".ui-tree-sticky__shadow")).not.toBeNull(); + expect(widget).not.toHaveAttribute("aria-hidden"); + expect(widget).toHaveAttribute("tabindex", "0"); + expect(pinned[0]).toHaveAttribute("role", "treeitem"); + expect(pinned[0]).toHaveAccessibleName("alpha"); + expect(pinned[0]).toHaveAttribute("aria-level", "1"); + expect(pinned[0]).toHaveAttribute("aria-posinset", "1"); + expect(pinned[0]).toHaveAttribute("aria-setsize", "1"); + expect(pinned[0]).toHaveAttribute("aria-selected", "false"); + expect(pinned[1]).toHaveAttribute("aria-expanded", "true"); + const twistie = pinned[1]?.querySelector(".ui-tree-item__chevron"); + expect(twistie).not.toBeNull(); + fireEvent.click(twistie!); + expect(onExpandedIdsChange).toHaveBeenCalledWith(["alpha"]); + expect(onSelectedItemChange).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).toHaveBeenCalledWith("src"); + onSelectedItemChange.mockClear(); + fireEvent.click(pinned[0]); + expect(onSelectedItemChange).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).toHaveBeenCalledWith("alpha"); + expect(onExpandedIdsChange).toHaveBeenCalledOnce(); + const realAlpha = document.querySelector( + '[data-tree-id="alpha"]', + ); + expect(screen.getByRole("tree")).toHaveAttribute( + "aria-activedescendant", + realAlpha?.id, + ); + expect(document.activeElement).toBe(screen.getByRole("tree")); + vi.mocked(scroller.scrollBy).mockClear(); + fireEvent.click(pinned[0], { ctrlKey: true }); + expect(scroller.scrollBy).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/webview/ui/treeController.test.tsx b/test/webview/ui/treeController.test.tsx new file mode 100644 index 0000000000..cd6f439283 --- /dev/null +++ b/test/webview/ui/treeController.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useRef, useState } from "react"; +import { describe, expect, it } from "vitest"; + +import { useTreeAdapter } from "@repo/ui/components/Tree/useTreeAdapter"; + +function ControlledTreeController(): React.JSX.Element { + const [selectedItemIds, setSelectedItemIds] = useState([ + "one", + ]); + const treeRef = useRef(null); + const adapter = useTreeAdapter({ + nodes: [ + { id: "one", label: "One" }, + { id: "two", label: "Two" }, + { id: "three", label: "Three" }, + ], + expandedIds: [], + expandMode: "singleClick", + multiSelect: true, + multiSelectModifier: "ctrlCmd", + selectedItemIds, + onSelectedItemsChange: setSelectedItemIds, + treeRef, + }); + + return ( +
+ {adapter.tabStopId} + {selectedItemIds.join(",")} + {adapter.model.visibleRows.map((row) => ( +
+ ))} +
+ ); +} + +describe("useTreeAdapter", () => { + it("keeps the Shift+Arrow target as the tab target after the selection echo", () => { + render(); + const one = document.querySelector('[data-tree-id="one"]'); + if (!one) throw new Error("Expected the first row."); + + fireEvent.keyDown(one, { key: "ArrowDown", shiftKey: true }); + + expect(screen.getByTestId("selection")).toHaveTextContent("one,two"); + expect(screen.getByTestId("tab-stop")).toHaveTextContent("two"); + }); +}); diff --git a/test/webview/ui/treeModel.test.tsx b/test/webview/ui/treeModel.test.tsx new file mode 100644 index 0000000000..08e6030f95 --- /dev/null +++ b/test/webview/ui/treeModel.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + parentId, + type TreeNode, + type TreeRowModel, +} from "@repo/ui/components/Tree/treeModel"; + +const NODES: readonly TreeNode[] = [ + { + id: "src", + label: "src", + children: [ + { id: "tree", label: Tree.tsx, textValue: "Tree.tsx" }, + { + id: "tests", + label: "tests", + children: [{ id: "unit", label: "unit" }], + }, + ], + }, + { id: "readme", label: "README.md" }, +]; + +const model = (...expandedIds: string[]) => + createTreeModel(NODES, new Set(expandedIds)); +const ids = (rows: readonly TreeRowModel[]): string[] => + rows.map((row) => row.node.id); +const rowOf = (id: string): TreeRowModel => { + const row = model("src", "tests").rowsById.get(id); + if (!row) throw new Error(`Expected row ${id}.`); + return row; +}; + +describe("createTreeModel", () => { + it.each([ + [[], ["src", "readme"]], + [["src"], ["src", "tree", "tests", "readme"]], + [ + ["src", "tests"], + ["src", "tree", "tests", "unit", "readme"], + ], + ] as const)( + "projects expanded subtrees %j in tree order", + (expanded, rows) => { + expect(ids(model(...expanded).visibleRows)).toEqual(rows); + }, + ); + + it("keeps hidden rows addressable while only visible ones are projected", () => { + const collapsed = model(); + expect(ids(collapsed.rows)).toEqual([ + "src", + "tree", + "tests", + "unit", + "readme", + ]); + expect([...collapsed.visibleIds]).toEqual(["src", "readme"]); + expect(collapsed.rowsById.get("unit")?.textValue).toBe("unit"); + }); + + it("derives row metadata from hierarchy, labels, and expansion", () => { + expect(rowOf("src")).toMatchObject({ + pathIds: [], + posInSet: 1, + setSize: 2, + textValue: "src", + expanded: true, + }); + expect(rowOf("unit")).toMatchObject({ + pathIds: ["src", "tests"], + posInSet: 1, + setSize: 1, + expanded: undefined, + }); + expect(rowOf("readme")).toMatchObject({ posInSet: 2, setSize: 2 }); + // A rich label carries its own text value. + expect(rowOf("tree").textValue).toBe("Tree.tsx"); + expect(parentId(rowOf("unit"))).toBe("tests"); + expect(parentId(rowOf("src"))).toBeUndefined(); + }); + + it.each([ + [ + "siblings", + [ + { id: "dup", label: "One" }, + { id: "dup", label: "Two" }, + ], + ], + [ + "a collapsed branch", + [ + { + id: "collapsed", + label: "Collapsed", + children: [ + { id: "dup", label: "One" }, + { id: "dup", label: "Two" }, + ], + }, + ], + ], + ] satisfies ReadonlyArray)( + "rejects an id reused by %s", + (_case, nodes) => { + expect(() => createTreeModel(nodes, new Set())).toThrow( + /must be unique/i, + ); + }, + ); +}); diff --git a/test/webview/ui/treePolicy.test.ts b/test/webview/ui/treePolicy.test.ts new file mode 100644 index 0000000000..19705f3655 --- /dev/null +++ b/test/webview/ui/treePolicy.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + type TreeModel, + type TreeNode, + type TreeRowModel, +} from "@repo/ui/components/Tree/treeModel"; +import { + keyboardCommands, + pointerCommands, + type KeyboardCommandInput, + type PointerCommandInput, + type TreeModifiers, +} from "@repo/ui/components/Tree/treePolicy"; + +const NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + { id: "last", label: "Last" }, +]; +const model = createTreeModel(NODES, new Set(["parent"])); +const collapsedModel = createTreeModel(NODES, new Set()); +const row = (id: string, from: TreeModel = model): TreeRowModel => { + const found = from.rowsById.get(id); + if (!found) throw new Error(`Expected row ${id}.`); + return found; +}; + +const NO_MODIFIERS: TreeModifiers = { + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, +}; +/** The modifiers a gesture holds down, by name. */ +const held = (...pressed: Array): TreeModifiers => ({ + ...NO_MODIFIERS, + ...Object.fromEntries(pressed.map((key) => [key, true])), +}); + +const pointer = (overrides: Partial = {}) => + pointerCommands({ + expandMode: "singleClick", + multiSelect: false, + multiSelectModifier: "ctrlCmd", + row: row("parent"), + source: "row", + onTwistie: false, + detail: 1, + modifiers: NO_MODIFIERS, + ...overrides, + }); +const keyboard = (overrides: Partial = {}) => + keyboardCommands({ + expandMode: "singleClick", + multiSelect: false, + multiSelectModifier: "ctrlCmd", + key: "ArrowDown", + row: row("parent"), + visibleRows: model.visibleRows, + fromAction: false, + selectedCount: 0, + hasFocusedRow: false, + modifiers: NO_MODIFIERS, + ...overrides, + }); + +const FOCUS_PARENT = { type: "focus", id: "parent" }; +const TOGGLE_PARENT = { type: "toggle", id: "parent", recursive: false }; +/** Selects replace the selection and keep hidden rows unless told otherwise. */ +const select = ( + id: string, + options: { toggle?: boolean; range?: boolean; preserveHidden?: boolean } = {}, +) => ({ + type: "select", + id, + toggle: false, + range: false, + preserveHidden: true, + ...options, +}); + +describe("pointerCommands", () => { + it("focuses and selects before expanding, so a click cannot reorder them", () => { + expect(pointer()).toEqual([ + { type: "focus", id: "parent" }, + { + type: "select", + id: "parent", + toggle: false, + range: false, + preserveHidden: true, + }, + { type: "toggle", id: "parent", recursive: false }, + ]); + }); + + it("keeps a twistie click off the selection, and Alt recursive", () => { + expect( + pointer({ onTwistie: true, detail: 2, modifiers: held("altKey") }), + ).toEqual([ + FOCUS_PARENT, + { type: "toggle", id: "parent", recursive: true }, + ]); + }); + + it("leaves Alt to selection when it is the selection modifier", () => { + expect( + pointer({ + onTwistie: true, + multiSelect: true, + multiSelectModifier: "alt", + modifiers: held("altKey"), + }), + ).toEqual([ + FOCUS_PARENT, + select("parent", { toggle: true, preserveHidden: false }), + ]); + }); + + it.each([ + [1, [FOCUS_PARENT, select("parent")]], + [2, [FOCUS_PARENT, select("parent"), TOGGLE_PARENT]], + ])("expands on click %i under doubleClick", (detail, commands) => { + expect(pointer({ expandMode: "doubleClick", detail })).toEqual(commands); + }); + + it("leaves a leaf nothing to expand", () => { + expect(pointer({ row: row("last") })).toEqual([ + { type: "focus", id: "last" }, + select("last"), + ]); + }); + + it("gives a selection gesture precedence over twistie expansion", () => { + expect( + pointer({ + multiSelect: true, + onTwistie: true, + modifiers: held("ctrlKey", "shiftKey"), + }), + ).toEqual([ + FOCUS_PARENT, + select("parent", { toggle: true, range: true, preserveHidden: false }), + ]); + }); + + it("selects from a pinned row without expanding it, twistie aside", () => { + expect(pointer({ source: "sticky" })).toEqual([ + FOCUS_PARENT, + select("parent"), + ]); + expect(pointer({ source: "sticky", onTwistie: true })).toEqual([ + FOCUS_PARENT, + select("parent"), + TOGGLE_PARENT, + ]); + // A selection gesture on a pinned row never moves focus to it. + expect( + pointer({ + source: "sticky", + multiSelect: true, + modifiers: held("shiftKey"), + }), + ).toEqual([select("parent", { range: true, preserveHidden: false })]); + }); +}); + +describe("keyboardCommands", () => { + it.each([ + ["ArrowDown", 1, false], + ["ArrowUp", -1, false], + ["PageDown", 1, true], + ["PageUp", -1, true], + ] as const)("moves the active row on %s", (key, offset, page) => { + expect(keyboard({ key })).toEqual({ + commands: [{ type: "move", id: "parent", offset, page, extend: false }], + preventDefault: true, + focusRowElementId: undefined, + }); + }); + + it("extends the selection with Shift, but never by the page", () => { + expect( + keyboard({ multiSelect: true, modifiers: held("shiftKey") }).commands, + ).toEqual([ + { type: "move", id: "parent", offset: 1, page: false, extend: true }, + ]); + expect( + keyboard({ + key: "PageDown", + multiSelect: true, + modifiers: held("shiftKey"), + }).commands, + ).toEqual([ + { type: "move", id: "parent", offset: 1, page: true, extend: false }, + ]); + // Shift alone extends nothing without multi-selection. + expect(keyboard({ modifiers: held("shiftKey") }).commands).toEqual([ + { type: "move", id: "parent", offset: 1, page: false, extend: false }, + ]); + }); + + it.each([ + ["Home", "parent"], + ["End", "last"], + ])("jumps to the %s row", (key, id) => { + expect(keyboard({ key }).commands).toEqual([{ type: "focus", id }]); + }); + + it("walks into and out of branches", () => { + expect(keyboard({ key: "ArrowRight" }).commands).toEqual([ + { type: "focus", id: "child" }, + ]); + expect(keyboard({ key: "ArrowLeft", row: row("child") }).commands).toEqual([ + FOCUS_PARENT, + ]); + expect(keyboard({ key: "ArrowLeft" }).commands).toEqual([TOGGLE_PARENT]); + expect( + keyboard({ + key: "ArrowRight", + row: row("parent", collapsedModel), + visibleRows: collapsedModel.visibleRows, + }).commands, + ).toEqual([TOGGLE_PARENT]); + }); + + it("keeps selection and expansion apart on Enter and Space", () => { + expect(keyboard({ key: "Enter" }).commands).toEqual([ + select("parent"), + TOGGLE_PARENT, + ]); + expect( + keyboard({ key: "Enter", expandMode: "doubleClick" }).commands, + ).toEqual([select("parent")]); + expect(keyboard({ key: " " }).commands).toEqual([TOGGLE_PARENT]); + expect(keyboard({ key: " ", row: row("child") }).commands).toEqual([ + select("child"), + ]); + }); + + it("adds to the selection with the selection modifier held", () => { + expect( + keyboard({ + key: "Enter", + multiSelect: true, + modifiers: held("ctrlKey", "shiftKey"), + }).commands, + ).toEqual([select("parent", { toggle: true })]); + expect( + keyboard({ + key: " ", + row: row("child"), + multiSelect: true, + modifiers: held("metaKey"), + }).commands, + ).toEqual([select("child", { toggle: true })]); + expect( + keyboard({ key: "a", multiSelect: true, modifiers: held("ctrlKey") }) + .commands, + ).toEqual([{ type: "selectScope", id: "parent" }]); + }); + + it.each([ + [0, false, false, false], + [1, false, true, false], + [1, true, true, true], + ])( + "dismisses %i selected rows with focus %s", + (selectedCount, hasFocusedRow, clearSelection, clearFocus) => { + expect( + keyboard({ key: "Escape", selectedCount, hasFocusedRow }), + ).toMatchObject({ + commands: [{ type: "dismiss", clearSelection, clearFocus }], + preventDefault: clearSelection || hasFocusedRow, + }); + }, + ); + + it("types ahead on a bare printable key, and leaves the rest to the host", () => { + expect(keyboard({ key: "B" })).toMatchObject({ + commands: [{ type: "typeahead", id: "parent", key: "b" }], + preventDefault: true, + }); + expect(keyboard({ key: "b", modifiers: held("ctrlKey") })).toEqual({ + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }); + expect(keyboard({ key: "Tab" })).toEqual({ + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }); + }); + + it("gives a row action its own keys and takes the navigating ones back", () => { + expect(keyboard({ key: "Enter", fromAction: true })).toEqual({ + commands: [], + preventDefault: false, + focusRowElementId: undefined, + }); + expect(keyboard({ key: "ArrowDown", fromAction: true })).toMatchObject({ + commands: [ + { type: "move", id: "parent", offset: 1, page: false, extend: false }, + ], + preventDefault: true, + focusRowElementId: "parent", + }); + }); +}); diff --git a/test/webview/ui/treeStickyState.test.ts b/test/webview/ui/treeStickyState.test.ts new file mode 100644 index 0000000000..b14e470a08 --- /dev/null +++ b/test/webview/ui/treeStickyState.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { computeStickyState } from "@repo/ui/components/Tree/sticky/stickyState"; +import { + createTreeModel, + ROW_HEIGHT_PX, + type TreeNode, +} from "@repo/ui/components/Tree/treeModel"; +const leaves = (prefix: string, count: number): TreeNode[] => + Array.from({ length: count }, (_, index) => ({ + id: `${prefix}/${index}`, + label: `${prefix}/${index}`, + })); +// Row indices: 0 a, 1-3 a/*, 4 b, 5-7 b/*, 8 c, 9-13 c/*, 14 z. +const ROWS = createTreeModel( + [ + { + id: "a", + label: "a", + children: [ + ...leaves("a", 3), + { + id: "b", + label: "b", + children: [ + ...leaves("b", 3), + { id: "c", label: "c", children: leaves("c", 5) }, + ], + }, + ], + }, + { id: "z", label: "z" }, + ], + new Set(["a", "b", "c"]), +).visibleRows; +const px = (rows: number): number => rows * ROW_HEIGHT_PX; +const VIEWPORT = px(10); +describe("computeStickyState", () => { + it.each([ + ["before scrolling", 0, VIEWPORT, 7, []], + ["without a viewport", px(2), 0, 7, []], + ["in the first subtree", px(1), VIEWPORT, 7, ["a"]], + ["at the deepest subtree", px(9), VIEWPORT, 7, ["a", "b", "c"]], + ["at the item cap", px(9), VIEWPORT, 2, ["a", "b"]], + ["at 40% of the viewport", px(9), px(1.5) / 0.4, 7, ["a"]], + ["past every branch", px(14), VIEWPORT, 7, []], + ] as const)( + "pins the expected chain %s", + (_case, scrollTop, height, cap, ids) => { + expect(computeStickyState(ROWS, scrollTop, height, cap).ids).toEqual(ids); + }, + ); + it("pushes the widget out as the last pinned subtree ends", () => { + const state = computeStickyState(ROWS, px(12), VIEWPORT, 7); + expect(state.ids).toEqual(["a", "b", "c"]); + expect(state.pushOffset).toBe(px(14) - (px(12) + px(3))); + }); +}); diff --git a/test/webview/ui/treeTestHelpers.tsx b/test/webview/ui/treeTestHelpers.tsx new file mode 100644 index 0000000000..310f93609d --- /dev/null +++ b/test/webview/ui/treeTestHelpers.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; + +import { Tree, type TreeNode, type TreeProps } from "@repo/ui"; + +/** + * Tree props as a test writes them. `TreeProps` is a union over the selection + * APIs, and a spread does not keep track of which side it is on, so the harness + * re-asserts it at the one point it renders. + */ +export type TreeTestProps = Partial & { + readonly nodes: readonly TreeNode[]; +}; +const asTreeProps = (props: object): TreeProps => props as TreeProps; + +/** A branch of two plus a leaf: depth, sibling order, and hideable rows. */ +export const BASIC_NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [ + { id: "child", label: "Child" }, + { id: "sibling", label: "Sibling" }, + ], + }, + { id: "last", label: "Last" }, +]; + +export const tree = (): HTMLElement => screen.getByRole("tree"); + +export const row = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + +const nameOf = (item: Element | null): string => + item?.getAttribute("aria-label") ?? ""; + +/** The visible rows, by name, in render order. */ +export const rowNames = (): string[] => + screen.getAllByRole("treeitem").map(nameOf); + +/** The row `aria-activedescendant` points at, which is the focused row. */ +export const activeRow = (): string | undefined => { + const id = tree().getAttribute("aria-activedescendant"); + const focused = id ? document.getElementById(id) : null; + return focused ? nameOf(focused) : undefined; +}; + +export const selectedRows = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-selected") === "true") + .map(nameOf); + +/** The branch rows currently open, by name. */ +export const expandedRows = (): string[] => + screen + .getAllByRole("treeitem") + .filter((item) => item.getAttribute("aria-expanded") === "true") + .map(nameOf); + +/** + * Sends a key to the container, where DOM focus lives; `from` targets a row, as + * a click leaves it. Returns false when the tree claimed the key. + */ +export const press = ( + key: string, + { from, ...init }: { from?: string } & KeyboardEventInit = {}, +): boolean => fireEvent.keyDown(from ? row(from) : tree(), { key, ...init }); + +export const clickRow = (name: string, init?: MouseEventInit): void => { + fireEvent.click(row(name), init); +}; + +/** Clicks a branch's twistie rather than its body. */ +export const clickTwistie = (name: string, init?: MouseEventInit): void => { + const chevron = row(name).querySelector(".ui-tree-item__chevron"); + if (!chevron) { + throw new Error(`Expected a twistie on ${name}.`); + } + fireEvent.click(chevron, init); +}; + +/** A row's indent guides, outermost first: true where one is drawn active. */ +export const activeGuides = (name: string): boolean[] => + [...row(name).querySelectorAll(".ui-tree-item__indent-slot")].map((slot) => + slot.classList.contains("ui-tree-item__indent-slot--active"), + ); + +/** A fully controlled Tree, for tests that drive the props themselves. */ +export function renderTree(props: TreeTestProps) { + const view = render(); + return { + ...view, + /** Re-renders with props changed, as a consumer's state would. */ + update: (next: Partial): void => + view.rerender(), + }; +} + +interface Recorder { + readonly selectedItemId: Array; + readonly selectedItemIds: Array; + readonly expandedIds: Array; +} + +function StatefulTree({ + props, + record, +}: { + props: TreeTestProps; + record: Recorder; +}): React.JSX.Element { + const [selectedId, setSelectedId] = useState(props.selectedItemId); + const [selectedIds, setSelectedIds] = useState(props.selectedItemIds ?? []); + const [expandedIds, setExpandedIds] = useState(props.expandedIds); + const selection = props.multiSelect + ? { + multiSelect: true, + selectedItemIds: selectedIds, + onSelectedItemsChange: (ids: readonly string[]) => { + record.selectedItemIds.push(ids); + setSelectedIds(ids); + }, + } + : { + multiSelect: false, + selectedItemId: selectedId, + onSelectedItemChange: (id: string | undefined) => { + record.selectedItemId.push(id); + setSelectedId(id); + }, + }; + return ( + { + record.expandedIds.push(ids); + setExpandedIds(ids); + }, + })} + /> + ); +} + +/** + * A Tree that keeps its own state, so a gesture's result lands in the DOM. + * `emitted` records what it reported to its consumer, newest last. + */ +export function renderStatefulTree(props: TreeTestProps) { + const emitted: Recorder = { + selectedItemId: [], + selectedItemIds: [], + expandedIds: [], + }; + const view = render(); + return { ...view, emitted }; +} diff --git a/test/webview/ui/treeTransition.test.ts b/test/webview/ui/treeTransition.test.ts new file mode 100644 index 0000000000..259fbdb1a9 --- /dev/null +++ b/test/webview/ui/treeTransition.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from "vitest"; + +import { + createTreeModel, + type TreeModel, + type TreeNode, + type TreeRowModel, +} from "@repo/ui/components/Tree/treeModel"; +import { + deriveTreeInteractionView, + initialTreeInteractionState, + rowFocused, + transitionTree, + type TreeInteractionState, +} from "@repo/ui/components/Tree/treeTransition"; + +const NODES: readonly TreeNode[] = [ + { + id: "parent", + label: "Parent", + children: [{ id: "child", label: "Child" }], + }, + { id: "last", label: "Last" }, +]; +const OPEN = createTreeModel(NODES, new Set(["parent"])); +const CLOSED = createTreeModel(NODES, new Set()); + +const rowOf = (model: TreeModel, id: string): TreeRowModel => { + const row = model.rowsById.get(id); + if (!row) throw new Error(`Expected row ${id}.`); + return row; +}; + +/** Focuses a row the way a click does, through the view's own selection key. */ +function focusRow( + id: string, + model: TreeModel, + controlledIds: readonly string[] = [], +): TreeInteractionState { + const state = initialTreeInteractionState(controlledIds); + const { controlledKey } = deriveTreeInteractionView( + state, + model, + controlledIds, + ); + return rowFocused(state, rowOf(model, id), controlledKey); +} + +const view = ( + state: TreeInteractionState, + model: TreeModel, + controlledIds: readonly string[] = [], +) => deriveTreeInteractionView(state, model, controlledIds); + +const transition = ( + state: TreeInteractionState, + commands: Parameters[1], + model: TreeModel, + overrides: { + expandedIds?: readonly string[]; + controlledIds?: readonly string[]; + multiSelect?: boolean; + now?: number; + } = {}, +) => + transitionTree(state, commands, { + model, + controlledIds: overrides.controlledIds ?? [], + expandedIds: overrides.expandedIds ?? ["parent"], + multiSelect: overrides.multiSelect ?? false, + now: overrides.now ?? 0, + }); + +describe("deriveTreeInteractionView", () => { + it("returns the state untouched while every remembered row still exists", () => { + const state = focusRow("child", OPEN); + expect(view(state, OPEN).state).toBe(state); + expect(view(state, OPEN).focusedId).toBe("child"); + }); + + it("keeps focus on a row a collapse only hid", () => { + const hidden = view(focusRow("child", OPEN), CLOSED); + expect(hidden.state.focusTarget?.id).toBe("child"); + expect(hidden.focusedId).toBeUndefined(); + // The tab stop stays away from the first row, which would move the user. + expect(hidden.tabStopId).toBeUndefined(); + }); + + it("falls back to the nearest visible ancestor when the row is gone", () => { + const removed = createTreeModel( + [{ id: "parent", label: "Parent", children: [] }, NODES[1]], + new Set(["parent"]), + ); + const reconciled = view(focusRow("child", OPEN), removed); + expect(reconciled.state.focusTarget?.id).toBe("parent"); + expect(reconciled.state.tabTargetId).toBe("parent"); + expect(reconciled.focusedId).toBe("parent"); + }); + + it("gives an unclaimed selection the tab stop, until focus claims it", () => { + const state = focusRow("parent", OPEN); + expect(view(state, OPEN, ["last"]).tabStopId).toBe("last"); + const claimed = transition( + state, + [ + { + type: "select", + id: "last", + toggle: false, + range: false, + preserveHidden: true, + }, + ], + OPEN, + ); + expect(view(claimed.state, OPEN, ["last"]).tabStopId).toBe("parent"); + }); + + it("draws guides down to the selected row, and the focused one when in focus", () => { + const selected = view(initialTreeInteractionState([]), OPEN, ["child"]); + expect([...selected.guideOwnerIds]).toEqual(["parent"]); + const blurred = view(focusRow("child", OPEN), OPEN); + expect([...blurred.guideOwnerIds]).toEqual([]); + }); +}); + +describe("transitionTree", () => { + it("clears the selection on dismiss, and focus only when asked", () => { + const state = focusRow("child", OPEN, ["child"]); + const selectionOnly = transition( + state, + [{ type: "dismiss", clearSelection: true, clearFocus: false }], + OPEN, + { controlledIds: ["child"] }, + ); + expect(selectionOnly.selection).toEqual([]); + expect(selectionOnly.state.focusTarget?.id).toBe("child"); + + const cleared = transition( + state, + [{ type: "dismiss", clearSelection: true, clearFocus: true }], + OPEN, + { controlledIds: ["child"] }, + ); + expect(cleared.state.focusTarget).toBeUndefined(); + // Tab still returns to where the user was. + expect(cleared.state.tabTargetId).toBe("child"); + }); + + it("moves focus within the visible rows, clamped at both ends", () => { + const state = focusRow("parent", OPEN); + const down = transition( + state, + [{ type: "move", id: "parent", offset: 1, page: false, extend: false }], + OPEN, + ); + expect(down.state.focusTarget?.id).toBe("child"); + const up = transition( + state, + [{ type: "move", id: "parent", offset: -1, page: false, extend: false }], + OPEN, + ); + expect(up.state.focusTarget?.id).toBe("parent"); + }); + + it("emits expansion in tree order, keeping ids the data does not have yet", () => { + const expanded = transition( + initialTreeInteractionState([]), + [{ type: "toggle", id: "parent", recursive: false }], + CLOSED, + { expandedIds: ["ghost"] }, + ); + expect(expanded.expandedIds).toEqual(["parent", "ghost"]); + }); + + it("toggles every branch under a recursive toggle", () => { + const nested = createTreeModel( + [ + { + id: "root", + label: "Root", + children: [ + { id: "one", label: "One", children: [] }, + { id: "two", label: "Two", children: [] }, + ], + }, + ], + new Set(["one"]), + ); + const expanded = transition( + initialTreeInteractionState([]), + [{ type: "toggle", id: "root", recursive: true }], + nested, + { expandedIds: ["one"] }, + ); + expect(expanded.expandedIds).toEqual(["root", "one", "two"]); + }); +}); + +describe("multi-selection", () => { + const withSelection = (ids: readonly string[]) => ({ + controlledIds: ids, + multiSelect: true, + }); + + it("keeps the anchor when a controlled selection only reorders", () => { + const state = initialTreeInteractionState(["child", "last"]); + expect(view(state, OPEN, ["last", "child"]).anchorId).toBe("child"); + }); + + it("adds to and removes from the selection when toggling", () => { + const added = transition( + initialTreeInteractionState(["child"]), + [ + { + type: "select", + id: "last", + toggle: true, + range: false, + preserveHidden: true, + }, + ], + OPEN, + withSelection(["child"]), + ); + expect(added.selection).toEqual(["child", "last"]); + const removed = transition( + initialTreeInteractionState(["child", "last"]), + [ + { + type: "select", + id: "last", + toggle: true, + range: false, + preserveHidden: true, + }, + ], + OPEN, + withSelection(["child", "last"]), + ); + expect(removed.selection).toEqual(["child"]); + }); + + it("selects the range from the anchor, in tree order", () => { + const anchored = focusRow("parent", OPEN, ["parent"]); + const ranged = transition( + anchored, + [ + { + type: "select", + id: "last", + toggle: false, + range: true, + preserveHidden: false, + }, + ], + OPEN, + withSelection(["parent"]), + ); + expect(ranged.selection).toEqual(["parent", "child", "last"]); + }); + + it("extends the selection as a Shift move travels", () => { + const extended = transition( + initialTreeInteractionState(["parent"]), + [{ type: "move", id: "parent", offset: 1, page: false, extend: true }], + OPEN, + withSelection(["parent"]), + ); + expect(extended.selection).toEqual(["parent", "child"]); + expect(extended.state.focusTarget?.id).toBe("child"); + }); + + it("moves a page by the offset the scroller measured", () => { + const paged = transition( + initialTreeInteractionState([]), + [{ type: "move", id: "parent", offset: 1, page: true, extend: false }], + OPEN, + ); + expect(paged.state.focusTarget?.id).toBe("child"); + }); + + it("scopes select-all to the sibling group, then widens to the parent", () => { + const group = transition( + initialTreeInteractionState([]), + [{ type: "selectScope", id: "child" }], + OPEN, + withSelection([]), + ); + expect(group.selection).toEqual(["child"]); + const widened = transition( + initialTreeInteractionState(["child"]), + [{ type: "selectScope", id: "child" }], + OPEN, + withSelection(["child"]), + ); + expect(widened.selection).toEqual(["parent", "child"]); + }); + + it("resets the anchor on dismiss", () => { + const dismissed = transition( + focusRow("child", OPEN, ["child"]), + [{ type: "dismiss", clearSelection: true, clearFocus: false }], + OPEN, + { controlledIds: ["child"] }, + ); + expect(dismissed.state.anchorId).toBeUndefined(); + }); + + it("buffers type-ahead keys until the query expires", () => { + const first = transition( + initialTreeInteractionState([]), + [{ type: "typeahead", id: "parent", key: "l" }], + OPEN, + { now: 1000 }, + ); + expect(first.state.focusTarget?.id).toBe("last"); + expect(first.state.typeQuery).toBe("l"); + // Within the window the keys join into one query; after it they do not. + const joined = transition( + first.state, + [{ type: "typeahead", id: "last", key: "a" }], + OPEN, + { now: 1100 }, + ); + expect(joined.state.typeQuery).toBe("la"); + const expired = transition( + first.state, + [{ type: "typeahead", id: "last", key: "a" }], + OPEN, + { now: 9000 }, + ); + expect(expired.state.typeQuery).toBe("a"); + }); +}); diff --git a/test/webview/workspaces/WorkspacesPanel.test.tsx b/test/webview/workspaces/WorkspacesPanel.test.tsx new file mode 100644 index 0000000000..09019912df --- /dev/null +++ b/test/webview/workspaces/WorkspacesPanel.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { TooltipProvider } from "@repo/ui"; +import { + MOCK_WORKSPACES, + type MockWorkspaceEntry, +} from "@repo/workspaces/mockData"; +import { WorkspacesPanel } from "@repo/workspaces/WorkspacesPanel"; + +const renderPanel = ( + workspaces: readonly MockWorkspaceEntry[] = MOCK_WORKSPACES, +): void => { + render( + + + , + ); +}; + +describe("WorkspacesPanel", () => { + it("lists owned workspaces and their agents in the tree", () => { + renderPanel(); + expect( + screen.getByRole("tree", { name: "Workspaces" }), + ).toBeInTheDocument(); + expect(screen.getByRole("treeitem", { name: "dev" })).toBeInTheDocument(); + expect( + screen.getByRole("treeitem", { name: "staging" }), + ).toBeInTheDocument(); + // The default "Mine" filter hides other owners. + expect(screen.queryByRole("treeitem", { name: "ci-pool" })).toBeNull(); + }); + + it("filters the tree live from the search input", () => { + renderPanel(); + fireEvent.change( + screen.getByRole("searchbox", { name: "Search workspaces" }), + { target: { value: "staging" } }, + ); + expect(screen.queryByRole("treeitem", { name: "dev" })).toBeNull(); + expect( + screen.getByRole("treeitem", { name: "staging" }), + ).toBeInTheDocument(); + }); + + it("shows an empty state when the search matches nothing", () => { + renderPanel(); + fireEvent.change( + screen.getByRole("searchbox", { name: "Search workspaces" }), + { target: { value: "does-not-exist" } }, + ); + expect(screen.getByText("No matching workspaces")).toBeInTheDocument(); + expect(screen.queryByRole("tree")).toBeNull(); + }); + + it("switches to the All filter to include other owners", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.click(screen.getByRole("button", { name: "Mine" })); + await user.click(screen.getByRole("menuitemradio", { name: "All" })); + expect( + screen.getByRole("treeitem", { name: "ci-pool (marcus)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("treeitem", { name: "code-review (priya)" }), + ).toBeInTheDocument(); + }); + + it("shows app statuses and metadata inline under their agent", () => { + renderPanel(); + const statuses = screen.getByRole("treeitem", { name: "App Statuses" }); + fireEvent.click(statuses); + expect( + screen.getByRole("treeitem", { + name: "CI Watcher: Building packages/ui", + }), + ).toBeInTheDocument(); + const metadata = screen.getByRole("treeitem", { name: "Agent Metadata" }); + fireEvent.click(metadata); + expect( + screen.getByRole("treeitem", { name: "CPU Usage: 23%" }), + ).toBeInTheDocument(); + }); + + it("shows loading and error states", () => { + const loading = render(); + expect(screen.getByText("Loading workspaces")).toBeInTheDocument(); + loading.unmount(); + const retry = (): void => undefined; + render(); + expect(screen.getByText("Failed to load workspaces")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Try again" }), + ).toBeInTheDocument(); + }); + + it("reveals hover actions on the focused workspace row", () => { + renderPanel(); + const workspace = screen.getByRole("treeitem", { name: "dev" }); + fireEvent.click(workspace); + expect( + screen.getByRole("button", { name: "Open dev" }), + ).toBeInTheDocument(); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 3cc835357f..88ec0b90d9 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -51,6 +51,10 @@ export default defineConfig({ "packages/tasks/src", ), "@repo/ui": path.resolve(import.meta.dirname, "packages/ui/src"), + "@repo/workspaces": path.resolve( + import.meta.dirname, + "packages/workspaces/src", + ), "@repo/netcheck": path.resolve( import.meta.dirname, "packages/netcheck/src",