Tooltip.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. // Hover/focus label bubble (figma tooltip pill: dark plate, white text).
  2. // TODO: interaction is a placeholder (horizontal overflow clamps, but there
  3. // is no vertical flip on viewport collision and no arrow) — visuals and
  4. // behavior get a proper pass later.
  5. // The anchor is the child element itself (cloneElement, no wrapper node), so
  6. // attaching a tooltip never changes the anchor's layout context. The bubble is
  7. // position:fixed and coordinates come from the anchor's rect at show time, so
  8. // it escapes ancestor overflow clipping (the sidebar rail clips its column)
  9. // without a portal.
  10. import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
  11. import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
  12. import css from './Tooltip.module.css'
  13. /** Bubble placement relative to the anchor. */
  14. export type TooltipSide = 'right' | 'bottom' | 'top'
  15. /** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
  16. interface AnchorProps {
  17. ref?: Ref<HTMLElement> | undefined
  18. onMouseEnter?: MouseEventHandler | undefined
  19. onMouseLeave?: MouseEventHandler | undefined
  20. onFocus?: FocusEventHandler | undefined
  21. onBlur?: FocusEventHandler | undefined
  22. }
  23. type TooltipLabel = string | (() => string)
  24. /**
  25. * Attach a hover/focus tooltip to an anchor element.
  26. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
  27. * @param props.side - placement relative to the anchor (default 'right').
  28. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
  29. * @param props.disabled - suppress the bubble while true; the anchor renders identically so
  30. * toggling never remounts it (which would cut its CSS transitions).
  31. * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
  32. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
  33. */
  34. export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
  35. const anchor = useRef<HTMLElement | null>(null)
  36. // React 18 keeps the element's ref outside props; forward it so wrapping an
  37. // anchor in Tooltip never silently severs the owner's ref.
  38. const childRef = (children as ReactElement<AnchorProps> & { ref?: Ref<HTMLElement> }).ref
  39. const mergedRef = useCallback((el: HTMLElement | null) => {
  40. anchor.current = el
  41. if (typeof childRef === 'function') childRef(el)
  42. else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
  43. }, [childRef])
  44. const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
  45. const bubble = useRef<HTMLSpanElement | null>(null)
  46. const resolvedLabel = pos === null
  47. ? null
  48. : typeof label === 'function' ? label() : label
  49. // Horizontal viewport clamp: fixed positioning knows nothing about edges, so
  50. // a centered bubble near the right edge would clip. Each measurement resets
  51. // the base position before applying a direct style offset, allowing a shorter
  52. // label or wider viewport to release a previous clamp without another render.
  53. useLayoutEffect(() => {
  54. if (pos === null) return
  55. const clamp = () => {
  56. const el = bubble.current
  57. /* v8 ignore next -- pos is set only while the bubble is mounted. */
  58. if (el === null) return
  59. const EDGE_MARGIN = 12
  60. el.style.left = `${pos.x}px`
  61. const r = el.getBoundingClientRect()
  62. let dx = 0
  63. if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
  64. if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
  65. el.style.left = `${pos.x + dx}px`
  66. }
  67. clamp()
  68. window.addEventListener('resize', clamp)
  69. return () => { window.removeEventListener('resize', clamp) }
  70. }, [pos, resolvedLabel])
  71. const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  72. // Hover and focus are independent triggers: the bubble hides only after
  73. // BOTH clear (hovering away from a focused anchor must not drop it).
  74. const triggers = useRef({ hover: false, focus: false })
  75. // Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
  76. // must drop an already-visible bubble: no mouseleave fires.
  77. const cancelShow = useCallback(() => {
  78. if (showTimer.current === null) return
  79. clearTimeout(showTimer.current)
  80. showTimer.current = null
  81. }, [])
  82. useEffect(() => {
  83. if (disabled) {
  84. cancelShow()
  85. triggers.current = { hover: false, focus: false }
  86. setPos(null)
  87. }
  88. return cancelShow
  89. }, [cancelShow, disabled])
  90. const show = () => {
  91. if (disabled) return
  92. const el = anchor.current
  93. /* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
  94. if (el === null) return
  95. const r = el.getBoundingClientRect()
  96. setPos(side === 'right'
  97. ? { x: r.right + 10, y: r.top + r.height / 2 }
  98. : side === 'top'
  99. ? { x: r.left + r.width / 2, y: r.top - 8 }
  100. : { x: r.left + r.width / 2, y: r.bottom + 8 })
  101. }
  102. const showAfterHoverDelay = () => {
  103. cancelShow()
  104. if (delayMs <= 0) {
  105. show()
  106. return
  107. }
  108. showTimer.current = setTimeout(() => {
  109. showTimer.current = null
  110. show()
  111. }, delayMs)
  112. }
  113. const hide = () => {
  114. cancelShow()
  115. if (!triggers.current.hover && !triggers.current.focus) setPos(null)
  116. }
  117. return (
  118. <>
  119. {cloneElement(children, {
  120. ref: mergedRef,
  121. onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
  122. onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) },
  123. onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
  124. onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
  125. })}
  126. {pos !== null && (
  127. <span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
  128. {resolvedLabel}
  129. </span>
  130. )}
  131. </>
  132. )
  133. }