@celestia-island/hikari 0.45.8 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ // installHkImageFallback — document-level "invalid image" rendering.
2
+ //
3
+ // A broken <img> (404, dead data URI, offline blob) otherwise renders the
4
+ // browser's raw broken-image glyph, which is exactly the kind of
5
+ // unpolished detail the design system exists to prevent (user direction
6
+ // 2026-09-13: ship the invalid-image re-render as a global hook, the same
7
+ // way the tooltip bridge replaces native tooltips). Mechanics:
8
+ //
9
+ // error (capture-phase delegated — resource errors do NOT bubble)
10
+ // → the img gains the `hk-img-broken` class (muted surface treatment
11
+ // in imageFallback.scss)
12
+ // → its src swaps to the bundled inline-SVG placeholder (a quiet
13
+ // broken-image glyph on a rounded muted tile), keeping the element,
14
+ // its layout box and its alt text intact — no DOM surgery, no
15
+ // layout shift
16
+ // load (capture-phase delegated)
17
+ // → a previously-broken img whose src later resolves (retry, upload
18
+ // finished, URL repaired) drops the class and renders normally.
19
+ //
20
+ // The swap is one-shot per element (`data-hk-img-fallback`): a fallback
21
+ // that itself fails (hostile custom fallbackSrc) can never loop.
22
+ //
23
+ // Escape hatches:
24
+ // - `[data-hk-img-native]` keeps the raw browser broken-image rendering
25
+ // - `selector` / `exclude` options scope the hook further
26
+ // - `fallbackSrc` overrides the bundled placeholder ("" = class only,
27
+ // no src swap — for hosts that style the class themselves)
28
+ import "./imageFallback.scss";
29
+
30
+ export interface HkImageFallbackOptions {
31
+ /** Which images are covered. Default: `img`. */
32
+ selector?: string;
33
+ /** Images matching this keep raw browser rendering. */
34
+ exclude?: string;
35
+ /** Placeholder src override. `""` marks the img but keeps its src. */
36
+ fallbackSrc?: string;
37
+ }
38
+
39
+ /** The bundled placeholder: quiet broken-image glyph on a muted tile. */
40
+ const PLACEHOLDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-hidden="true">
41
+ <rect width="48" height="48" rx="8" fill="#888f9b" fill-opacity="0.14"/>
42
+ <g fill="none" stroke="#888f9b" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.85">
43
+ <rect x="12" y="10" width="24" height="28" rx="3"/>
44
+ <circle cx="20" cy="18" r="2.5"/>
45
+ <path d="M15 33l7-8 5 6 3.5-3.5L36 33"/>
46
+ <path d="M9 39L39 9"/>
47
+ </g>
48
+ </svg>`;
49
+
50
+ const DEFAULT_FALLBACK_SRC = `data:image/svg+xml,${encodeURIComponent(PLACEHOLDER_SVG)}`;
51
+
52
+ const APPLIED_FLAG = "hkImgFallback" as const;
53
+
54
+ interface FallbackState {
55
+ root: Document;
56
+ listeners: Array<[type: string, fn: EventListener]>;
57
+ selector: string;
58
+ exclude: string;
59
+ fallbackSrc: string;
60
+ }
61
+
62
+ const INSTALLS = new WeakMap<Document, FallbackState>();
63
+
64
+ function onError(state: FallbackState, e: Event) {
65
+ const target = e.target;
66
+ if (!target || !(target instanceof HTMLImageElement)) return;
67
+ if (!target.matches(state.selector)) return;
68
+ if (state.exclude && target.matches(state.exclude)) return;
69
+ if (target.dataset[APPLIED_FLAG] === "1") return; // already marked — never loop
70
+ target.dataset[APPLIED_FLAG] = "1";
71
+ target.classList.add("hk-img-broken");
72
+ if (state.fallbackSrc && target.getAttribute("src") !== state.fallbackSrc) {
73
+ target.src = state.fallbackSrc;
74
+ }
75
+ }
76
+
77
+ function onLoad(state: FallbackState, e: Event) {
78
+ const target = e.target;
79
+ if (!target || !(target instanceof HTMLImageElement)) return;
80
+ if (!target.classList.contains("hk-img-broken")) return;
81
+ // The swapped placeholder fires its own load — the class comes off only
82
+ // for an image that RESOLVED with real content again (src repaired,
83
+ // retry succeeded). A load OF the placeholder keeps the treatment.
84
+ if (state.fallbackSrc && target.getAttribute("src") === state.fallbackSrc) return;
85
+ target.classList.remove("hk-img-broken");
86
+ delete target.dataset[APPLIED_FLAG]; // a later breakage can re-mark it
87
+ }
88
+
89
+ function uninstall(state: FallbackState) {
90
+ if (INSTALLS.get(state.root) === state) INSTALLS.delete(state.root);
91
+ for (const [type, fn] of state.listeners.splice(0)) {
92
+ state.root.removeEventListener(type, fn, true);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Install the document-level broken-image re-render. Returns the
98
+ * uninstaller (removes listeners; already-marked images keep their
99
+ * placeholder). Idempotent per document.
100
+ */
101
+ export function installHkImageFallback(options: HkImageFallbackOptions = {}): () => void {
102
+ if (typeof document === "undefined") return () => undefined;
103
+ const existing = INSTALLS.get(document);
104
+ if (existing) return () => uninstall(existing);
105
+
106
+ const state: FallbackState = {
107
+ root: document,
108
+ listeners: [],
109
+ selector: options.selector ?? "img",
110
+ exclude: options.exclude ?? "[data-hk-img-native]",
111
+ fallbackSrc: options.fallbackSrc !== undefined ? options.fallbackSrc : DEFAULT_FALLBACK_SRC,
112
+ };
113
+
114
+ // Resource errors never bubble — capture phase is the only delegation
115
+ // point that sees them from the document.
116
+ const on = (type: string, fn: EventListener) => {
117
+ document.addEventListener(type, fn, true);
118
+ state.listeners.push([type, fn]);
119
+ };
120
+ on("error", (e) => onError(state, e));
121
+ on("load", (e) => onLoad(state, e));
122
+
123
+ INSTALLS.set(document, state);
124
+ return () => uninstall(state);
125
+ }
@@ -3,6 +3,19 @@ export * from "./cronBus";
3
3
  export * from "./intervalBus";
4
4
  export * from "./pageLifecycle";
5
5
  export * from "./mobileViewport";
6
+ export {
7
+ installHkTooltipBridge,
8
+ type HkTooltipBridgeOptions,
9
+ } from "./tooltipBridge";
10
+ export {
11
+ installHkImageFallback,
12
+ type HkImageFallbackOptions,
13
+ } from "./imageFallback";
14
+ export {
15
+ tooltipPositionStyle,
16
+ TOOLTIP_GAP_PX,
17
+ type TooltipPlacement,
18
+ } from "./tooltipPosition";
6
19
  export {
7
20
  sanitizeHistoryUrl,
8
21
  installHistorySafetyNet,
@@ -0,0 +1,213 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { installHkTooltipBridge, type HkTooltipBridgeOptions } from "./tooltipBridge";
4
+ import { usePopupManager } from "./usePopupManager";
5
+
6
+ /**
7
+ * installHkTooltipBridge contract tests:
8
+ * - engage: hovering/focusing a [title] element REMOVES the attribute
9
+ * (native tooltip cannot appear), stashes it in data-hk-title, and
10
+ * shows the shared `.hk-tooltip-popup` after the delay with the text
11
+ * + aria-describedby on the trigger
12
+ * - disengage: the title attribute is restored exactly when WE removed
13
+ * it; fresher writers (host re-set, HkPopover's `title=""` blanking,
14
+ * Vue undefined-removal) always win
15
+ * - escape hatches: empty titles, [data-hk-tooltip-native], elements
16
+ * inside an HkTooltip wrapper — never engage
17
+ * - pointerdown hides but stays engaged; scroll/resize and Esc disengage
18
+ * - idempotent install per document; uninstall tears everything down
19
+ */
20
+
21
+ const manager = usePopupManager();
22
+
23
+ function install(options: HkTooltipBridgeOptions = {}) {
24
+ const uninstall = installHkTooltipBridge(options);
25
+ uninstalls.push(uninstall);
26
+ return uninstall;
27
+ }
28
+
29
+ const uninstalls: Array<() => void> = [];
30
+
31
+ function trigger(title: string): HTMLElement {
32
+ const el = document.createElement("button");
33
+ el.setAttribute("title", title);
34
+ el.textContent = "probe";
35
+ document.body.appendChild(el);
36
+ return el;
37
+ }
38
+
39
+ function hover(el: Element) {
40
+ el.dispatchEvent(new MouseEvent("pointerover", { bubbles: true }));
41
+ }
42
+
43
+ function unhover(el: Element) {
44
+ el.dispatchEvent(new MouseEvent("pointerout", { bubbles: true }));
45
+ }
46
+
47
+ function bridgePopup(): HTMLElement | null {
48
+ return document.querySelector<HTMLElement>(".hk-tooltip-popup.hk-tooltip-bridge");
49
+ }
50
+
51
+ afterEach(() => {
52
+ vi.useRealTimers();
53
+ for (const u of uninstalls.splice(0)) u();
54
+ for (const entry of [...manager.registry.value.values()]) {
55
+ manager.unregister(entry.id);
56
+ }
57
+ document.querySelectorAll(".hk-tooltip-popup").forEach((el) => el.remove());
58
+ document.querySelectorAll<HTMLElement>("[data-hk-title]").forEach((el) => {
59
+ delete el.dataset.hkTitle;
60
+ });
61
+ });
62
+
63
+ describe("installHkTooltipBridge", () => {
64
+ it("engages on hover: removes the title, shows the popup after the delay", () => {
65
+ vi.useFakeTimers();
66
+ install({ delay: 300 });
67
+ const el = trigger("Hello world");
68
+ hover(el);
69
+ expect(el.hasAttribute("title")).toBe(false);
70
+ expect(el.dataset.hkTitle).toBe("Hello world");
71
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
72
+ vi.advanceTimersByTime(300);
73
+ const popup = bridgePopup()!;
74
+ expect(popup.classList.contains("hk-tooltip-visible")).toBe(true);
75
+ expect(popup.querySelector(".hk-tooltip-content")?.textContent).toBe("Hello world");
76
+ expect(el.getAttribute("aria-describedby")).toBe(popup.id);
77
+ });
78
+
79
+ it("restores the title and hides the popup on leave", () => {
80
+ vi.useFakeTimers();
81
+ install();
82
+ const el = trigger("Back soon");
83
+ hover(el);
84
+ vi.advanceTimersByTime(300);
85
+ unhover(el);
86
+ expect(el.getAttribute("title")).toBe("Back soon");
87
+ expect(el.hasAttribute("aria-describedby")).toBe(false);
88
+ expect(el.dataset.hkTitle).toBeUndefined();
89
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
90
+ });
91
+
92
+ it("cancels a pending show when the pointer leaves before the delay", () => {
93
+ vi.useFakeTimers();
94
+ install();
95
+ const el = trigger("Never shown");
96
+ hover(el);
97
+ unhover(el);
98
+ vi.advanceTimersByTime(1000);
99
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
100
+ expect(el.getAttribute("title")).toBe("Never shown");
101
+ });
102
+
103
+ it("engages on keyboard focus too and disengages on blur", () => {
104
+ vi.useFakeTimers();
105
+ install();
106
+ const el = trigger("Focused");
107
+ el.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
108
+ vi.advanceTimersByTime(300);
109
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(true);
110
+ el.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
111
+ expect(el.getAttribute("title")).toBe("Focused");
112
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
113
+ });
114
+
115
+ it("never engages on empty titles, native escape hatches or HkTooltip-managed triggers", () => {
116
+ vi.useFakeTimers();
117
+ install();
118
+ const blank = trigger("");
119
+ hover(blank);
120
+ expect(blank.hasAttribute("title")).toBe(true);
121
+ const native = trigger("Native");
122
+ native.setAttribute("data-hk-tooltip-native", "");
123
+ hover(native);
124
+ expect(native.getAttribute("title")).toBe("Native");
125
+ const wrapped = trigger("Managed");
126
+ const wrapper = document.createElement("span");
127
+ wrapper.className = "hk-tooltip-wrapper";
128
+ wrapper.appendChild(wrapped);
129
+ document.body.appendChild(wrapper);
130
+ hover(wrapped);
131
+ expect(wrapped.getAttribute("title")).toBe("Managed");
132
+ vi.advanceTimersByTime(1000);
133
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
134
+ });
135
+
136
+ it("honors the exclude option", () => {
137
+ vi.useFakeTimers();
138
+ install({ exclude: ".quiet" });
139
+ const el = trigger("Excluded");
140
+ el.className = "quiet";
141
+ hover(el);
142
+ expect(el.getAttribute("title")).toBe("Excluded");
143
+ });
144
+
145
+ it("never restores over a fresher writer while engaged", () => {
146
+ vi.useFakeTimers();
147
+ install();
148
+ const el = trigger("Old");
149
+ hover(el);
150
+ // The host (or a Vue binding) re-set the title mid-hover.
151
+ el.setAttribute("title", "New");
152
+ unhover(el);
153
+ expect(el.getAttribute("title")).toBe("New");
154
+ });
155
+
156
+ it("leaves a blanked title alone (HkPopover open-state suppression)", () => {
157
+ vi.useFakeTimers();
158
+ install();
159
+ const el = trigger("Suppressed");
160
+ hover(el);
161
+ // HkPopover blanks titles via `title=""` while a popover is open.
162
+ el.setAttribute("title", "");
163
+ unhover(el);
164
+ expect(el.getAttribute("title")).toBe("");
165
+ });
166
+
167
+ it("hides on pointerdown but stays engaged until the pointer leaves", () => {
168
+ vi.useFakeTimers();
169
+ install();
170
+ const el = trigger("Stays");
171
+ hover(el);
172
+ vi.advanceTimersByTime(300);
173
+ el.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
174
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
175
+ expect(el.hasAttribute("title")).toBe(false); // still engaged — no native flash
176
+ unhover(el);
177
+ expect(el.getAttribute("title")).toBe("Stays");
178
+ });
179
+
180
+ it("disengages on Escape", () => {
181
+ vi.useFakeTimers();
182
+ install();
183
+ const el = trigger("Escaped");
184
+ hover(el);
185
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
186
+ expect(el.getAttribute("title")).toBe("Escaped");
187
+ });
188
+
189
+ it("disengages on scroll and resize (restores the title, drops the hold)", () => {
190
+ vi.useFakeTimers();
191
+ install();
192
+ const el = trigger("Scrolled");
193
+ hover(el);
194
+ document.dispatchEvent(new Event("scroll"));
195
+ expect(el.getAttribute("title")).toBe("Scrolled");
196
+ expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
197
+ hover(el);
198
+ window.dispatchEvent(new Event("resize"));
199
+ expect(el.getAttribute("title")).toBe("Scrolled");
200
+ });
201
+
202
+ it("is idempotent per document and uninstallable", () => {
203
+ vi.useFakeTimers();
204
+ install();
205
+ const second = installHkTooltipBridge();
206
+ // The duplicate install reuses the first bridge: still one popup.
207
+ expect(document.querySelectorAll(".hk-tooltip-bridge").length).toBe(1);
208
+ second();
209
+ const el = trigger("After uninstall");
210
+ hover(el);
211
+ expect(el.getAttribute("title")).toBe("After uninstall");
212
+ });
213
+ });
@@ -0,0 +1,292 @@
1
+ // installHkTooltipBridge — document-level replacement for NATIVE tooltips.
2
+ //
3
+ // Hover/focus on ANY element carrying a `title` attribute shows an
4
+ // HkTooltip-styled popup instead of the browser's built-in one, so every
5
+ // existing `title=` call site in a host app upgrades to the house tooltip
6
+ // look with zero per-component migration. The mechanics:
7
+ //
8
+ // engage (pointerover / focusin, delegated)
9
+ // → the element's title is moved aside into `data-hk-title` and the
10
+ // attribute is REMOVED — the native tooltip can no longer appear
11
+ // → after the delay the shared `.hk-tooltip-popup` element is filled,
12
+ // positioned (tooltipPositionStyle: same placement/zoom math as
13
+ // HkTooltip) and faded in
14
+ // disengage (pointerout / focusout / scroll / resize / Esc); pointerdown
15
+ // only hides the popup — the hold stays so no native tooltip flashes
16
+ // → the popup fades out and the title attribute is RESTORED, so
17
+ // view-source fidelity and non-pointer readers keep the original
18
+ // markup (screen readers announce the title on focus as before;
19
+ // while the popup is up it carries aria-describedby instead).
20
+ //
21
+ // Escape hatches:
22
+ // - `[data-hk-tooltip-native]` keeps its native browser tooltip
23
+ // - elements inside an `.hk-tooltip-wrapper` are already HkTooltip-
24
+ // managed and are skipped (no double popups)
25
+ // - empty / whitespace-only titles never engage
26
+ //
27
+ // The bridge holds one popup-manager handle (kind "tooltip"), so its
28
+ // popups share the tooltip z band with component tooltips. Installing
29
+ // twice on the same document is idempotent: the second install returns
30
+ // the first install's uninstall.
31
+ import { tooltipPositionStyle, type TooltipPlacement } from "./tooltipPosition";
32
+ import { usePopupManager, type PopupHandle } from "./usePopupManager";
33
+ // The popup reuses HkTooltip's popup classes — carry the sheet so a host
34
+ // that installs the bridge without mounting HkTooltip still renders the
35
+ // house look (scss imports are deduped, so double-import is free).
36
+ import "../components/HkTooltip.scss";
37
+
38
+ export interface HkTooltipBridgeOptions {
39
+ /** Hover/focus → popup delay in ms (HkTooltip parity: 300). */
40
+ delay?: number;
41
+ /** Popup side relative to the trigger. */
42
+ placement?: TooltipPlacement;
43
+ /** Popup max-width (the shared SCSS caps at 280px by default). */
44
+ maxWidth?: string;
45
+ /** Which elements carry tooltip text. Default: `[title]`. */
46
+ selector?: string;
47
+ /** Elements matching this never engage (their title stays native). */
48
+ exclude?: string;
49
+ }
50
+
51
+ interface BridgeState {
52
+ root: Document;
53
+ popup: HTMLDivElement;
54
+ content: HTMLDivElement;
55
+ handle: PopupHandle | null;
56
+ listeners: Array<{ target: EventTarget; type: string; fn: EventListener; opts?: AddEventListenerOptions }>;
57
+ active: Element | null;
58
+ savedTitle: string | null;
59
+ timer: ReturnType<typeof setTimeout> | null;
60
+ delay: number;
61
+ placement: TooltipPlacement;
62
+ maxWidth: string | undefined;
63
+ selector: string;
64
+ exclude: string;
65
+ describedBy: string;
66
+ }
67
+
68
+ const INSTALLS = new WeakMap<Document, BridgeState>();
69
+ let bridgeSeq = 0;
70
+
71
+ function stopTimer(state: BridgeState) {
72
+ if (state.timer !== null) {
73
+ clearTimeout(state.timer);
74
+ state.timer = null;
75
+ }
76
+ }
77
+
78
+ /** Fade the popup out without touching the held title. */
79
+ function hidePopup(state: BridgeState) {
80
+ stopTimer(state);
81
+ state.popup.classList.remove("hk-tooltip-visible");
82
+ if (state.active) {
83
+ // Hand the description relationship back: once the popup is gone the
84
+ // restored title attribute speaks for itself again.
85
+ state.active.removeAttribute("aria-describedby");
86
+ }
87
+ }
88
+
89
+ /** Restore the title attribute stashed at engage time and drop the hold. */
90
+ function restoreTitle(state: BridgeState) {
91
+ const el = state.active;
92
+ const saved = state.savedTitle;
93
+ state.active = null;
94
+ state.savedTitle = null;
95
+ if (!el) return;
96
+ // Write back ONLY into the exact state WE created (attr removed).
97
+ // Anything else is a fresher writer that must win:
98
+ // - a host re-set a title while we held it removed → theirs;
99
+ // - HkPopover's open-state suppression blanks titles via `title=""`
100
+ // and restores its own snapshots on close — resurrecting ours over
101
+ // its blank would defeat it (engage already skips empty titles, so
102
+ // blanked elements can never be the engaged ones);
103
+ // - a Vue binding flipped to `undefined` → Vue REMOVES the attr, and
104
+ // a stale resurrection would keep showing dead text.
105
+ // Vue never re-adds an attr it saw removed while its vnode value stayed
106
+ // unchanged, so this restore is what keeps static titles alive after a
107
+ // plain hover.
108
+ if (saved !== null && !el.hasAttribute("title")) {
109
+ el.setAttribute("title", saved);
110
+ }
111
+ delete (el as HTMLElement).dataset.hkTitle;
112
+ }
113
+
114
+ function disengage(state: BridgeState) {
115
+ stopTimer(state);
116
+ hidePopup(state);
117
+ restoreTitle(state);
118
+ }
119
+
120
+ function showPopup(state: BridgeState, el: Element) {
121
+ const text = state.savedTitle;
122
+ if (!text || !state.popup.isConnected) return;
123
+ state.content.textContent = text;
124
+ // Same geometry as component tooltips: the fixed popup pins to the
125
+ // trigger rect with the shared placement switch (ancestor zoom aware).
126
+ Object.assign(state.popup.style, tooltipPositionStyle(
127
+ el.getBoundingClientRect(),
128
+ state.placement,
129
+ state.maxWidth,
130
+ ));
131
+ state.popup.classList.add("hk-tooltip-visible");
132
+ el.setAttribute("aria-describedby", state.describedBy);
133
+ }
134
+
135
+ function engage(state: BridgeState, el: Element) {
136
+ if (state.active === el) return; // moving across the trigger's children
137
+ if (state.active) disengage(state);
138
+ const title = el.getAttribute("title");
139
+ if (!title || !title.trim()) return;
140
+ state.active = el;
141
+ state.savedTitle = title;
142
+ (el as HTMLElement).dataset.hkTitle = title;
143
+ el.removeAttribute("title");
144
+ stopTimer(state);
145
+ state.timer = setTimeout(() => {
146
+ state.timer = null;
147
+ showPopup(state, el);
148
+ }, state.delay);
149
+ }
150
+
151
+ function shouldEngage(state: BridgeState, target: EventTarget | null): Element | null {
152
+ if (!target || !(target instanceof Element)) return null;
153
+ // Skip HkTooltip-managed triggers and the bridge's own popup.
154
+ if (target.closest(".hk-tooltip-wrapper") || target.closest(".hk-tooltip-popup")) return null;
155
+ if (target.closest("[data-hk-tooltip-native]")) return null;
156
+ if (state.exclude && target.closest(state.exclude)) return null;
157
+ const el = target.closest(state.selector);
158
+ // HTML and SVG elements both carry dataset/getBoundingClientRect/attrs —
159
+ // an <svg title=...> gets the house popup just like a <button>.
160
+ return el instanceof HTMLElement || el instanceof SVGElement ? el : null;
161
+ }
162
+
163
+ function uninstall(state: BridgeState) {
164
+ if (INSTALLS.get(state.root) === state) INSTALLS.delete(state.root);
165
+ disengage(state);
166
+ for (const { target, type, fn, opts } of state.listeners.splice(0)) {
167
+ target.removeEventListener(type, fn, opts);
168
+ }
169
+ if (state.handle) {
170
+ try {
171
+ usePopupManager().unregister(state.handle.id);
172
+ } catch {
173
+ /* handle already gone */
174
+ }
175
+ state.handle = null;
176
+ }
177
+ state.popup.remove();
178
+ }
179
+
180
+ /**
181
+ * Install the document-level native-tooltip replacement. Returns the
182
+ * uninstaller (removes listeners, restores any held title, drops the
183
+ * popup element). Idempotent per document.
184
+ */
185
+ export function installHkTooltipBridge(options: HkTooltipBridgeOptions = {}): () => void {
186
+ if (typeof document === "undefined") return () => undefined;
187
+ const existing = INSTALLS.get(document);
188
+ if (existing) return () => uninstall(existing);
189
+
190
+ const popup = document.createElement("div");
191
+ popup.className = "hk-tooltip-popup hk-tooltip-bridge";
192
+ const content = document.createElement("div");
193
+ content.className = "hk-tooltip-content";
194
+ popup.appendChild(content);
195
+ document.body.appendChild(popup);
196
+
197
+ const state: BridgeState = {
198
+ root: document,
199
+ popup,
200
+ content,
201
+ handle: null,
202
+ listeners: [],
203
+ active: null,
204
+ savedTitle: null,
205
+ timer: null,
206
+ delay: options.delay ?? 300,
207
+ placement: options.placement ?? "top",
208
+ maxWidth: options.maxWidth,
209
+ selector: options.selector ?? "[title]",
210
+ exclude: options.exclude ?? "",
211
+ describedBy: `hk-tooltip-bridge-${++bridgeSeq}`,
212
+ };
213
+ popup.id = state.describedBy;
214
+
215
+ // One popup-manager handle for the bridge's lifetime: its popups hold
216
+ // the tooltip band (above overlays, below toasts) exactly like
217
+ // component tooltips do.
218
+ try {
219
+ state.handle = usePopupManager().register("tooltip", false);
220
+ popup.style.zIndex = String(state.handle.zIndex);
221
+ } catch {
222
+ state.handle = null; // popup manager unavailable — CSS fallback z-index
223
+ }
224
+
225
+ const on = (type: string, fn: EventListener, opts?: AddEventListenerOptions) => {
226
+ document.addEventListener(type, fn, opts);
227
+ state.listeners.push({ target: document, type, fn, opts });
228
+ };
229
+
230
+ on("pointerover", (e) => {
231
+ const el = shouldEngage(state, e.target);
232
+ if (el) engage(state, el);
233
+ }, { capture: true, passive: true });
234
+
235
+ on("pointerout", (e) => {
236
+ if (!state.active) return;
237
+ const target = e.target instanceof Element ? e.target : null;
238
+ if (!target) return;
239
+ // Only leave when the pointer exited the engaged element entirely.
240
+ // Containment — NOT the selector: engagement removed the title
241
+ // attribute, so the engaged element no longer matches `[title]`.
242
+ const related = (e as MouseEvent).relatedTarget;
243
+ if ((target === state.active || state.active.contains(target))
244
+ && !(related instanceof Element && state.active.contains(related))) {
245
+ disengage(state);
246
+ }
247
+ }, { capture: true, passive: true });
248
+
249
+ on("pointerdown", () => {
250
+ if (state.active) hidePopup(state); // native also hides on press; stay engaged
251
+ }, { capture: true, passive: true });
252
+
253
+ on("focusin", (e) => {
254
+ const el = shouldEngage(state, e.target);
255
+ if (el) engage(state, el);
256
+ }, { capture: true });
257
+
258
+ on("focusout", (e) => {
259
+ if (!state.active) return;
260
+ if (e.target instanceof Element
261
+ && (e.target === state.active || state.active.contains(e.target))) {
262
+ disengage(state);
263
+ }
264
+ }, { capture: true });
265
+
266
+ // Any scroll/resize anywhere invalidates the fixed-position geometry —
267
+ // DISCONNECT entirely (restore the title, drop the hold), not just hide:
268
+ // a hide-only path would leave the trigger with its title attribute
269
+ // removed and no aria-describedby while the pointer rests on it. The
270
+ // popup re-arms through a fresh engage on the next pointer move.
271
+ // NOTE: resize fires at WINDOW (never routed through document capture),
272
+ // so it binds there explicitly; element scrolls are capture-reachable
273
+ // from the document.
274
+ on("scroll", () => {
275
+ if (state.active) disengage(state);
276
+ }, { capture: true, passive: true });
277
+ const view = document.defaultView;
278
+ if (view) {
279
+ const onResize = () => {
280
+ if (state.active) disengage(state);
281
+ };
282
+ view.addEventListener("resize", onResize);
283
+ state.listeners.push({ target: view, type: "resize", fn: onResize });
284
+ }
285
+
286
+ on("keydown", (e) => {
287
+ if ((e as KeyboardEvent).key === "Escape" && state.active) disengage(state);
288
+ }, { capture: true });
289
+
290
+ INSTALLS.set(document, state);
291
+ return () => uninstall(state);
292
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { TOOLTIP_GAP_PX, tooltipPositionStyle } from "./tooltipPosition";
4
+
5
+ /** A 100x40 rect at (200,100) — big enough for every placement side. */
6
+ function rect(): DOMRect {
7
+ return {
8
+ left: 200, top: 100, right: 300, bottom: 140,
9
+ width: 100, height: 40, x: 200, y: 100,
10
+ toJSON: () => ({}),
11
+ } as DOMRect;
12
+ }
13
+
14
+ describe("tooltipPositionStyle", () => {
15
+ it("pins the popup 8px outside the rect with per-placement transforms", () => {
16
+ expect(TOOLTIP_GAP_PX).toBe(8);
17
+ const top = tooltipPositionStyle(rect(), "top");
18
+ expect(top.top).toBe("92px"); // 100 - 8
19
+ expect(top.left).toBe("250px"); // hcenter
20
+ expect(top.transform).toBe("translate(-50%, -100%)");
21
+
22
+ const bottom = tooltipPositionStyle(rect(), "bottom");
23
+ expect(bottom.top).toBe("148px"); // 140 + 8
24
+ expect(bottom.transform).toBe("translate(-50%, 0)");
25
+
26
+ const left = tooltipPositionStyle(rect(), "left");
27
+ expect(left.left).toBe("192px"); // 200 - 8
28
+ expect(left.top).toBe("120px"); // vcenter
29
+ expect(left.transform).toBe("translate(-100%, -50%)");
30
+
31
+ const right = tooltipPositionStyle(rect(), "right");
32
+ expect(right.left).toBe("308px"); // 300 + 8
33
+ expect(right.transform).toBe("translate(0, -50%)");
34
+ });
35
+
36
+ it("carries the maxWidth override onto the popup style", () => {
37
+ const style = tooltipPositionStyle(rect(), "top", "180px");
38
+ expect(style.maxWidth).toBe("180px");
39
+ expect(tooltipPositionStyle(rect(), "top").maxWidth).toBeUndefined();
40
+ });
41
+ });