1
0

Tooltip.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. // Cloning the anchor preserves its layout context. Fixed positioning lets the
  2. // bubble escape ancestor overflow clipping without a portal.
  3. import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
  4. import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
  5. import css from './Tooltip.module.css'
  6. /** Bubble placement relative to the anchor. */
  7. export type TooltipSide = 'right' | 'bottom' | 'top'
  8. /** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
  9. interface AnchorProps {
  10. ref?: Ref<HTMLElement> | undefined
  11. onMouseEnter?: MouseEventHandler | undefined
  12. onMouseLeave?: MouseEventHandler | undefined
  13. onFocus?: FocusEventHandler | undefined
  14. onBlur?: FocusEventHandler | undefined
  15. }
  16. type TooltipLabel = string | (() => string)
  17. /**
  18. * Attach a hover/focus tooltip to an anchor element.
  19. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
  20. * @param props.side - placement relative to the anchor (default 'right').
  21. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
  22. * @param props.disabled - suppress the bubble while true; the anchor renders identically so
  23. * toggling never remounts it (which would cut its CSS transitions).
  24. * @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
  25. * half-viewport cap would render a slab wider than the surface the anchor sits on.
  26. * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
  27. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
  28. */
  29. export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
  30. const anchor = useRef<HTMLElement | null>(null)
  31. // React 18 keeps the element's ref outside props; forward it so wrapping an
  32. // anchor in Tooltip never silently severs the owner's ref.
  33. const childRef = (children as ReactElement<AnchorProps> & { ref?: Ref<HTMLElement> }).ref
  34. const mergedRef = useCallback((el: HTMLElement | null) => {
  35. anchor.current = el
  36. if (typeof childRef === 'function') childRef(el)
  37. else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
  38. }, [childRef])
  39. // The anchor's edges rather than final coordinates: a vertical flip has to
  40. // re-derive the bubble's own top from the opposite edge.
  41. const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
  42. // Where the bubble actually sits, which is the requested side until the
  43. // viewport refuses it.
  44. const [placement, setPlacement] = useState<TooltipSide>(side)
  45. const bubble = useRef<HTMLSpanElement | null>(null)
  46. const resolvedLabel = pos === null
  47. ? null
  48. : typeof label === 'function' ? label() : label
  49. const y = pos === null
  50. ? 0
  51. : placement === 'right'
  52. ? pos.top + (pos.bottom - pos.top) / 2
  53. : placement === 'top' ? pos.top - 8 : pos.bottom + 8
  54. const EDGE_MARGIN = 12
  55. // Viewport fit: fixed positioning knows nothing about edges, so a centered
  56. // bubble near the right edge would clip and a long label under an anchor low
  57. // on the page would run off the bottom. Horizontally the bubble slides back
  58. // inside; vertically it flips to the opposite side, which is the only move
  59. // that does not cover the anchor being read. Each measurement resets the base
  60. // position first, so a shorter label or a larger viewport releases a previous
  61. // adjustment without another render.
  62. useLayoutEffect(() => {
  63. if (pos === null) return
  64. const fit = () => {
  65. const el = bubble.current
  66. /* v8 ignore next -- pos is set only while the bubble is mounted. */
  67. if (el === null) return
  68. el.style.left = `${pos.x}px`
  69. const r = el.getBoundingClientRect()
  70. let dx = 0
  71. if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
  72. if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
  73. el.style.left = `${pos.x + dx}px`
  74. if (side === 'right') return
  75. // Flip only into a side that genuinely fits, so an anchor with room on
  76. // neither side keeps the requested placement instead of oscillating.
  77. const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
  78. const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
  79. if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
  80. if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
  81. }
  82. fit()
  83. window.addEventListener('resize', fit)
  84. return () => { window.removeEventListener('resize', fit) }
  85. }, [placement, pos, resolvedLabel, side])
  86. const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  87. // Hover and focus are independent triggers: the bubble hides only after
  88. // BOTH clear (hovering away from a focused anchor must not drop it).
  89. const triggers = useRef({ hover: false, focus: false })
  90. // Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
  91. // must drop an already-visible bubble: no mouseleave fires.
  92. const cancelShow = useCallback(() => {
  93. if (showTimer.current === null) return
  94. clearTimeout(showTimer.current)
  95. showTimer.current = null
  96. }, [])
  97. useEffect(() => {
  98. if (disabled) {
  99. cancelShow()
  100. triggers.current = { hover: false, focus: false }
  101. setPos(null)
  102. }
  103. return cancelShow
  104. }, [cancelShow, disabled])
  105. const show = () => {
  106. if (disabled) return
  107. const el = anchor.current
  108. /* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
  109. if (el === null) return
  110. const r = el.getBoundingClientRect()
  111. // Every show starts from the requested side; the fit pass flips it only
  112. // where this anchor's position demands it.
  113. setPlacement(side)
  114. setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
  115. }
  116. const showAfterHoverDelay = () => {
  117. cancelShow()
  118. if (delayMs <= 0) {
  119. show()
  120. return
  121. }
  122. showTimer.current = setTimeout(() => {
  123. showTimer.current = null
  124. show()
  125. }, delayMs)
  126. }
  127. const hide = () => {
  128. cancelShow()
  129. if (!triggers.current.hover && !triggers.current.focus) setPos(null)
  130. }
  131. return (
  132. <>
  133. {cloneElement(children, {
  134. ref: mergedRef,
  135. onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
  136. onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) },
  137. onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
  138. onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
  139. })}
  140. {pos !== null && (
  141. <span
  142. ref={bubble}
  143. className={css.bubble}
  144. data-side={placement}
  145. style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
  146. role="tooltip"
  147. >
  148. {resolvedLabel}
  149. </span>
  150. )}
  151. </>
  152. )
  153. }