Skip to content

Commit 46e0d09

Browse files
committed
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.
1 parent c2b8232 commit 46e0d09

18 files changed

Lines changed: 689 additions & 103 deletions

packages/ui/README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ flowchart LR
141141
Transition --> Adapter[useTreeAdapter.ts]
142142
Adapter --> Rows[Tree.tsx and TreeRow.tsx]
143143
Adapter --> Sticky[StickyScroll.tsx]
144+
Rows --> Hover[TreeHover.tsx]
144145
```
145146

146147
The model, policy, and transitions stay pure. The adapter owns React and DOM
@@ -155,6 +156,15 @@ while the tree has focus. The package default uses inset Modern UI rows;
155156
`data-ui-style="stable"` restores edge-to-edge square rows and stable focus
156157
styling.
157158

159+
Labels hover with the node's text value, so truncated rows stay readable.
160+
Set `tooltip` for richer content or `null` to opt out. One bubble serves the
161+
whole tree, as in the native list: an invisible anchor moves to whatever the
162+
pointer reaches, taking its x from the cursor and its y from the target's box,
163+
the way a native hover placed at the mouse does. Each new target waits out the
164+
show delay, except within a row's action bar, where crossing between buttons is
165+
instant, the exception native grants a dense cluster of targets. Ctrl+K Ctrl+I opens the focused
166+
row's hover with no delay at all, and moving the focus closes it.
167+
158168
## Overlays
159169

160170
`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives,
@@ -172,7 +182,18 @@ tooltips.
172182
`TooltipProvider` ancestor. Mount one provider per app so that a pointer
173183
moving between nearby triggers skips the show delay, like native hovers.
174184
The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`,
175-
and tooltips stop growing at half the window height.
185+
and tooltips stop growing at half the window height. Components that own
186+
their hovers fall back to a private provider when the app has none, so
187+
`Tree` rows and `IconButton` work unwrapped. A private provider keeps its own
188+
skip-delay, though, so an app with several of them makes every hover wait out
189+
the full delay; mount one provider and they share it. `IconButton` hints with
190+
its label like a native action bar item; pass `tooltip` to say something else,
191+
or `null` for a button that stays quiet.
192+
193+
`HoverDelegateScope` hands every `Tooltip` inside it to one shared bubble
194+
instead of a bubble each, the way a VS Code list serves its rows and their
195+
action bars from a single hover widget. `Tree` uses it, which is also what
196+
lets one place decide when a hover is instant rather than delayed.
176197

177198
Overlay content is portalled to `body`, inherits webview typography from
178199
there, and shares the `.ui-overlay` base for stacking, border, shadow,

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"dependencies": {
2828
"@radix-ui/react-context-menu": "^2.3.7",
2929
"@radix-ui/react-dropdown-menu": "^2.1.24",
30+
"@radix-ui/react-slot": "^1.3.3",
3031
"@radix-ui/react-tooltip": "^1.2.16",
3132
"@vscode/codicons": "catalog:"
3233
},

packages/ui/src/components/IconButton/IconButton.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { type ComponentProps } from "react";
1+
import { type ComponentProps, type ReactNode } from "react";
22

33
import { cx } from "#cx";
44

55
import "../control.css";
66
import { Icon } from "../Icon/Icon";
7+
import { Tooltip, TooltipScope } from "../Tooltip/Tooltip";
78

89
import "./IconButton.css";
910

@@ -15,18 +16,20 @@ export interface IconButtonProps extends Omit<
1516
> {
1617
icon: CodiconName;
1718
label: string;
19+
/** Hover content; defaults to the label, `null` opts out. */
20+
tooltip?: ReactNode;
1821
}
1922

20-
/* No default title: native toolbar buttons hint with the styled hover
21-
widget, not the browser box. Wrap in Tooltip for that. */
23+
/* Hints through the hover widget, never the browser title box. */
2224
export function IconButton({
2325
icon,
2426
label,
27+
tooltip = label,
2528
className,
2629
type = "button",
2730
...props
2831
}: IconButtonProps): React.JSX.Element {
29-
return (
32+
const button = (
3033
<button
3134
{...props}
3235
type={type}
@@ -36,4 +39,10 @@ export function IconButton({
3639
<Icon name={icon} />
3740
</button>
3841
);
42+
if (!tooltip) return button;
43+
return (
44+
<TooltipScope>
45+
<Tooltip content={tooltip}>{button}</Tooltip>
46+
</TooltipScope>
47+
);
3948
}

packages/ui/src/components/Tooltip/Tooltip.tsx

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,89 @@
1+
import { Slot } from "@radix-ui/react-slot";
12
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3+
import {
4+
createContext,
5+
use,
6+
type ComponentProps,
7+
type ComponentPropsWithRef,
8+
type PointerEvent,
9+
type ReactNode,
10+
} from "react";
211

312
import { cx } from "#cx";
413

514
import "../overlay.css";
615

716
import "./Tooltip.css";
817

9-
import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react";
10-
1118
export type TooltipProviderProps = ComponentProps<
1219
typeof TooltipPrimitive.Provider
1320
>;
1421

22+
/** VS Code's `workbench.hover.delay`. */
23+
const DEFAULT_DELAY_MS = 500;
24+
1525
/**
1626
* App-level tooltip context; `Tooltip` throws without one. Sharing a single
1727
* provider lets a pointer moving between nearby triggers skip the show delay,
18-
* like native hovers. The default delay is VS Code's `workbench.hover.delay`.
28+
* like native hovers.
1929
*/
20-
export function TooltipProvider(
21-
props: TooltipProviderProps,
22-
): React.JSX.Element {
23-
return <TooltipPrimitive.Provider delayDuration={500} {...props} />;
30+
export function TooltipProvider({
31+
delayDuration = DEFAULT_DELAY_MS,
32+
...props
33+
}: TooltipProviderProps): React.JSX.Element {
34+
return (
35+
<TooltipContext value={delayDuration}>
36+
<TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
37+
</TooltipContext>
38+
);
39+
}
40+
41+
const TooltipContext = createContext<number | null>(null);
42+
43+
/** Owns tooltips without forcing a provider on consumers; defers to any app-level one. */
44+
export function TooltipScope({ children }: { children: ReactNode }): ReactNode {
45+
return use(TooltipContext) === null ? (
46+
<TooltipProvider>{children}</TooltipProvider>
47+
) : (
48+
children
49+
);
50+
}
51+
52+
/** The surrounding provider's show delay, for surfaces that time their own. */
53+
export function useTooltipDelay(): number {
54+
return use(TooltipContext) ?? DEFAULT_DELAY_MS;
55+
}
56+
57+
export interface HoverTarget {
58+
readonly content: ReactNode;
59+
readonly element: HTMLElement;
60+
}
61+
62+
/** `immediate` skips the show delay. */
63+
export type HoverDelegate = (
64+
target: HoverTarget | undefined,
65+
immediate?: boolean,
66+
) => void;
67+
68+
const HoverDelegateContext = createContext<HoverDelegate | undefined>(
69+
undefined,
70+
);
71+
72+
/**
73+
* Hands every `Tooltip` inside to one shared bubble, the way a VS Code list
74+
* serves its rows and their action bars from a single hover widget. Pass
75+
* `undefined` to hand them back.
76+
*/
77+
export function HoverDelegateScope({
78+
delegate,
79+
children,
80+
}: {
81+
delegate: HoverDelegate | undefined;
82+
children: ReactNode;
83+
}): React.JSX.Element {
84+
return (
85+
<HoverDelegateContext value={delegate}>{children}</HoverDelegateContext>
86+
);
2487
}
2588

2689
export interface TooltipProps extends Omit<
@@ -30,22 +93,41 @@ export interface TooltipProps extends Omit<
3093
content: ReactNode;
3194
/** The trigger element; must accept a forwarded ref (asChild). */
3295
children: ReactNode;
96+
open?: boolean;
97+
onOpenChange?: (open: boolean) => void;
3398
}
3499

35100
/** Hover bubble matching the native hover widget; requires a `TooltipProvider` ancestor. */
36101
export function Tooltip({
37102
content,
38103
children,
39104
className,
105+
open,
106+
onOpenChange,
40107
...props
41108
}: TooltipProps): React.JSX.Element {
109+
const delegate = use(HoverDelegateContext);
110+
if (delegate) {
111+
return (
112+
<Slot
113+
onPointerEnter={(event: PointerEvent<HTMLElement>) =>
114+
delegate({ content, element: event.currentTarget })
115+
}
116+
onPointerLeave={() => delegate(undefined)}
117+
>
118+
{children}
119+
</Slot>
120+
);
121+
}
42122
return (
43-
<TooltipPrimitive.Root>
123+
<TooltipPrimitive.Root open={open} onOpenChange={onOpenChange}>
44124
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
45125
<TooltipPrimitive.Portal>
46126
<TooltipPrimitive.Content
47-
// Native hovers sit flush with the target, left-aligned
48-
align="start"
127+
// Native sits a hover 2px into the bottom edge of its target.
128+
side="bottom"
129+
sideOffset={-2}
130+
align="center"
49131
collisionPadding={8}
50132
{...props}
51133
className={cx("ui-overlay ui-tooltip", className)}

packages/ui/src/components/Tree/Tree.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.ui-tree {
22
--ui-tree-indent-size: 8px;
33
--ui-tree-row-height: 22px;
4+
position: relative;
45
width: 100%;
56
min-width: 0;
67
outline: 0;
@@ -26,7 +27,8 @@
2627
.ui-tree-sticky {
2728
position: sticky;
2829
top: 0;
29-
z-index: 100;
30+
/* Native pins the sticky container below the hover widget's stacking */
31+
z-index: 13;
3032
height: 0;
3133
outline: 0;
3234
}
@@ -205,3 +207,10 @@
205207
> .ui-tree-item__row {
206208
outline-color: var(--ui-list-focus-and-selection-outline);
207209
}
210+
211+
/* The shared hover anchors here instead of to each row's label. */
212+
.ui-tree-hover-anchor {
213+
position: absolute;
214+
pointer-events: none;
215+
opacity: 0;
216+
}

0 commit comments

Comments
 (0)