diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 5853531bd4..4da0eebc2d 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -14,7 +14,7 @@ color: var(--dsw-static-neutral-bluish-00); font-size: 14px; line-height: 22px; - white-space: nowrap; + white-space: pre-line; pointer-events: none; animation: tooltip-in 150ms var(--ds-ease-in-out); } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 30e0c5fe0e..e85583a50c 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,6 +1,6 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (no show delay, no flip on viewport -// collision, no arrow) — visuals and behavior get a proper pass later. +// TODO: interaction is a placeholder (no flip on viewport collision or +// arrow) — visuals and behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so @@ -27,12 +27,13 @@ interface AnchorProps { * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text. * @param props.side - placement relative to the anchor (default 'right'). + * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ -export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { +export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -43,15 +44,26 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { else if (childRef != null) (childRef as MutableRefObject).current = el }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). const triggers = useRef({ hover: false, focus: false }) // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. + const cancelShow = useCallback(() => { + if (showTimer.current === null) return + clearTimeout(showTimer.current) + showTimer.current = null + }, []) useEffect(() => { - if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) } - }, [disabled]) + if (disabled) { + cancelShow() + triggers.current = { hover: false, focus: false } + setPos(null) + } + return cancelShow + }, [cancelShow, disabled]) const show = () => { if (disabled) return @@ -63,7 +75,19 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { ? { x: r.right + 10, y: r.top + r.height / 2 } : { x: r.left + r.width / 2, y: r.bottom + 8 }) } + const showAfterHoverDelay = () => { + cancelShow() + if (delayMs <= 0) { + show() + return + } + showTimer.current = setTimeout(() => { + showTimer.current = null + show() + }, delayMs) + } const hide = () => { + cancelShow() if (!triggers.current.hover && !triggers.current.focus) setPos(null) } @@ -71,9 +95,9 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { <> {cloneElement(children, { ref: mergedRef, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) }, - onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) }, + onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 591a5eb67a..5dc9a1a378 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -1,11 +1,37 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('Tooltip', () => { + it('can delay pointer hover without delaying keyboard focus', () => { + vi.useFakeTimers() + try { + render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseLeave(anchor) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + fireEvent.mouseLeave(anchor) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + } finally { + vi.useRealTimers() + } + }) + it('shows the bubble to the right on hover and hides it on leave', () => { render(