@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 +1 -1
- package/src/components/HkCrossfade.test.tsx +48 -0
- package/src/components/HkCrossfade.tsx +45 -3
- package/src/components/HkFaceShell.rail.test.tsx +37 -0
- package/src/components/HkSidebar.rail.test.tsx +36 -0
- package/src/components/HkSidebarRailSwap.tsx +11 -5
- package/src/components/HkSolarLocationSetting.scss +91 -0
- package/src/components/HkSolarLocationSetting.test.tsx +379 -0
- package/src/components/HkSolarLocationSetting.tsx +267 -0
- package/src/composables/useSidebarRail.test.ts +158 -1
- package/src/composables/useSidebarRail.ts +72 -4
- package/src/i18n/locales/ar/platform.json +14 -0
- package/src/i18n/locales/de/platform.json +14 -0
- package/src/i18n/locales/en/platform.json +14 -0
- package/src/i18n/locales/es/platform.json +14 -0
- package/src/i18n/locales/fr/platform.json +14 -0
- package/src/i18n/locales/ja/platform.json +14 -0
- package/src/i18n/locales/ko/platform.json +14 -0
- package/src/i18n/locales/pt/platform.json +14 -0
- package/src/i18n/locales/ru/platform.json +14 -0
- package/src/i18n/locales/zh-Hans/platform.json +14 -0
- package/src/i18n/locales/zh-Hant/platform.json +14 -0
- package/src/index.ts +8 -0
- package/src/theme/index.ts +12 -2
- package/src/theme/useSolarTime.test.ts +295 -4
- package/src/theme/useSolarTime.ts +273 -27
- package/src/theme/useTheme.test.ts +19 -0
- package/src/theme/useTheme.ts +15 -1
package/package.json
CHANGED
|
@@ -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={() => {
|
|
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={
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
+
}
|