| 1 | import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; |
| 2 | import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; |
| 3 | import { createPortal } from "react-dom"; |
| 4 | import { |
| 5 | elementLayoutSize, |
| 6 | isElementExplicitlyHidden, |
| 7 | MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES, |
| 8 | validAnchorRect, |
| 9 | } from "../lib/anchoredOverlay"; |
| 10 | |
| 11 | type TooltipSide = "top" | "bottom" | "left" | "right"; |
| 12 | type TooltipPosition = { left: number; top: number; side: TooltipSide; arrowX: number; arrowY: number }; |
| 13 | |
| 14 | const GAP = 8; |
| 15 | const EDGE_PAD = 8; |
| 16 | const ARROW_SIZE = 7; |
| 17 | const ARROW_PAD = 12; |
| 18 | |
| 19 | function clamp(value: number, min: number, max: number): number { |
| 20 | return Math.min(max, Math.max(min, value)); |
| 21 | } |
| 22 | |
| 23 | function oppositeSide(side: TooltipSide): TooltipSide { |
| 24 | if (side === "top") return "bottom"; |
| 25 | if (side === "bottom") return "top"; |
| 26 | if (side === "left") return "right"; |
| 27 | return "left"; |
| 28 | } |
| 29 | |
| 30 | function samePosition( |
| 31 | current: TooltipPosition | null, |
| 32 | next: TooltipPosition, |
| 33 | ): boolean { |
| 34 | return !!current && ( |
| 35 | current.side === next.side && |
| 36 | Math.abs(current.left - next.left) < 0.5 && |
| 37 | Math.abs(current.top - next.top) < 0.5 && |
| 38 | Math.abs(current.arrowX - next.arrowX) < 0.5 && |
| 39 | Math.abs(current.arrowY - next.arrowY) < 0.5 |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | export function Tooltip({ |
| 44 | label, |
| 45 | children, |
| 46 | side = "top", |
| 47 | fill = false, |
| 48 | block = false, |
| 49 | disabled = false, |
| 50 | className, |
| 51 | delay = 180, |
| 52 | }: { |
| 53 | label?: ReactNode; |
| 54 | children: ReactNode; |
| 55 | side?: TooltipSide; |
| 56 | fill?: boolean; |
| 57 | block?: boolean; |
| 58 | disabled?: boolean; |
| 59 | className?: string; |
| 60 | delay?: number; |
| 61 | }) { |
| 62 | const id = useId(); |
| 63 | const triggerRef = useRef<HTMLElement | null>(null); |
| 64 | const tooltipRef = useRef<HTMLDivElement>(null); |
| 65 | const showTimerRef = useRef<number | null>(null); |
| 66 | const [open, setOpen] = useState(false); |
| 67 | const [position, setPosition] = useState<TooltipPosition | null>(null); |
| 68 | const positionRef = useRef<TooltipPosition | null>(null); |
| 69 | const active = !disabled && label !== undefined && label !== null && label !== ""; |
| 70 | |
| 71 | const clearTimer = useCallback(() => { |
| 72 | if (showTimerRef.current === null) return; |
| 73 | window.clearTimeout(showTimerRef.current); |
| 74 | showTimerRef.current = null; |
| 75 | }, []); |
| 76 | |
| 77 | const show = (delay = 180) => { |
| 78 | if (!active) return; |
| 79 | clearTimer(); |
| 80 | showTimerRef.current = window.setTimeout(() => { |
| 81 | showTimerRef.current = null; |
| 82 | if (validAnchorRect(triggerRef.current)) setOpen(true); |
| 83 | }, delay); |
| 84 | }; |
| 85 | |
| 86 | const hide = useCallback(() => { |
| 87 | clearTimer(); |
| 88 | setOpen(false); |
| 89 | positionRef.current = null; |
| 90 | setPosition(null); |
| 91 | }, [clearTimer]); |
| 92 | |
| 93 | useLayoutEffect(() => { |
| 94 | if (!open || !active) return; |
| 95 | let frame: number | null = null; |
| 96 | let measured = false; |
| 97 | let initialMeasurementRetries = 0; |
| 98 | const updatePosition = () => { |
| 99 | frame = null; |
| 100 | const trigger = triggerRef.current; |
| 101 | const rect = validAnchorRect(trigger); |
| 102 | const tip = tooltipRef.current; |
| 103 | const tipSize = tip ? elementLayoutSize(tip) : null; |
| 104 | if (!rect || !tipSize) { |
| 105 | const explicitlyHidden = !!trigger && (!trigger.isConnected || isElementExplicitlyHidden(trigger)); |
| 106 | if (!measured && !explicitlyHidden && initialMeasurementRetries < MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES) { |
| 107 | initialMeasurementRetries += 1; |
| 108 | frame = window.requestAnimationFrame(updatePosition); |
| 109 | } else { |
| 110 | hide(); |
| 111 | } |
| 112 | return; |
| 113 | } |
| 114 | measured = true; |
| 115 | initialMeasurementRetries = 0; |
| 116 | const space = { |
| 117 | top: rect.top - EDGE_PAD, |
| 118 | bottom: window.innerHeight - rect.bottom - EDGE_PAD, |
| 119 | left: rect.left - EDGE_PAD, |
| 120 | right: window.innerWidth - rect.right - EDGE_PAD, |
| 121 | }; |
| 122 | let actualSide = side; |
| 123 | if ((side === "top" || side === "bottom") && space[side] < tipSize.height + GAP + ARROW_SIZE) { |
| 124 | const opposite = oppositeSide(side); |
| 125 | if (space[opposite] > space[side]) actualSide = opposite; |
| 126 | } else if ((side === "left" || side === "right") && space[side] < tipSize.width + GAP + ARROW_SIZE) { |
| 127 | const opposite = oppositeSide(side); |
| 128 | if (space[opposite] > space[side]) actualSide = opposite; |
| 129 | } |
| 130 | |
| 131 | let left = |
| 132 | actualSide === "left" |
| 133 | ? rect.left - tipSize.width - GAP - ARROW_SIZE |
| 134 | : actualSide === "right" |
| 135 | ? rect.right + GAP + ARROW_SIZE |
| 136 | : rect.left + rect.width / 2 - tipSize.width / 2; |
| 137 | let top = |
| 138 | actualSide === "top" |
| 139 | ? rect.top - tipSize.height - GAP - ARROW_SIZE |
| 140 | : actualSide === "bottom" |
| 141 | ? rect.bottom + GAP + ARROW_SIZE |
| 142 | : rect.top + rect.height / 2 - tipSize.height / 2; |
| 143 | |
| 144 | left = clamp(left, EDGE_PAD, window.innerWidth - tipSize.width - EDGE_PAD); |
| 145 | top = clamp(top, EDGE_PAD, window.innerHeight - tipSize.height - EDGE_PAD); |
| 146 | const arrowX = clamp(rect.left + rect.width / 2 - left, ARROW_PAD, tipSize.width - ARROW_PAD); |
| 147 | const arrowY = clamp(rect.top + rect.height / 2 - top, ARROW_PAD, tipSize.height - ARROW_PAD); |
| 148 | |
| 149 | const next = { left, top, side: actualSide, arrowX, arrowY }; |
| 150 | if (!samePosition(positionRef.current, next)) { |
| 151 | positionRef.current = next; |
| 152 | setPosition(next); |
| 153 | } |
| 154 | frame = window.requestAnimationFrame(updatePosition); |
| 155 | }; |
| 156 | updatePosition(); |
| 157 | return () => { |
| 158 | if (frame !== null) window.cancelAnimationFrame(frame); |
| 159 | }; |
| 160 | }, [active, hide, open, side]); |
| 161 | |
| 162 | useEffect(() => { |
| 163 | if (!active) hide(); |
| 164 | return () => clearTimer(); |
| 165 | }, [active, clearTimer, hide]); |
| 166 | |
| 167 | const triggerClass = `tooltip-trigger${fill ? " tooltip-trigger--fill" : ""}${block ? " tooltip-trigger--block" : ""}${className ? ` ${className}` : ""}`; |
| 168 | const setTriggerRef = (node: HTMLElement | null) => { |
| 169 | triggerRef.current = node; |
| 170 | }; |
| 171 | const triggerProps = { |
| 172 | className: triggerClass, |
| 173 | "aria-describedby": open ? id : undefined, |
| 174 | onMouseEnter: () => show(delay), |
| 175 | onMouseLeave: hide, |
| 176 | onPointerDownCapture: hide, |
| 177 | onFocus: () => show(0), |
| 178 | onBlur: hide, |
| 179 | onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => { |
| 180 | if (event.key === "Escape" || event.key === "Enter" || event.key === " ") hide(); |
| 181 | }, |
| 182 | }; |
| 183 | |
| 184 | return ( |
| 185 | <> |
| 186 | {block ? <div ref={setTriggerRef} {...triggerProps}>{children}</div> : <span ref={setTriggerRef} {...triggerProps}>{children}</span>} |
| 187 | {open && |
| 188 | active && |
| 189 | createPortal( |
| 190 | <div |
| 191 | id={id} |
| 192 | ref={tooltipRef} |
| 193 | className={`tooltip tooltip--${position?.side ?? side}`} |
| 194 | role="tooltip" |
| 195 | style={{ |
| 196 | left: position?.left ?? 0, |
| 197 | top: position?.top ?? 0, |
| 198 | visibility: position ? "visible" : "hidden", |
| 199 | pointerEvents: position ? undefined : "none", |
| 200 | "--tooltip-arrow-x": `${position?.arrowX ?? 0}px`, |
| 201 | "--tooltip-arrow-y": `${position?.arrowY ?? 0}px`, |
| 202 | } as CSSProperties} |
| 203 | > |
| 204 | {label} |
| 205 | </div>, |
| 206 | document.body, |
| 207 | )} |
| 208 | </> |
| 209 | ); |
| 210 | } |
| 211 |