1import { createSignal, onCleanup, onMount } from "solid-js";
2import { Portal } from "solid-js/web";
3
4type Anchor = HTMLElement | SVGElement;
5
6const GAP = 8;
7const EDGE = 6;
8/** The innermost tip wins; an empty one defers to the tip around it. */
9const 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 */
15export 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}