@celestia-island/hikari 0.55.335 → 0.55.336
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -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}
|
|
@@ -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[] {
|