@takazudo/zudo-doc 9.0.0 → 9.1.0

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/dist/wind.json CHANGED
@@ -67,6 +67,7 @@
67
67
  "col-span-full",
68
68
  "col-start-1",
69
69
  "contents",
70
+ "cursor-default",
70
71
  "cursor-not-allowed",
71
72
  "cursor-pointer",
72
73
  "disabled:cursor-default",
@@ -91,7 +92,6 @@
91
92
  "focus-visible:outline-accent",
92
93
  "focus-visible:outline-offset-2",
93
94
  "focus-visible:text-accent",
94
- "focus-visible:text-fg",
95
95
  "focus-visible:underline",
96
96
  "focus-within:border-accent",
97
97
  "focus-within:z-local-1",
@@ -147,9 +147,11 @@
147
147
  "h-[0.5rem]",
148
148
  "h-[0.625rem]",
149
149
  "h-[0.875rem]",
150
+ "h-[1.0625rem]",
150
151
  "h-[1.125rem]",
151
152
  "h-[1.25rem]",
152
153
  "h-[1.575rem]",
154
+ "h-[1.875rem]",
153
155
  "h-[10rem]",
154
156
  "h-[14px]",
155
157
  "h-[1em]",
@@ -290,6 +292,7 @@
290
292
  "my-vsp-md",
291
293
  "no-underline",
292
294
  "object-contain",
295
+ "opacity-50",
293
296
  "opacity-60",
294
297
  "outline-none",
295
298
  "overflow-auto",
@@ -455,11 +458,13 @@
455
458
  "w-[0.5rem]",
456
459
  "w-[0.625rem]",
457
460
  "w-[0.875rem]",
461
+ "w-[1.0625rem]",
458
462
  "w-[1.125rem]",
459
463
  "w-[1.25rem]",
460
464
  "w-[1.575rem]",
461
465
  "w-[1.5rem]",
462
466
  "w-[1.75rem]",
467
+ "w-[1.875rem]",
463
468
  "w-[12rem]",
464
469
  "w-[14px]",
465
470
  "w-[16px]",
@@ -1,7 +1,7 @@
1
1
  import type { PanelConfig, PanelInstanceHandle } from "@takazudo/zdtp";
2
2
  export * from "@takazudo/zdtp";
3
3
  /**
4
- * Mount zdtp's opaque Preact root only while the activating island still owns
4
+ * Mount zdtp's opaque panel root only while the activating island still owns
5
5
  * the pending import. Once configured, zdtp keeps its document-lifetime root
6
6
  * and its existing navigation cleanup semantics.
7
7
  */
@@ -28,9 +28,9 @@
28
28
  // SOURCE, strips TypeScript types deterministically via esbuild's
29
29
  // `transformSync()` (same rationale as gen-search-widget-script.mjs — see
30
30
  // that file's header comment for the full `ts.transpileModule()` vs esbuild
31
- // history, zudolab/zudo-doc#3422 / #3430 — identical here: esbuild is only a
31
+ // history, zudolab/zudo-doc#3422 / #3430 — identical here: esbuild is a
32
32
  // package dependency (it is also used by the shipped ejected-header generator),
33
- // and the effective floor tracks the root `pnpm.overrides` range), executes
33
+ // and its range matches the root `pnpm.overrides` entry), executes
34
34
  // each transpiled CommonJS module in an isolated sandbox (empty
35
35
  // `module`/`exports`; all four source files are import-free), then reads
36
36
  // the REAL runtime values off each sandbox's `exports`:
@@ -1,5 +1,5 @@
1
1
  /** @jsxRuntime automatic */
2
- // Server-rendered, zero-hydration content loading overlay.
2
+ // Server-rendered, zero-hydration full-viewport loading overlay.
3
3
  //
4
4
  // Renders the overlay and its bootstrap into the document:
5
5
  //
@@ -53,7 +53,7 @@ export function buildPageLoadingOverlayBootstrap(overlayId: string): string {
53
53
  const after = JSON.stringify(AFTER_NAVIGATE_EVENT);
54
54
  return `(function(){
55
55
  var id=${id};
56
- function show(){var el=document.getElementById(id);if(!el)return;var content=document.querySelector(".zd-sidebar-content-wrapper");var header=document.querySelector("body > header");el.style.left=Math.max(0,content?content.getBoundingClientRect().left:0)+"px";el.style.top=Math.max(0,header?header.getBoundingClientRect().bottom:0)+"px";el.setAttribute("data-visible","");el.setAttribute("aria-hidden","false");}
56
+ function show(){var el=document.getElementById(id);if(!el)return;el.setAttribute("data-visible","");el.setAttribute("aria-hidden","false");}
57
57
  function hide(){var el=document.getElementById(id);if(!el)return;el.removeAttribute("data-visible");el.setAttribute("aria-hidden","true");}
58
58
  function setPending(ev){document.querySelectorAll("[data-zd-nav-pending]").forEach(function(el){el.removeAttribute("data-zd-nav-pending");});var src=ev&&ev.sourceElement;if(src&&src instanceof Element)src.setAttribute("data-zd-nav-pending","");}
59
59
  function clearPending(){document.querySelectorAll("[data-zd-nav-pending]").forEach(function(el){el.removeAttribute("data-zd-nav-pending");});}
@@ -66,7 +66,11 @@ document.addEventListener(${JSON.stringify(NAVIGATION_ABORTED_EVENT)},function()
66
66
  }
67
67
 
68
68
  /**
69
- * Content loading overlay shown while a dynamic navigation is pending.
69
+ * Full-viewport loading overlay shown while a dynamic navigation is pending.
70
+ *
71
+ * Geometry belongs to `page-loading.css` (`position: fixed; inset: 0`); the
72
+ * bootstrap only toggles visibility. Header and sidebar stay stationary through
73
+ * their own named View Transition snapshots, not by clipping the scrim.
70
74
  *
71
75
  * Mount this once per layout (typically inside `DocLayoutWithDefaults`'s
72
76
  * `bodyEnd` slot, alongside the existing body-end providers). It is
@@ -0,0 +1,247 @@
1
+ import { computed, signal, type ReadonlySignal, type Ref } from "@takazudo/zfb/zudo-react";
2
+
3
+ /** Trigger attribute: the tooltip's title text. */
4
+ export const TIP_ATTR = "data-sidebar-tip";
5
+ /** Trigger attribute: optional secondary tooltip line. */
6
+ export const TIP_DETAIL_ATTR = "data-sidebar-tip-detail";
7
+
8
+ const TRIGGER_SELECTOR = `[${TIP_ATTR}]`;
9
+ const FRAME_SELECTOR = "[data-sidebar-focus]";
10
+ const POINTER_SHOW_DELAY_MS = 250;
11
+ // Long enough to cross the gap from the trigger onto the tooltip.
12
+ const HIDE_DELAY_MS = 120;
13
+ const GAP_PX = 6;
14
+ const VIEWPORT_MARGIN_PX = 8;
15
+
16
+ let instanceCount = 0;
17
+
18
+ export interface BranchPreview {
19
+ /** Occurrence previewed in this sidebar instance, or null. Never persisted or broadcast. */
20
+ active: ReadonlySignal<string | null>;
21
+ clear: () => void;
22
+ }
23
+
24
+ /** Hover preview is mouse/pen only so a touch tap focuses in one step and leaves nothing behind. */
25
+ const isHoverPointer = (event: Event) => {
26
+ const type = (event as PointerEvent).pointerType;
27
+ return type === "mouse" || type === "pen";
28
+ };
29
+
30
+ const isFocusVisible = (element: Element) => {
31
+ try {
32
+ return element.matches(":focus-visible");
33
+ } catch {
34
+ return true;
35
+ }
36
+ };
37
+
38
+ const isActionableFrame = (element: Element) =>
39
+ element.matches(FRAME_SELECTOR) && element.getAttribute("aria-disabled") !== "true";
40
+
41
+ const previewKey = (element: Element) =>
42
+ isActionableFrame(element) ? (element as HTMLElement).dataset.sidebarFocusScope || null : null;
43
+
44
+ /**
45
+ * Transient branch preview + tooltip for one sidebar island instance.
46
+ * Call `activate()` from the island's `onActivate`; it returns the cleanup.
47
+ * The desktop tree and the mobile drawer are separate instances, so each
48
+ * visible sidebar owns at most one preview and one tooltip.
49
+ */
50
+ export function createBranchPreview(navRef: Ref<HTMLElement>): BranchPreview & { activate: () => () => void } {
51
+ const pointerKey = signal<string | null>(null);
52
+ const focusKey = signal<string | null>(null);
53
+ // Pointer hover over an actionable frame wins over the keyboard-focused one.
54
+ const active = computed(() => pointerKey.value ?? focusKey.value);
55
+ let tooltip: HTMLElement | null = null;
56
+ let owner: HTMLElement | null = null;
57
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
58
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
59
+
60
+ const hideTooltip = () => {
61
+ clearTimeout(showTimer);
62
+ clearTimeout(hideTimer);
63
+ if (owner?.getAttribute("aria-describedby") === tooltip?.id) owner?.removeAttribute("aria-describedby");
64
+ owner = null;
65
+ if (tooltip) tooltip.hidden = true;
66
+ };
67
+
68
+ const clear = () => {
69
+ pointerKey.value = null;
70
+ focusKey.value = null;
71
+ hideTooltip();
72
+ };
73
+
74
+ const place = (trigger: HTMLElement, tip: HTMLElement) => {
75
+ // Measure from the origin: a stale right-side `left` would shrink-wrap the
76
+ // fixed tooltip to the remaining viewport width.
77
+ tip.style.left = "0px";
78
+ tip.style.top = "0px";
79
+ const anchor = trigger.getBoundingClientRect();
80
+ const box = tip.getBoundingClientRect();
81
+ const maxLeft = window.innerWidth - box.width - VIEWPORT_MARGIN_PX;
82
+ // Start past the sidebar's edge so the hoverable tooltip never covers the
83
+ // row's disclosure, which sits right beside the frame.
84
+ const edge = Math.max(anchor.right, navRef.current?.getBoundingClientRect().right ?? anchor.right);
85
+ let left = edge + GAP_PX;
86
+ let top = anchor.top + anchor.height / 2 - box.height / 2;
87
+ // No room beside the trigger (narrow drawer): drop below it instead.
88
+ if (left > maxLeft) {
89
+ left = Math.max(VIEWPORT_MARGIN_PX, Math.min(anchor.left + anchor.width / 2 - box.width / 2, maxLeft));
90
+ top = anchor.bottom + GAP_PX;
91
+ if (top + box.height > window.innerHeight - VIEWPORT_MARGIN_PX) top = anchor.top - GAP_PX - box.height;
92
+ }
93
+ top = Math.max(VIEWPORT_MARGIN_PX, Math.min(top, window.innerHeight - box.height - VIEWPORT_MARGIN_PX));
94
+ tip.style.left = `${Math.round(left)}px`;
95
+ tip.style.top = `${Math.round(top)}px`;
96
+ };
97
+
98
+ const showTooltip = (trigger: HTMLElement) => {
99
+ if (!tooltip) return;
100
+ clearTimeout(hideTimer);
101
+ if (owner && owner !== trigger && owner.getAttribute("aria-describedby") === tooltip.id) owner.removeAttribute("aria-describedby");
102
+ owner = trigger;
103
+ const title = document.createElement("strong");
104
+ title.textContent = trigger.getAttribute(TIP_ATTR) ?? "";
105
+ const parts: Node[] = [title];
106
+ const detail = trigger.getAttribute(TIP_DETAIL_ATTR);
107
+ if (detail) {
108
+ const line = document.createElement("span");
109
+ line.textContent = detail;
110
+ parts.push(line);
111
+ }
112
+ tooltip.replaceChildren(...parts);
113
+ tooltip.hidden = false;
114
+ trigger.setAttribute("aria-describedby", tooltip.id);
115
+ place(trigger, tooltip);
116
+ };
117
+
118
+ const scheduleHide = () => {
119
+ clearTimeout(showTimer);
120
+ clearTimeout(hideTimer);
121
+ hideTimer = setTimeout(hideTooltip, HIDE_DELAY_MS);
122
+ };
123
+
124
+ const triggerOf = (target: EventTarget | null) => {
125
+ const nav = navRef.current;
126
+ if (!nav || !(target instanceof Element)) return null;
127
+ const trigger = target.closest<HTMLElement>(TRIGGER_SELECTOR);
128
+ return trigger && nav.contains(trigger) ? trigger : null;
129
+ };
130
+
131
+ const activate = () => {
132
+ const tip = document.createElement("div");
133
+ tip.id = `zd-sidebar-tip-${++instanceCount}`;
134
+ tip.setAttribute("role", "tooltip");
135
+ tip.setAttribute("data-sidebar-tooltip", "");
136
+ tip.hidden = true;
137
+ document.body.append(tip);
138
+ tooltip = tip;
139
+
140
+ const onPointerOver = (event: Event) => {
141
+ const trigger = triggerOf(event.target);
142
+ if (!trigger || !isHoverPointer(event) || trigger.contains((event as PointerEvent).relatedTarget as Node | null)) return;
143
+ pointerKey.value = previewKey(trigger);
144
+ clearTimeout(hideTimer);
145
+ clearTimeout(showTimer);
146
+ if (owner === trigger && !tip.hidden) return;
147
+ showTimer = setTimeout(() => showTooltip(trigger), POINTER_SHOW_DELAY_MS);
148
+ };
149
+ const onPointerOut = (event: Event) => {
150
+ const trigger = triggerOf(event.target);
151
+ const next = (event as PointerEvent).relatedTarget as Node | null;
152
+ if (!trigger || trigger.contains(next)) return;
153
+ pointerKey.value = null;
154
+ // Moving onto the tooltip keeps it open (hoverable content).
155
+ if (next && tip.contains(next)) return;
156
+ if (owner === trigger && trigger === document.activeElement && isFocusVisible(trigger)) return;
157
+ scheduleHide();
158
+ };
159
+ const onFocusIn = (event: Event) => {
160
+ const trigger = triggerOf(event.target);
161
+ if (!trigger || !isFocusVisible(trigger)) return;
162
+ focusKey.value = previewKey(trigger);
163
+ clearTimeout(showTimer);
164
+ showTooltip(trigger);
165
+ };
166
+ const onFocusOut = (event: Event) => {
167
+ const trigger = triggerOf(event.target);
168
+ if (!trigger) return;
169
+ focusKey.value = null;
170
+ if (owner === trigger) scheduleHide();
171
+ };
172
+ const onTipEnter = () => clearTimeout(hideTimer);
173
+ const onTipLeave = (event: Event) => {
174
+ const next = (event as PointerEvent).relatedTarget as Node | null;
175
+ if (owner && next && owner.contains(next)) return;
176
+ if (owner && owner === document.activeElement && isFocusVisible(owner)) return;
177
+ scheduleHide();
178
+ };
179
+ // Capture phase so an open tooltip consumes the first Escape before the
180
+ // mobile drawer's document listener sees it (that listener honours
181
+ // defaultPrevented — layered Escape ownership, zudolab/zudo-doc#4393).
182
+ const onKeyDown = (event: Event) => {
183
+ const key = event as KeyboardEvent;
184
+ if (key.key !== "Escape" || key.isComposing || tip.hidden) return;
185
+ // Escape aimed at an unrelated surface (e.g. a dialog opened while the
186
+ // pointer still rests on a frame) must reach its own owner.
187
+ const target = key.target;
188
+ if (target instanceof Element && target !== document.body && !navRef.current?.contains(target) && !tip.contains(target)) {
189
+ hideTooltip();
190
+ pointerKey.value = null;
191
+ return;
192
+ }
193
+ key.preventDefault();
194
+ key.stopPropagation();
195
+ hideTooltip();
196
+ pointerKey.value = null;
197
+ };
198
+ // Keyboard focus scrolls the sidebar to reveal its target, so follow the
199
+ // trigger instead of dismissing; a trigger that left the DOM dismisses.
200
+ let scrollFrame = 0;
201
+ const onScroll = () => {
202
+ if (tip.hidden || scrollFrame) return;
203
+ scrollFrame = requestAnimationFrame(() => {
204
+ scrollFrame = 0;
205
+ if (tip.hidden) return;
206
+ if (owner?.isConnected) place(owner, tip);
207
+ else hideTooltip();
208
+ });
209
+ };
210
+ // The drawer stays mounted when closed and goes `inert`; a hidden sidebar
211
+ // must not keep a preview or tooltip.
212
+ // The <nav> sits inside a <Show> (the root-menu view can replace it), so
213
+ // fall back to the drawer itself when the ref is not mounted yet.
214
+ const drawer = navRef.current ? navRef.current.closest("[data-zd-mobile-sidebar]") : document.querySelector("[data-zd-mobile-sidebar]");
215
+ const observer = drawer ? new MutationObserver(() => { if (drawer.hasAttribute("inert")) clear(); }) : undefined;
216
+ observer?.observe(drawer!, { attributes: true, attributeFilter: ["inert"] });
217
+ // The lg breakpoint (Wind `lg:` / features.css) where the drawer and the desktop sidebar swap.
218
+ const media = window.matchMedia?.("(min-width: 1024px)");
219
+ const onBreakpoint = () => clear();
220
+ media?.addEventListener?.("change", onBreakpoint);
221
+
222
+ document.addEventListener("pointerover", onPointerOver);
223
+ document.addEventListener("pointerout", onPointerOut);
224
+ document.addEventListener("focusin", onFocusIn);
225
+ document.addEventListener("focusout", onFocusOut);
226
+ document.addEventListener("keydown", onKeyDown, true);
227
+ document.addEventListener("scroll", onScroll, true);
228
+ tip.addEventListener("pointerenter", onTipEnter);
229
+ tip.addEventListener("pointerleave", onTipLeave);
230
+ return () => {
231
+ cancelAnimationFrame(scrollFrame);
232
+ clear();
233
+ observer?.disconnect();
234
+ media?.removeEventListener?.("change", onBreakpoint);
235
+ document.removeEventListener("pointerover", onPointerOver);
236
+ document.removeEventListener("pointerout", onPointerOut);
237
+ document.removeEventListener("focusin", onFocusIn);
238
+ document.removeEventListener("focusout", onFocusOut);
239
+ document.removeEventListener("keydown", onKeyDown, true);
240
+ document.removeEventListener("scroll", onScroll, true);
241
+ tip.remove();
242
+ tooltip = null;
243
+ };
244
+ };
245
+
246
+ return { active, clear, activate };
247
+ }
@@ -19,15 +19,17 @@ import type { ResolvedDateFormats } from "../settings.js";
19
19
  import {
20
20
  INDENT,
21
21
  BASE_PAD,
22
+ CONNECTOR_OFFSET,
22
23
  connectorLeft,
23
24
  ConnectorLines,
24
25
  CategoryLinkIcon,
25
26
  } from "../tree-nav-shared/index.js";
26
- import { ChevronRight, ChevronLeft, Search } from "../icons/index.js";
27
+ import { ChevronRight, ChevronLeft, Search, FocusFrame, RestoreArrow } from "../icons/index.js";
27
28
  import { ThemeToggle, type ThemeToggleLabels } from "../theme-toggle/index.js";
28
29
  import { smartBreakToHtml } from "../smart-break/index.js";
29
30
  import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
30
31
  import { useSidebarScope, type ScopeControls } from "./scope-state.js";
32
+ import { createBranchPreview } from "./branch-preview.js";
31
33
  import { filterTree } from "../sidebar-filter/index.js";
32
34
  import { findActiveSlug, normalizePath } from "../sidebar-active-slug/index.js";
33
35
  import {
@@ -47,6 +49,7 @@ import { formatMonthDay } from "../format-date/index.js";
47
49
 
48
50
  const STORAGE_KEY = "zd-sidebar-open";
49
51
  const GROUPED_TRAY_ITEM_DEPTH = 3;
52
+ const TOGGLE_BOX_SIZE = "1.5rem";
50
53
 
51
54
  function ToggleChevron({
52
55
  isExpanded,
@@ -73,6 +76,11 @@ function padLeft(depth: number, forCategory: boolean): string {
73
76
  return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
74
77
  }
75
78
 
79
+ /** Puts a 1px vertical guide on the center of a category row's leading toggle box. */
80
+ function toggleGuideLeft(depth: number): string {
81
+ return `calc(${padLeft(depth, true)} + ${TOGGLE_BOX_SIZE} / 2 - 0.5px)`;
82
+ }
83
+
76
84
  function getOpenSet(): Set<string> {
77
85
  try {
78
86
  const raw = sessionStorage.getItem(STORAGE_KEY);
@@ -274,10 +282,31 @@ export function SidebarTree({
274
282
  const scope = getScope();
275
283
  const activeSlug = useActiveSlug(navigation?.roots ?? nodes, currentSlug, currentPath);
276
284
  const query = signal("");
277
- const { selected, selectedNodes, broader, labels, canRestore, changeScope, save, navRef, scopeControls } = useSidebarScope({
285
+ const { selected, selectedNodes, broader, labels, canRestore, changeScope, save, navRef, scopeControls: baseScopeControls } = useSidebarScope({
278
286
  nodes, navigation, activeSlug, query,
279
287
  locale: localeProp ?? localeLinks?.find((link) => link.active)?.code ?? "en",
280
288
  });
289
+ const branchPreview = createBranchPreview(navRef);
290
+ const scopeControls: TreeScopeControls | undefined = baseScopeControls
291
+ ? { ...baseScopeControls, preview: branchPreview.active }
292
+ : undefined;
293
+ if (navigation) {
294
+ scope.onActivate(() => {
295
+ const dispose = branchPreview.activate();
296
+ const onNavigate = () => branchPreview.clear();
297
+ document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
298
+ return () => {
299
+ document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
300
+ dispose();
301
+ };
302
+ });
303
+ // Scope changes, filtering and remounted rows invalidate a transient preview.
304
+ scope.effect(() => {
305
+ void selected.value;
306
+ void query.value;
307
+ branchPreview.clear();
308
+ });
309
+ }
281
310
  const showingRootMenu = signal(false);
282
311
  const filterRef: Ref<HTMLInputElement> = { current: null };
283
312
  const filterPlaceholder = signal("Filter...");
@@ -386,14 +415,10 @@ export function SidebarTree({
386
415
  </button>
387
416
  <Show when={canRestore}>{() => (
388
417
  <button type="button" data-sidebar-restore on:click={() => changeScope(null)}
389
- title={labels.restore} aria-label={labels.restore}
418
+ aria-label={labels.restore} data-sidebar-tip={labels.restoreTip}
390
419
  style="width:40px;height:40px"
391
- class="grid place-items-center shrink-0 rounded text-muted hover:text-fg focus-visible:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
392
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
393
- <circle cx="12" cy="12" r="10" />
394
- <circle cx="12" cy="12" r="6" />
395
- <circle cx="12" cy="12" r="2" />
396
- </svg>
420
+ class="grid place-items-center shrink-0 rounded text-muted hover:text-accent hover:bg-surface focus-visible:text-accent focus-visible:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
421
+ <RestoreArrow class="h-[1.0625rem] w-[1.0625rem]" />
397
422
  </button>
398
423
  )}</Show>
399
424
  </div>
@@ -443,8 +468,12 @@ export function SidebarTree({
443
468
  }
444
469
  SidebarTree.displayName = "SidebarTree";
445
470
 
471
+ interface TreeScopeControls extends ScopeControls {
472
+ /** This sidebar instance's transient branch preview (occurrence id). */
473
+ preview: ReadonlySignal<string | null>;
474
+ }
446
475
  interface ActiveProps {
447
- scopeControls?: ScopeControls;
476
+ scopeControls?: TreeScopeControls;
448
477
  currentSlug: ReadonlySignal<string | undefined>;
449
478
  forceOpen: ReadonlySignal<boolean>;
450
479
  }
@@ -547,6 +576,7 @@ function TrayItem({
547
576
  isLast,
548
577
  showDate = false,
549
578
  depth = 1,
579
+ guideLeft,
550
580
  locale,
551
581
  dateFormats,
552
582
  }: {
@@ -556,6 +586,8 @@ function TrayItem({
556
586
  isLast: ReadonlySignal<boolean>;
557
587
  showDate?: boolean;
558
588
  depth?: number;
589
+ /** Draws this row's connector from the parent's toggle center instead of its depth column. */
590
+ guideLeft?: string;
559
591
  locale?: string;
560
592
  dateFormats?: ResolvedDateFormats;
561
593
  }) {
@@ -574,6 +606,12 @@ function TrayItem({
574
606
  depth={depth}
575
607
  isLast={isLast}
576
608
  topPad="var(--spacing-vsp-2xs)"
609
+ left={guideLeft}
610
+ width={
611
+ guideLeft
612
+ ? `calc(${padLeft(depth, false)} - ${CONNECTOR_OFFSET} - ${guideLeft})`
613
+ : undefined
614
+ }
577
615
  />
578
616
  <a
579
617
  href={computed(() => item.value.href ?? "")}
@@ -621,19 +659,23 @@ function ConnectorLinesLive({
621
659
  depth,
622
660
  isLast,
623
661
  topPad,
662
+ left,
663
+ width,
624
664
  }: {
625
665
  depth: number;
626
666
  isLast: ReadonlySignal<boolean>;
627
667
  topPad: string;
668
+ left?: string;
669
+ width?: string;
628
670
  }) {
629
671
  return (
630
672
  <Show
631
673
  when={isLast}
632
674
  fallback={() => (
633
- <ConnectorLines depth={depth} isLast={false} topPad={topPad} />
675
+ <ConnectorLines depth={depth} isLast={false} topPad={topPad} left={left} width={width} />
634
676
  )}
635
677
  >
636
- {() => <ConnectorLines depth={depth} isLast={true} topPad={topPad} />}
678
+ {() => <ConnectorLines depth={depth} isLast={true} topPad={topPad} left={left} width={width} />}
637
679
  </Show>
638
680
  );
639
681
  }
@@ -746,7 +788,15 @@ function TrayGroupNode({
746
788
  </div>
747
789
  <Show when={expanded}>
748
790
  {() => (
749
- <div>
791
+ <div class="relative">
792
+ <div
793
+ class="absolute border-l border-dashed border-muted"
794
+ style={{
795
+ left: toggleGuideLeft(1),
796
+ top: "calc(-1 * var(--spacing-vsp-xs))",
797
+ height: "var(--spacing-vsp-xs)",
798
+ }}
799
+ />
750
800
  <For each={items} by={(item) => item.slug}>
751
801
  {(item, index) => (
752
802
  <TrayItem
@@ -759,6 +809,7 @@ function TrayGroupNode({
759
809
  locale={locale}
760
810
  dateFormats={dateFormats}
761
811
  depth={GROUPED_TRAY_ITEM_DEPTH}
812
+ guideLeft={toggleGuideLeft(1)}
762
813
  />
763
814
  )}
764
815
  </For>
@@ -865,18 +916,33 @@ function CategoryNode({
865
916
  if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value));
866
917
  });
867
918
  const expanded = computed(() => forceOpen.value || open.value);
919
+ const focusedRoot = computed(() => !!scopeControls?.isFocusedRoot(node.value.occurrenceId));
920
+ const previewed = computed(() => !!node.value.occurrenceId && scopeControls?.preview.value === node.value.occurrenceId);
921
+ const arrived = computed(() => !!node.value.occurrenceId && scopeControls?.arrived.value === node.value.occurrenceId);
868
922
  const focusButton = scopeControls ? (
869
923
  <button type="button" data-sidebar-focus data-sidebar-focus-scope={computed(() => node.value.occurrenceId ?? "")}
870
924
  on:click={() => scopeControls.focus(node.value)}
871
- aria-label={computed(() => `${scopeControls.focusLabel}: ${node.value.label}`)}
872
- title={computed(() => `${scopeControls.focusLabel}: ${node.value.label}`)}
873
- class={computed(() => `ml-auto shrink-0 rounded px-hsp-xs py-vsp-2xs ${active.value ? "text-bg hover:bg-bg/10 focus-visible:bg-bg/10" : "text-muted hover:bg-surface hover:text-fg focus-visible:bg-surface focus-visible:text-fg"}`)}>
874
- <span aria-hidden="true">◎</span>
925
+ aria-disabled={computed(() => (focusedRoot.value ? "true" : null))}
926
+ tabindex={computed(() => (focusedRoot.value ? -1 : null))}
927
+ aria-label={computed(() => (focusedRoot.value ? scopeControls.labels.focused(node.value.label) : scopeControls.labels.focus(node.value.label)))}
928
+ data-sidebar-tip={computed(() => (focusedRoot.value ? scopeControls.labels.focusedTip : scopeControls.labels.focusTip))}
929
+ data-sidebar-tip-detail={computed(() => node.value.label)}
930
+ class={computed(() => `ml-auto grid shrink-0 place-items-center rounded w-[1.875rem] h-[1.875rem] ${
931
+ focusedRoot.value
932
+ ? `cursor-default opacity-50 ${active.value ? "text-bg" : "text-muted"}`
933
+ : active.value
934
+ ? "text-bg hover:bg-bg/10 focus-visible:bg-bg/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
935
+ : "text-muted hover:bg-accent/10 hover:text-accent focus-visible:bg-accent/10 focus-visible:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
936
+ }`)}>
937
+ <FocusFrame class="h-[1.125rem] w-[1.125rem]" />
875
938
  </button>
876
939
  ) : null;
877
940
  const paddingLeft = padLeft(depth, true);
878
941
  return (
879
942
  <div
943
+ data-sidebar-branch
944
+ data-sidebar-preview={computed(() => (previewed.value ? "" : null))}
945
+ data-sidebar-arrived={computed(() => (arrived.value ? "" : null))}
880
946
  class={computed(
881
947
  () =>
882
948
  `${depth === 0 ? "border-t border-muted first:border-t-0" : ""} ${depth >= 1 && !isLast.value ? "relative" : ""}`,
@@ -888,11 +954,12 @@ function CategoryNode({
888
954
  {() => (
889
955
  <div
890
956
  class="absolute border-l border-solid border-muted z-local-1"
957
+ data-tree-connector
891
958
  style={{ left: connectorLeft(depth), top: "0px", bottom: "0px" }}
892
959
  />
893
960
  )}
894
961
  </Show>
895
- <div class="relative">
962
+ <div class="relative" data-sidebar-row data-sidebar-current={computed(() => (active.value ? "" : null))}>
896
963
  <ConnectorLinesLive
897
964
  depth={depth}
898
965
  isLast={isLast}
@@ -980,7 +1047,7 @@ function CategoryNode({
980
1047
  </div>
981
1048
  <Show when={expanded}>
982
1049
  {() => (
983
- <div>
1050
+ <div data-sidebar-children>
984
1051
  <NodeList
985
1052
  nodes={computed(() => node.value.children)}
986
1053
  currentSlug={currentSlug}