@celestia-island/hikari 0.55.267 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.267",
3
+ "version": "0.55.269",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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 — a touch tap mounts each one
70
- // immediately — and then carry the label text.
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 PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }));
75
+ w.dispatchEvent(new MouseEvent("mouseenter"));
73
76
  }
74
- await Promise.resolve();
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: "h2",
56
- label: { en: "Hydrogen", "zh-Hans": "氢气" },
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: "h2",
54
- label: { en: "Hydrogen", "zh-Hans": "氢气" },
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 without hovering: a touch tap mounts the
132
- * popup immediately (showNow), no fake timers needed. */
133
- function engageTooltip(c: HTMLElement) {
134
- c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(
135
- new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
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 AND on touch tap
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("opens the tooltip immediately on a touch tap and closes on an outside tap", async () => {
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 nextTick();
579
-
580
- let popup = document.querySelector<HTMLElement>(".hk-tooltip-popup");
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 nextTick();
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
- popup = document.querySelector<HTMLElement>(".hk-tooltip-popup");
593
- expect(popup?.classList.contains("hk-tooltip-visible")).toBe(false);
594
- outside.remove();
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
@@ -246,7 +246,7 @@ describe("HkSidebarRail geometry contract", () => {
246
246
  expect(btn).toContain("--hi-icon-button-radius: 9999px");
247
247
  });
248
248
 
249
- it("hosts the rail flip's icon flight: paint-only layer, dead clones, triply-scoped hide", () => {
249
+ it("hosts the rail flip's icon flight: paint-only layer, dead clones, single-glyph hide", () => {
250
250
  // useRailIconFlight clones the flipping icons onto a layer ABOVE the
251
251
  // dissolve. The layer is chrome: it must never intercept a pointer
252
252
  // (the leaving generation is pointer-dead for the dissolve and the
@@ -84,9 +84,11 @@ import "./HkSidebarRail.scss";
84
84
  * icon slides slightly left as it collapses"). This component hands the
85
85
  * flip to the flight choreographer: icons opted in by HkMenu
86
86
  * (`data-flip-icon`) are cloned into a flight layer on THIS host and
87
- * animated from the leaving rect to the entering one, while the leaving
88
- * copies hide (`data-flipping` + the leave class) and the rest of the
89
- * content keeps the plain dissolve. Skipped under reduced motion / the
87
+ * animated from the leaving rect to the entering one, while BOTH ends
88
+ * of each matched pair hide (`data-flipping` + the per-cell
89
+ * `data-flip-flying` mark — the single-glyph contract: the clone is the
90
+ * one visible glyph from arm to landing) and the rest of the content
91
+ * keeps the plain dissolve. Skipped under reduced motion / the
90
92
  * animation switch / no WAAPI / no matching pair — every such case
91
93
  * degrades to today's dissolve.
92
94
  *
@@ -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 taps", () => {
259
- function tap(el: Element) {
260
- el.dispatchEvent(
261
- new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
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
- it("shows immediately (no hover delay) on a touch tap", async () => {
266
- const { container } = mount({ text: "tapped", delay: 3000 });
267
- tap(wrapper(container));
268
- await nextTick();
269
- // The 3000ms hover delay must NOT gate the touch path.
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
- it("follows a moving anchor on the touch-opened path too", async () => {
274
- const { container } = mount({ text: "touch moving", delay: 3000, placement: "bottom" });
275
- let top = 100;
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("toggles closed when the trigger is tapped again", async () => {
292
- const { container } = mount({ text: "toggle", delay: 3000 });
293
- tap(wrapper(container));
294
- await nextTick();
295
- expect(popup().className).toContain("hk-tooltip-visible");
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
- tap(wrapper(container));
298
- await nextTick();
299
- expect(popup().className).not.toContain("hk-tooltip-visible");
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("closes on a tap anywhere else (capture-phase document listener)", async () => {
303
- const { container } = mount({ text: "outside", delay: 0 });
304
- tap(wrapper(container));
305
- await nextTick();
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 visible class.
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 re-open from the synthetic mouseenter right after a closing tap", async () => {
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
- tap(wrapper(container));
330
- await nextTick();
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
- expect(popup().className).not.toContain("hk-tooltip-visible");
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(popup().className).not.toContain("hk-tooltip-visible");
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
- dismissTouchListener();
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 taps ──────────────────────────────────────────────────
128
- // Touch has no hover: a finger tap must open the bubble immediately
129
- // (no delay) and the NEXT tap — anywhere else — must close it.
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 re-open what the tap
132
- // just closed.
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 touchDismiss: ((e: PointerEvent) => void) | null = null;
139
+ let touchHintCancel: (() => void) | null = null;
138
140
 
139
- function dismissTouchListener() {
140
- if (touchDismiss) {
141
- document.removeEventListener("pointerdown", touchDismiss, true);
142
- touchDismiss = null;
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
- if (visible.value) {
150
- hide();
151
- return;
152
- }
153
- showNow();
154
- touchDismiss = (ev: PointerEvent) => {
155
- const node = wrapperRef.value;
156
- if (node && ev.target instanceof Node && node.contains(ev.target)) {
157
- // A re-tap on the trigger itself is the toggle case — handled
158
- // by the branch above on its own pointerdown.
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
- dismissTouchListener();
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 tap must not
198
- // re-open the bubble the tap just toggled closed.
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
  }}