@celestia-island/hikari 0.55.241 → 0.55.243

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.241",
3
+ "version": "0.55.243",
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",
@@ -139,6 +139,54 @@ describe("HkCrossfade — DOM contract", () => {
139
139
  expect(list[1]!.classList.contains("hk-crossfade-enter-from")).toBe(true);
140
140
  });
141
141
 
142
+ it("marks the leaving copy INERT for the dissolve (a11y: dead content)", async () => {
143
+ // pointer-events: none only stops the mouse — the frozen copy must
144
+ // also leave the tab order and the accessibility tree, or a screen
145
+ // reader walks a second, stale copy (duplicate landmarks/roles; in
146
+ // the rail swap it is a whole second sidebar). A cancelled leave has
147
+ // to restore the item (see the next case).
148
+ vi.useFakeTimers();
149
+ freezeRaf();
150
+ const { container, set } = mountCrossfade();
151
+ await set("b");
152
+ await vi.advanceTimersByTimeAsync(200);
153
+
154
+ const list = items(container);
155
+ expect(list[0]!.hasAttribute("inert"), "the leaving copy is inert").toBe(true);
156
+ expect(list[0]!.getAttribute("aria-hidden")).toBe("true");
157
+ expect(list[1]!.hasAttribute("inert"), "the live copy stays interactive").toBe(false);
158
+ expect(list[1]!.hasAttribute("aria-hidden")).toBe(false);
159
+ });
160
+
161
+ it("a flip-back never leaves a LIVE copy inert (Vue removes the leaving node)", async () => {
162
+ // Pins the behavior the inert contract relies on: on a keyed
163
+ // flip-back Vue cancels the in-flight leave of the old element
164
+ // through its internal callback (no element argument -> routed to
165
+ // afterLeave, which REMOVES it) and re-enters the key as a FRESH
166
+ // element. So no live copy can carry inert; if Vue ever changed that
167
+ // shape (a reused, re-entered element), this test is the tripwire.
168
+ vi.useFakeTimers();
169
+ freezeRaf();
170
+ const { container, set } = mountCrossfade();
171
+ await set("b");
172
+ await vi.advanceTimersByTimeAsync(100);
173
+ const oldA = items(container).find((el) => el.dataset.crossfadeKey === "a")!;
174
+ expect(oldA.hasAttribute("inert")).toBe(true);
175
+
176
+ await set("a");
177
+ await vi.advanceTimersByTimeAsync(100);
178
+
179
+ // The inert old node is gone (not left mounted), and every live node
180
+ // for the key renders un-inert.
181
+ expect(oldA.isConnected, "the inert leaving node is removed").toBe(false);
182
+ const live = items(container).filter((el) => el.dataset.crossfadeKey === "a");
183
+ expect(live.length).toBeGreaterThan(0);
184
+ for (const el of live) {
185
+ expect(el.hasAttribute("inert"), "a live copy must never be inert").toBe(false);
186
+ expect(el.getAttribute("aria-hidden")).toBeNull();
187
+ }
188
+ });
189
+
142
190
  it("watchdog completes a stalled swap: sweeps frozen classes, drops the stale item", async () => {
143
191
  vi.useFakeTimers();
144
192
  freezeRaf();
@@ -40,7 +40,16 @@ const REPORT_MS = CROSSFADE_WATCHDOG_MS;
40
40
  * opacity ahead of the outgoing one's decay and stops the composite from
41
41
  * dipping toward the background mid-swap.
42
42
  * - The leaving item drops pointer events: it is dead content and must not
43
- * intercept clicks while it dissolves under the incoming one.
43
+ * intercept clicks while it dissolves under the incoming one. It is also
44
+ * marked `inert` + `aria-hidden` for the dissolve (2026-10-31): a frozen
45
+ * copy must leave the TAB ORDER and the accessibility tree too, or a
46
+ * screen reader walks a stale duplicate (in the rail swap it is a whole
47
+ * second sidebar). TRADE-OFF: a control focused in the leaving item is
48
+ * blurred by the browser the moment the attribute lands, `--duration`
49
+ * earlier than the unmount would have; hosts that care move focus FIRST
50
+ * (the rail kit's closePeek does exactly that). happy-dom applies no
51
+ * inert focus fixup, so that browser behavior is intentionally not
52
+ * covered by this suite.
44
53
  * - `appear` dissolves the initially rendered item in; off by default —
45
54
  * first paint should never animate (view shells, restored state).
46
55
  * - `disabled` renders the active item bare (synchronous swap, no watchdog):
@@ -115,6 +124,25 @@ export default defineComponent({
115
124
  if (pending === 0) report.cancel();
116
125
  };
117
126
 
127
+ /** Mark a leaving item inert (out of the tab order and the a11y tree)
128
+ * for the dissolve. Attribute-based (not the `inert` IDL property) so
129
+ * the DOM contract is observable in tests and SSR-less hosts alike.
130
+ * The restore is defence: on a keyed flip-back Vue removes the
131
+ * leaving node and re-enters a FRESH element (its internal
132
+ * cancel-callback runs with no argument and routes to afterLeave), so
133
+ * nothing live is ever left inert by that route — but any shape that
134
+ * DOES cancel a leave must not keep a live copy inert. */
135
+ function setLeavingInert(el: Element | null | undefined, on: boolean): void {
136
+ if (!el) return;
137
+ if (on) {
138
+ el.setAttribute("inert", "");
139
+ el.setAttribute("aria-hidden", "true");
140
+ return;
141
+ }
142
+ el.removeAttribute("inert");
143
+ el.removeAttribute("aria-hidden");
144
+ }
145
+
118
146
  // ── Swap-stall watchdog ──────────────────────────────────────────
119
147
  /** Budget for one arm: the base sweep window plus the choreographed
120
148
  * enter hold — a delayed enter is mid-flight, not stalled, when the
@@ -211,9 +239,23 @@ export default defineComponent({
211
239
  onBeforeEnter={() => { armReport(); armWatchdog(); }}
212
240
  onAfterEnter={settleReport}
213
241
  onEnterCancelled={settleReport}
214
- onBeforeLeave={() => { armReport(); armWatchdog(); }}
242
+ onBeforeLeave={(el: Element) => {
243
+ // DEAD CONTENT for the dissolve: drop it from the tab order
244
+ // AND the accessibility tree. pointer-events: none (the
245
+ // leave rule) only stops the mouse — a frozen copy otherwise
246
+ // stays reachable by Tab and by a screen reader's virtual
247
+ // cursor for the whole dissolve, and in the rail swap it is
248
+ // a second full sidebar (duplicate landmark/roles, stale
249
+ // controls) while the live one dissolves in.
250
+ setLeavingInert(el, true);
251
+ armReport();
252
+ armWatchdog();
253
+ }}
215
254
  onAfterLeave={settleReport}
216
- onLeaveCancelled={settleReport}
255
+ onLeaveCancelled={(el: Element) => {
256
+ setLeavingInert(el, false);
257
+ settleReport();
258
+ }}
217
259
  >
218
260
  {item()}
219
261
  </Transition>
@@ -475,9 +475,46 @@ describe("HkFaceShell rail chrome: toggle placement and the clip wrapper", () =>
475
475
  // marker reads rail while the DENSITY lives on the generation.
476
476
  expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
477
477
  expect(c.aside().hasAttribute("data-rail"), "the root keeps the state marker").toBe(true);
478
+ // The leaving generation's ITEM is inert for the dissolve (dead
479
+ // content leaves the tab order and the a11y tree); the live one is
480
+ // not.
481
+ const leavingItem = mid[0]!.parentElement!;
482
+ const enteringItem = mid[1]!.parentElement!;
483
+ expect(leavingItem.hasAttribute("inert"), "the dying copy is inert").toBe(true);
484
+ expect(leavingItem.getAttribute("aria-hidden")).toBe("true");
485
+ expect(enteringItem.hasAttribute("inert"), "the live copy stays interactive").toBe(false);
478
486
  vi.unstubAllGlobals();
479
487
  });
480
488
 
489
+ it("hands focus to the toggle when a peek close unmounts the focused row", async () => {
490
+ // The rail/full swap mounts the entering generation fresh and marks
491
+ // the leaving one inert, so a focused row inside the clip dies with
492
+ // it. useSidebarRail's close paths hand focus to the toggle — this is
493
+ // the SHELL half of that contract (the HkSidebar suite pins the other
494
+ // host; different host ref, different toggle placement).
495
+ vi.useFakeTimers();
496
+ const c = mountShell({ sidebarCollapsible: true }, () =>
497
+ h("div", { class: "sidebar-mock" }, h("button", { class: "nav-btn" }, "NAV")),
498
+ );
499
+ c.toggle().click();
500
+ await vi.advanceTimersByTimeAsync(0);
501
+ c.aside().dispatchEvent(POINTER_ENTER());
502
+ await vi.advanceTimersByTimeAsync(400);
503
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
504
+
505
+ // The ACTIVE (entering) generation's row: the leaving copy is inert
506
+ // and (correctly) refuses focus.
507
+ const gens = c.panel().querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation");
508
+ const row = gens[gens.length - 1]!.querySelector<HTMLButtonElement>("button.nav-btn")!;
509
+ row.focus();
510
+ expect(document.activeElement).toBe(row);
511
+
512
+ c.aside().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
513
+ await vi.advanceTimersByTimeAsync(0);
514
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
515
+ expect(document.activeElement, "focus moved to the toggle").toBe(c.toggle());
516
+ });
517
+
481
518
  it("does not touch the legacy sidebar path (no clip wrapper, no rail classes)", () => {
482
519
  const c = mountShell();
483
520
  expect(c.container.querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
@@ -242,6 +242,11 @@ describe("HkSidebar rail chrome: toggle placement and the clip wrapper", () => {
242
242
  expect(entering!.style.width).toBe("64px");
243
243
  // The band content lives inside the entering generation.
244
244
  expect(entering!.querySelector(".hk-sidebar-body")).toBeTruthy();
245
+ // The leaving generation's ITEM is inert for the dissolve (dead
246
+ // content leaves the tab order and the a11y tree); the live one is not.
247
+ expect(leaving!.parentElement!.hasAttribute("inert"), "the dying copy is inert").toBe(true);
248
+ expect(leaving!.parentElement!.getAttribute("aria-hidden")).toBe("true");
249
+ expect(entering!.parentElement!.hasAttribute("inert")).toBe(false);
245
250
  vi.unstubAllGlobals();
246
251
  });
247
252
 
@@ -318,6 +323,37 @@ describe("HkSidebar pin window: expand must not replay the slide", () => {
318
323
  });
319
324
  });
320
325
 
326
+ describe("HkSidebar peek close: focus is handed back to the toggle", () => {
327
+ beforeEach(() => {
328
+ vi.useFakeTimers();
329
+ });
330
+
331
+ it("Escape from a focused row inside the swapped clip lands focus on the toggle", async () => {
332
+ // The peek close flips the density, which MOUNTS the entering
333
+ // generation fresh — the focused row is unmounted a dissolve later.
334
+ // Without the handoff (useSidebarRail.closePeek) focus would fall to
335
+ // <body> and take the host's :focus-within reveal with it.
336
+ const c = mountSidebar({ collapsible: true });
337
+ c.toggle().click();
338
+ await vi.advanceTimersByTimeAsync(0);
339
+
340
+ // The slot's faux nav row lives INSIDE the swapped clip.
341
+ const row = c.container.querySelector<HTMLButtonElement>("nav button")!;
342
+ row.focus();
343
+ expect(document.activeElement).toBe(row);
344
+
345
+ c.aside().dispatchEvent(new Event("pointerenter"));
346
+ await vi.advanceTimersByTimeAsync(400);
347
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
348
+
349
+ c.aside().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
350
+ await vi.advanceTimersByTimeAsync(0);
351
+ await vi.advanceTimersByTimeAsync(0);
352
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
353
+ expect(document.activeElement, "focus moved to the toggle").toBe(c.toggle());
354
+ });
355
+ });
356
+
321
357
  describe("HkSidebar hover suppression: a pointer on the toggle never peeks", () => {
322
358
  beforeEach(() => {
323
359
  vi.useFakeTimers();
@@ -65,11 +65,17 @@ import "./HkSidebarRail.scss";
65
65
  * inside the slot. A peek OPEN also hands hover to the full row under
66
66
  * the resting pointer without user intent — inherent to any dissolve.
67
67
  * - FOCUS: when the leaving generation unmounts at the end of the
68
- * dissolve, focus inside it (a nav row focused in an open peek)
69
- * drops to <body> — the sharpest edge is Escape-closing a peek from
70
- * a focused row, which also drops the host's :focus-within (the
71
- * toggle's keyboard reveal) until the next Tab. Hosts that need a
72
- * softer landing can refocus the toggle from the peek-closer path.
68
+ * dissolve (and the leaving item is marked inert, which blurs a
69
+ * focused descendant), focus inside it would drop to <body> — taking
70
+ * the host's :focus-within (the toggle's keyboard reveal) with it. The
71
+ * KIT closes this for rail hosts: useSidebarRail.closePeek (and every
72
+ * path that funnels through it, including the pointerleave grace close)
73
+ * hands focus to the toggle when the focused node lived inside the
74
+ * swapped window (2026-10-31). A bespoke host that drives its own
75
+ * close paths still owns the handoff.
76
+ * - The leaving generation also carries `inert` + `aria-hidden` for the
77
+ * dissolve (HkCrossfade), so the frozen copy is neither tabbable nor
78
+ * announced while the live one fades in.
73
79
  *
74
80
  * Reduced motion: HkCrossfade keeps a short opacity exchange (a fade
75
81
  * carries no movement) while the kit's sheets kill the width slides —
@@ -0,0 +1,91 @@
1
+ /* HkSolarLocationSetting — the solar-clock location strategy row and
2
+ * its editor modal body (see the component docblock for the strategy
3
+ * ladder). AI-generated: developed with the ZCode agent (model
4
+ * GLM-5.3); human-directed. */
5
+
6
+ /* ── The settings-page row: live summary + Edit button ────────────────── */
7
+ .hk-solar-setting {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--space-8, 0.5rem);
11
+ min-width: 0;
12
+ }
13
+
14
+ .hk-solar-summary {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: var(--space-6, 0.375rem);
18
+ min-width: 0;
19
+ flex: 1 1 auto;
20
+ color: rgb(var(--color-text-secondary, var(--color-muted)));
21
+ font-size: var(--text-sm);
22
+ }
23
+
24
+ .hk-solar-summary-icon {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ flex-shrink: 0;
29
+ color: rgb(var(--color-muted));
30
+ }
31
+
32
+ /* Zone ids ("America/Argentina/Buenos_Aires") can outrun narrow side
33
+ * columns — the summary clips at the ellipsis, never pushes the Edit
34
+ * button off the row. */
35
+ .hk-solar-summary-text {
36
+ min-width: 0;
37
+ overflow: hidden;
38
+ text-overflow: ellipsis;
39
+ white-space: nowrap;
40
+ }
41
+
42
+ .hk-solar-edit {
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ /* ── Editor body (inside the modal) ───────────────────────────────────── */
47
+ .hk-solar-editor {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: var(--space-12, 0.75rem);
51
+ }
52
+
53
+ /* Per-strategy explanation under the segmented strip — muted caption
54
+ * prose, same tier as the select's field label. */
55
+ .hk-solar-hint {
56
+ margin: 0;
57
+ color: rgb(var(--color-muted));
58
+ font-size: var(--text-xs);
59
+ line-height: 1.5;
60
+ }
61
+
62
+ .hk-solar-manual {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--space-12, 0.75rem);
66
+ }
67
+
68
+ .hk-solar-hemisphere {
69
+ display: flex;
70
+ flex-direction: column;
71
+ }
72
+
73
+ /* Field label for the hemisphere group — the SAME metrics as
74
+ * .hk-select-label so the two manual fields read as one form. */
75
+ .hk-solar-field-label {
76
+ display: block;
77
+ margin-bottom: var(--space-4, 0.25rem);
78
+ font-size: var(--text-xs);
79
+ font-weight: 600;
80
+ letter-spacing: 0.02em;
81
+ color: rgb(var(--color-text) / var(--hk-field-label-alpha, 72%));
82
+ }
83
+
84
+ /* Resolved-coordinates readout (manual pane only) — a quiet trace of
85
+ * what the engine will actually feed the sun clock. */
86
+ .hk-solar-resolved {
87
+ margin: 0;
88
+ color: rgb(var(--color-muted));
89
+ font-size: var(--text-xs);
90
+ font-variant-numeric: tabular-nums;
91
+ }