@celestia-island/hikari 0.55.335 → 0.55.337
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/HkNumberInput.test.tsx +78 -0
- package/src/components/HkNumberInput.tsx +17 -2
- package/src/components/HkTreemap.test.tsx +87 -23
- package/src/components/HkTreemap.tsx +64 -17
- package/src/overlay/overlayRoot.styles.test.ts +27 -0
- package/src/overlay/overlayRoot.ts +10 -1
- package/src/utils/dom.test.ts +94 -1
- package/src/utils/dom.ts +13 -1
package/package.json
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkNumberInput from "./HkNumberInput";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkNumberInput's label-association contract (#1206): a bare `label` prop
|
|
8
|
+
* used to render a <label> with NO programmatic association to the field
|
|
9
|
+
* (no `for`, no wrapping), so the number field announced as "spin button"
|
|
10
|
+
* to screen readers. Mirrors HkInput's contract — `for` points at the
|
|
11
|
+
* field id, which is `props.id` when given and a useId() generation
|
|
12
|
+
* otherwise — and follows the repo test convention (raw createApp +
|
|
13
|
+
* document queries, no @vue/test-utils).
|
|
14
|
+
*/
|
|
15
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
16
|
+
|
|
17
|
+
function mountInput(props: Record<string, unknown>): HTMLElement {
|
|
18
|
+
const container = document.createElement("div");
|
|
19
|
+
document.body.appendChild(container);
|
|
20
|
+
const app = createApp({ render: () => h(HkNumberInput, props) });
|
|
21
|
+
app.mount(container);
|
|
22
|
+
mounts.push({ app, container });
|
|
23
|
+
return container;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
28
|
+
app.unmount();
|
|
29
|
+
container.remove();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("HkNumberInput label association (#1206)", () => {
|
|
34
|
+
it("associates a bare label with the field via for/id", () => {
|
|
35
|
+
const container = mountInput({ label: "Per-phone cap", modelValue: 10 });
|
|
36
|
+
console.log("HTML:", container.innerHTML.slice(0, 300));
|
|
37
|
+
const label = container.querySelector("label.hk-input-label");
|
|
38
|
+
const field = container.querySelector<HTMLInputElement>("input.hk-number-input-field");
|
|
39
|
+
expect(label).not.toBeNull();
|
|
40
|
+
expect(field).not.toBeNull();
|
|
41
|
+
expect(label!.getAttribute("for")).toBe(field!.id);
|
|
42
|
+
expect(field!.id).not.toBe("");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("an explicit id prop wins over the generated one", () => {
|
|
46
|
+
const container = mountInput({ label: "Cap", modelValue: 1, id: "cap-field" });
|
|
47
|
+
const label = container.querySelector("label.hk-input-label");
|
|
48
|
+
const field = container.querySelector<HTMLInputElement>("input.hk-number-input-field");
|
|
49
|
+
expect(field!.id).toBe("cap-field");
|
|
50
|
+
expect(label!.getAttribute("for")).toBe("cap-field");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps field ids unique among instances of ONE app", () => {
|
|
54
|
+
// useId's counter is PER APP: two separate createApp mounts both start
|
|
55
|
+
// at v-0 (uniqueness is a within-app contract — a real host has one
|
|
56
|
+
// app). Two siblings in the same app must never share an id, or the
|
|
57
|
+
// second label's `for` would point at the FIRST field.
|
|
58
|
+
const container = document.createElement("div");
|
|
59
|
+
document.body.appendChild(container);
|
|
60
|
+
const app = createApp({
|
|
61
|
+
render: () =>
|
|
62
|
+
h("div", [
|
|
63
|
+
h(HkNumberInput, { modelValue: 1, label: "First" }),
|
|
64
|
+
h(HkNumberInput, { modelValue: 2, label: "Second" }),
|
|
65
|
+
]),
|
|
66
|
+
});
|
|
67
|
+
app.mount(container);
|
|
68
|
+
mounts.push({ app, container });
|
|
69
|
+
const fields = [...container.querySelectorAll<HTMLInputElement>("input.hk-number-input-field")];
|
|
70
|
+
expect(fields).toHaveLength(2);
|
|
71
|
+
const ids = fields.map((f) => f.id);
|
|
72
|
+
expect(new Set(ids).size).toBe(2);
|
|
73
|
+
// Each label's `for` points at ITS OWN field, in document order.
|
|
74
|
+
const labels = [...container.querySelectorAll("label.hk-input-label")];
|
|
75
|
+
expect(labels[0]!.getAttribute("for")).toBe(ids[0]);
|
|
76
|
+
expect(labels[1]!.getAttribute("for")).toBe(ids[1]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, ref, useId, type PropType } from "vue";
|
|
2
2
|
import HkNumberSteppers from "./HkNumberSteppers";
|
|
3
3
|
import "./HkNumberInput.scss";
|
|
4
4
|
|
|
@@ -12,6 +12,13 @@ export default defineComponent({
|
|
|
12
12
|
disabled: { type: Boolean, default: false },
|
|
13
13
|
placeholder: { type: String, default: undefined },
|
|
14
14
|
label: { type: String, default: undefined },
|
|
15
|
+
/* `id` for the field element; the rendered label's `for` points here.
|
|
16
|
+
Generated via Vue's useId when omitted, so a bare `label` prop is
|
|
17
|
+
fully associated for screen readers and label clicks with zero
|
|
18
|
+
caller effort (HkInput's contract, same shape). Without this the
|
|
19
|
+
bare <label> had NO programmatic association at all — the field
|
|
20
|
+
announced as "spin button" (2026-11-10, #1206). */
|
|
21
|
+
id: { type: String, default: undefined },
|
|
15
22
|
/* Affix text rendered INSIDE the field (before/after the input,
|
|
16
23
|
flex-centered by the inner row). Slots of the same name take
|
|
17
24
|
precedence; empty/undefined means no affix. Consumers should use
|
|
@@ -60,6 +67,13 @@ export default defineComponent({
|
|
|
60
67
|
emitValue(props.modelValue - props.step);
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
// Field identity: the rendered label points at this id, so a bare
|
|
71
|
+
// `label` prop gives a fully associated field (screen readers, label
|
|
72
|
+
// clicks). An explicit `id` prop wins. useId() must run synchronously
|
|
73
|
+
// in setup; it is SSR-safe (HkInput's same shape).
|
|
74
|
+
const generatedId = useId();
|
|
75
|
+
const fieldId = computed(() => props.id ?? generatedId);
|
|
76
|
+
|
|
63
77
|
const wrapperClass = computed(() => [
|
|
64
78
|
"hk-number-input",
|
|
65
79
|
`hk-number-input-${props.size}`,
|
|
@@ -68,7 +82,7 @@ export default defineComponent({
|
|
|
68
82
|
|
|
69
83
|
return () => (
|
|
70
84
|
<div class={wrapperClass.value}>
|
|
71
|
-
{props.label && <label class="hk-input-label">{props.label}</label>}
|
|
85
|
+
{props.label && <label class="hk-input-label" for={fieldId.value}>{props.label}</label>}
|
|
72
86
|
<div class="hk-number-input-inner">
|
|
73
87
|
{(slots.prefix || props.prefix) ? (
|
|
74
88
|
<span class="hk-number-input-prefix">
|
|
@@ -77,6 +91,7 @@ export default defineComponent({
|
|
|
77
91
|
) : null}
|
|
78
92
|
<input
|
|
79
93
|
ref={inputRef}
|
|
94
|
+
id={fieldId.value}
|
|
80
95
|
type="number"
|
|
81
96
|
class="hk-number-input-field"
|
|
82
97
|
value={props.modelValue}
|
|
@@ -139,6 +139,30 @@ afterEach(() => {
|
|
|
139
139
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
/** WCAG helpers for the ink assertions — an independent re-implementation of
|
|
143
|
+
* the requirement (AA contrast), deliberately not the component's own code. */
|
|
144
|
+
const srgbLinear = (channel: number) => {
|
|
145
|
+
const value = channel / 255;
|
|
146
|
+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
147
|
+
};
|
|
148
|
+
const hexChannels = (hex: string): [number, number, number] => {
|
|
149
|
+
const raw = hex.length === 4
|
|
150
|
+
? hex.slice(1).split("").map((char) => char + char).join("")
|
|
151
|
+
: hex.slice(1);
|
|
152
|
+
return [
|
|
153
|
+
Number.parseInt(raw.slice(0, 2), 16),
|
|
154
|
+
Number.parseInt(raw.slice(2, 4), 16),
|
|
155
|
+
Number.parseInt(raw.slice(4, 6), 16),
|
|
156
|
+
];
|
|
157
|
+
};
|
|
158
|
+
const luminanceOf = (rgb: readonly number[]) =>
|
|
159
|
+
0.2126 * srgbLinear(rgb[0]!) + 0.7152 * srgbLinear(rgb[1]!) + 0.0722 * srgbLinear(rgb[2]!);
|
|
160
|
+
const contrastAgainst = (backgroundLuminance: number, ink: string) => {
|
|
161
|
+
const text = luminanceOf(hexChannels(ink));
|
|
162
|
+
return (Math.max(backgroundLuminance, text) + 0.05)
|
|
163
|
+
/ (Math.min(backgroundLuminance, text) + 0.05);
|
|
164
|
+
};
|
|
165
|
+
|
|
142
166
|
describe("HkTreemap pure layout input", () => {
|
|
143
167
|
it("sums nested groups from leaves and ignores caller-provided parent values", () => {
|
|
144
168
|
const layout = buildHkTreemapLayout(NODES);
|
|
@@ -251,38 +275,78 @@ describe("HkTreemap pure layout input", () => {
|
|
|
251
275
|
expect(contrastTextColor("rgb(78 121 167 / 40%)")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
252
276
|
expect(contrastTextColor("#4e79a780")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
253
277
|
expect(contrastTextColor("not-a-color")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
278
|
+
// Parse strictness is part of the mirror contract: trailing garbage,
|
|
279
|
+
// inf/NaN spellings and broken hues are NOT truncated numbers — both
|
|
280
|
+
// runtimes must answer "no solid answer" identically.
|
|
281
|
+
expect(contrastTextColor("rgb(10x 20 30)")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
282
|
+
expect(contrastTextColor("rgb(inf 0 0)")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
283
|
+
expect(contrastTextColor("rgb(50x% 0 0)")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
284
|
+
expect(contrastTextColor("hsl(10x, 50%, 50%)")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
285
|
+
// The deg suffix still parses (the Rust angle channel strips it too) —
|
|
286
|
+
// in ANY case, because functional forms match on the lowercased value.
|
|
287
|
+
expect(contrastTextColor("hsl(120deg, 50%, 50%)")).toBe(contrastTextColor("hsl(120, 50%, 50%)"));
|
|
288
|
+
expect(contrastTextColor("hsl(120DEG, 50%, 50%)")).toBe(contrastTextColor("hsl(120, 50%, 50%)"));
|
|
289
|
+
// Uppercase NONE channels are filtered exactly like lowercase ones, and
|
|
290
|
+
// a BOM is whitespace on both sides of the mirror.
|
|
291
|
+
expect(contrastTextColor("rgb(NONE 1 2 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
|
|
292
|
+
expect(contrastTextColor("rgb(1 2 3 / NONE)")).toBe(contrastTextColor("rgb(1 2 3)"));
|
|
293
|
+
expect(contrastTextColor("\uFEFF#fff")).toBe(contrastTextColor("#fff"));
|
|
294
|
+
expect(contrastTextColor("rgb(1\uFEFF2 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
|
|
295
|
+
// The whitespace MODEL is pinned too, not just these shapes: an
|
|
296
|
+
// interleaved BOM between real spaces still trims, and U+0085 (NEL) is
|
|
297
|
+
// a control character — NOT JS whitespace — so it never parses.
|
|
298
|
+
expect(contrastTextColor("\uFEFF \uFEFF#fff")).toBe(contrastTextColor("#fff"));
|
|
299
|
+
expect(contrastTextColor("#fff\uFEFF \uFEFF")).toBe(contrastTextColor("#fff"));
|
|
300
|
+
expect(contrastTextColor("\u0085#fff")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
301
|
+
expect(contrastTextColor("#fff\u0085")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
302
|
+
expect(contrastTextColor("rgb(1\u00852 3)")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
303
|
+
// A line terminator inside the envelope parses (the Rust mirror's
|
|
304
|
+
// strip_prefix/strip_suffix never sees it as a terminator).
|
|
305
|
+
expect(contrastTextColor("rgb(1\n2 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
|
|
306
|
+
expect(contrastTextColor("rgb(1\u20282 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
|
|
307
|
+
expect(contrastTextColor("rgb(\t1\n2\r3)")).toBe(contrastTextColor("rgb(1 2 3)"));
|
|
308
|
+
expect(contrastTextColor("hsl(120\u2028, 50%, 50%)")).toBe(contrastTextColor("hsl(120, 50%, 50%)"));
|
|
254
309
|
});
|
|
255
310
|
|
|
256
311
|
it("keeps every default palette cell AND its group band at WCAG AA", () => {
|
|
257
|
-
const linear = (channel: number) => {
|
|
258
|
-
const value = channel / 255;
|
|
259
|
-
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
260
|
-
};
|
|
261
|
-
const channels = (hex: string) => {
|
|
262
|
-
const raw = hex.length === 4
|
|
263
|
-
? hex.slice(1).split("").map((char) => char + char).join("")
|
|
264
|
-
: hex.slice(1);
|
|
265
|
-
return [0, 2, 4].map((at) => Number.parseInt(raw.slice(at, at + 2), 16));
|
|
266
|
-
};
|
|
267
|
-
const luminance = (rgb: number[]) => 0.2126 * linear(rgb[0]!)
|
|
268
|
-
+ 0.7152 * linear(rgb[1]!)
|
|
269
|
-
+ 0.0722 * linear(rgb[2]!);
|
|
270
|
-
const ratio = (plate: number, ink: string) => {
|
|
271
|
-
const text = luminance(channels(ink));
|
|
272
|
-
return (Math.max(plate, text) + 0.05) / (Math.min(plate, text) + 0.05);
|
|
273
|
-
};
|
|
274
312
|
for (const color of HK_TREEMAP_DEFAULT_COLORS) {
|
|
275
|
-
const plate =
|
|
313
|
+
const plate = luminanceOf(hexChannels(color));
|
|
276
314
|
const ink = contrastTextColor(color);
|
|
277
|
-
expect(["#fff", "#0b1220"]).toContain(ink);
|
|
278
|
-
expect(
|
|
315
|
+
expect(["#fff", "#0b1220", "#000"]).toContain(ink);
|
|
316
|
+
expect(contrastAgainst(plate, ink)).toBeGreaterThanOrEqual(4.5);
|
|
279
317
|
// The band the group label actually sits on is the scrimmed plate —
|
|
280
318
|
// pin it too, the worst palette case (#59a14f) sits at 4.506:1.
|
|
281
|
-
const band =
|
|
282
|
-
|
|
319
|
+
const band = luminanceOf(
|
|
320
|
+
hexChannels(color).map((channel) => channel * (1 - HK_TREEMAP_HEAD_SCRIM)),
|
|
283
321
|
);
|
|
284
322
|
const bandInk = contrastTextColor(color, HK_TREEMAP_HEAD_SCRIM);
|
|
285
|
-
expect(
|
|
323
|
+
expect(contrastAgainst(band, bandInk)).toBeGreaterThanOrEqual(4.5);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("never leaves a solid plate under AA — black covers the mid-tone dead zone", () => {
|
|
328
|
+
// The white/near-black pair cannot reach 4.5:1 for plate luminances in
|
|
329
|
+
// (0.1833, 0.2024); pure black is the escape hatch and must appear there.
|
|
330
|
+
const deadZoneGrey = "rgb(120 120 130)";
|
|
331
|
+
expect(contrastAgainst(luminanceOf(hexChannels("#787882")), "#fff")).toBeLessThan(4.5);
|
|
332
|
+
expect(contrastAgainst(luminanceOf(hexChannels("#787882")), "#0b1220")).toBeLessThan(4.5);
|
|
333
|
+
expect(contrastTextColor(deadZoneGrey)).toBe("#000");
|
|
334
|
+
// …and only there: no default palette member is ever black.
|
|
335
|
+
for (const color of HK_TREEMAP_DEFAULT_COLORS) {
|
|
336
|
+
expect(contrastTextColor(color)).not.toBe("#000");
|
|
337
|
+
}
|
|
338
|
+
// The same grey's header band is light enough for white.
|
|
339
|
+
expect(contrastTextColor(deadZoneGrey, HK_TREEMAP_HEAD_SCRIM)).toBe("#fff");
|
|
340
|
+
// The band itself can land in the dead zone: #63a04d's plate keeps the
|
|
341
|
+
// near-black ink (5.94:1), but its scrimmed band cannot clear AA with
|
|
342
|
+
// either softer ink (white 4.49, near-black 4.17) -> black (4.68).
|
|
343
|
+
expect(contrastTextColor("#63a04d")).toBe("#0b1220");
|
|
344
|
+
expect(contrastTextColor("#63a04d", HK_TREEMAP_HEAD_SCRIM)).toBe("#000");
|
|
345
|
+
// Property: every grey step clears AA with the ink it picks.
|
|
346
|
+
for (let value = 0; value <= 255; value += 1) {
|
|
347
|
+
const color = `rgb(${value} ${value} ${value})`;
|
|
348
|
+
const plate = luminanceOf([value, value, value]);
|
|
349
|
+
expect(contrastAgainst(plate, contrastTextColor(color))).toBeGreaterThanOrEqual(4.5);
|
|
286
350
|
}
|
|
287
351
|
});
|
|
288
352
|
|
|
@@ -65,15 +65,37 @@ function parseHexColor(raw: string): RgbaChannels | null {
|
|
|
65
65
|
];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Strict decimal/scientific numeric parse — the WHOLE string must be one
|
|
69
|
+
* number, mirroring the Rust mirror's `str::parse::<f64>()` + `is_finite()`
|
|
70
|
+
* gate: trailing garbage ("10x") and inf/NaN spellings are rejected, not
|
|
71
|
+
* truncated. Returns NaN for anything that is not one number. */
|
|
72
|
+
const NUMERIC_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
73
|
+
function parseStrictNumber(raw: string): number {
|
|
74
|
+
const value = raw.trim();
|
|
75
|
+
return NUMERIC_PATTERN.test(value) ? Number(value) : Number.NaN;
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
function numericChannel(raw: string): number | null {
|
|
69
79
|
if (raw.endsWith("%")) {
|
|
70
|
-
const percent =
|
|
80
|
+
const percent = parseStrictNumber(raw.slice(0, -1));
|
|
71
81
|
return Number.isFinite(percent) ? Math.min(255, Math.max(0, (percent / 100) * 255)) : null;
|
|
72
82
|
}
|
|
73
|
-
const value =
|
|
83
|
+
const value = parseStrictNumber(raw);
|
|
74
84
|
return Number.isFinite(value) ? Math.min(255, Math.max(0, value)) : null;
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/** An hsl hue: a strict number with the optional `deg` suffix (all trailing
|
|
88
|
+
* repeats stripped, like the Rust mirror's `trim_end_matches("deg")`). */
|
|
89
|
+
function angleChannel(raw: string): number {
|
|
90
|
+
return parseStrictNumber(raw.replace(/(?:deg)+$/, ""));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An hsl saturation/lightness: a strict number with an optional `%` suffix
|
|
94
|
+
* (CSS requires the percent, but both forms appear in the wild). */
|
|
95
|
+
function percentChannel(raw: string): number {
|
|
96
|
+
return parseStrictNumber(raw.replace(/%+$/, ""));
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
function hueToRgb(hue: number, saturation: number, lightness: number): [number, number, number] {
|
|
78
100
|
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
79
101
|
const sector = ((hue % 360) + 360) % 360 / 60;
|
|
@@ -96,17 +118,25 @@ function hueToRgb(hue: number, saturation: number, lightness: number): [number,
|
|
|
96
118
|
function alphaComponent(raw: string | undefined): number | null {
|
|
97
119
|
if (raw == null || raw === "") return 1;
|
|
98
120
|
if (raw.endsWith("%")) {
|
|
99
|
-
const percent =
|
|
121
|
+
const percent = parseStrictNumber(raw.slice(0, -1));
|
|
100
122
|
return Number.isFinite(percent) ? Math.min(1, Math.max(0, percent / 100)) : null;
|
|
101
123
|
}
|
|
102
|
-
const value =
|
|
124
|
+
const value = parseStrictNumber(raw);
|
|
103
125
|
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null;
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
function parseCssColor(raw: string): RgbaChannels | null {
|
|
107
|
-
|
|
129
|
+
// JS trim() strips U+FEFF; the Rust mirror is taught the same character so
|
|
130
|
+
// both runtimes accept or reject a BOM-prefixed colour identically.
|
|
131
|
+
const value = raw.trim().replace(/^\uFEFF+/, "").replace(/\uFEFF+$/, "").trim();
|
|
108
132
|
if (value.startsWith("#")) return parseHexColor(value);
|
|
109
|
-
|
|
133
|
+
// Functional forms are matched on the LOWERCASED value — exactly the Rust
|
|
134
|
+
// mirror's to_ascii_lowercase() — so case-insensitive CSS units (`DEG`,
|
|
135
|
+
// `NONE` channels) parse or reject identically on both sides.
|
|
136
|
+
const lower = value.toLowerCase();
|
|
137
|
+
// [\s\S] (not `.`): a line terminator inside the envelope is legal for the
|
|
138
|
+
// Rust mirror's strip_prefix/strip_suffix, so it must be legal here too.
|
|
139
|
+
const rgb = /^rgba?\(([\s\S]*)\)$/.exec(lower);
|
|
110
140
|
if (rgb) {
|
|
111
141
|
const parts = rgb[1]!.split(/[\s,/]+/).filter((part) => part && part !== "none");
|
|
112
142
|
if (parts.length < 3) return null;
|
|
@@ -116,13 +146,13 @@ function parseCssColor(raw: string): RgbaChannels | null {
|
|
|
116
146
|
if (alpha === null) return null;
|
|
117
147
|
return [...(channels as [number, number, number]), alpha];
|
|
118
148
|
}
|
|
119
|
-
const hsl = /^hsla?\((
|
|
149
|
+
const hsl = /^hsla?\(([\s\S]*)\)$/.exec(lower);
|
|
120
150
|
if (hsl) {
|
|
121
151
|
const parts = hsl[1]!.split(/[\s,/]+/).filter((part) => part && part !== "none");
|
|
122
152
|
if (parts.length < 3) return null;
|
|
123
|
-
const hue =
|
|
124
|
-
const saturation =
|
|
125
|
-
const lightness =
|
|
153
|
+
const hue = angleChannel(parts[0]!);
|
|
154
|
+
const saturation = percentChannel(parts[1]!);
|
|
155
|
+
const lightness = percentChannel(parts[2]!);
|
|
126
156
|
if (![hue, saturation, lightness].every(Number.isFinite)) return null;
|
|
127
157
|
const alpha = alphaComponent(parts[3]);
|
|
128
158
|
if (alpha === null) return null;
|
|
@@ -164,13 +194,25 @@ function contrastRatio(a: number, b: number): number {
|
|
|
164
194
|
return (lighter + 0.05) / (darker + 0.05);
|
|
165
195
|
}
|
|
166
196
|
|
|
167
|
-
/** The two inks a SOLID fill
|
|
197
|
+
/** The two inks a SOLID fill normally carries. The dark end is the family's
|
|
168
198
|
* near-black (a hair of blue keeps it from reading as dead #000 on the
|
|
169
199
|
* pale palette members). */
|
|
170
200
|
const SOLID_LIGHT_INK = "#fff";
|
|
171
201
|
const SOLID_DARK_INK = "#0b1220";
|
|
172
202
|
|
|
173
|
-
/**
|
|
203
|
+
/** Last-resort ink: the softer pair leaves a narrow mid-tone dead zone
|
|
204
|
+
* (plate luminance in the open interval (0.1833, 0.2024)) where neither clears AA; there pure black
|
|
205
|
+
* is the higher-contrast choice that can reach the floor. Outside that band
|
|
206
|
+
* the softer pair is SELECTED even though black would beat the near-black on
|
|
207
|
+
* raw ratio everywhere — black stays a floor escape, not the normal ink. */
|
|
208
|
+
const SOLID_NEUTRAL_INK = "#000";
|
|
209
|
+
|
|
210
|
+
/** WCAG AA floor for the fully-opaque cell label; the meta line and the
|
|
211
|
+
* italic unknown label keep their pre-existing 0.85 / 0.9 opacity (a
|
|
212
|
+
* deliberate de-emphasis, not part of this contract). */
|
|
213
|
+
const AA_CONTRAST = 4.5;
|
|
214
|
+
|
|
215
|
+
/** Relative luminances of the candidate inks (white is the reference 1). */
|
|
174
216
|
const SOLID_DARK_INK_LUMINANCE = relativeLuminance(parseHexColor(SOLID_DARK_INK)!);
|
|
175
217
|
const SOLID_LIGHT_INK_LUMINANCE = 1;
|
|
176
218
|
|
|
@@ -209,10 +251,13 @@ export function contrastTextColor(background: string, scrim = 0): string {
|
|
|
209
251
|
1,
|
|
210
252
|
];
|
|
211
253
|
const luminance = relativeLuminance(effective);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
254
|
+
const dark = contrastRatio(luminance, SOLID_DARK_INK_LUMINANCE);
|
|
255
|
+
const light = contrastRatio(luminance, SOLID_LIGHT_INK_LUMINANCE);
|
|
256
|
+
const best = Math.max(dark, light);
|
|
257
|
+
// Neither softer ink clears the floor -> pure black, which is strictly the
|
|
258
|
+
// higher-contrast choice inside that band (see SOLID_NEUTRAL_INK).
|
|
259
|
+
if (best < AA_CONTRAST) return SOLID_NEUTRAL_INK;
|
|
260
|
+
return dark >= light ? SOLID_DARK_INK : SOLID_LIGHT_INK;
|
|
216
261
|
}
|
|
217
262
|
|
|
218
263
|
export const HK_TREEMAP_DEFAULT_COLORS = [
|
|
@@ -433,7 +478,9 @@ export function createHkTreemapSummary(
|
|
|
433
478
|
*
|
|
434
479
|
* Cell ink is derived from each SOLID plate's own luminance (WCAG
|
|
435
480
|
* contrast), because a big opaque block is the background that matters;
|
|
436
|
-
* only a transparent/unparseable fill defers to the theme text token.
|
|
481
|
+
* only a transparent/unparseable fill defers to the theme text token. The
|
|
482
|
+
* mid-tone band where neither the white nor the family near-black clears AA
|
|
483
|
+
* falls back to pure black.
|
|
437
484
|
*/
|
|
438
485
|
export default defineComponent({
|
|
439
486
|
name: "HkTreemap",
|
|
@@ -40,6 +40,33 @@ function sourcesIn(dir: string): string[] {
|
|
|
40
40
|
return out;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
describe("overlay root accessibility contract (#1206)", () => {
|
|
44
|
+
it("creates a root that is NOT aria-hidden", () => {
|
|
45
|
+
// The root USED to be created wholesale aria-hidden (2026-11-10
|
|
46
|
+
// a11y review). Since HkModal / HkDrawer / HkToast / every floating
|
|
47
|
+
// layer teleport INTO this root, that hid the whole popup subtree
|
|
48
|
+
// from the accessibility tree — role="dialog", aria-modal,
|
|
49
|
+
// aria-label and aria-busy never reached AT. Decorative layers
|
|
50
|
+
// declare their own aria-hidden where needed (the modal blur
|
|
51
|
+
// plate); the root itself must stay out of the hiding business,
|
|
52
|
+
// exactly like hudRoot's documented contract.
|
|
53
|
+
const root = ensureOverlayRoot();
|
|
54
|
+
if (!root) throw new Error("overlay root was not created");
|
|
55
|
+
expect(root.id).toBe("hk-overlay-root");
|
|
56
|
+
expect(root.hasAttribute("aria-hidden")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps the root free of an aria-hidden writer", () => {
|
|
60
|
+
// Guard the fix at the source level too: the module must not grow a
|
|
61
|
+
// new setAttribute writer for the root's aria-hidden (the same
|
|
62
|
+
// regression the runtime assertion above catches, caught statically).
|
|
63
|
+
// The comment deliberately NAMES the attribute to explain its
|
|
64
|
+
// absence, so this matches the writer shape, not the word.
|
|
65
|
+
const source = readFileSync(resolve(overlayDir, "overlayRoot.ts"), "utf8");
|
|
66
|
+
expect(source).not.toMatch(/setAttribute\(["']aria-hidden/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
43
70
|
describe("overlay root document-space elevation contract", () => {
|
|
44
71
|
const css = compile(resolve(overlayDir, "overlayRoot.scss"), {
|
|
45
72
|
style: "expanded",
|
|
@@ -83,7 +83,16 @@ export function ensureOverlayRoot(): HTMLElement | null {
|
|
|
83
83
|
if (!root) {
|
|
84
84
|
root = document.createElement("div");
|
|
85
85
|
root.id = "hk-overlay-root";
|
|
86
|
-
|
|
86
|
+
// Deliberately NO aria-hidden here — the exact opposite of what the
|
|
87
|
+
// 2026-11-10 a11y review found (#1206): the root used to be created
|
|
88
|
+
// wholesale hidden, and since HkModal / HkDrawer / HkToast / every
|
|
89
|
+
// floating layer teleport INTO this root, the whole popup subtree was
|
|
90
|
+
// invisible to assistive tech (role="dialog", aria-modal, aria-label
|
|
91
|
+
// and aria-busy never reached the tree). Decorative layers declare
|
|
92
|
+
// their own aria-hidden where needed (the modal blur plate), and the
|
|
93
|
+
// root itself is a zero-size, pointer-transparent wrapper that has
|
|
94
|
+
// nothing to announce — matching hudRoot's contract.
|
|
95
|
+
root.setAttribute("data-hk-overlay-root", "");
|
|
87
96
|
// Inline styles — this element is created BEFORE any stylesheet
|
|
88
97
|
// loads, so it must be self-sufficient. `isolation` fences the
|
|
89
98
|
// root's internal z-index space from the document's global stacking
|
package/src/utils/dom.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { clearLeaveGeometry, pinLeaveGeometry } from "./dom";
|
|
4
4
|
|
|
@@ -168,3 +168,96 @@ describe("pinLeaveGeometry", () => {
|
|
|
168
168
|
expect(el.style.marginLeft).toBe("4px");
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* FOCUSABLE_SELECTOR contract (#1206): every element-type branch must
|
|
174
|
+
* exclude [tabindex="-1"].
|
|
175
|
+
*
|
|
176
|
+
* The generic `[tabindex]` branch always did; the named branches did not,
|
|
177
|
+
* so a `button[tabindex="-1"]` (HkNumberSteppers' hold-to-repeat buttons
|
|
178
|
+
* are exactly that) MATCHED and could become a container's last
|
|
179
|
+
* "focusable" element. `trapFocus` only wraps when the focused element is
|
|
180
|
+
* the first/last member of the set — with a non-tab-stop as `last`, a
|
|
181
|
+
* forward Tab from the previous input walked straight out of the dialog
|
|
182
|
+
* (chest's SMS policy window was the reporter; the selector's own doc
|
|
183
|
+
* comment already claimed "excluding tabindex=-1", the implementation
|
|
184
|
+
* just never delivered it for the named branches).
|
|
185
|
+
*
|
|
186
|
+
* These tests use REAL DOM nodes appended to the document: happy-dom
|
|
187
|
+
* implements matches()/focus() on connected nodes, and the trap's wrap
|
|
188
|
+
* semantics depend on document.activeElement.
|
|
189
|
+
*/
|
|
190
|
+
import { FOCUSABLE_SELECTOR, getFocusableElements, trapFocus } from "./dom";
|
|
191
|
+
|
|
192
|
+
describe("FOCUSABLE_SELECTOR excludes tabindex=-1 in every branch", () => {
|
|
193
|
+
let host: HTMLElement;
|
|
194
|
+
|
|
195
|
+
const tabFrom = (el: HTMLElement): boolean => {
|
|
196
|
+
el.focus();
|
|
197
|
+
const ev = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
|
198
|
+
trapFocus(host, ev);
|
|
199
|
+
return ev.defaultPrevented;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
host = document.createElement("div");
|
|
204
|
+
document.body.appendChild(host);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
afterEach(() => {
|
|
208
|
+
host.remove();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("excludes a button[tabindex=-1] that would otherwise be the trap's last", () => {
|
|
212
|
+
// The reporter's shape: switch/input, then a number field whose
|
|
213
|
+
// stepper column ends with tabindex=-1 buttons, then a disabled Save.
|
|
214
|
+
host.innerHTML = `
|
|
215
|
+
<input type="checkbox" data-name="switch" />
|
|
216
|
+
<input type="number" data-name="ttl" />
|
|
217
|
+
<button type="button" tabindex="-1" data-name="ttl-up"></button>
|
|
218
|
+
<button type="button" tabindex="-1" data-name="ttl-down"></button>
|
|
219
|
+
<input type="number" data-name="cap" />
|
|
220
|
+
<button type="button" tabindex="-1" data-name="cap-up"></button>
|
|
221
|
+
<button type="button" tabindex="-1" data-name="cap-down"></button>
|
|
222
|
+
`;
|
|
223
|
+
const focusable = getFocusableElements(host);
|
|
224
|
+
// The three real inputs; the four steppers are excluded.
|
|
225
|
+
expect(focusable).toHaveLength(3);
|
|
226
|
+
const last = focusable[focusable.length - 1]!;
|
|
227
|
+
expect(last.dataset.name).toBe("cap");
|
|
228
|
+
// The native tab order ends here too — so the trap's last IS the
|
|
229
|
+
// last real tab stop, and a forward Tab from `cap` wraps to first.
|
|
230
|
+
expect((last.nextElementSibling as HTMLElement).tabIndex).toBe(-1);
|
|
231
|
+
expect(tabFrom(last)).toBe(true);
|
|
232
|
+
expect((document.activeElement as HTMLElement).dataset.name).toBe("switch");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("excludes tabindex=-1 in the a[href], textarea and select branches too", () => {
|
|
236
|
+
host.innerHTML = `
|
|
237
|
+
<a href="#" tabindex="-1" data-name="link"></a>
|
|
238
|
+
<a href="#" data-name="link-ok"></a>
|
|
239
|
+
<textarea tabindex="-1" data-name="ta"></textarea>
|
|
240
|
+
<textarea data-name="ta-ok"></textarea>
|
|
241
|
+
<select tabindex="-1" data-name="sel"></select>
|
|
242
|
+
<select data-name="sel-ok"></select>
|
|
243
|
+
`;
|
|
244
|
+
const names = getFocusableElements(host).map((el) => el.dataset.name);
|
|
245
|
+
expect(names).toEqual(["link-ok", "ta-ok", "sel-ok"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("still counts explicit tabindex=0 as a real tab stop", () => {
|
|
249
|
+
host.innerHTML = `
|
|
250
|
+
<button type="button" data-name="first"></button>
|
|
251
|
+
<div tabindex="0" data-name="panel"></div>
|
|
252
|
+
`;
|
|
253
|
+
const names = getFocusableElements(host).map((el) => el.dataset.name);
|
|
254
|
+
expect(names).toEqual(["first", "panel"]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("falls back to an empty set when nothing is focusable (trap no-ops)", () => {
|
|
258
|
+
host.innerHTML = `<button type="button" tabindex="-1"></button>`;
|
|
259
|
+
expect(getFocusableElements(host)).toHaveLength(0);
|
|
260
|
+
// trapFocus with an empty set must not throw and must not move focus.
|
|
261
|
+
expect(tabFrom(document.activeElement as HTMLElement)).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
package/src/utils/dom.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elements a native Tab can actually reach.
|
|
3
|
+
*
|
|
4
|
+
* Every element-type branch excludes `[tabindex="-1"]` — the generic
|
|
5
|
+
* `[tabindex]` branch already did, but the named branches did not, so a
|
|
6
|
+
* `button[tabindex="-1"]` (HkNumberSteppers' hold-to-repeat buttons are
|
|
7
|
+
* exactly that) matched and could become a trap's `last`. `trapFocus`
|
|
8
|
+
* only wraps when the focused element IS the first/last member of this
|
|
9
|
+
* set; with a non-tab-stop as `last`, a forward Tab from the previous
|
|
10
|
+
* input walked straight out of the dialog (2026-11-10, #1206 — chest's
|
|
11
|
+
* SMS policy window was the reporter).
|
|
12
|
+
*/
|
|
1
13
|
export const FOCUSABLE_SELECTOR =
|
|
2
|
-
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
14
|
+
'a[href]:not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])';
|
|
3
15
|
|
|
4
16
|
/** All focusable elements inside a container (excluding tabindex=-1). */
|
|
5
17
|
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|