@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.335",
3
+ "version": "0.55.336",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
- root.setAttribute("aria-hidden", "true");
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
@@ -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[] {