Explorar o código

feat(ui-primitives): support delayed multiline tooltips

_Kerman hai 2 meses
pai
achega
94dabcb7ed

+ 1 - 1
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);
 }

+ 32 - 8
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<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)
   // 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<HTMLElement | null>).current = el
   }, [childRef])
   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
   // 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 && (

+ 27 - 1
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(
+        <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', () => {
     render(
       <Tooltip label="Open sidebar">