@celestia-island/hikari 0.55.241 → 0.55.242
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/composables/useSidebarRail.test.ts +158 -1
- package/src/composables/useSidebarRail.ts +72 -4
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 —
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, defineComponent, h, ref, type Ref } from "vue";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type Ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import { __teardownMediaQueries } from "../runtime/useMediaQuery";
|
|
5
5
|
import {
|
|
6
6
|
useSidebarRail,
|
|
7
|
+
SIDEBAR_RAIL_CLIP_SELECTOR,
|
|
7
8
|
SIDEBAR_RAIL_PEEK_DELAY,
|
|
8
9
|
SIDEBAR_RAIL_PEEK_GRACE,
|
|
9
10
|
SIDEBAR_RAIL_PIN_FALLBACK,
|
|
11
|
+
SIDEBAR_RAIL_TOGGLE_BUTTON_SELECTOR,
|
|
10
12
|
type SidebarRailHandle,
|
|
11
13
|
} from "./useSidebarRail";
|
|
12
14
|
|
|
@@ -484,3 +486,158 @@ describe("useSidebarRail — pin window (expand must not replay the slide)", ()
|
|
|
484
486
|
}
|
|
485
487
|
});
|
|
486
488
|
});
|
|
489
|
+
describe("useSidebarRail — peek-close focus handoff (2026-10-31)", () => {
|
|
490
|
+
beforeEach(() => {
|
|
491
|
+
vi.useFakeTimers();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
/** The rail host DOM the handoff reasons about: the swapped content
|
|
495
|
+
* window (the clip) with a focused row inside, the straddling toggle
|
|
496
|
+
* OUTSIDE it (the handoff target), and panel chrome that is neither.
|
|
497
|
+
* Built from the EXPORTED selectors, so a drifted constant fails here
|
|
498
|
+
* (it would look for a target the real markup never renders). */
|
|
499
|
+
function focusFixture() {
|
|
500
|
+
// "…-toggle button" -> the wrapper's class name (drop the leading ".").
|
|
501
|
+
const toggleClass = SIDEBAR_RAIL_TOGGLE_BUTTON_SELECTOR.split(" ")[0]!.slice(1);
|
|
502
|
+
const host = document.createElement("div");
|
|
503
|
+
host.innerHTML = [
|
|
504
|
+
`<div class="${SIDEBAR_RAIL_CLIP_SELECTOR.slice(1)}"><button class="nav-row">nav</button></div>`,
|
|
505
|
+
`<div class="${toggleClass}"><button class="toggle-btn">toggle</button></div>`,
|
|
506
|
+
'<button class="panel-chrome">chrome</button>',
|
|
507
|
+
].join("");
|
|
508
|
+
document.body.appendChild(host);
|
|
509
|
+
const outside = document.createElement("button");
|
|
510
|
+
document.body.appendChild(outside);
|
|
511
|
+
return {
|
|
512
|
+
host,
|
|
513
|
+
row: host.querySelector<HTMLButtonElement>(".nav-row")!,
|
|
514
|
+
toggle: host.querySelector<HTMLButtonElement>(SIDEBAR_RAIL_TOGGLE_BUTTON_SELECTOR)!,
|
|
515
|
+
chrome: host.querySelector<HTMLButtonElement>(".panel-chrome")!,
|
|
516
|
+
outside,
|
|
517
|
+
cleanup: () => {
|
|
518
|
+
host.remove();
|
|
519
|
+
outside.remove();
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Open a peek on the fixture's host. */
|
|
525
|
+
function openPeek(f: ReturnType<typeof focusFixture>): SidebarRailHandle {
|
|
526
|
+
const rail = mountRail({ host: () => f.host });
|
|
527
|
+
rail.collapse();
|
|
528
|
+
rail.onPointerEnter();
|
|
529
|
+
vi.advanceTimersByTime(SIDEBAR_RAIL_PEEK_DELAY);
|
|
530
|
+
expect(rail.peeking.value).toBe(true);
|
|
531
|
+
return rail;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
it("hands focus to the toggle when the focused row is inside the swapped clip", async () => {
|
|
535
|
+
const f = focusFixture();
|
|
536
|
+
try {
|
|
537
|
+
const rail = openPeek(f);
|
|
538
|
+
f.row.focus();
|
|
539
|
+
expect(document.activeElement).toBe(f.row);
|
|
540
|
+
|
|
541
|
+
rail.closePeek();
|
|
542
|
+
await nextTick();
|
|
543
|
+
expect(
|
|
544
|
+
document.activeElement,
|
|
545
|
+
"the focused row is unmounted by the flip — focus must land on the toggle",
|
|
546
|
+
).toBe(f.toggle);
|
|
547
|
+
} finally {
|
|
548
|
+
f.cleanup();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("Escape closes through the same handoff (the keyboard path)", async () => {
|
|
553
|
+
const f = focusFixture();
|
|
554
|
+
try {
|
|
555
|
+
const rail = openPeek(f);
|
|
556
|
+
f.row.focus();
|
|
557
|
+
const consumed = rail.onKeydown(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
558
|
+
expect(consumed).toBe(true);
|
|
559
|
+
await nextTick();
|
|
560
|
+
expect(document.activeElement).toBe(f.toggle);
|
|
561
|
+
} finally {
|
|
562
|
+
f.cleanup();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("leaves focus alone when it lives OUTSIDE the swapped clip", async () => {
|
|
567
|
+
const f = focusFixture();
|
|
568
|
+
try {
|
|
569
|
+
const rail = openPeek(f);
|
|
570
|
+
|
|
571
|
+
// Panel chrome inside the host but outside the clip survives the
|
|
572
|
+
// flip — it keeps the focus it had.
|
|
573
|
+
f.chrome.focus();
|
|
574
|
+
rail.closePeek();
|
|
575
|
+
await nextTick();
|
|
576
|
+
expect(document.activeElement).toBe(f.chrome);
|
|
577
|
+
|
|
578
|
+
// Focus outside the host entirely is never touched either.
|
|
579
|
+
const rail2 = openPeek(f);
|
|
580
|
+
f.outside.focus();
|
|
581
|
+
rail2.closePeek();
|
|
582
|
+
await nextTick();
|
|
583
|
+
expect(document.activeElement).toBe(f.outside);
|
|
584
|
+
} finally {
|
|
585
|
+
f.cleanup();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("a NO-OP closePeek (no open peek) never steals focus", async () => {
|
|
590
|
+
const f = focusFixture();
|
|
591
|
+
try {
|
|
592
|
+
const rail = mountRail({ host: () => f.host });
|
|
593
|
+
f.row.focus();
|
|
594
|
+
rail.closePeek(); // expanded, nothing to close — a nav-click path
|
|
595
|
+
await nextTick();
|
|
596
|
+
expect(document.activeElement, "navigation focus stays where the user put it").toBe(f.row);
|
|
597
|
+
} finally {
|
|
598
|
+
f.cleanup();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("the pointerleave GRACE close hands off too (the most common close)", async () => {
|
|
603
|
+
// Regression pin for the 2026-10-31 review finding: the grace timer
|
|
604
|
+
// and the grace<=0 branch used to write `peeking` directly, bypassing
|
|
605
|
+
// the handoff — hover-peek, focus a header inside it, move the
|
|
606
|
+
// pointer away, and focus died with the leaving generation.
|
|
607
|
+
const f = focusFixture();
|
|
608
|
+
try {
|
|
609
|
+
const rail = openPeek(f);
|
|
610
|
+
f.row.focus();
|
|
611
|
+
|
|
612
|
+
rail.onPointerLeave();
|
|
613
|
+
vi.advanceTimersByTime(SIDEBAR_RAIL_PEEK_GRACE - 1);
|
|
614
|
+
expect(rail.peeking.value, "still inside the grace").toBe(true);
|
|
615
|
+
expect(document.activeElement, "focus untouched before the close").toBe(f.row);
|
|
616
|
+
|
|
617
|
+
vi.advanceTimersByTime(1);
|
|
618
|
+
expect(rail.peeking.value).toBe(false);
|
|
619
|
+
expect(document.activeElement, "the grace close hands focus to the toggle").toBe(f.toggle);
|
|
620
|
+
} finally {
|
|
621
|
+
f.cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("the grace<=0 branch (no grace configured) hands off as well", async () => {
|
|
626
|
+
const f = focusFixture();
|
|
627
|
+
try {
|
|
628
|
+
const rail = mountRail({ host: () => f.host, peekGrace: 0 });
|
|
629
|
+
rail.collapse();
|
|
630
|
+
rail.onPointerEnter();
|
|
631
|
+
vi.advanceTimersByTime(SIDEBAR_RAIL_PEEK_DELAY);
|
|
632
|
+
expect(rail.peeking.value).toBe(true);
|
|
633
|
+
f.row.focus();
|
|
634
|
+
|
|
635
|
+
rail.onPointerLeave();
|
|
636
|
+
expect(rail.peeking.value).toBe(false);
|
|
637
|
+
expect(document.activeElement).toBe(f.toggle);
|
|
638
|
+
} finally {
|
|
639
|
+
f.cleanup();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
@@ -115,7 +115,12 @@ export interface SidebarRailHandle {
|
|
|
115
115
|
* it would otherwise start a spurious grace close. Cancels a pending
|
|
116
116
|
* peek arm; arms the grace close while peeking. */
|
|
117
117
|
onPointerLeave(e?: { relatedTarget?: EventTarget | null } | null): void;
|
|
118
|
-
/** Close an open peek immediately (Escape, nav click, mode change).
|
|
118
|
+
/** Close an open peek immediately (Escape, nav click, mode change).
|
|
119
|
+
* A REAL close hands focus to the straddling toggle when the focused
|
|
120
|
+
* node lived inside the swapped content window (the rail/full handoff
|
|
121
|
+
* unmounts it and marks it inert; otherwise focus would fall to <body>
|
|
122
|
+
* and take the host's :focus-within reveal with it). A no-op close (no
|
|
123
|
+
* peek open) never touches focus. */
|
|
119
124
|
closePeek(): void;
|
|
120
125
|
/** Clear the pin window early — bind the host's width `transitionend`
|
|
121
126
|
* here (filter it to the host's own `width` transition first). */
|
|
@@ -130,6 +135,14 @@ export interface SidebarRailHandle {
|
|
|
130
135
|
export const SIDEBAR_RAIL_PEEK_DELAY = 400;
|
|
131
136
|
/** Default grace after pointerleave before the peek drawer closes. */
|
|
132
137
|
export const SIDEBAR_RAIL_PEEK_GRACE = 150;
|
|
138
|
+
/** Selector of the panel's content window — the clip the rail handoff
|
|
139
|
+
* (HkSidebarRailSwap) swaps its generations inside. Focus living here
|
|
140
|
+
* cannot survive a peek close: the density flip mounts the entering
|
|
141
|
+
* generation fresh and unmounts the focused copy a dissolve later. */
|
|
142
|
+
export const SIDEBAR_RAIL_CLIP_SELECTOR = ".hk-sidebar-rail-clip";
|
|
143
|
+
/** Selector of the straddling toggle's <button> — the focus target a
|
|
144
|
+
* peek close hands off to (the one control OUTSIDE the swap). */
|
|
145
|
+
export const SIDEBAR_RAIL_TOGGLE_BUTTON_SELECTOR = ".hk-sidebar-rail-toggle button";
|
|
133
146
|
/** Safety net for the pin window: hosts clear it on their width
|
|
134
147
|
* `transitionend`, but a transition that never fires (equal widths, a
|
|
135
148
|
* host without a width animation) must not strand `data-pinning`. */
|
|
@@ -234,12 +247,67 @@ export function useSidebarRail(options: SidebarRailOptions = {}): SidebarRailHan
|
|
|
234
247
|
arm();
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
|
|
250
|
+
/**
|
|
251
|
+
* A peek close swaps the panel's content (the rail/full handoff), so a
|
|
252
|
+
* control focused inside the clip is unmounted with the leaving
|
|
253
|
+
* generation — and the leaving generation is marked `inert` for the
|
|
254
|
+
* dissolve, which blurs a focused descendant. Focus would fall to
|
|
255
|
+
* <body>, taking the host's :focus-within reveal (the toggle's
|
|
256
|
+
* keyboard affordance) with it. Hand focus to the toggle: it is the one
|
|
257
|
+
* control outside the swap and stays mounted through the flip.
|
|
258
|
+
*
|
|
259
|
+
* Runs SYNCHRONOUSLY, before the flip lands — the focused node still
|
|
260
|
+
* exists at this moment, and "move focus before hiding" is the ARIA
|
|
261
|
+
* guidance. A host that renders the toggle conditionally on `peeking`
|
|
262
|
+
* would lose the target; no shipped host does that, and the panel
|
|
263
|
+
* chrome around the clip is exactly where the toggle lives.
|
|
264
|
+
*
|
|
265
|
+
* Guarded three ways: SSR (no document), focus outside the host (the
|
|
266
|
+
* caller's own control keeps it), and focus outside the CLIP (the
|
|
267
|
+
* panel chrome survives — nothing is being unmounted).
|
|
268
|
+
*/
|
|
269
|
+
function handoffFocusFromClip(): void {
|
|
270
|
+
if (typeof document === "undefined") return;
|
|
271
|
+
const host = hostEl();
|
|
272
|
+
const active = document.activeElement;
|
|
273
|
+
if (!host || !(active instanceof HTMLElement) || !host.contains(active)) return;
|
|
274
|
+
const clip = active.closest<HTMLElement>(SIDEBAR_RAIL_CLIP_SELECTOR);
|
|
275
|
+
if (!clip) return;
|
|
276
|
+
// The toggle is the clip's SIBLING inside the panel. Scope the lookup
|
|
277
|
+
// to that panel and skip toggles nested INSIDE a clip: a nested
|
|
278
|
+
// collapsible sidebar renders its own clip+toggle before the outer
|
|
279
|
+
// toggle in document order, and focusing that one would land inside
|
|
280
|
+
// the leaving (soon inert) generation — a silent no-op.
|
|
281
|
+
const panel = clip.parentElement ?? host;
|
|
282
|
+
for (const btn of panel.querySelectorAll<HTMLButtonElement>(
|
|
283
|
+
SIDEBAR_RAIL_TOGGLE_BUTTON_SELECTOR,
|
|
284
|
+
)) {
|
|
285
|
+
if (btn.closest(SIDEBAR_RAIL_CLIP_SELECTOR)) continue;
|
|
286
|
+
btn.focus();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* THE one writer that closes the peek. Every close path — Escape, a nav
|
|
293
|
+
* click, the pin path, the grace timer after pointerleave — funnels
|
|
294
|
+
* through here so the focus handoff can never be bypassed (the
|
|
295
|
+
* pointerleave close used to write `peeking` directly and dropped
|
|
296
|
+
* focus; 2026-10-31 review finding). Focus is handed off FIRST, while
|
|
297
|
+
* the focused node still exists.
|
|
298
|
+
*/
|
|
299
|
+
function closePeekNow(): void {
|
|
238
300
|
clearArm();
|
|
239
301
|
clearClose();
|
|
302
|
+
if (!peeking.value) return;
|
|
303
|
+
handoffFocusFromClip();
|
|
240
304
|
peeking.value = false;
|
|
241
305
|
}
|
|
242
306
|
|
|
307
|
+
function closePeek(): void {
|
|
308
|
+
closePeekNow();
|
|
309
|
+
}
|
|
310
|
+
|
|
243
311
|
function endPinning(): void {
|
|
244
312
|
clearPin();
|
|
245
313
|
pinning.value = false;
|
|
@@ -321,12 +389,12 @@ export function useSidebarRail(options: SidebarRailOptions = {}): SidebarRailHan
|
|
|
321
389
|
clearClose();
|
|
322
390
|
const grace = peekGrace();
|
|
323
391
|
if (grace <= 0) {
|
|
324
|
-
|
|
392
|
+
closePeekNow();
|
|
325
393
|
return;
|
|
326
394
|
}
|
|
327
395
|
closeTimer = setTimeout(() => {
|
|
328
396
|
closeTimer = null;
|
|
329
|
-
|
|
397
|
+
closePeekNow();
|
|
330
398
|
}, grace);
|
|
331
399
|
},
|
|
332
400
|
closePeek,
|