@celestia-island/hikari 0.55.331 → 0.55.334

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.331",
3
+ "version": "0.55.334",
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",
@@ -62,23 +62,23 @@
62
62
  "prepublishOnly": "vue-tsc --noEmit"
63
63
  },
64
64
  "peerDependencies": {
65
- "@types/three": "^*",
65
+ "@types/three": "^0.185",
66
66
  "three": "^0.185",
67
- "vue": "^*"
67
+ "vue": "^3"
68
68
  },
69
69
  "dependencies": {
70
- "@celestia-island/tairitsu-transport": "^0.2.0",
71
- "@xterm/addon-fit": "^0.*",
70
+ "@celestia-island/tairitsu-transport": "^0.2",
71
+ "@xterm/addon-fit": "^0.11",
72
72
  "@xterm/addon-image": "^0.10.0-beta.301",
73
73
  "@xterm/xterm": "^6.1.0-beta.304",
74
- "dompurify": "^*",
75
- "highlight.js": "^*",
76
- "lucide-vue-next": "^*",
77
- "marked": "^*"
74
+ "dompurify": "^3",
75
+ "highlight.js": "^11",
76
+ "lucide-vue-next": "^1",
77
+ "marked": "^18"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/node": "^*",
81
- "@types/three": "^*",
81
+ "@types/three": "^0.185",
82
82
  "@vitejs/plugin-vue": "^*",
83
83
  "@vitejs/plugin-vue-jsx": "^*",
84
84
  "@vue/server-renderer": "^*",
@@ -111,6 +111,57 @@
111
111
  background: rgb(var(--color-primary));
112
112
  }
113
113
 
114
+ // ── overspend (max + overValue) ─────────────────────────────────────
115
+ // The track grows to `max + overValue`: the main fill keeps the quota's
116
+ // share, the overspend stretch takes the rest to the inline end, and the
117
+ // 1px divider is CARVED out of the stretch's inline-start edge — the track
118
+ // background shows through the seam. A painted line would need a colour of
119
+ // its own (and could not sit correctly over the translucent track); the
120
+ // carved gap is one geometry that also mirrors correctly in RTL.
121
+ .hk-progress-bar-overspend {
122
+ position: absolute;
123
+ top: 0;
124
+ height: 100%;
125
+ // Square on the seam side, rounded at the track's end: the 1px gap then
126
+ // reads as a divider over the whole track height instead of a lens
127
+ // between two round caps.
128
+ border-start-start-radius: 0;
129
+ border-end-start-radius: 0;
130
+ border-start-end-radius: var(--radius-full);
131
+ border-end-end-radius: var(--radius-full);
132
+ transition:
133
+ width var(--duration-normal) var(--ease-standard),
134
+ inset-inline-start var(--duration-normal) var(--ease-standard);
135
+
136
+ &-muted {
137
+ background: rgb(var(--color-muted));
138
+ }
139
+
140
+ &-danger {
141
+ background: rgb(var(--color-error));
142
+ }
143
+ }
144
+
145
+ // While an overspend stretch follows it, the main fill squares its
146
+ // inline-end corners so the seam divider stays exactly 1px wide.
147
+ .hk-progress-bar-track--overspend .hk-progress-bar-fill {
148
+ border-start-end-radius: 0;
149
+ border-end-end-radius: 0;
150
+ }
151
+
152
+ // Overspend caption ("超支 0.8 度"), riding the label row after the value.
153
+ .hk-progress-bar-overspend-label {
154
+ margin-inline-start: var(--space-6);
155
+
156
+ &-muted {
157
+ color: rgb(var(--color-muted));
158
+ }
159
+
160
+ &-danger {
161
+ color: rgb(var(--color-error));
162
+ }
163
+ }
164
+
114
165
  // Keyframes animate the logical inset so the sweep mirrors in RTL together
115
166
  // with the fill/secondary anchoring (LTR-identical).
116
167
  @keyframes hk-progress-indeterminate {
@@ -138,6 +189,7 @@
138
189
  // (house guard: the family bar-fill pattern).
139
190
  .hk-progress-bar-fill,
140
191
  .hk-progress-bar-secondary,
192
+ .hk-progress-bar-overspend,
141
193
  .hk-progress-bar-seg {
142
194
  transition: none;
143
195
  }
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Source contract for the overspend stretch's look. vitest cannot see
9
+ * stylesheets (happy-dom loads no SCSS), so a deleted rule would leave the
10
+ * DOM geometry guards green while the stretch painted as another primary
11
+ * fill — the whole point of the second segment is that it reads as PAST
12
+ * quota.
13
+ */
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const scss = readFileSync(join(here, "HkProgressBar.scss"), "utf8");
16
+
17
+ /** The DECLARATIONS of a rule, comments stripped (a comment that names a
18
+ * property must not satisfy a guard about it). */
19
+ function ruleBlock(selector: string): string {
20
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ const head = new RegExp(`${escaped}\\s*\\{`).exec(scss);
22
+ if (!head) throw new Error(`no rule for ${selector}`);
23
+ // Nested SCSS blocks: walk braces to the matching close.
24
+ const open = head.index + head[0].length - 1;
25
+ let depth = 0;
26
+ let end = open;
27
+ for (; end < scss.length; end++) {
28
+ if (scss[end] === "{") depth += 1;
29
+ else if (scss[end] === "}") {
30
+ depth -= 1;
31
+ if (depth === 0) break;
32
+ }
33
+ }
34
+ return scss.slice(open + 1, end)
35
+ .split("\n")
36
+ .filter((line) => {
37
+ const t = line.trim();
38
+ return !t.startsWith("*") && !t.startsWith("/*") && !t.startsWith("//");
39
+ })
40
+ .join("\n");
41
+ }
42
+
43
+ describe("HkProgressBar overspend sheet contract", () => {
44
+ it("positions the stretch as an inline-end block on the stretched track", () => {
45
+ const rule = ruleBlock(".hk-progress-bar-overspend");
46
+ expect(rule).toMatch(/position:\s*absolute/);
47
+ // Logical anchoring — the stretch sits at the inline end in RTL too.
48
+ expect(scss).not.toMatch(/\.hk-progress-bar-overspend[^{]*\{[^}]*\b(?:left|right):/);
49
+ });
50
+
51
+ it("tones the stretch muted by default and error-toned for danger", () => {
52
+ const block = ruleBlock(".hk-progress-bar-overspend");
53
+ expect(block).toMatch(/&-muted\s*\{[^}]*background:\s*rgb\(var\(--color-muted\)\)/);
54
+ expect(block).toMatch(/&-danger\s*\{[^}]*background:\s*rgb\(var\(--color-error\)\)/);
55
+ });
56
+
57
+ it("squares both sides of the 1px seam so it reads as a divider", () => {
58
+ const block = ruleBlock(".hk-progress-bar-overspend");
59
+ expect(block).toMatch(/border-start-start-radius:\s*0/);
60
+ expect(block).toMatch(/border-end-start-radius:\s*0/);
61
+ const fill = ruleBlock(".hk-progress-bar-track--overspend .hk-progress-bar-fill");
62
+ expect(fill).toMatch(/border-start-end-radius:\s*0/);
63
+ expect(fill).toMatch(/border-end-end-radius:\s*0/);
64
+ });
65
+
66
+ it("moves with the family cadence and stops under reduced motion", () => {
67
+ const block = ruleBlock(".hk-progress-bar-overspend");
68
+ expect(block).toMatch(/transition:[\s\S]*width var\(--duration-normal\)/);
69
+ const media = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/.exec(scss);
70
+ expect(media, "reduced-motion guard").not.toBeNull();
71
+ expect(media![1]!).toMatch(/\.hk-progress-bar-overspend[\s\S]*transition:\s*none/);
72
+ });
73
+
74
+ it("co-locates the caption with the value label row", () => {
75
+ const rule = ruleBlock(".hk-progress-bar-overspend-label");
76
+ expect(rule).toMatch(/margin-inline-start:\s*var\(--space-6\)/);
77
+ expect(rule).toMatch(/&-danger\s*\{[^}]*color:\s*rgb\(var\(--color-error\)\)/);
78
+ });
79
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
- import { createApp, h } from "vue";
2
+ import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import HkProgressBar from "./HkProgressBar";
5
5
 
@@ -84,3 +84,204 @@ describe("HkProgressBar single-value mode (backward compat)", () => {
84
84
  expect(c.querySelector(".hk-progress-bar-indeterminate")).not.toBeNull();
85
85
  });
86
86
  });
87
+
88
+ /** Settled innerHTML of a fresh mount — the regression fixtures compare it. */
89
+ async function renderHtml(props: Record<string, unknown>) {
90
+ const c = mount(props);
91
+ await nextTick();
92
+ return c.innerHTML;
93
+ }
94
+
95
+ function overspendBlock(c: HTMLElement): HTMLElement | null {
96
+ return c.querySelector<HTMLElement>(".hk-progress-bar-overspend");
97
+ }
98
+
99
+ /**
100
+ * The overspend block's inline geometry, split back into its numbers. The
101
+ * seam is the `+ 1px` / `- 1px` pair: the block starts one pixel past the
102
+ * quota edge and loses that pixel from its own width, so the visible
103
+ * divider is exactly 1px at any track width (and the pair always sums back
104
+ * to a full track when quota is exhausted).
105
+ */
106
+ function overspendGeometry(c: HTMLElement) {
107
+ const block = overspendBlock(c);
108
+ expect(block, "overspend block").not.toBeNull();
109
+ const start = block!.style.getPropertyValue("inset-inline-start");
110
+ const width = block!.style.width;
111
+ const startMatch = /^calc\(([\d.]+)% \+ 1px\)$/.exec(start);
112
+ const widthMatch = /^calc\(([\d.]+)% - 1px\)$/.exec(width);
113
+ expect(startMatch, `start seam ${start}`).not.toBeNull();
114
+ expect(widthMatch, `width seam ${width}`).not.toBeNull();
115
+ return {
116
+ edge: Number.parseFloat(startMatch![1]!),
117
+ over: Number.parseFloat(widthMatch![1]!),
118
+ };
119
+ }
120
+
121
+ describe("HkProgressBar overspend mode (CONTRACT §9 fixtures)", () => {
122
+ it("0 — an empty bar grows no overspend block", () => {
123
+ const c = mount({ value: 0, max: 100 });
124
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("0%");
125
+ expect(overspendBlock(c)).toBeNull();
126
+ expect(c.querySelector(".hk-progress-bar-track--overspend")).toBeNull();
127
+ });
128
+
129
+ it("half — a half bar grows no overspend block", () => {
130
+ const c = mount({ value: 50, max: 100 });
131
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("50%");
132
+ expect(overspendBlock(c)).toBeNull();
133
+ });
134
+
135
+ it("full — exactly at quota there is still no overspend block", () => {
136
+ const c = mount({ value: 100, max: 100 });
137
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("100%");
138
+ expect(overspendBlock(c)).toBeNull();
139
+ });
140
+
141
+ it("over 1% — the quota edge keeps the fill and the stretch follows it", () => {
142
+ const c = mount({ value: 100, max: 100, overValue: 1 });
143
+ // Track total = 101: 100/101 keeps the quota edge, 1/101 is overspend.
144
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("99.0099%");
145
+ const g = overspendGeometry(c);
146
+ expect(g.edge).toBe(99.0099);
147
+ expect(g.over).toBe(0.9901);
148
+ expect(g.edge + g.over).toBeCloseTo(100, 4);
149
+ expect(overspendBlock(c)!.className).toContain("hk-progress-bar-overspend-muted");
150
+ expect(overspendBlock(c)!.getAttribute("aria-hidden")).toBe("true");
151
+ });
152
+
153
+ it("over 50% — half again as much as quota, in the danger tone", () => {
154
+ const c = mount({ value: 100, max: 100, overValue: 50, overspendTone: "danger" });
155
+ // Track total = 150: 100/150 quota edge, 50/150 overspend.
156
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("66.6667%");
157
+ const g = overspendGeometry(c);
158
+ expect(g.edge).toBe(66.6667);
159
+ expect(g.over).toBe(33.3333);
160
+ expect(g.edge + g.over).toBeCloseTo(100, 4);
161
+ expect(overspendBlock(c)!.className).toContain("hk-progress-bar-overspend-danger");
162
+ expect(overspendBlock(c)!.className).not.toContain("hk-progress-bar-overspend-muted");
163
+ });
164
+
165
+ it("max=0 — a bar with no quota at all is entirely overspend", () => {
166
+ const c = mount({ value: 0, max: 0, overValue: 5 });
167
+ // No quota slice: total = overValue, so the fill is 0% and the stretch
168
+ // takes the whole track (minus the 1px seam).
169
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("0%");
170
+ const g = overspendGeometry(c);
171
+ expect(g.edge).toBe(0);
172
+ expect(g.over).toBe(100);
173
+ });
174
+
175
+ it("derives overValue when the value itself runs past max", () => {
176
+ const c = mount({ value: 105, max: 100 });
177
+ // total = 105: 100/105 quota edge, 5/105 overspend.
178
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("95.2381%");
179
+ const g = overspendGeometry(c);
180
+ expect(g.edge).toBe(95.2381);
181
+ expect(g.over).toBe(4.7619);
182
+ });
183
+
184
+ it("derives exactly what the clamped quota-card shape states explicitly", () => {
185
+ // chest's card passes value = min(used, quota) + overValue = used - quota;
186
+ // a caller handing the raw value must paint the same picture.
187
+ const derived = overspendGeometry(mount({ value: 105, max: 100 }));
188
+ const explicit = overspendGeometry(mount({ value: 100, max: 100, overValue: 5 }));
189
+ expect(derived).toEqual(explicit);
190
+ const card = overspendGeometry(mount({ value: 100, max: 100, overValue: 4 }));
191
+ expect(card.edge).toBe(96.1538);
192
+ expect(card.over).toBe(3.8462);
193
+ });
194
+
195
+ it("keeps the legacy fill when overValue is explicitly 0 even past max", () => {
196
+ const c = mount({ value: 140, max: 100, overValue: 0 });
197
+ expect(overspendBlock(c)).toBeNull();
198
+ expect((c.querySelector(".hk-progress-bar-fill") as HTMLElement).style.width).toBe("100%");
199
+ });
200
+
201
+ it("renders the caption with its tone beside the value label", () => {
202
+ const c = mount({
203
+ value: 100, max: 100, overValue: 2, showLabel: true,
204
+ overspendLabel: "超支 2.0 度", overspendTone: "danger",
205
+ });
206
+ expect(c.querySelector(".hk-progress-bar-label")!.textContent).toBe("100%超支 2.0 度");
207
+ const caption = c.querySelector(".hk-progress-bar-overspend-label")!;
208
+ expect(caption.textContent).toBe("超支 2.0 度");
209
+ expect(caption.className).toContain("hk-progress-bar-overspend-label-danger");
210
+ });
211
+
212
+ it("shows the caption alone when showLabel is off, and never without overspend", () => {
213
+ const alone = mount({ value: 100, max: 100, overValue: 1, overspendLabel: "超支 1.0 度" });
214
+ expect(alone.querySelector(".hk-progress-bar-overspend-label")!.textContent).toBe("超支 1.0 度");
215
+ // The value label itself stays off — only the caption row rendered.
216
+ expect(alone.querySelector(".hk-progress-bar-label")!.textContent).toBe("超支 1.0 度");
217
+
218
+ const dormant = mount({ value: 50, max: 100, overspendLabel: "超支 1.0 度" });
219
+ expect(dormant.querySelector(".hk-progress-bar-overspend-label")).toBeNull();
220
+ });
221
+
222
+ it("scales the secondary overlay onto the stretched track", () => {
223
+ const c = mount({ value: 100, max: 100, overValue: 50, secondary: 75 });
224
+ // secondary 75 of the stretched total 150 — not of the old 100 max.
225
+ expect((c.querySelector(".hk-progress-bar-secondary") as HTMLElement).style.width).toBe("50%");
226
+ });
227
+
228
+ it("ignores overspend in segmented mode (segments cannot exceed 100%)", () => {
229
+ const c = mount({ segments: [{ value: 50 }], overValue: 10 });
230
+ expect(overspendBlock(c)).toBeNull();
231
+ expect((c.querySelector(".hk-progress-bar-seg") as HTMLElement).style.width).toBe("50%");
232
+ });
233
+
234
+ it("ignores overspend on the indeterminate sweep (no quantity to overrun)", () => {
235
+ const c = mount({ overValue: 10 });
236
+ expect(c.querySelector(".hk-progress-bar-indeterminate")).not.toBeNull();
237
+ expect(overspendBlock(c)).toBeNull();
238
+ });
239
+
240
+ it("never emits NaN widths for a degenerate (non-positive) total", () => {
241
+ const c = mount({ value: 5, max: -10, overValue: 5 });
242
+ expect(overspendBlock(c)).toBeNull();
243
+ expect(c.innerHTML).not.toContain("NaN");
244
+ });
245
+ });
246
+
247
+ /**
248
+ * The no-overspend render must stay BYTE-identical to the pre-overspend
249
+ * implementation. These markup strings were recorded from master e6263f45
250
+ * (the commit this change branches from) BEFORE the component grew the
251
+ * overspend branch; the `<!---->` placeholders are Vue's own v-if anchors,
252
+ * so an extra conditional layer or a changed class list shows up here as a
253
+ * diff, not just as a "close enough" layout. That is the pixel-equivalence
254
+ * guard the contract asks for.
255
+ */
256
+ describe("HkProgressBar no-overspend regression (byte-identical render)", () => {
257
+ const RECORDED: Array<[string, Record<string, unknown>, string]> = [
258
+ ["0", { value: 0, max: 100 },
259
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track"><!----><div class="hk-progress-bar-fill hk-progress-bar-fill-loading" style="width: 0%;"></div></div></div>'],
260
+ ["half", { value: 50, max: 100 },
261
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track"><!----><div class="hk-progress-bar-fill hk-progress-bar-fill-loading" style="width: 50%;"></div></div></div>'],
262
+ ["full", { value: 100, max: 100 },
263
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track"><!----><div class="hk-progress-bar-fill hk-progress-bar-fill-loading" style="width: 100%;"></div></div></div>'],
264
+ ["indeterminate", {},
265
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track"><div class="hk-progress-bar-indeterminate hk-progress-bar-indeterminate-loading"></div></div></div>'],
266
+ ["secondary + label + size + status", { value: 40, max: 100, secondary: 80, status: "done", size: "md", showLabel: true },
267
+ '<div class="hk-progress-bar" data-size="md"><span class="hk-progress-bar-label">40%</span><div class="hk-progress-bar-track"><div class="hk-progress-bar-secondary hk-progress-bar-secondary-done" style="width: 80%;"></div><div class="hk-progress-bar-fill hk-progress-bar-fill-done" style="width: 40%;"></div></div></div>'],
268
+ ["segments", { segments: [{ value: 30, color: "#f00" }, { value: 20, class: "b" }] },
269
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track hk-progress-bar-track--segments"><div class="hk-progress-bar-seg" style="width: 30%; background: #f00;"></div><div class="hk-progress-bar-seg b" style="width: 20%;"></div></div></div>'],
270
+ ["max=0 (the legacy NaN-width quirk stays put)", { value: 0, max: 0 },
271
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track"><!----><div class="hk-progress-bar-fill hk-progress-bar-fill-loading"></div></div></div>'],
272
+ ["custom label never leaks without showLabel", { value: 65, max: 100, label: "自定义" },
273
+ '<div class="hk-progress-bar" data-size="sm"><!----><div class="hk-progress-bar-track"><!----><div class="hk-progress-bar-fill hk-progress-bar-fill-loading" style="width: 65%;"></div></div></div>'],
274
+ ["xs + error status", { value: 12, max: 100, size: "xs", status: "error" },
275
+ '<div class="hk-progress-bar" data-size="xs"><!----><div class="hk-progress-bar-track"><!----><div class="hk-progress-bar-fill hk-progress-bar-fill-error" style="width: 12%;"></div></div></div>'],
276
+ ];
277
+
278
+ for (const [name, props, golden] of RECORDED) {
279
+ it(`renders exactly the pre-overspend markup: ${name}`, async () => {
280
+ expect(await renderHtml(props)).toBe(golden);
281
+ });
282
+ }
283
+
284
+ it("treats an explicit overValue: 0 as the legacy full bar", async () => {
285
+ expect(await renderHtml({ value: 140, max: 100, overValue: 0 })).toBe(RECORDED[2]![2]);
286
+ });
287
+ });
@@ -1,9 +1,15 @@
1
- import { computed, defineComponent, type PropType } from "vue";
1
+ import { computed, defineComponent, type PropType, type VNode } from "vue";
2
2
 
3
3
  import "./HkProgressBar.scss";
4
4
 
5
5
  type ProgressBarStatus = "loading" | "done" | "error";
6
6
 
7
+ /**
8
+ * Tone of the overspend block: the muted track grey (default) or the
9
+ * error colour, for a window that is already meaningfully past quota.
10
+ */
11
+ export type OverspendTone = "muted" | "danger";
12
+
7
13
  /**
8
14
  * One block of a segmented progress bar. `value` is a percentage (0-100);
9
15
  * blocks are laid left-to-right in order and their values must not sum
@@ -20,6 +26,19 @@ export interface HProgressBarSegment {
20
26
  class?: string;
21
27
  }
22
28
 
29
+ /** Keep percentage widths stable across float noise (4 decimals ≈ 0.1px on a 1000px track). */
30
+ const clampPct = (n: number) => Math.min(100, Math.max(0, n));
31
+ const roundPct = (n: number) => Math.round(n * 1e4) / 1e4;
32
+
33
+ interface OverspendGeometry {
34
+ /** The stretched track: `max + overValue`. */
35
+ total: number;
36
+ /** Quota-edge position, as a percentage of `total`. */
37
+ mainPct: number;
38
+ /** Overspend block width, as a percentage of `total`. */
39
+ overPct: number;
40
+ }
41
+
23
42
  export default defineComponent({
24
43
  name: "HkProgressBar",
25
44
  props: {
@@ -37,6 +56,21 @@ export default defineComponent({
37
56
  * segments are present.
38
57
  */
39
58
  segments: { type: Array as PropType<HProgressBarSegment[]>, default: undefined },
59
+ /**
60
+ * Overspend amount, in the same unit as `value`/`max`: how far the
61
+ * measured quantity runs past `max`. Pass it explicitly when the caller
62
+ * clamps `value` itself (`value = min(used, quota)`, the quota-card
63
+ * shape); a `value` already past `max` derives `value - max` instead.
64
+ * `0` (or omitted) keeps the legacy single-fill bar untouched.
65
+ */
66
+ overValue: { type: Number, default: null },
67
+ /**
68
+ * Caption for the overspend stretch ("超支 0.8 度"). Rendered next to
69
+ * the value label while overspend is active, in the overspend tone.
70
+ */
71
+ overspendLabel: { type: String, default: undefined },
72
+ /** Overspend block tone; see {@link OverspendTone}. */
73
+ overspendTone: { type: String as PropType<OverspendTone>, default: "muted" },
40
74
  },
41
75
  setup(props) {
42
76
  const segmented = computed(() => props.segments != null);
@@ -46,9 +80,40 @@ export default defineComponent({
46
80
  return Math.min(100, Math.max(0, (props.value / props.max) * 100));
47
81
  });
48
82
 
83
+ /**
84
+ * Overspend geometry, or `null` on every legacy path.
85
+ *
86
+ * The track grows to `max + overValue` so the quota edge stays at a
87
+ * fixed visual share while the overspend stretch occupies the rest —
88
+ * the same reading the quota card needs (a bar at 100% of quota keeps
89
+ * filling, just in the muted "past quota" tone). Segments cannot sum
90
+ * past 100% and the indeterminate sweep has no quantity at all, so
91
+ * neither enters this path; `overValue: 0` does not either, which is
92
+ * what keeps the no-overspend render byte-identical to the legacy one.
93
+ */
94
+ const overspendGeometry = computed<OverspendGeometry | null>(() => {
95
+ if (segmented.value || props.value == null) return null;
96
+ const value = props.value;
97
+ const over = Math.max(0, props.overValue != null ? props.overValue : value - props.max);
98
+ if (!(over > 0)) return null;
99
+ const total = props.max + over;
100
+ // A degenerate max/over pair (negative or zero total) has no
101
+ // meaningful split — leave it to the legacy fill rather than emit
102
+ // NaN widths.
103
+ if (!(total > 0)) return null;
104
+ return {
105
+ total,
106
+ // Quota edge: the consumed amount capped at `max`, so a value past
107
+ // quota never paints past the divider.
108
+ mainPct: roundPct(clampPct((Math.min(value, props.max) / total) * 100)),
109
+ overPct: roundPct(clampPct((over / total) * 100)),
110
+ };
111
+ });
112
+
49
113
  const secondaryPct = computed(() => {
50
114
  if (props.secondary == null) return null;
51
- return Math.min(100, Math.max(0, (props.secondary / props.max) * 100));
115
+ const total = overspendGeometry.value?.total ?? props.max;
116
+ return Math.min(100, Math.max(0, (props.secondary / total) * 100));
52
117
  });
53
118
 
54
119
  const displayLabel = computed(() => {
@@ -60,17 +125,56 @@ export default defineComponent({
60
125
  return `${Math.round(pct.value)}%`;
61
126
  });
62
127
 
128
+ const overspendCaption = computed(() =>
129
+ overspendGeometry.value != null && props.overspendLabel ? props.overspendLabel : null,
130
+ );
131
+
63
132
  const segWidth = (value: number) => `${Math.min(100, Math.max(0, value))}%`;
64
133
 
134
+ /**
135
+ * Label row. With no overspend caption this renders exactly the legacy
136
+ * `<span class="hk-progress-bar-label">label</span>` (one text child),
137
+ * or nothing at all — the byte-identity the regression fixture pins.
138
+ */
139
+ function renderLabel(): VNode | null {
140
+ if (!props.showLabel && overspendCaption.value == null) return null;
141
+ if (overspendCaption.value == null) {
142
+ return <span class="hk-progress-bar-label">{displayLabel.value}</span>;
143
+ }
144
+ return (
145
+ <span class="hk-progress-bar-label">
146
+ {props.showLabel ? displayLabel.value : ""}
147
+ <span
148
+ class={[
149
+ "hk-progress-bar-overspend-label",
150
+ `hk-progress-bar-overspend-label-${props.overspendTone}`,
151
+ ]}
152
+ >
153
+ {overspendCaption.value}
154
+ </span>
155
+ </span>
156
+ );
157
+ }
158
+
159
+ /** The secondary overlay (same node in both fill modes). */
160
+ function renderSecondary(): VNode | null {
161
+ if (secondaryPct.value == null) return null;
162
+ return (
163
+ <div
164
+ class={["hk-progress-bar-secondary", `hk-progress-bar-secondary-${props.status}`]}
165
+ style={{ width: `${secondaryPct.value}%` }}
166
+ />
167
+ );
168
+ }
169
+
65
170
  return () => (
66
171
  <div class="hk-progress-bar" data-size={props.size}>
67
- {props.showLabel && (
68
- <span class="hk-progress-bar-label">{displayLabel.value}</span>
69
- )}
172
+ {renderLabel()}
70
173
  <div
71
174
  class={[
72
175
  "hk-progress-bar-track",
73
176
  segmented.value ? "hk-progress-bar-track--segments" : "",
177
+ overspendGeometry.value ? "hk-progress-bar-track--overspend" : "",
74
178
  ]}
75
179
  >
76
180
  {segmented.value ? (
@@ -84,23 +188,44 @@ export default defineComponent({
84
188
  }}
85
189
  />
86
190
  ))
191
+ ) : overspendGeometry.value ? (
192
+ <>
193
+ {renderSecondary()}
194
+ <div
195
+ class={["hk-progress-bar-fill", `hk-progress-bar-fill-${props.status}`]}
196
+ style={{ width: `${overspendGeometry.value.mainPct}%` }}
197
+ />
198
+ {/*
199
+ The 1px divider is carved OUT of the overspend block's
200
+ inline-start edge rather than painted as its own line: the
201
+ track's background shows through the seam, so the separator
202
+ needs no colour of its own and the same geometry mirrors in
203
+ RTL. `calc` keeps the seam exactly 1px whatever the track
204
+ width; a sub-pixel overspend ends up 0 wide, which is the
205
+ honest picture of a sliver past quota.
206
+ */}
207
+ <div
208
+ class={[
209
+ "hk-progress-bar-overspend",
210
+ `hk-progress-bar-overspend-${props.overspendTone}`,
211
+ ]}
212
+ style={{
213
+ insetInlineStart: `calc(${overspendGeometry.value.mainPct}% + 1px)`,
214
+ width: `calc(${overspendGeometry.value.overPct}% - 1px)`,
215
+ }}
216
+ aria-hidden="true"
217
+ />
218
+ </>
219
+ ) : pct.value != null ? (
220
+ <>
221
+ {renderSecondary()}
222
+ <div
223
+ class={["hk-progress-bar-fill", `hk-progress-bar-fill-${props.status}`]}
224
+ style={{ width: `${pct.value}%` }}
225
+ />
226
+ </>
87
227
  ) : (
88
- pct.value != null ? (
89
- <>
90
- {secondaryPct.value != null && (
91
- <div
92
- class={["hk-progress-bar-secondary", `hk-progress-bar-secondary-${props.status}`]}
93
- style={{ width: `${secondaryPct.value}%` }}
94
- />
95
- )}
96
- <div
97
- class={["hk-progress-bar-fill", `hk-progress-bar-fill-${props.status}`]}
98
- style={{ width: `${pct.value}%` }}
99
- />
100
- </>
101
- ) : (
102
- <div class={["hk-progress-bar-indeterminate", `hk-progress-bar-indeterminate-${props.status}`]} />
103
- )
228
+ <div class={["hk-progress-bar-indeterminate", `hk-progress-bar-indeterminate-${props.status}`]} />
104
229
  )}
105
230
  </div>
106
231
  </div>
@@ -55,6 +55,35 @@
55
55
  }
56
56
  }
57
57
 
58
+ /* Click-trigger confirm mode (trigger="click"): the bubble carries the
59
+ decision — its actions live inside it — so it must take pointer events,
60
+ unlike the hover annotation (pointer-transparent: it can never eat the
61
+ pointer that summoned it). Armed only while VISIBLE: the popup node
62
+ survives a close (lazy-mount contract), and an opacity-0 fixed box that
63
+ still hit-tests would swallow clicks in its last footprint. */
64
+ .hk-tooltip-popup--click {
65
+ pointer-events: none;
66
+ }
67
+
68
+ .hk-tooltip-popup--click.hk-tooltip-visible {
69
+ pointer-events: auto;
70
+ }
71
+
72
+ /* In click mode `text` is the confirm title: weight it and give the body
73
+ below (the `content` slot) room, so the popup reads as a decision rather
74
+ than a one-line label. */
75
+ .hk-tooltip-popup--click .hk-tooltip-content {
76
+ font-weight: 600;
77
+ margin-block-end: var(--space-6);
78
+ }
79
+
80
+ /* The dialog surface receives focus on open (tabIndex -1): a keyboard user
81
+ must see where focus went before Tabbing on to the actions. */
82
+ .hk-tooltip-popup--click:focus-visible {
83
+ outline: 2px solid rgb(var(--color-primary));
84
+ outline-offset: 2px;
85
+ }
86
+
58
87
  /* Light mode needs no override — the base rule above already is the
59
88
  light palette; only dark re-inks the popup. */
60
89
  [data-mode="dark"] .hk-tooltip-popup {
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Source contract for the click-trigger confirm surface. vitest cannot see
9
+ * stylesheets, and the load-bearing bit is a NEGATIVE one: the hover bubble
10
+ * must stay pointer-transparent (it can never eat the pointer that summoned
11
+ * it), while the click popup must take events only while visible — the node
12
+ * survives a close, so an armed hidden box would swallow clicks in its last
13
+ * footprint.
14
+ */
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const scss = readFileSync(join(here, "HkTooltip.scss"), "utf8");
17
+
18
+ function ruleBlock(selector: string): string {
19
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(scss);
21
+ if (!match) throw new Error(`no rule for ${selector}`);
22
+ return match[1]!
23
+ .split("\n")
24
+ .filter((line) => {
25
+ const t = line.trim();
26
+ return !t.startsWith("*") && !t.startsWith("/*") && !t.startsWith("//");
27
+ })
28
+ .join("\n");
29
+ }
30
+
31
+ describe("HkTooltip click-trigger sheet contract", () => {
32
+ it("keeps the hover bubble pointer-transparent (annotation, not a control)", () => {
33
+ expect(ruleBlock(".hk-tooltip-popup")).toMatch(/pointer-events:\s*none/);
34
+ });
35
+
36
+ it("arms pointer events on the click popup only while visible", () => {
37
+ expect(ruleBlock(".hk-tooltip-popup--click")).toMatch(/pointer-events:\s*none/);
38
+ expect(ruleBlock(".hk-tooltip-popup--click.hk-tooltip-visible")).toMatch(
39
+ /pointer-events:\s*auto/,
40
+ );
41
+ });
42
+
43
+ it("weights the confirm title and leaves the actions room", () => {
44
+ const rule = ruleBlock(".hk-tooltip-popup--click .hk-tooltip-content");
45
+ expect(rule).toMatch(/font-weight:\s*600/);
46
+ expect(rule).toMatch(/margin-block-end:\s*var\(--space-6\)/);
47
+ });
48
+
49
+ it("rings the focused dialog surface", () => {
50
+ const rule = ruleBlock(".hk-tooltip-popup--click:focus-visible");
51
+ expect(rule).toMatch(/outline:\s*2px solid rgb\(var\(--color-primary\)\)/);
52
+ });
53
+ });
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick } from "vue";
3
3
 
4
- import HkTooltip, { type HkTooltipSlotProps } from "./HkTooltip";
4
+ import HkTooltip, { type HkTooltipConfirmSlotProps, type HkTooltipSlotProps } from "./HkTooltip";
5
5
  import { POPUP_Z_BANDS, POPUP_Z_STEP, usePopupManager } from "../runtime/usePopupManager";
6
6
 
7
7
  /**
@@ -426,3 +426,250 @@ describe("HkTooltip anchoring", () => {
426
426
  expect(popup().style.width).toBe("max-content");
427
427
  });
428
428
  });
429
+
430
+ /**
431
+ * Click-trigger confirm mode (`trigger="click"`): the chest billing flow
432
+ * needs the "this will overspend your window — continue?" decision attached
433
+ * to the confirm BUTTON, not to a radio. The hover tooltip stays exactly
434
+ * what it was; this mode adds an anchored dialog surface with actions.
435
+ */
436
+ function mountClick(
437
+ props: Record<string, unknown> = {},
438
+ content?: (slotProps: HkTooltipConfirmSlotProps) => unknown,
439
+ ) {
440
+ const container = document.createElement("div");
441
+ (document.querySelector("#hk-overlay-root") ?? document.body).appendChild(container);
442
+ const defaultSlot = () =>
443
+ h("button", { class: "trigger-btn", type: "button" }, "确认");
444
+ const app = createApp({
445
+ render: () =>
446
+ h(
447
+ HkTooltip,
448
+ { trigger: "click", text: "确认继续?", ...props } as never,
449
+ content
450
+ ? {
451
+ default: defaultSlot,
452
+ content: (slotProps: HkTooltipConfirmSlotProps) => content(slotProps),
453
+ }
454
+ : { default: defaultSlot },
455
+ ),
456
+ });
457
+ app.mount(container);
458
+ const entry = { app, container };
459
+ mounts.push(entry);
460
+ return entry;
461
+ }
462
+
463
+ function triggerButton(container: HTMLElement): HTMLElement {
464
+ return container.querySelector<HTMLElement>(".trigger-btn")!;
465
+ }
466
+
467
+ describe("HkTooltip click-trigger confirm mode", () => {
468
+ it("opens on a trigger click (ignoring the hover delay) and toggles closed", async () => {
469
+ const { container } = mountClick({ delay: 5000 });
470
+ const btn = triggerButton(container);
471
+ btn.click();
472
+ await nextTick();
473
+ expect(popup().className).toContain("hk-tooltip-visible");
474
+ // The click is the intent — no 5s hover delay is waited out.
475
+ btn.click();
476
+ await nextTick();
477
+ expect(popup().className).not.toContain("hk-tooltip-visible");
478
+ });
479
+
480
+ it("never opens on hover or focus, unlike the hover mode", async () => {
481
+ const { container } = mountClick();
482
+ wrapper(container).dispatchEvent(new MouseEvent("mouseenter"));
483
+ container
484
+ .querySelector(".hk-tooltip-trigger")!
485
+ .dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
486
+ await settle();
487
+ // Not even the lazy popup mount ran: the hover path is fully gated.
488
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
489
+ });
490
+
491
+ it("renders the text title plus the slot body and two actions", async () => {
492
+ let cancelled = 0;
493
+ const { container } = mountClick({}, ({ close }) =>
494
+ h("div", { class: "confirm-body" }, [
495
+ h("button", { class: "cancel-btn", type: "button", onClick: () => { cancelled += 1; close(); } }, "取消"),
496
+ h("button", { class: "confirm-btn", type: "button" }, "继续"),
497
+ ]));
498
+ triggerButton(container).click();
499
+ await nextTick();
500
+ const p = popup();
501
+ expect(p.querySelector(".hk-tooltip-content")!.textContent).toBe("确认继续?");
502
+ expect(p.querySelectorAll(".confirm-body button")).toHaveLength(2);
503
+ expect((p.querySelector(".confirm-btn") as HTMLElement).textContent).toBe("继续");
504
+
505
+ (p.querySelector(".cancel-btn") as HTMLElement).click();
506
+ await nextTick();
507
+ expect(cancelled).toBe(1);
508
+ expect(popup().className).not.toContain("hk-tooltip-visible");
509
+ });
510
+
511
+ it("gives the trigger dialog affordances and the popup dialog semantics", async () => {
512
+ const { container } = mountClick();
513
+ const anchor = wrapper(container);
514
+ expect(anchor.getAttribute("aria-haspopup")).toBe("dialog");
515
+ expect(anchor.getAttribute("aria-expanded")).toBe("false");
516
+ // A hover tooltip describes; a click confirm does not (it opens a dialog).
517
+ expect(anchor.getAttribute("aria-describedby")).toBeNull();
518
+
519
+ triggerButton(container).click();
520
+ await nextTick();
521
+ const p = popup();
522
+ expect(p.getAttribute("role")).toBe("dialog");
523
+ expect(p.className).toContain("hk-tooltip-popup--click");
524
+ expect(p.getAttribute("tabindex")).toBe("-1");
525
+ // Focus lands on the dialog surface so a keyboard user Tabs to the
526
+ // actions, not around the trigger.
527
+ expect(document.activeElement).toBe(p);
528
+ const titleId = p.getAttribute("aria-labelledby");
529
+ expect(titleId).toBeTruthy();
530
+ expect(p.querySelector(".hk-tooltip-content")!.getAttribute("id")).toBe(titleId);
531
+ expect(anchor.getAttribute("aria-expanded")).toBe("true");
532
+ });
533
+
534
+ it("dismisses on an outside pointerdown but not on one inside the popup", async () => {
535
+ const { container } = mountClick();
536
+ triggerButton(container).click();
537
+ await nextTick();
538
+ const p = popup();
539
+
540
+ // A press on the actions must not dismiss before their click fires.
541
+ p.querySelector(".hk-tooltip-content")!.dispatchEvent(
542
+ new PointerEvent("pointerdown", { bubbles: true }),
543
+ );
544
+ await nextTick();
545
+ expect(popup().className).toContain("hk-tooltip-visible");
546
+
547
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
548
+ await nextTick();
549
+ expect(popup().className).not.toContain("hk-tooltip-visible");
550
+ });
551
+
552
+ it("dismisses on Escape, consumes the key and returns focus to the trigger", async () => {
553
+ const { container } = mountClick();
554
+ const btn = triggerButton(container);
555
+ btn.focus();
556
+ btn.click();
557
+ await nextTick();
558
+ expect(document.activeElement).toBe(popup());
559
+
560
+ const esc = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
561
+ document.dispatchEvent(esc);
562
+ await nextTick();
563
+ await nextTick();
564
+ expect(esc.defaultPrevented).toBe(true);
565
+ expect(popup().className).not.toContain("hk-tooltip-visible");
566
+ expect(document.activeElement).toBe(btn);
567
+ });
568
+
569
+ it("hands focus back from the slot's close() too", async () => {
570
+ const { container } = mountClick({}, ({ close }) =>
571
+ h("button", { class: "cancel-btn", type: "button", onClick: close }, "取消"));
572
+ const btn = triggerButton(container);
573
+ btn.focus();
574
+ btn.click();
575
+ await nextTick();
576
+ (popup().querySelector(".cancel-btn") as HTMLElement).click();
577
+ await nextTick();
578
+ await nextTick();
579
+ expect(document.activeElement).toBe(btn);
580
+ });
581
+
582
+ it("drops its document listeners when unmounted while open", async () => {
583
+ const entry = mountClick();
584
+ triggerButton(entry.container).click();
585
+ await nextTick();
586
+ entry.app.unmount();
587
+ mounts.splice(mounts.indexOf(entry), 1);
588
+ entry.container.remove();
589
+ // A stale listener would touch the detached popup ref; both gestures
590
+ // must be inert after unmount.
591
+ expect(() => {
592
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
593
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
594
+ }).not.toThrow();
595
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
596
+ });
597
+
598
+ it("omits the empty title block when the slot owns the whole body", async () => {
599
+ const { container } = mountClick({ text: "" }, ({ close }) =>
600
+ h("div", { class: "confirm-body" }, [
601
+ h("button", { class: "cancel-btn", type: "button", onClick: close }, "取消"),
602
+ ]));
603
+ triggerButton(container).click();
604
+ await nextTick();
605
+ const p = popup();
606
+ // No text → no padded title block, and therefore no dangling
607
+ // aria-labelledby either; the slot body carries the popup.
608
+ expect(p.querySelector(".hk-tooltip-content")).toBeNull();
609
+ expect(p.getAttribute("aria-labelledby")).toBeNull();
610
+ expect(p.querySelector(".confirm-body")).not.toBeNull();
611
+ });
612
+
613
+ it("leaves the hover tooltip's shape untouched (no click affordances)", async () => {
614
+ const { container } = mount({ text: "hover tip", delay: 0 });
615
+ const anchor = wrapper(container);
616
+ expect(anchor.hasAttribute("aria-haspopup")).toBe(false);
617
+ expect(anchor.hasAttribute("aria-expanded")).toBe(false);
618
+ enter(container);
619
+ await settle();
620
+ const p = popup();
621
+ expect(p.getAttribute("role")).toBe("tooltip");
622
+ expect(p.className).not.toContain("hk-tooltip-popup--click");
623
+ expect(p.hasAttribute("tabindex")).toBe(false);
624
+ expect(p.querySelector(".hk-tooltip-content")!.textContent).toBe("hover tip");
625
+ // The describedby contract stays the hover one.
626
+ expect(anchor.getAttribute("aria-describedby")).toBe(p.getAttribute("id"));
627
+ leave(container);
628
+ await nextTick();
629
+ expect(popup().className).not.toContain("hk-tooltip-visible");
630
+ });
631
+ });
632
+
633
+ describe("HkTooltip closed-popup reachability (R2 S2)", () => {
634
+ it("keeps a closed click popup's actions out of the tab order (inert)", async () => {
635
+ const { container } = mountClick({}, ({ close }) =>
636
+ h("div", { class: "confirm-body" }, [
637
+ h("button", { class: "cancel-btn", type: "button", onClick: close }, "取消"),
638
+ h("button", { class: "confirm-btn", type: "button" }, "继续"),
639
+ ]));
640
+ triggerButton(container).click();
641
+ await nextTick();
642
+ const p = popup();
643
+ // Open: the actions are real tab stops.
644
+ expect(p.hasAttribute("inert")).toBe(false);
645
+ expect(document.activeElement).toBe(p);
646
+
647
+ (p.querySelector(".cancel-btn") as HTMLElement).click();
648
+ await nextTick();
649
+ // The node (and its buttons) survive the lazy-mount contract — that is
650
+ // exactly why the reachability gate has to live on the popup itself…
651
+ expect(popup()).toBe(p);
652
+ expect(p.querySelector(".confirm-btn")).not.toBeNull();
653
+ expect(p.className).not.toContain("hk-tooltip-visible");
654
+ // …and a closed confirm surface must leave both the tab order and the
655
+ // a11y tree, or a keyboard user tabs into an invisible "继续".
656
+ expect(p.hasAttribute("inert")).toBe(true);
657
+ });
658
+
659
+ it("leaves the hover tooltip non-inert (it has no actions to hide)", async () => {
660
+ const { container } = mount({ text: "hover tip", delay: 0 });
661
+ enter(container);
662
+ await settle();
663
+ expect(popup().hasAttribute("inert")).toBe(false);
664
+ leave(container);
665
+ await nextTick();
666
+ expect(popup().hasAttribute("inert")).toBe(false);
667
+ });
668
+
669
+ it("stays silent for a text-less hover trigger (no empty bubble)", async () => {
670
+ const { container } = mount({ text: "", delay: 0 });
671
+ enter(container);
672
+ await settle();
673
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
674
+ });
675
+ });
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, onBeforeUnmount, ref, Teleport, type CSSProperties, type PropType, type SlotsType } from "vue";
1
+ import { computed, defineComponent, nextTick, 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
4
  import { armTouchHint } from "../runtime/touchHint";
@@ -17,29 +17,69 @@ export interface HkTooltipSlotProps {
17
17
  visible: boolean;
18
18
  }
19
19
 
20
+ /**
21
+ * The `content` slot payload — the click-trigger confirm body.
22
+ *
23
+ * `close()` hides the popup and hands focus back to the trigger; wire it to
24
+ * both actions (cancel and confirm) so one decision ends the popup. The
25
+ * caller keeps ownership of what "confirm" DOES — this component only owns
26
+ * the confirm surface, never the side effect.
27
+ */
28
+ export interface HkTooltipConfirmSlotProps {
29
+ close: () => void;
30
+ }
31
+
32
+ /** Hover hints follow the pointer; click popups own an explicit decision. */
33
+ export type TooltipTrigger = "hover" | "click";
34
+
20
35
  let popupSeq = 0;
21
36
 
22
37
  export default defineComponent({
23
38
  name: "HkTooltip",
24
39
  props: {
25
- text: { type: String, required: true },
40
+ /** The hint/caption text. In click mode it is the confirm popup's title;
41
+ * omit it when a `content` slot carries its own heading. */
42
+ text: { type: String, default: "" },
26
43
  placement: { type: String as PropType<TooltipPlacement>, default: "top" },
27
44
  delay: { type: Number, default: 300 },
28
45
  maxWidth: { type: String, default: undefined },
46
+ /**
47
+ * `"hover"` (default) is the classic annotation: shows on hover/focus
48
+ * after `delay`, hides on leave, never takes pointer events.
49
+ *
50
+ * `"click"` turns the same anchored bubble into an explicit-decision
51
+ * surface: the trigger's click toggles it, no hover/focus path opens or
52
+ * closes it, the popup takes pointer events (so it can hold buttons),
53
+ * outside pointerdown and Escape dismiss it, and focus moves into it on
54
+ * open and back to the trigger on an Escape/`close()` dismissal. This is
55
+ * the "are you sure?" shape a confirm-on-submit flow needs; `delay` is
56
+ * ignored in this mode.
57
+ */
58
+ trigger: { type: String as PropType<TooltipTrigger>, default: "hover" },
29
59
  },
30
60
  slots: Object as SlotsType<{
31
61
  default?: (props: HkTooltipSlotProps) => unknown;
62
+ /** Click mode: the popup body below `text` (actions included). */
63
+ content?: (props: HkTooltipConfirmSlotProps) => unknown;
32
64
  }>,
33
65
  setup(props, { slots }) {
34
66
  const visible = ref(false);
67
+ const clickMode = computed(() => props.trigger === "click");
35
68
  // Stable id for the aria-describedby link from the trigger wrapper to
36
69
  // the popup (set while the popup is visible, mirroring the tooltip
37
70
  // bridge's contract).
38
71
  const popupId = `hk-tooltip-${++popupSeq}`;
72
+ const titleId = `${popupId}-title`;
39
73
  const wrapperRef = ref<HTMLElement | null>(null);
40
74
  const popupRef = ref<HTMLElement | null>(null);
41
75
  let showTimer: ReturnType<typeof setTimeout> | null = null;
42
76
  let positionFrame: number | null = null;
77
+ // Click mode owns its own dismissal wiring: listeners exist only while
78
+ // the popup is open, and the trigger focus is remembered so a dismissal
79
+ // decision can hand focus back.
80
+ let dismissPointer: ((event: PointerEvent) => void) | null = null;
81
+ let dismissEscape: ((event: KeyboardEvent) => void) | null = null;
82
+ let restoreFocusEl: HTMLElement | null = null;
43
83
 
44
84
  // The band handle and the popup DOM are both LAZY (dense-list contract,
45
85
  // 2026-10-05): a tooltip that is never engaged — the overwhelming
@@ -96,6 +136,14 @@ export default defineComponent({
96
136
  }
97
137
 
98
138
  function show() {
139
+ // Hover/focus entry only: the click-trigger wrapper passes NO
140
+ // hover/focus handlers at all (see the render below), so a confirm
141
+ // surface can never pop open from a pointer passing over it.
142
+ //
143
+ // `text` is optional now (click mode may title from it or not), so a
144
+ // text-less hover trigger must stay silent like the touch-hint path
145
+ // does — an empty bubble is a worse answer than no bubble.
146
+ if (!props.text.trim()) return;
99
147
  clearShowTimer();
100
148
  ensureEngaged();
101
149
  showTimer = setTimeout(() => {
@@ -104,13 +152,89 @@ export default defineComponent({
104
152
  }, props.delay);
105
153
  }
106
154
 
107
- function hide() {
155
+ function detachDismissal() {
156
+ if (dismissPointer) {
157
+ document.removeEventListener("pointerdown", dismissPointer, true);
158
+ dismissPointer = null;
159
+ }
160
+ if (dismissEscape) {
161
+ document.removeEventListener("keydown", dismissEscape, true);
162
+ dismissEscape = null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Click mode dismissal wiring, live only while the popup is open:
168
+ * - a pointerdown OUTSIDE both the trigger and the popup dismisses (an
169
+ * outside click already moves focus elsewhere, so no focus hand-back);
170
+ * - Escape dismisses and hands focus back to the trigger, consuming the
171
+ * key only when it actually closed the popup — the same one-press-one-
172
+ * thing rule HkSelect uses for triggers hosted inside a dialog.
173
+ */
174
+ function attachDismissal() {
175
+ if (dismissPointer || dismissEscape) return;
176
+ dismissPointer = (event: PointerEvent) => {
177
+ const target = event.target as Node | null;
178
+ if (target == null) return;
179
+ // The actions and the trigger keep the popup open; the trigger's
180
+ // own click handler toggles it.
181
+ if (popupRef.value?.contains(target) || wrapperRef.value?.contains(target)) return;
182
+ hide();
183
+ };
184
+ dismissEscape = (event: KeyboardEvent) => {
185
+ if (event.key !== "Escape" || !visible.value) return;
186
+ event.preventDefault();
187
+ event.stopPropagation();
188
+ hide({ restoreFocus: true });
189
+ };
190
+ document.addEventListener("pointerdown", dismissPointer, true);
191
+ document.addEventListener("keydown", dismissEscape, true);
192
+ }
193
+
194
+ function hide(opts: { restoreFocus?: boolean } = {}) {
108
195
  clearShowTimer();
196
+ const wasVisible = visible.value;
109
197
  visible.value = false;
110
198
  stopPositionTracking();
111
199
  // A hide means the hover/focus gesture is over — a touch hint
112
200
  // armed in parallel (pen press while hovering) retires with it.
113
201
  cancelTouchHint();
202
+ detachDismissal();
203
+ const restore = opts.restoreFocus === true ? restoreFocusEl : null;
204
+ restoreFocusEl = null;
205
+ if (restore && wasVisible) {
206
+ // Hand focus back only when it still lives on the popup/trigger:
207
+ // a dismissal that already moved focus (outside click, caller
208
+ // navigation) must not yank it back.
209
+ const active = document.activeElement;
210
+ const stillOurs = active == null || active === document.body || active === restore
211
+ || popupRef.value?.contains(active) === true
212
+ || wrapperRef.value?.contains(active) === true;
213
+ if (stillOurs) nextTick(() => restore.focus());
214
+ }
215
+ }
216
+
217
+ /** Click mode: the trigger toggles the popup (open immediately). */
218
+ function onTriggerClick() {
219
+ if (visible.value) {
220
+ hide();
221
+ return;
222
+ }
223
+ clearShowTimer();
224
+ ensureEngaged();
225
+ visible.value = true;
226
+ trackPositionWhileVisible();
227
+ // A keyboard activation must land on the actions rather than staying
228
+ // on the trigger; remember the origin for the focus hand-back.
229
+ const active = document.activeElement;
230
+ restoreFocusEl = active instanceof HTMLElement ? active : null;
231
+ attachDismissal();
232
+ nextTick(() => popupRef.value?.focus());
233
+ }
234
+
235
+ /** The confirm slot's `close`: dismiss and hand focus back. */
236
+ function closeFromPopup() {
237
+ hide({ restoreFocus: true });
114
238
  }
115
239
 
116
240
  function clearShowTimer() {
@@ -146,6 +270,9 @@ export default defineComponent({
146
270
  }
147
271
 
148
272
  function onPointerdown(e: PointerEvent) {
273
+ // Click mode owns touch too: a tap opens the confirm popup, so the
274
+ // long-press hint bar would be a second, conflicting surface.
275
+ if (clickMode.value) return;
149
276
  if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
150
277
  if (!props.text.trim()) return; // nothing to hint
151
278
  lastTouchAt = performance.now();
@@ -165,6 +292,7 @@ export default defineComponent({
165
292
  clearShowTimer();
166
293
  stopPositionTracking();
167
294
  cancelTouchHint();
295
+ detachDismissal();
168
296
  if (popupHandle) {
169
297
  manager.unregister(popupHandle.id);
170
298
  popupHandle = null;
@@ -174,6 +302,7 @@ export default defineComponent({
174
302
  const tooltipCls = computed(() => [
175
303
  "hk-tooltip-popup",
176
304
  `hk-tooltip-${props.placement}`,
305
+ clickMode.value ? "hk-tooltip-popup--click" : "",
177
306
  visible.value ? "hk-tooltip-visible" : "",
178
307
  ]);
179
308
 
@@ -184,23 +313,53 @@ export default defineComponent({
184
313
  zIndex.value != null ? { zIndex: zIndex.value } : {},
185
314
  );
186
315
 
316
+ /**
317
+ * Popup children as an array: absent halves must not leave a comment
318
+ * placeholder behind, so the hover tooltip's DOM stays exactly the
319
+ * pre-click-mode shape (the `content` slot is click mode only).
320
+ */
321
+ function popupChildren(): unknown[] {
322
+ const kids: unknown[] = [];
323
+ // A text-only tooltip keeps its content block even when `text` is
324
+ // empty (the legacy bubble); with a `content` slot and no text the
325
+ // slot owns the body and an empty padded block would be dead space.
326
+ if (props.text !== "" || !slots.content) {
327
+ kids.push(
328
+ <div class="hk-tooltip-content" id={clickMode.value ? titleId : undefined}>
329
+ {props.text}
330
+ </div>,
331
+ );
332
+ }
333
+ if (slots.content) kids.push(slots.content({ close: closeFromPopup }));
334
+ return kids;
335
+ }
336
+
187
337
  return () => (
188
338
  <span
189
339
  ref={wrapperRef}
190
340
  class="hk-tooltip-wrapper"
191
341
  data-position={props.placement}
192
- aria-describedby={visible.value ? popupId : undefined}
342
+ // Hover hints describe their trigger; a click confirm announces the
343
+ // dialog it will open instead.
344
+ aria-describedby={!clickMode.value && visible.value ? popupId : undefined}
345
+ aria-haspopup={clickMode.value ? "dialog" : undefined}
346
+ aria-expanded={clickMode.value ? visible.value : undefined}
347
+ onClick={clickMode.value ? onTriggerClick : undefined}
193
348
  onPointerdown={onPointerdown}
194
- onMouseenter={() => {
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.
198
- if (performance.now() - lastTouchAt < 600) return;
199
- show();
200
- }}
201
- onMouseleave={hide}
202
- onFocusin={show}
203
- onFocusout={hide}
349
+ onMouseenter={
350
+ clickMode.value
351
+ ? undefined
352
+ : () => {
353
+ // Synthetic mouseenter right after a touch press must not
354
+ // flash the anchored popup the touch mode never shows — the
355
+ // top hint bar owns touch.
356
+ if (performance.now() - lastTouchAt < 600) return;
357
+ show();
358
+ }
359
+ }
360
+ onMouseleave={clickMode.value ? undefined : () => hide()}
361
+ onFocusin={clickMode.value ? undefined : show}
362
+ onFocusout={clickMode.value ? undefined : () => hide()}
204
363
  >
205
364
  <span class="hk-tooltip-trigger">
206
365
  {slots.default?.({ popupId, visible: visible.value })}
@@ -210,11 +369,21 @@ export default defineComponent({
210
369
  <div
211
370
  ref={popupRef}
212
371
  id={popupId}
213
- role="tooltip"
372
+ role={clickMode.value ? "dialog" : "tooltip"}
214
373
  class={tooltipCls.value}
215
374
  style={popupStyle.value}
375
+ // Click mode keeps the popup node mounted after a close (lazy
376
+ // contract), but its actions must not stay reachable: an
377
+ // invisible button is still a tab stop and still speaks. `inert`
378
+ // is the platform's own "out of the tab order and out of the
379
+ // a11y tree" switch, armed exactly while the popup is closed.
380
+ inert={clickMode.value ? !visible.value : undefined}
381
+ // A dialog surface takes focus so keyboard users land on the
382
+ // actions (the slot's buttons are the next Tab stops).
383
+ tabindex={clickMode.value ? -1 : undefined}
384
+ aria-labelledby={clickMode.value && props.text !== "" ? titleId : undefined}
216
385
  >
217
- <div class="hk-tooltip-content">{props.text}</div>
386
+ {popupChildren()}
218
387
  </div>
219
388
  )}
220
389
  </Teleport>
package/src/index.ts CHANGED
@@ -874,7 +874,11 @@ export { default as HkToast } from "./components/HkToast";
874
874
  export { default as HkPersistentToast, type PersistentToastTone } from "./components/HkPersistentToast";
875
875
  export { default as HkPersistentToastGroup } from "./components/HkPersistentToastGroup";
876
876
  export { default as HkTooltip } from "./components/HkTooltip";
877
- export { type HkTooltipSlotProps } from "./components/HkTooltip";
877
+ export {
878
+ type HkTooltipConfirmSlotProps,
879
+ type HkTooltipSlotProps,
880
+ type TooltipTrigger,
881
+ } from "./components/HkTooltip";
878
882
  export { default as HkTree } from "./components/HkTree";
879
883
  export { default as HkWindowedItem } from "./components/HkWindowedItem";
880
884
  export { default as HkWaterfall } from "./components/HkWaterfall";