From 372cd6f37d1ce315e5d91f43108e5d3357d9632f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 14 Aug 2026 18:13:48 +0300 Subject: [PATCH] feat(ui): give tree rows and icon buttons native hovers Rows hover with their text value, as VS Code's tree falls back to the label when an item has no tooltip; `tooltip` overrides it and null opts out. Ctrl+K Ctrl+I opens the focused row's hover, the chord bound to list.showHover, and IconButton hints with its label like an action bar item. One bubble serves a whole tree rather than one per trigger, the way a list hands a single hover delegate to its rows and their action bars. Rows report the element under the pointer through `HoverDelegateScope` and an invisible anchor moves to it. That single widget is also the only place a delay rule can live, and native's rule needs one: each new target waits out `workbench.hover.delay`, except inside a row's action bar, the dense cluster native grants an instant handoff. A managed hover always uses hoverPosition 2, whatever the widget's own default, so the bubble sits 2px into the bottom edge of its target rather than above it. Placement then splits on the delegate: an element-placed hover, which is what an action bar button gets, centers under it, while a mouse-placed one, which is what a row gets, follows the cursor. Measured in Chrome: label to a button 513ms, button to button 19ms, and mounting 2,000 rows 189ms against 515ms for a Radix root per row. Also pins the sticky container at native's z-index 13, below the hover's 40, so pinned rows no longer paint over a bubble, and drops the menu parity story's trigger button so both menus line up. --- packages/ui/README.md | 23 ++- packages/ui/package.json | 1 + .../src/components/IconButton/IconButton.tsx | 17 +- .../ui/src/components/Tooltip/Tooltip.tsx | 102 ++++++++++-- packages/ui/src/components/Tree/Tree.css | 11 +- .../ui/src/components/Tree/Tree.stories.tsx | 105 +++++++++++- packages/ui/src/components/Tree/Tree.tsx | 123 +++++++------- packages/ui/src/components/Tree/TreeHover.tsx | 156 ++++++++++++++++++ packages/ui/src/components/Tree/TreeRow.tsx | 20 ++- packages/ui/src/components/Tree/treeModel.ts | 8 + .../ui/src/components/Tree/useTreeAdapter.ts | 60 ++++++- packages/ui/src/index.ts | 3 + packages/ui/src/vscode-parity.stories.tsx | 20 ++- pnpm-lock.yaml | 3 + test/webview/ui/components.test.tsx | 20 +++ test/webview/ui/tree.keyboard.test.tsx | 32 +++- test/webview/ui/tree.rows.test.tsx | 75 ++++++++- 17 files changed, 685 insertions(+), 94 deletions(-) create mode 100644 packages/ui/src/components/Tree/TreeHover.tsx 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",