diff --git a/packages/ui/README.md b/packages/ui/README.md index dddedb1d7b..7e1b8c741c 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -140,6 +140,7 @@ flowchart LR 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 @@ -154,6 +155,15 @@ 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 `Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives, @@ -171,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, diff --git a/packages/ui/package.json b/packages/ui/package.json index 2bc6616926..673456aac4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,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/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} + + + + ), + }), + ], + { 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 index ab080f657b..eef0bc6cbe 100644 --- a/packages/ui/src/components/Tree/Tree.tsx +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -3,8 +3,11 @@ 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"; @@ -62,6 +65,7 @@ export function Tree({ ...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 } @@ -75,65 +79,72 @@ export function Tree({ 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(); + +
{ + treeRef.current = element; + setForwardedRef(ref, element); + }} + role="tree" + tabIndex={0} + aria-activedescendant={ + adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined } - }} - onClick={adapter.onClick} - onKeyDown={adapter.onKeyDown} - > - {stickyScroll ? ( - - ) : null} - {adapter.model.visibleRows.map((row) => ( - - ))} -
+ 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 index fcd5b1e4a9..0b4832cada 100644 --- a/packages/ui/src/components/Tree/TreeRow.tsx +++ b/packages/ui/src/components/Tree/TreeRow.tsx @@ -3,8 +3,9 @@ import { type CSSProperties, memo } from "react"; import { cx } from "#cx"; import { Icon } from "../Icon/Icon"; +import { Tooltip } from "../Tooltip/Tooltip"; -import type { TreeRowModel } from "./treeModel"; +import { rowTooltip, type TreeRowModel } from "./treeModel"; interface TreeRowProps { readonly row: TreeRowModel; @@ -29,6 +30,14 @@ export const TreeRow = memo(function TreeRow({ }: 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 (
)} - - {node.icon ? : null} - {typeof node.label === "string" ? ( - {node.label} - ) : ( - node.label - )} - + {tooltip ? {label} : label} {/* Native keeps a row's actions live on plain hover; CSS reveals them. */} {node.action ? ( {node.action} diff --git a/packages/ui/src/components/Tree/treeModel.ts b/packages/ui/src/components/Tree/treeModel.ts index ffee3a93f7..9850ab3094 100644 --- a/packages/ui/src/components/Tree/treeModel.ts +++ b/packages/ui/src/components/Tree/treeModel.ts @@ -14,6 +14,8 @@ type TreeNodeLabel = 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[]; @@ -40,6 +42,12 @@ export interface TreeModel { 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); } diff --git a/packages/ui/src/components/Tree/useTreeAdapter.ts b/packages/ui/src/components/Tree/useTreeAdapter.ts index 83ff4cbefa..af66f89106 100644 --- a/packages/ui/src/components/Tree/useTreeAdapter.ts +++ b/packages/ui/src/components/Tree/useTreeAdapter.ts @@ -1,4 +1,10 @@ -import { type KeyboardEvent, type MouseEvent, useMemo, useState } from "react"; +import { + type KeyboardEvent, + type MouseEvent, + useMemo, + useRef, + useState, +} from "react"; import { closestRow, @@ -9,6 +15,7 @@ import { import { createTreeModel, ROW_HEIGHT_PX, + rowTooltip, type TreeNode, type TreeRowModel, } from "./treeModel"; @@ -30,8 +37,16 @@ import { 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 = @@ -58,6 +73,7 @@ interface AdapterOptions { readonly multiSelectModifier: TreeMultiSelectModifier; readonly onKeyDown?: (event: KeyboardEvent) => void; readonly treeRef: React.RefObject; + readonly hoverControl?: TreeHoverControl; } function rowElement(tree: HTMLElement | null, id: string): HTMLElement | null { @@ -90,6 +106,7 @@ export function useTreeAdapter(options: AdapterOptions & SelectionProps) { ); const { visibleRows, rowsById } = model; const selected = controlledIds(options); + const chordRef = useRef(false); const [state, setState] = useState(() => initialTreeInteractionState(selected), ); @@ -218,6 +235,30 @@ export function useTreeAdapter(options: AdapterOptions & SelectionProps) { } 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) { @@ -231,6 +272,18 @@ export function useTreeAdapter(options: AdapterOptions & SelectionProps) { 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, @@ -273,7 +326,10 @@ export function useTreeAdapter(options: AdapterOptions & SelectionProps) { isSelectionGesture: (event: MouseEvent) => isSelectionGesture(event, behavior), onFocusIn, - onBlurOut: () => setState((current) => treeFocusChanged(current, false)), + onBlurOut: () => { + showHover(undefined); + setState((current) => treeFocusChanged(current, false)); + }, onClick, onPointer, onKeyDown, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4ac37b0213..2724e330bb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -67,6 +67,9 @@ export { type KeybindingPlatform, } from "./keybinding"; export { + type HoverDelegate, + HoverDelegateScope, + type HoverTarget, Tooltip, type TooltipProps, TooltipProvider, diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index a384d08849..8b8a3d9ba9 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -178,8 +178,8 @@ 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e23127f43..e619154c86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,6 +460,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.24 version: 2.1.24(@types/react-dom@19.2.4)(@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.4)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8) 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.keyboard.test.tsx b/test/webview/ui/tree.keyboard.test.tsx index 3d9744818f..a08dc6ae5b 100644 --- a/test/webview/ui/tree.keyboard.test.tsx +++ b/test/webview/ui/tree.keyboard.test.tsx @@ -1,7 +1,13 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Tree, type TreeNode, type TreeProps } from "@repo/ui"; +import { Tree, TooltipProvider, type TreeNode, type TreeProps } from "@repo/ui"; import { BASIC_NODES, @@ -173,6 +179,28 @@ describe("Tree keyboard navigation", () => { } }); + 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()); diff --git a/test/webview/ui/tree.rows.test.tsx b/test/webview/ui/tree.rows.test.tsx index d17fe79f24..3eedeb9088 100644 --- a/test/webview/ui/tree.rows.test.tsx +++ b/test/webview/ui/tree.rows.test.tsx @@ -1,7 +1,9 @@ -import { fireEvent, screen } from "@testing-library/react"; +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, @@ -15,7 +17,9 @@ import { tree, } from "./treeTestHelpers"; -import type { TreeNode } from "@repo/ui"; +/** 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", () => { @@ -44,6 +48,73 @@ describe("Tree rows", () => { ).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",