feat(ui-primitives): support delayed multiline tooltips
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
color: var(--dsw-static-neutral-bluish-00);
|
color: var(--dsw-static-neutral-bluish-00);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
white-space: nowrap;
|
white-space: pre-line;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||||
// TODO: interaction is a placeholder (no show delay, no flip on viewport
|
// TODO: interaction is a placeholder (no flip on viewport collision or
|
||||||
// collision, no arrow) — visuals and behavior get a proper pass later.
|
// arrow) — visuals and behavior get a proper pass later.
|
||||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
// 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
|
// 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
|
// 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.
|
* Attach a hover/focus tooltip to an anchor element.
|
||||||
* @param props.label - bubble text.
|
* @param props.label - bubble text.
|
||||||
* @param props.side - placement relative to the anchor (default 'right').
|
* @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
|
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||||
* toggling never remounts it (which would cut its CSS transitions).
|
* 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.
|
* @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.
|
* @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<AnchorProps> }) {
|
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||||
const anchor = useRef<HTMLElement | null>(null)
|
const anchor = useRef<HTMLElement | null>(null)
|
||||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||||
// anchor in Tooltip never silently severs the owner's ref.
|
// 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<HTMLElement | null>).current = el
|
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||||
}, [childRef])
|
}, [childRef])
|
||||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
// Hover and focus are independent triggers: the bubble hides only after
|
// Hover and focus are independent triggers: the bubble hides only after
|
||||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||||
const triggers = useRef({ hover: false, focus: false })
|
const triggers = useRef({ hover: false, focus: false })
|
||||||
|
|
||||||
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
|
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
|
||||||
// must drop an already-visible bubble: no mouseleave fires.
|
// must drop an already-visible bubble: no mouseleave fires.
|
||||||
|
const cancelShow = useCallback(() => {
|
||||||
|
if (showTimer.current === null) return
|
||||||
|
clearTimeout(showTimer.current)
|
||||||
|
showTimer.current = null
|
||||||
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
|
if (disabled) {
|
||||||
}, [disabled])
|
cancelShow()
|
||||||
|
triggers.current = { hover: false, focus: false }
|
||||||
|
setPos(null)
|
||||||
|
}
|
||||||
|
return cancelShow
|
||||||
|
}, [cancelShow, disabled])
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
if (disabled) return
|
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.right + 10, y: r.top + r.height / 2 }
|
||||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
: { 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 = () => {
|
const hide = () => {
|
||||||
|
cancelShow()
|
||||||
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
|
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +95,9 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
|||||||
<>
|
<>
|
||||||
{cloneElement(children, {
|
{cloneElement(children, {
|
||||||
ref: mergedRef,
|
ref: mergedRef,
|
||||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
|
||||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
|
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) },
|
||||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
|
||||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||||
})}
|
})}
|
||||||
{pos !== null && (
|
{pos !== null && (
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('Tooltip', () => {
|
describe('Tooltip', () => {
|
||||||
|
it('can delay pointer hover without delaying keyboard focus', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<Tooltip label="Timing details" delayMs={500}>
|
||||||
|
<button type="button">anchor</button>
|
||||||
|
</Tooltip>,
|
||||||
|
)
|
||||||
|
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', () => {
|
it('shows the bubble to the right on hover and hides it on leave', () => {
|
||||||
render(
|
render(
|
||||||
<Tooltip label="Open sidebar">
|
<Tooltip label="Open sidebar">
|
||||||
|
|||||||
Reference in New Issue
Block a user