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