@celestia-island/hikari 0.55.268 → 0.55.269
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/components/HkAuthMethodList.test.tsx +8 -5
- package/src/components/HkColorSchemeDialog.test.ts +3 -3
- package/src/components/HkColorSchemeEditor.test.ts +2 -2
- package/src/components/HkCopyBadge.test.tsx +16 -14
- package/src/components/HkInput.password.test.tsx +33 -18
- package/src/components/HkModalBreadcrumb.test.tsx +1 -1
- package/src/components/HkTooltip.test.tsx +60 -60
- package/src/components/HkTooltip.tsx +36 -37
- package/src/composables/useRailIconFlight.test.ts +13 -5
- package/src/index.ts +5 -0
- package/src/runtime/index.ts +10 -0
- package/src/runtime/tooltipBridge.test.ts +18 -1
- package/src/runtime/tooltipBridge.ts +10 -1
- package/src/runtime/tooltipPosition.test.ts +128 -0
- package/src/runtime/tooltipPosition.ts +55 -0
- package/src/runtime/touchHint.scss +73 -0
- package/src/runtime/touchHint.test.ts +407 -0
- package/src/runtime/touchHint.ts +576 -0
- package/src/theme/tokenGroups.test.ts +7 -7
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkAuthMethodList from "./HkAuthMethodList";
|
|
@@ -15,6 +15,7 @@ function mount(node: ReturnType<typeof h>) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
afterEach(() => {
|
|
18
|
+
vi.useRealTimers();
|
|
18
19
|
for (const { app, container } of mounts.splice(0)) {
|
|
19
20
|
app.unmount();
|
|
20
21
|
container.remove();
|
|
@@ -61,17 +62,19 @@ describe("HkAuthMethodList", () => {
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
it("wraps every tile in a tooltip carrying the provider label", async () => {
|
|
65
|
+
vi.useFakeTimers();
|
|
64
66
|
const c = mount(
|
|
65
67
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
|
|
66
68
|
);
|
|
67
69
|
const wrappers = c.querySelectorAll(".hk-tooltip-wrapper");
|
|
68
70
|
expect(wrappers.length).toBe(2);
|
|
69
|
-
// Popups mount lazily on first engage —
|
|
70
|
-
//
|
|
71
|
+
// Popups mount lazily on first engage — hover each tile through the
|
|
72
|
+
// 300ms show delay (a touch press no longer mounts the popup: the
|
|
73
|
+
// touch half is the long-press hint bar, never the anchored popup).
|
|
71
74
|
for (const w of wrappers) {
|
|
72
|
-
w.dispatchEvent(new
|
|
75
|
+
w.dispatchEvent(new MouseEvent("mouseenter"));
|
|
73
76
|
}
|
|
74
|
-
await
|
|
77
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
75
78
|
const popups = [...document.querySelectorAll(".hk-tooltip-popup .hk-tooltip-content")]
|
|
76
79
|
.map((el) => el.textContent);
|
|
77
80
|
expect(popups).toContain("GitHub");
|
|
@@ -52,8 +52,8 @@ const SECTIONED_GROUP: TokenGroupDefinition = {
|
|
|
52
52
|
label: { en: "Process media", "zh-Hans": "工艺介质" },
|
|
53
53
|
slots: [
|
|
54
54
|
{
|
|
55
|
-
key: "
|
|
56
|
-
label: { en: "
|
|
55
|
+
key: "buffer",
|
|
56
|
+
label: { en: "Buffer", "zh-Hans": "缓冲" },
|
|
57
57
|
defaults: { dark: { r: 20, g: 184, b: 166 }, light: { r: 15, g: 118, b: 110 } },
|
|
58
58
|
},
|
|
59
59
|
],
|
|
@@ -151,7 +151,7 @@ describe("HkColorSchemeDialog extension groups", () => {
|
|
|
151
151
|
);
|
|
152
152
|
expect(labels).toContain("L1 相(黄)");
|
|
153
153
|
expect(labels).toContain("L2 相(绿)");
|
|
154
|
-
expect(labels).toContain("
|
|
154
|
+
expect(labels).toContain("缓冲");
|
|
155
155
|
|
|
156
156
|
await setLocale("en");
|
|
157
157
|
});
|
|
@@ -50,8 +50,8 @@ const SECTIONED_GROUP: TokenGroupDefinition = {
|
|
|
50
50
|
label: { en: "Process media", "zh-Hans": "工艺介质" },
|
|
51
51
|
slots: [
|
|
52
52
|
{
|
|
53
|
-
key: "
|
|
54
|
-
label: { en: "
|
|
53
|
+
key: "buffer",
|
|
54
|
+
label: { en: "Buffer", "zh-Hans": "缓冲" },
|
|
55
55
|
defaults: { dark: { r: 20, g: 184, b: 166 }, light: { r: 15, g: 118, b: 110 } },
|
|
56
56
|
},
|
|
57
57
|
],
|
|
@@ -21,6 +21,7 @@ function mount(renderNode: () => ReturnType<typeof h>) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
24
25
|
for (const { app, container } of mounts.splice(0)) {
|
|
25
26
|
app.unmount();
|
|
26
27
|
container.remove();
|
|
@@ -128,33 +129,36 @@ describe("HkCopyBadge", () => {
|
|
|
128
129
|
expect(writeText).not.toHaveBeenCalled();
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
/** Engage the lazy tooltip
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
132
|
+
/** Engage the lazy tooltip the way a mouse user does: hover the
|
|
133
|
+
* wrapper through the 300ms show delay. (A touch press no longer
|
|
134
|
+
* mounts the popup — the touch half is the long-press hint bar, so
|
|
135
|
+
* the tap vehicle this helper replaced is gone for good.) */
|
|
136
|
+
async function engageTooltip(c: HTMLElement) {
|
|
137
|
+
c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(new MouseEvent("mouseenter"));
|
|
138
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
139
|
+
await nextTick();
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
it("renders the custom tooltip text", async () => {
|
|
143
|
+
vi.useFakeTimers();
|
|
140
144
|
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
141
145
|
// The popup mounts lazily on first engage (dense-list contract).
|
|
142
146
|
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
143
|
-
engageTooltip(c);
|
|
144
|
-
await nextTick();
|
|
147
|
+
await engageTooltip(c);
|
|
145
148
|
const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
146
149
|
expect(content?.textContent).toBe("Open details");
|
|
147
150
|
});
|
|
148
151
|
|
|
149
152
|
it("shows the localized click-to-copy tooltip by default", async () => {
|
|
153
|
+
vi.useFakeTimers();
|
|
150
154
|
const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
|
|
151
|
-
engageTooltip(c);
|
|
152
|
-
await nextTick();
|
|
155
|
+
await engageTooltip(c);
|
|
153
156
|
const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
154
157
|
expect(content?.textContent).toBe("Click to copy");
|
|
155
158
|
});
|
|
156
159
|
|
|
157
160
|
it("links the badge to the popup via aria-describedby across the whole lifecycle", async () => {
|
|
161
|
+
vi.useFakeTimers();
|
|
158
162
|
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
159
163
|
const badge = c.querySelector(".hk-badge")!;
|
|
160
164
|
|
|
@@ -163,8 +167,7 @@ describe("HkCopyBadge", () => {
|
|
|
163
167
|
expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
164
168
|
|
|
165
169
|
// Engage: describedby == the popup id.
|
|
166
|
-
engageTooltip(c);
|
|
167
|
-
await nextTick();
|
|
170
|
+
await engageTooltip(c);
|
|
168
171
|
const popup = document.body.querySelector(".hk-tooltip-popup")!;
|
|
169
172
|
expect(badge.getAttribute("aria-describedby")).toBe(popup.getAttribute("id"));
|
|
170
173
|
expect(popup.querySelector(".hk-tooltip-content")!.textContent).toBe("Open details");
|
|
@@ -175,8 +178,7 @@ describe("HkCopyBadge", () => {
|
|
|
175
178
|
expect(badge.getAttribute("aria-describedby")).toBeNull();
|
|
176
179
|
|
|
177
180
|
// Re-engage: returns, reusing the same popup element.
|
|
178
|
-
engageTooltip(c);
|
|
179
|
-
await nextTick();
|
|
181
|
+
await engageTooltip(c);
|
|
180
182
|
expect(badge.getAttribute("aria-describedby")).toBe(
|
|
181
183
|
document.body.querySelector(".hk-tooltip-popup")!.getAttribute("id"),
|
|
182
184
|
);
|
|
@@ -34,7 +34,8 @@ const fakeCreateImageData = fakeFn<CanvasRenderingContext2D["createImageData"]>(
|
|
|
34
34
|
* click-to-toggle with auto-hide (revealAutoHideMs)
|
|
35
35
|
* - the strength dot classifies through the shared passwordLevel util
|
|
36
36
|
* unless strengthEvaluator overrides it, and carries a localized
|
|
37
|
-
* tooltip that opens on hover
|
|
37
|
+
* tooltip that opens on hover (desktop) / via the touch long-press
|
|
38
|
+
* hint bar (the touch half is the top bar, never the anchored popup)
|
|
38
39
|
* - the native input stays type="password" at all times — the reveal
|
|
39
40
|
* pass draws on the canvas, so the value is never DOM text
|
|
40
41
|
*/
|
|
@@ -108,10 +109,14 @@ function fireBeforeinput(input: HTMLInputElement, inputType: string) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
afterEach(() => {
|
|
112
|
+
vi.useRealTimers();
|
|
111
113
|
for (const { app, container } of mounts.splice(0)) {
|
|
112
114
|
app.unmount();
|
|
113
115
|
container.remove();
|
|
114
116
|
}
|
|
117
|
+
// The touch hint bar is presenter-owned module state (runtime/
|
|
118
|
+
// touchHint.ts) — tear the node out so a case remounts it fresh.
|
|
119
|
+
document.querySelectorAll(".hk-touch-hint-bar").forEach((el) => el.remove());
|
|
115
120
|
});
|
|
116
121
|
|
|
117
122
|
describe("HkInput password surface identity", () => {
|
|
@@ -566,32 +571,42 @@ describe("HkInput password strength traffic light", () => {
|
|
|
566
571
|
).toBe(false);
|
|
567
572
|
});
|
|
568
573
|
|
|
569
|
-
it("
|
|
574
|
+
it("hints the strength on touch through the long-press bar, never the anchored popup", async () => {
|
|
575
|
+
vi.useFakeTimers();
|
|
570
576
|
const { container } = mountPasswordInput("Password1", {
|
|
571
577
|
passwordTrailing: "strength",
|
|
572
578
|
});
|
|
573
579
|
const wrapper = container.querySelector<HTMLElement>(".hk-tooltip-wrapper")!;
|
|
574
580
|
|
|
581
|
+
// A quick tap shows NOTHING: neither the anchored popup (hover/focus
|
|
582
|
+
// only) nor the top hint bar (the hold never elapsed).
|
|
575
583
|
wrapper.dispatchEvent(
|
|
576
|
-
new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
|
|
584
|
+
new PointerEvent("pointerdown", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
577
585
|
);
|
|
578
|
-
await
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
expect(popup?.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
582
|
-
expect(popup?.textContent).toContain("Fair");
|
|
583
|
-
|
|
584
|
-
// Tap somewhere else: the capture-phase document listener hides it.
|
|
585
|
-
const outside = document.createElement("div");
|
|
586
|
-
document.body.appendChild(outside);
|
|
587
|
-
outside.dispatchEvent(
|
|
588
|
-
new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
|
|
586
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
587
|
+
document.dispatchEvent(
|
|
588
|
+
new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
589
589
|
);
|
|
590
|
-
await
|
|
590
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
591
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
592
|
+
expect(document.querySelector(".hk-touch-hint-bar")).toBeNull();
|
|
591
593
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
594
|
+
// The long-press fires the top-docked bar with the strength text…
|
|
595
|
+
wrapper.dispatchEvent(
|
|
596
|
+
new PointerEvent("pointerdown", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
597
|
+
);
|
|
598
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
599
|
+
const bar = document.querySelector<HTMLElement>(".hk-touch-hint-bar");
|
|
600
|
+
expect(bar?.classList.contains("is-on")).toBe(true);
|
|
601
|
+
expect(bar?.textContent).toContain("Fair");
|
|
602
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
603
|
+
|
|
604
|
+
// …the lift starts the linger window, and the bar retires after it.
|
|
605
|
+
document.dispatchEvent(
|
|
606
|
+
new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
607
|
+
);
|
|
608
|
+
await vi.advanceTimersByTimeAsync(2200);
|
|
609
|
+
expect(bar?.classList.contains("is-on")).toBe(false);
|
|
595
610
|
});
|
|
596
611
|
});
|
|
597
612
|
|
|
@@ -109,7 +109,7 @@ function menuRows(): HTMLElement[] {
|
|
|
109
109
|
return [...document.body.querySelectorAll<HTMLElement>(".hk-menu-action-item")];
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
const LONG = "【e2e-w0-002310
|
|
112
|
+
const LONG = "【e2e-w0-002310】自动化测试流水线冒烟:请确认当前会话缓冲区与缓存状态正常";
|
|
113
113
|
const LONG_TAIL = "【e2e-w0-002310】…";
|
|
114
114
|
|
|
115
115
|
/** A stack that outgrows any viewport this suite uses: every label is cut to
|
|
@@ -15,6 +15,11 @@ import { POPUP_Z_BANDS, POPUP_Z_STEP, usePopupManager } from "../runtime/usePopu
|
|
|
15
15
|
* - show/hide: anchor mouseenter shows after the delay (default 300ms),
|
|
16
16
|
* mouseleave hides and cancels a pending show; focusin/focusout ride
|
|
17
17
|
* the same path
|
|
18
|
+
* - the touch half: a touch/pen press ARMS the long-press recognizer of
|
|
19
|
+
* runtime/touchHint.ts — the hint surfaces in the top-docked touch
|
|
20
|
+
* hint bar, never in the anchored popup (which stays hover/focus
|
|
21
|
+
* only); a quick tap shows nothing at all, and the tap's synthetic
|
|
22
|
+
* mouseenter cannot flash the popup
|
|
18
23
|
* - anchoring: placement drives the popup class, the wrapper
|
|
19
24
|
* data-position and the measured offset style (happy-dom reports a
|
|
20
25
|
* zero rect, so the numbers pin the 8px gap and per-placement
|
|
@@ -83,6 +88,9 @@ afterEach(() => {
|
|
|
83
88
|
manager.unregister(entry.id);
|
|
84
89
|
}
|
|
85
90
|
document.querySelectorAll(".hk-tooltip-popup").forEach((el) => el.remove());
|
|
91
|
+
// The touch hint bar is presenter-owned module state — tear the node
|
|
92
|
+
// out so the next case remounts it fresh (it self-heals on show).
|
|
93
|
+
document.querySelectorAll(".hk-touch-hint-bar").forEach((el) => el.remove());
|
|
86
94
|
});
|
|
87
95
|
|
|
88
96
|
describe("HkTooltip popup-manager registration", () => {
|
|
@@ -255,62 +263,58 @@ describe("HkTooltip show/hide", () => {
|
|
|
255
263
|
});
|
|
256
264
|
});
|
|
257
265
|
|
|
258
|
-
describe("HkTooltip touch
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
266
|
+
describe("HkTooltip touch hint bar", () => {
|
|
267
|
+
// The touch half is the top-docked hint bar (runtime/touchHint.ts):
|
|
268
|
+
// a touch press ARMS a long-press recognizer against the wrapper, and
|
|
269
|
+
// the anchored popup stays a hover/focus (and pen-hover) surface.
|
|
270
|
+
function touchDown(el: Element) {
|
|
271
|
+
el.dispatchEvent(new PointerEvent("pointerdown", {
|
|
272
|
+
pointerType: "touch", pointerId: 1, clientX: 40, clientY: 40, bubbles: true,
|
|
273
|
+
}));
|
|
263
274
|
}
|
|
264
275
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
expect(popup().className).toContain("hk-tooltip-visible");
|
|
271
|
-
});
|
|
276
|
+
function touchUp() {
|
|
277
|
+
document.dispatchEvent(new PointerEvent("pointerup", {
|
|
278
|
+
pointerType: "touch", pointerId: 1, bubbles: true,
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
272
281
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
wrapper(container).getBoundingClientRect = () => ({
|
|
277
|
-
x: 200, y: top, left: 200, top, right: 280, bottom: top + 40,
|
|
278
|
-
width: 80, height: 40, toJSON: () => ({}),
|
|
279
|
-
}) as DOMRect;
|
|
280
|
-
tap(wrapper(container));
|
|
281
|
-
await settle();
|
|
282
|
-
expect(popup().style.top).toBe("148px");
|
|
283
|
-
top = 120;
|
|
284
|
-
await settle();
|
|
285
|
-
expect(popup().style.top).toBe("168px");
|
|
286
|
-
tap(wrapper(container)); // close also stops the tracking loop
|
|
287
|
-
await nextTick();
|
|
288
|
-
expect(popup().className).not.toContain("hk-tooltip-visible");
|
|
289
|
-
});
|
|
282
|
+
function hintBar(): HTMLElement | null {
|
|
283
|
+
return document.querySelector<HTMLElement>(".hk-touch-hint-bar");
|
|
284
|
+
}
|
|
290
285
|
|
|
291
|
-
it("
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
286
|
+
it("long-press shows the top hint bar with the text, not the anchored popup", async () => {
|
|
287
|
+
vi.useFakeTimers();
|
|
288
|
+
const { container } = mount({ text: "hold me", delay: 3000 });
|
|
289
|
+
touchDown(wrapper(container));
|
|
290
|
+
await vi.advanceTimersByTimeAsync(449);
|
|
291
|
+
expect(hintBar()?.classList.contains("is-on")).toBeFalsy();
|
|
292
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
293
|
+
expect(hintBar()?.classList.contains("is-on")).toBe(true);
|
|
294
|
+
expect(hintBar()?.textContent).toBe("hold me");
|
|
295
|
+
// The anchored popup never enters the touch path: no teleported
|
|
296
|
+
// node at all — the lazy engage never ran.
|
|
297
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
298
|
+
touchUp(); // end the fired hold (restores the wrapper guards)
|
|
299
|
+
});
|
|
296
300
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
301
|
+
it("a quick tap shows neither the anchored popup nor the bar", async () => {
|
|
302
|
+
vi.useFakeTimers();
|
|
303
|
+
const { container } = mount({ text: "tap", delay: 3000 });
|
|
304
|
+
touchDown(wrapper(container));
|
|
305
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
306
|
+
touchUp();
|
|
307
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
308
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
309
|
+
expect(hintBar()?.classList.contains("is-on")).toBeFalsy();
|
|
300
310
|
});
|
|
301
311
|
|
|
302
|
-
it("
|
|
303
|
-
const { container } = mount({ text: "
|
|
304
|
-
|
|
305
|
-
await
|
|
312
|
+
it("mouse hover still shows the anchored popup (desktop unchanged)", async () => {
|
|
313
|
+
const { container } = mount({ text: "mouse only", delay: 0 });
|
|
314
|
+
enter(container);
|
|
315
|
+
await settle();
|
|
306
316
|
expect(popup().className).toContain("hk-tooltip-visible");
|
|
307
|
-
|
|
308
|
-
const elsewhere = document.createElement("div");
|
|
309
|
-
document.body.appendChild(elsewhere);
|
|
310
|
-
tap(elsewhere);
|
|
311
|
-
await nextTick();
|
|
312
|
-
expect(popup().className).not.toContain("hk-tooltip-visible");
|
|
313
|
-
elsewhere.remove();
|
|
317
|
+
expect(hintBar()?.classList.contains("is-on")).toBeFalsy();
|
|
314
318
|
});
|
|
315
319
|
|
|
316
320
|
it("ignores mouse pointerdown (the hover path owns mice)", async () => {
|
|
@@ -320,25 +324,21 @@ describe("HkTooltip touch taps", () => {
|
|
|
320
324
|
);
|
|
321
325
|
await nextTick();
|
|
322
326
|
// A mouse press never engages the lazy tooltip at all — no popup
|
|
323
|
-
// node, no
|
|
327
|
+
// node, no touch hint bar.
|
|
324
328
|
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
329
|
+
expect(hintBar()).toBeNull();
|
|
325
330
|
});
|
|
326
331
|
|
|
327
|
-
it("does not
|
|
332
|
+
it("does not flash the anchored popup from the synthetic mouseenter after a tap", async () => {
|
|
328
333
|
const { container } = mount({ text: "synthetic", delay: 0 });
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
expect(popup().className).toContain("hk-tooltip-visible");
|
|
332
|
-
|
|
333
|
-
tap(wrapper(container)); // close via toggle
|
|
334
|
+
touchDown(wrapper(container));
|
|
335
|
+
touchUp();
|
|
334
336
|
await nextTick();
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// The tap's synthetic mouseenter arrives synchronously after the
|
|
338
|
-
// pointerdown — it must not resurrect the bubble.
|
|
337
|
+
// The tap's synthetic mouseenter arrives right after the pointer
|
|
338
|
+
// events — it must not open the hover-only anchored popup.
|
|
339
339
|
enter(container);
|
|
340
340
|
await settle();
|
|
341
|
-
expect(
|
|
341
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
342
342
|
});
|
|
343
343
|
});
|
|
344
344
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, ref, Teleport, type CSSProperties, type PropType, type SlotsType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
3
|
import { applyTooltipPosition, type TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
|
+
import { armTouchHint } from "../runtime/touchHint";
|
|
4
5
|
import "./HkTooltip.scss";
|
|
5
6
|
import { ensureOverlayRoot } from "../overlay/overlayRoot";
|
|
6
7
|
|
|
@@ -103,18 +104,13 @@ export default defineComponent({
|
|
|
103
104
|
}, props.delay);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function showNow() {
|
|
107
|
-
clearShowTimer();
|
|
108
|
-
ensureEngaged();
|
|
109
|
-
visible.value = true;
|
|
110
|
-
trackPositionWhileVisible();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
107
|
function hide() {
|
|
114
108
|
clearShowTimer();
|
|
115
109
|
visible.value = false;
|
|
116
110
|
stopPositionTracking();
|
|
117
|
-
|
|
111
|
+
// A hide means the hover/focus gesture is over — a touch hint
|
|
112
|
+
// armed in parallel (pen press while hovering) retires with it.
|
|
113
|
+
cancelTouchHint();
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
function clearShowTimer() {
|
|
@@ -124,49 +120,51 @@ export default defineComponent({
|
|
|
124
120
|
}
|
|
125
121
|
}
|
|
126
122
|
|
|
127
|
-
// ── touch
|
|
128
|
-
// Touch has no hover
|
|
129
|
-
//
|
|
123
|
+
// ── touch long-press ────────────────────────────────────────────
|
|
124
|
+
// Touch has no hover, and the "what is this?" gesture on a phone is
|
|
125
|
+
// the LONG-PRESS — owned by the touch hint bar docked at the top of
|
|
126
|
+
// the viewport (runtime/touchHint.ts), never by this anchored popup
|
|
127
|
+
// (the finger would occlude the very element being inspected). A
|
|
128
|
+
// quick tap shows nothing at all: neither the popup nor the bar.
|
|
129
|
+
// Pen joins touch for the press (a stylus hold is the same
|
|
130
|
+
// deliberate ask) while KEEPING hover — stylus users float this
|
|
131
|
+
// popup, and a pen long-press may surface the bar beside it.
|
|
130
132
|
// Tap-derived synthetic mouseenter/mouseleave events are ignored for
|
|
131
|
-
// a short window after a touch so they cannot
|
|
132
|
-
//
|
|
133
|
+
// a short window after a touch so they cannot flash the anchored
|
|
134
|
+
// popup the touch mode never shows.
|
|
133
135
|
// -Infinity sentinel: a plain 0 would suppress hover during the
|
|
134
136
|
// first 600ms of page life (performance.now() starts near 0) and
|
|
135
137
|
// under test fake timers (frozen at 0).
|
|
136
138
|
let lastTouchAt = -Infinity;
|
|
137
|
-
let
|
|
139
|
+
let touchHintCancel: (() => void) | null = null;
|
|
138
140
|
|
|
139
|
-
function
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
function cancelTouchHint() {
|
|
142
|
+
if (touchHintCancel) {
|
|
143
|
+
touchHintCancel();
|
|
144
|
+
touchHintCancel = null;
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
function onPointerdown(e: PointerEvent) {
|
|
147
|
-
if (e.pointerType !== "touch") return;
|
|
149
|
+
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
|
|
150
|
+
if (!props.text.trim()) return; // nothing to hint
|
|
148
151
|
lastTouchAt = performance.now();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
hide();
|
|
162
|
-
};
|
|
163
|
-
document.addEventListener("pointerdown", touchDismiss, true);
|
|
152
|
+
// One hold at a time: a fresh press re-arms from scratch.
|
|
153
|
+
cancelTouchHint();
|
|
154
|
+
const node = wrapperRef.value;
|
|
155
|
+
if (!node) return;
|
|
156
|
+
touchHintCancel = armTouchHint(node, props.text, {
|
|
157
|
+
pointerId: e.pointerId,
|
|
158
|
+
// The exact contact point keeps the recognizer's slop math
|
|
159
|
+
// honest (the arm hook cannot see this event itself).
|
|
160
|
+
origin: { x: e.clientX, y: e.clientY },
|
|
161
|
+
});
|
|
164
162
|
}
|
|
165
163
|
|
|
166
164
|
onBeforeUnmount(() => {
|
|
167
165
|
clearShowTimer();
|
|
168
166
|
stopPositionTracking();
|
|
169
|
-
|
|
167
|
+
cancelTouchHint();
|
|
170
168
|
if (popupHandle) {
|
|
171
169
|
manager.unregister(popupHandle.id);
|
|
172
170
|
popupHandle = null;
|
|
@@ -194,8 +192,9 @@ export default defineComponent({
|
|
|
194
192
|
aria-describedby={visible.value ? popupId : undefined}
|
|
195
193
|
onPointerdown={onPointerdown}
|
|
196
194
|
onMouseenter={() => {
|
|
197
|
-
// Synthetic mouseenter right after a touch
|
|
198
|
-
//
|
|
195
|
+
// Synthetic mouseenter right after a touch press must not
|
|
196
|
+
// flash the anchored popup the touch mode never shows — the
|
|
197
|
+
// top hint bar owns touch.
|
|
199
198
|
if (performance.now() - lastTouchAt < 600) return;
|
|
200
199
|
show();
|
|
201
200
|
}}
|
|
@@ -185,7 +185,13 @@ beforeEach(() => {
|
|
|
185
185
|
) {
|
|
186
186
|
const listeners = new Map<string, EventListener>();
|
|
187
187
|
let cancels = 0;
|
|
188
|
-
|
|
188
|
+
// Typed as a PARTIAL Animation (plus this suite's own fire()
|
|
189
|
+
// harness method, which the recorded animations read back) so the
|
|
190
|
+
// return below is a SINGLE cast — the type-escape gate rejects the
|
|
191
|
+
// double-cast this literal used to carry.
|
|
192
|
+
const handle: Partial<Animation> & {
|
|
193
|
+
fire(type: "finish" | "cancel"): void;
|
|
194
|
+
} = {
|
|
189
195
|
addEventListener: (type: string, listener: EventListener) => {
|
|
190
196
|
listeners.set(type, listener);
|
|
191
197
|
},
|
|
@@ -204,7 +210,7 @@ beforeEach(() => {
|
|
|
204
210
|
fire: handle.fire,
|
|
205
211
|
cancelCount: () => cancels,
|
|
206
212
|
});
|
|
207
|
-
return handle as
|
|
213
|
+
return handle as Animation;
|
|
208
214
|
} as typeof animatable.animate;
|
|
209
215
|
});
|
|
210
216
|
|
|
@@ -484,7 +490,7 @@ describe("useRailIconFlight", () => {
|
|
|
484
490
|
// Minimal MediaQueryList fake — spread of a live list loses the
|
|
485
491
|
// accessor. `matches: true` must be readable at SETUP time: the
|
|
486
492
|
// composable subscribes once and reads its ref in flightCapable().
|
|
487
|
-
const fakeList = {
|
|
493
|
+
const fakeList: Partial<MediaQueryList> = {
|
|
488
494
|
matches: true,
|
|
489
495
|
media: "(prefers-reduced-motion: reduce)",
|
|
490
496
|
onchange: null,
|
|
@@ -493,8 +499,10 @@ describe("useRailIconFlight", () => {
|
|
|
493
499
|
addListener: () => {},
|
|
494
500
|
removeListener: () => {},
|
|
495
501
|
dispatchEvent: () => false,
|
|
496
|
-
}
|
|
497
|
-
|
|
502
|
+
};
|
|
503
|
+
// Single cast from the Partial (see the animate fake above): the
|
|
504
|
+
// gate owns the double-cast this file used to carry.
|
|
505
|
+
vi.spyOn(window, "matchMedia").mockReturnValue(fakeList as MediaQueryList);
|
|
498
506
|
const h = mountSwap();
|
|
499
507
|
await h.set(true);
|
|
500
508
|
const { leaving, entering } = dissolvePair(h, true);
|
package/src/index.ts
CHANGED
|
@@ -424,6 +424,11 @@ export {
|
|
|
424
424
|
installHkTapHighlight,
|
|
425
425
|
type HkTapHighlightOptions,
|
|
426
426
|
type HkTapHighlightColor,
|
|
427
|
+
installHkTouchHintBridge,
|
|
428
|
+
showHkTouchHintBar,
|
|
429
|
+
armTouchHint,
|
|
430
|
+
type HkTouchHintOptions,
|
|
431
|
+
type HkTouchHintArmOptions,
|
|
427
432
|
reportHkRuntime,
|
|
428
433
|
getHkRuntimeEntry,
|
|
429
434
|
listHkRuntime,
|
package/src/runtime/index.ts
CHANGED
|
@@ -8,6 +8,15 @@ export {
|
|
|
8
8
|
installHkTooltipBridge,
|
|
9
9
|
type HkTooltipBridgeOptions,
|
|
10
10
|
} from "./tooltipBridge";
|
|
11
|
+
// The touch half of the tooltip story: the top-docked long-press
|
|
12
|
+
// hint bar (bridge installer, bare presenter, component arm hook).
|
|
13
|
+
export {
|
|
14
|
+
installHkTouchHintBridge,
|
|
15
|
+
showHkTouchHintBar,
|
|
16
|
+
armTouchHint,
|
|
17
|
+
type HkTouchHintOptions,
|
|
18
|
+
type HkTouchHintArmOptions,
|
|
19
|
+
} from "./touchHint";
|
|
11
20
|
export {
|
|
12
21
|
installHkImageFallback,
|
|
13
22
|
type HkImageFallbackOptions,
|
|
@@ -21,6 +30,7 @@ export {
|
|
|
21
30
|
tooltipPositionStyle,
|
|
22
31
|
applyTooltipPosition,
|
|
23
32
|
resolveTooltipFlip,
|
|
33
|
+
snapToPixelGrid,
|
|
24
34
|
TOOLTIP_GAP_PX,
|
|
25
35
|
type TooltipPlacement,
|
|
26
36
|
} from "./tooltipPosition";
|
|
@@ -13,7 +13,9 @@ import { usePopupManager } from "./usePopupManager";
|
|
|
13
13
|
* it; fresher writers (host re-set, HkPopover's `title=""` blanking,
|
|
14
14
|
* Vue undefined-removal) always win
|
|
15
15
|
* - escape hatches: empty titles, [data-hk-tooltip-native], elements
|
|
16
|
-
* inside an HkTooltip wrapper — never engage
|
|
16
|
+
* inside an HkTooltip wrapper — never engage; a pointerover with
|
|
17
|
+
* pointerType "touch" never engages either (the touch hint bar in
|
|
18
|
+
* runtime/touchHint.ts owns touch pointers)
|
|
17
19
|
* - pointerdown hides but stays engaged; scroll/resize and Esc disengage
|
|
18
20
|
* - the popup mounts inside #hk-overlay-root (same home as the component
|
|
19
21
|
* tooltip's teleported popup) — at body level its in-root ordinal z
|
|
@@ -137,6 +139,21 @@ describe("installHkTooltipBridge", () => {
|
|
|
137
139
|
expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
|
|
138
140
|
});
|
|
139
141
|
|
|
142
|
+
it("never engages a touch pointerover (the touch hint bar owns touch)", () => {
|
|
143
|
+
vi.useFakeTimers();
|
|
144
|
+
install();
|
|
145
|
+
const el = trigger("Touch owned");
|
|
146
|
+
el.dispatchEvent(
|
|
147
|
+
new PointerEvent("pointerover", { pointerType: "touch", bubbles: true }),
|
|
148
|
+
);
|
|
149
|
+
// Title untouched: engaging here would strip the attribute for an
|
|
150
|
+
// anchored popup the touch mode never shows, and break the touch
|
|
151
|
+
// bar's own title/data-hk-title fallback chain.
|
|
152
|
+
expect(el.hasAttribute("title")).toBe(true);
|
|
153
|
+
vi.advanceTimersByTime(1000);
|
|
154
|
+
expect(bridgePopup()?.classList.contains("hk-tooltip-visible")).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
140
157
|
it("honors the exclude option", () => {
|
|
141
158
|
vi.useFakeTimers();
|
|
142
159
|
install({ exclude: ".quiet" });
|