@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.241",
3
+ "version": "0.55.242",
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 —
@@ -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
- function closePeek(): void {
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
- peeking.value = false;
392
+ closePeekNow();
325
393
  return;
326
394
  }
327
395
  closeTimer = setTimeout(() => {
328
396
  closeTimer = null;
329
- peeking.value = false;
397
+ closePeekNow();
330
398
  }, grace);
331
399
  },
332
400
  closePeek,