@celestia-island/hikari 0.55.332 → 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 +1 -1
- package/src/components/HkProgressBar.scss +52 -0
- package/src/components/HkProgressBar.styles.test.ts +79 -0
- package/src/components/HkProgressBar.test.tsx +202 -1
- package/src/components/HkProgressBar.tsx +146 -21
- package/src/components/HkTooltip.scss +29 -0
- package/src/components/HkTooltip.styles.test.ts +53 -0
- package/src/components/HkTooltip.test.tsx +248 -1
- package/src/components/HkTooltip.tsx +185 -16
- package/src/index.ts +5 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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 {
|
|
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";
|