@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.
- package/package.json +1 -1
- package/src/DemoApp.tsx +37 -1
- package/src/components/HkAuthMethodList.scss +13 -39
- package/src/components/HkAuthMethodList.test.tsx +55 -73
- package/src/components/HkAuthMethodList.tsx +40 -149
- package/src/components/HkCrossfade.scss +61 -0
- package/src/components/HkCrossfade.test.tsx +270 -0
- package/src/components/HkCrossfade.tsx +197 -0
- package/src/components/HkIconButtonGroup.scss +104 -0
- package/src/components/HkIconButtonGroup.test.tsx +182 -0
- package/src/components/HkIconButtonGroup.tsx +152 -0
- package/src/components/HkTooltip.tsx +7 -42
- package/src/index.ts +6 -0
- package/src/runtime/imageFallback.scss +13 -0
- package/src/runtime/imageFallback.test.ts +149 -0
- package/src/runtime/imageFallback.ts +125 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/tooltipBridge.test.ts +213 -0
- package/src/runtime/tooltipBridge.ts +292 -0
- package/src/runtime/tooltipPosition.test.ts +41 -0
- package/src/runtime/tooltipPosition.ts +61 -0
|
@@ -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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
+
});
|