| 1 | import { createSignal, onCleanup, onMount } from "solid-js"; |
| 2 | import { Portal } from "solid-js/web"; |
| 3 | |
| 4 | type Anchor = HTMLElement | SVGElement; |
| 5 | |
| 6 | const GAP = 8; |
| 7 | const EDGE = 6; |
| 8 | /** The innermost tip wins; an empty one defers to the tip around it. */ |
| 9 | const TIPPED = "[data-tip]:not([data-tip=''])"; |
| 10 | |
| 11 | /** |
| 12 | * Shows the `data-tip` of whatever is hovered or keyboard-focused, disabled buttons included; mount once. It's a |
| 13 | * popover so it stays above modal dialogs and menus in the top layer. |
| 14 | */ |
| 15 | export function Tooltips() { |
| 16 | const [text, setText] = createSignal(""); |
| 17 | const [place, setPlace] = createSignal({ x: 0, y: 0, arrow: 0, side: "top" }); |
| 18 | let tip!: HTMLDivElement; |
| 19 | let anchor: Anchor | null = null; |
| 20 | let described = false; |
| 21 | // Keeps following the last anchor after hiding, so a click that changes its tip ("Copied") shows it again. |
| 22 | const watch = new MutationObserver(() => { |
| 23 | if (anchor?.matches(":hover, :focus-visible")) show(anchor); |
| 24 | }); |
| 25 | |
| 26 | function show(target: Anchor) { |
| 27 | const next = target.dataset.tip; |
| 28 | if (!next) return hide(); |
| 29 | if (target !== anchor) { |
| 30 | hide(); |
| 31 | watch.disconnect(); |
| 32 | watch.observe(target, { attributeFilter: ["data-tip"] }); |
| 33 | anchor = target; |
| 34 | } |
| 35 | if (!described && !anchor.hasAttribute("aria-describedby") && anchor.getAttribute("aria-label") !== next) { |
| 36 | anchor.setAttribute("aria-describedby", "tooltip"); |
| 37 | described = true; |
| 38 | } |
| 39 | setText(next); |
| 40 | if (!tip.matches(":popover-open")) tip.showPopover(); |
| 41 | // Balanced lines leave the box as wide as the longest unbalanced one; shrink it to the widest line so it centers. |
| 42 | tip.style.width = ""; |
| 43 | const lines = document.createRange(); |
| 44 | lines.selectNodeContents(tip); |
| 45 | const scale = tip.getBoundingClientRect().width / tip.offsetWidth; |
| 46 | const widest = Math.max(...[...lines.getClientRects()].map((line) => line.width)) / scale; |
| 47 | tip.style.width = `${Math.ceil(widest)}px`; |
| 48 | const box = target.getBoundingClientRect(); |
| 49 | const center = box.left + box.width / 2; |
| 50 | const x = Math.min(Math.max(EDGE, center - tip.offsetWidth / 2), innerWidth - tip.offsetWidth - EDGE); |
| 51 | const above = box.top - tip.offsetHeight - GAP; |
| 52 | setPlace({ |
| 53 | x, |
| 54 | y: above >= EDGE ? above : box.bottom + GAP, |
| 55 | arrow: Math.min(Math.max(10, center - x), tip.offsetWidth - 10), |
| 56 | side: above >= EDGE ? "top" : "bottom", |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | function hide() { |
| 61 | if (tip.matches(":popover-open")) tip.hidePopover(); |
| 62 | if (described) anchor?.removeAttribute("aria-describedby"); |
| 63 | described = false; |
| 64 | } |
| 65 | |
| 66 | onMount(() => { |
| 67 | const over = (event: Event) => { |
| 68 | const target = (event.target as Element).closest<Anchor>(TIPPED); |
| 69 | if (target) show(target); |
| 70 | else hide(); |
| 71 | }; |
| 72 | const out = (event: PointerEvent) => !event.relatedTarget && hide(); |
| 73 | const focus = (event: Event) => { |
| 74 | const target = (event.target as Element).closest<Anchor>(TIPPED); |
| 75 | if (target?.matches(":focus-visible")) show(target); |
| 76 | }; |
| 77 | const key = (event: KeyboardEvent) => event.key === "Escape" && hide(); |
| 78 | const listeners = [["pointerover", over], ["pointerout", out], ["focusin", focus], ["focusout", hide], ["pointerdown", hide], ["keydown", key], |
| 79 | ["scroll", hide]] as const; |
| 80 | for (const [type, listener] of listeners) document.addEventListener(type, listener as EventListener, true); |
| 81 | onCleanup(() => { |
| 82 | for (const [type, listener] of listeners) document.removeEventListener(type, listener as EventListener, true); |
| 83 | watch.disconnect(); |
| 84 | }); |
| 85 | }); |
| 86 | |
| 87 | return ( |
| 88 | <Portal> |
| 89 | <div ref={tip} id="tooltip" role="tooltip" class="tooltip" popover="manual" data-side={place().side} |
| 90 | style={{ left: `${place().x}px`, top: `${place().y}px`, "--arrow": `${place().arrow}px` }}> |
| 91 | {/* A path or URL has no spaces, so it may wrap after any slash, and balancing picks the middle one. */} |
| 92 | {/\s/.test(text()) ? text() : text().replaceAll("/", "/\u200b")} |
| 93 | </div> |
| 94 | </Portal> |
| 95 | ); |
| 96 | } |