@takazudo/zudo-doc 1.0.2 → 1.2.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/doc-body-end-islands/index.d.ts +32 -0
- package/dist/doc-body-end-islands/index.js +40 -0
- package/dist/doc-page-renderer/index.js +5 -2
- package/dist/eject/index.js +24 -0
- package/dist/nav-indexing/doc-card-grid.js +1 -1
- package/dist/nav-indexing/nav-card-grid.js +1 -1
- package/dist/plugins/routes.js +3 -1
- package/dist/preset.d.ts +28 -8
- package/dist/preset.js +37 -9
- package/dist/routes/404.js +2 -2
- package/dist/routes/_chrome.d.ts +9 -9
- package/dist/routes/_chrome.js +53 -12
- package/dist/routes/_context.d.ts +5 -0
- package/dist/routes/_context.js +2 -0
- package/dist/routes/index.js +2 -2
- package/dist/routes/locale-index.js +2 -2
- package/dist/safelist.css +1 -1
- package/dist/settings.d.ts +12 -10
- package/eject/desktop-sidebar-toggle-island/index.tsx +105 -0
- package/eject/doc-history/index.tsx +649 -0
- package/eject/image-enlarge/index.tsx +219 -0
- package/eject/sidebar-toggle-island/index.tsx +165 -0
- package/eject/sidebar-tree-island/index.tsx +589 -0
- package/eject/site-tree-nav-island/index.tsx +222 -0
- package/package.json +10 -6
- package/routes-src/404.tsx +2 -2
- package/routes-src/_chrome.tsx +105 -17
- package/routes-src/_context.ts +5 -0
- package/routes-src/_virtual.d.ts +6 -2
- package/routes-src/index.tsx +2 -2
- package/routes-src/locale-index.tsx +2 -2
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** @jsxRuntime automatic */
|
|
4
|
+
/** @jsxImportSource preact */
|
|
5
|
+
// Use preact hook entrypoints directly — the "react" → "preact/compat" alias
|
|
6
|
+
// lets us consume React-typed components in this Preact app.
|
|
7
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from "preact/hooks";
|
|
8
|
+
import { memo } from "preact/compat";
|
|
9
|
+
import type { SidebarNavNode, SidebarRootMenuItem, SidebarLocaleLink } from "../sidebar/types.js";
|
|
10
|
+
import { INDENT, BASE_PAD, connectorLeft, ConnectorLines, CategoryLinkIcon } from "../tree-nav-shared/index.js";
|
|
11
|
+
import { ChevronRight, ChevronLeft, Search } from "../icons/index.js";
|
|
12
|
+
// BARE ThemeToggle — renders inside the SidebarToggle island, so it must
|
|
13
|
+
// NOT bring its own island wrapper.
|
|
14
|
+
import { ThemeToggle } from "../theme-toggle/index.js";
|
|
15
|
+
import { smartBreakToHtml } from "../smart-break/index.js";
|
|
16
|
+
// After zudolab/zudo-doc#1335 the host components also pull lifecycle event
|
|
17
|
+
// names from the v2 transitions module rather than hard-coding literals.
|
|
18
|
+
import { AFTER_NAVIGATE_EVENT, BEFORE_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
19
|
+
|
|
20
|
+
function ToggleChevron({ isExpanded, className }: { isExpanded: boolean; className?: string }) {
|
|
21
|
+
return (
|
|
22
|
+
<ChevronRight
|
|
23
|
+
className={`h-[0.625rem] w-[0.625rem] shrink-0 transition-transform duration-150 ${isExpanded ? "rotate-90" : ""} ${className ?? ""}`}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STORAGE_KEY = "zd-sidebar-open";
|
|
29
|
+
|
|
30
|
+
function padLeft(depth: number, forCategory: boolean): string {
|
|
31
|
+
if (depth === 0) return `calc(${BASE_PAD} + ${forCategory ? "0.15rem" : "0rem"})`;
|
|
32
|
+
return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getOpenSet(): Set<string> {
|
|
36
|
+
try {
|
|
37
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
38
|
+
if (!raw) return new Set();
|
|
39
|
+
const parsed: unknown = JSON.parse(raw);
|
|
40
|
+
return Array.isArray(parsed) ? new Set(parsed.filter((v): v is string => typeof v === "string")) : new Set();
|
|
41
|
+
} catch {
|
|
42
|
+
return new Set();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveOpenSet(set: Set<string>) {
|
|
47
|
+
try {
|
|
48
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...set]));
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizePath(p: string): string {
|
|
55
|
+
return p.replace(/\/$/, "") || "/";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find the slug of the node whose href matches the given pathname */
|
|
59
|
+
function findActiveSlug(nodes: SidebarNavNode[], pathname: string): string | undefined {
|
|
60
|
+
for (const node of nodes) {
|
|
61
|
+
if (node.href && normalizePath(node.href) === pathname) return node.slug;
|
|
62
|
+
const found = findActiveSlug(node.children, pathname);
|
|
63
|
+
// "" is the canonical root-index slug (#1891) — a truthiness check
|
|
64
|
+
// would discard a legitimate root match.
|
|
65
|
+
if (found !== undefined) return found;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Derive the active slug from the current document URL. Used as a hydration-
|
|
72
|
+
* time fallback when the parent island does not forward `currentSlug` through
|
|
73
|
+
* its prop boundary, and at every View Transition to keep the highlight in
|
|
74
|
+
* sync.
|
|
75
|
+
*/
|
|
76
|
+
function deriveActiveSlugFromUrl(nodes: SidebarNavNode[]): string | undefined {
|
|
77
|
+
if (typeof window === "undefined") return undefined;
|
|
78
|
+
const pathname = normalizePath(window.location.pathname);
|
|
79
|
+
return findActiveSlug(nodes, pathname);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Track the current active slug, updating on View Transition navigations.
|
|
84
|
+
*
|
|
85
|
+
* The initial-state initialiser prefers the SSR-supplied `initial` prop, but
|
|
86
|
+
* falls back to deriving the slug from `window.location.pathname` when the
|
|
87
|
+
* prop is missing.
|
|
88
|
+
*/
|
|
89
|
+
function useActiveSlug(nodes: SidebarNavNode[], initial?: string): string | undefined {
|
|
90
|
+
const [slug, setSlug] = useState<string | undefined>(() =>
|
|
91
|
+
initial !== undefined ? initial : deriveActiveSlugFromUrl(nodes),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const update = () => {
|
|
96
|
+
const found = deriveActiveSlugFromUrl(nodes);
|
|
97
|
+
if (found !== undefined) setSlug(found);
|
|
98
|
+
};
|
|
99
|
+
update();
|
|
100
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, update);
|
|
101
|
+
return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, update);
|
|
102
|
+
}, [nodes]);
|
|
103
|
+
|
|
104
|
+
return slug;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Preserve `#desktop-sidebar` scrollTop across SPA navigations.
|
|
109
|
+
*/
|
|
110
|
+
function useSidebarScrollPreserve() {
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
let savedScrollTop = 0;
|
|
113
|
+
let restoreTimer: ReturnType<typeof setTimeout> | undefined;
|
|
114
|
+
|
|
115
|
+
const onBefore = () => {
|
|
116
|
+
if (restoreTimer !== undefined) {
|
|
117
|
+
clearTimeout(restoreTimer);
|
|
118
|
+
restoreTimer = undefined;
|
|
119
|
+
}
|
|
120
|
+
const aside = document.querySelector<HTMLElement>("#desktop-sidebar");
|
|
121
|
+
if (aside) savedScrollTop = aside.scrollTop;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const onAfter = () => {
|
|
125
|
+
const aside = document.querySelector<HTMLElement>("#desktop-sidebar");
|
|
126
|
+
if (!aside) return;
|
|
127
|
+
restoreTimer = setTimeout(() => {
|
|
128
|
+
restoreTimer = undefined;
|
|
129
|
+
aside.scrollTop = savedScrollTop;
|
|
130
|
+
}, 50);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
document.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
|
|
134
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
|
|
135
|
+
return () => {
|
|
136
|
+
document.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
|
|
137
|
+
document.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
|
|
138
|
+
if (restoreTimer !== undefined) clearTimeout(restoreTimer);
|
|
139
|
+
};
|
|
140
|
+
}, []);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function filterTree(nodes: SidebarNavNode[], query: string): SidebarNavNode[] {
|
|
144
|
+
return nodes.reduce<SidebarNavNode[]>((acc, node) => {
|
|
145
|
+
const matchesLabel = node.label.toLowerCase().includes(query.toLowerCase());
|
|
146
|
+
const filteredChildren = node.children.length > 0
|
|
147
|
+
? filterTree(node.children, query)
|
|
148
|
+
: [];
|
|
149
|
+
|
|
150
|
+
if (matchesLabel || filteredChildren.length > 0) {
|
|
151
|
+
acc.push({
|
|
152
|
+
...node,
|
|
153
|
+
children: matchesLabel ? node.children : filteredChildren,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return acc;
|
|
157
|
+
}, []);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function RootMenuItemEntry({ item }: { item: SidebarRootMenuItem }) {
|
|
161
|
+
const [expanded, setExpanded] = useState(false);
|
|
162
|
+
const hasChildren = item.children && item.children.length > 0;
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="border-t border-muted">
|
|
166
|
+
<div className="flex items-center">
|
|
167
|
+
<a
|
|
168
|
+
href={item.href}
|
|
169
|
+
className="flex flex-1 items-center gap-hsp-xs px-hsp-sm py-vsp-xs text-small font-semibold text-fg hover:text-accent hover:underline break-words"
|
|
170
|
+
>
|
|
171
|
+
<CategoryLinkIcon className="w-[14px]" />
|
|
172
|
+
<span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(item.label) }} />
|
|
173
|
+
</a>
|
|
174
|
+
{hasChildren && (
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={() => setExpanded((prev) => !prev)}
|
|
178
|
+
className="flex items-center justify-center px-hsp-sm py-vsp-xs text-muted hover:text-fg"
|
|
179
|
+
aria-expanded={expanded}
|
|
180
|
+
aria-label={expanded ? `Collapse ${item.label}` : `Expand ${item.label}`}
|
|
181
|
+
>
|
|
182
|
+
<ToggleChevron isExpanded={expanded} className="text-muted" />
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
{hasChildren && expanded && (
|
|
187
|
+
<div className="pb-vsp-xs">
|
|
188
|
+
{item.children!.map((child) => (
|
|
189
|
+
<a
|
|
190
|
+
key={child.href}
|
|
191
|
+
href={child.href}
|
|
192
|
+
className="block pl-hsp-xl pr-hsp-sm py-vsp-2xs text-small text-muted hover:text-accent hover:underline break-words"
|
|
193
|
+
>
|
|
194
|
+
<span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(child.label) }} />
|
|
195
|
+
</a>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface SidebarTreeProps {
|
|
204
|
+
nodes: SidebarNavNode[];
|
|
205
|
+
currentSlug?: string;
|
|
206
|
+
rootMenuItems?: SidebarRootMenuItem[];
|
|
207
|
+
backToMenuLabel?: string;
|
|
208
|
+
localeLinks?: SidebarLocaleLink[];
|
|
209
|
+
themeDefaultMode?: "light" | "dark";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function SidebarFooter({ links, themeDefaultMode }: { links?: SidebarLocaleLink[]; themeDefaultMode?: "light" | "dark" }) {
|
|
213
|
+
if (!links && !themeDefaultMode) return null;
|
|
214
|
+
return (
|
|
215
|
+
// pb-[50vh] provides scroll room so the footer doesn't sit at the very bottom of the viewport
|
|
216
|
+
<div className="lg:hidden flex items-center gap-hsp-md border-t border-muted px-hsp-sm py-vsp-xs pb-[50vh] text-small">
|
|
217
|
+
{themeDefaultMode && <ThemeToggle defaultMode={themeDefaultMode} />}
|
|
218
|
+
{links && links.map((link, i) => (
|
|
219
|
+
<span key={link.href} className="flex items-center gap-hsp-xs">
|
|
220
|
+
{i > 0 && <span className="text-muted">/</span>}
|
|
221
|
+
{link.active ? (
|
|
222
|
+
<span aria-current="true" className="font-medium text-fg">{link.label}</span>
|
|
223
|
+
) : (
|
|
224
|
+
<a href={link.href} lang={link.code} className="text-muted hover:text-fg">
|
|
225
|
+
{link.label}
|
|
226
|
+
</a>
|
|
227
|
+
)}
|
|
228
|
+
</span>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function SidebarTree({ nodes, currentSlug, rootMenuItems, backToMenuLabel, localeLinks, themeDefaultMode }: SidebarTreeProps) {
|
|
235
|
+
const activeSlug = useActiveSlug(nodes, currentSlug);
|
|
236
|
+
useSidebarScrollPreserve();
|
|
237
|
+
const [query, setQuery] = useState("");
|
|
238
|
+
const [showingRootMenu, setShowingRootMenu] = useState(false);
|
|
239
|
+
const filterRef = useRef<HTMLInputElement>(null);
|
|
240
|
+
const [filterPlaceholder, setFilterPlaceholder] = useState("Filter...");
|
|
241
|
+
|
|
242
|
+
// Detect OS to show appropriate keyboard shortcut in placeholder
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const platform = (navigator as { userAgentData?: { platform: string } }).userAgentData?.platform ?? navigator.platform;
|
|
245
|
+
const isMac = /mac/i.test(platform);
|
|
246
|
+
setFilterPlaceholder(isMac ? "Filter... (⌘ + /)" : "Filter... (Ctrl + /)");
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
// Global shortcut: Cmd+/ (Mac) or Ctrl+/ to focus the filter input
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
252
|
+
if (e.isComposing) return;
|
|
253
|
+
if (e.key === "/" && (e.metaKey || e.ctrlKey)) {
|
|
254
|
+
const el = filterRef.current;
|
|
255
|
+
if (!el || el.offsetParent === null) return; // skip if hidden
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
el.focus();
|
|
258
|
+
el.select();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
262
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
263
|
+
}, []);
|
|
264
|
+
|
|
265
|
+
const filteredNodes = useMemo(
|
|
266
|
+
() => (query ? filterTree(nodes, query) : nodes),
|
|
267
|
+
[nodes, query],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const footer = useMemo(
|
|
271
|
+
() => (localeLinks || themeDefaultMode) ? <SidebarFooter links={localeLinks} themeDefaultMode={themeDefaultMode} /> : null,
|
|
272
|
+
[localeLinks, themeDefaultMode],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Root menu view: show headerNav items as a simple list (Docusaurus-style)
|
|
276
|
+
if (showingRootMenu && rootMenuItems) {
|
|
277
|
+
return (
|
|
278
|
+
<nav>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={() => setShowingRootMenu(false)}
|
|
282
|
+
className="flex w-full items-center gap-hsp-xs px-hsp-sm py-vsp-xs text-left text-small text-muted hover:text-fg border-b border-muted"
|
|
283
|
+
>
|
|
284
|
+
<ChevronRight className="h-icon-sm w-icon-sm shrink-0" />
|
|
285
|
+
{backToMenuLabel ?? "Back to main menu"}
|
|
286
|
+
</button>
|
|
287
|
+
{rootMenuItems.map((item) => (
|
|
288
|
+
<RootMenuItemEntry key={item.href} item={item} />
|
|
289
|
+
))}
|
|
290
|
+
{footer}
|
|
291
|
+
</nav>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Top page: show only header nav links, no doc tree or filter.
|
|
296
|
+
if (activeSlug === undefined && rootMenuItems) {
|
|
297
|
+
return (
|
|
298
|
+
<nav>
|
|
299
|
+
{rootMenuItems.map((item) => (
|
|
300
|
+
<RootMenuItemEntry key={item.href} item={item} />
|
|
301
|
+
))}
|
|
302
|
+
{footer}
|
|
303
|
+
</nav>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<nav>
|
|
309
|
+
{rootMenuItems && (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={() => setShowingRootMenu(true)}
|
|
313
|
+
className="lg:hidden flex w-full items-center gap-hsp-xs px-hsp-sm py-vsp-xs text-left text-small text-muted hover:text-fg border-b border-muted"
|
|
314
|
+
>
|
|
315
|
+
<ChevronLeft className="h-icon-sm w-icon-sm shrink-0" />
|
|
316
|
+
{backToMenuLabel ?? "Back to main menu"}
|
|
317
|
+
</button>
|
|
318
|
+
)}
|
|
319
|
+
<div className="px-hsp-sm py-vsp-xs">
|
|
320
|
+
<div className="flex items-center gap-hsp-xs bg-surface rounded px-hsp-sm py-vsp-2xs">
|
|
321
|
+
<Search className="h-[14px] w-[14px] text-muted shrink-0" />
|
|
322
|
+
<input
|
|
323
|
+
ref={filterRef}
|
|
324
|
+
type="text"
|
|
325
|
+
aria-label="Filter navigation"
|
|
326
|
+
placeholder={filterPlaceholder}
|
|
327
|
+
value={query}
|
|
328
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
329
|
+
className="bg-transparent text-small outline-none w-full text-fg placeholder:text-muted"
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
<NodeList
|
|
334
|
+
nodes={filteredNodes}
|
|
335
|
+
currentSlug={activeSlug}
|
|
336
|
+
depth={0}
|
|
337
|
+
forceOpen={!!query}
|
|
338
|
+
/>
|
|
339
|
+
{footer}
|
|
340
|
+
</nav>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
SidebarTree.displayName = "SidebarTree";
|
|
344
|
+
|
|
345
|
+
// NodeList is memo-wrapped so that when only the filter query changes but
|
|
346
|
+
// a subtree's nodes/currentSlug/depth/forceOpen are unchanged, Preact can
|
|
347
|
+
// skip re-rendering the whole subtree.
|
|
348
|
+
const NodeList = memo(function NodeList({
|
|
349
|
+
nodes,
|
|
350
|
+
currentSlug,
|
|
351
|
+
depth,
|
|
352
|
+
forceOpen,
|
|
353
|
+
}: {
|
|
354
|
+
nodes: SidebarNavNode[];
|
|
355
|
+
currentSlug?: string;
|
|
356
|
+
depth: number;
|
|
357
|
+
forceOpen: boolean;
|
|
358
|
+
}) {
|
|
359
|
+
return (
|
|
360
|
+
<>
|
|
361
|
+
{nodes.map((node, index) => {
|
|
362
|
+
const isLast = index === nodes.length - 1;
|
|
363
|
+
return node.children.length > 0 ? (
|
|
364
|
+
<CategoryNode
|
|
365
|
+
key={node.slug}
|
|
366
|
+
node={node}
|
|
367
|
+
currentSlug={currentSlug}
|
|
368
|
+
depth={depth}
|
|
369
|
+
isLast={isLast}
|
|
370
|
+
forceOpen={forceOpen}
|
|
371
|
+
/>
|
|
372
|
+
) : (
|
|
373
|
+
<LeafNode
|
|
374
|
+
key={node.slug}
|
|
375
|
+
node={node}
|
|
376
|
+
currentSlug={currentSlug}
|
|
377
|
+
depth={depth}
|
|
378
|
+
isLast={isLast}
|
|
379
|
+
/>
|
|
380
|
+
);
|
|
381
|
+
})}
|
|
382
|
+
</>
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/** Check if currentSlug is anywhere in this node's subtree */
|
|
387
|
+
function subtreeContainsSlug(node: SidebarNavNode, slug?: string): boolean {
|
|
388
|
+
if (!slug) return false;
|
|
389
|
+
if (node.slug === slug) return true;
|
|
390
|
+
return node.children.some((child) => subtreeContainsSlug(child, slug));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// CategoryNode is memo-wrapped so unchanged category nodes are skipped during
|
|
394
|
+
// filter-query re-renders.
|
|
395
|
+
const CategoryNode = memo(function CategoryNode({
|
|
396
|
+
node,
|
|
397
|
+
currentSlug,
|
|
398
|
+
depth,
|
|
399
|
+
isLast,
|
|
400
|
+
forceOpen,
|
|
401
|
+
}: {
|
|
402
|
+
node: SidebarNavNode;
|
|
403
|
+
currentSlug?: string;
|
|
404
|
+
depth: number;
|
|
405
|
+
isLast: boolean;
|
|
406
|
+
forceOpen: boolean;
|
|
407
|
+
}) {
|
|
408
|
+
const containsCurrent = useMemo(
|
|
409
|
+
() => subtreeContainsSlug(node, currentSlug),
|
|
410
|
+
[node, currentSlug],
|
|
411
|
+
);
|
|
412
|
+
const isActive = node.slug === currentSlug;
|
|
413
|
+
const labelHtml = useMemo(() => smartBreakToHtml(node.label), [node.label]);
|
|
414
|
+
|
|
415
|
+
const [open, setOpen] = useState(containsCurrent ? true : !node.collapsed);
|
|
416
|
+
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
const stored = getOpenSet();
|
|
419
|
+
if (stored.has(node.slug) && !open) {
|
|
420
|
+
setOpen(true);
|
|
421
|
+
}
|
|
422
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
423
|
+
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (subtreeContainsSlug(node, currentSlug) && !open) {
|
|
426
|
+
setOpen(true);
|
|
427
|
+
const stored = getOpenSet();
|
|
428
|
+
stored.add(node.slug);
|
|
429
|
+
saveOpenSet(stored);
|
|
430
|
+
}
|
|
431
|
+
}, [currentSlug]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
432
|
+
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (open) {
|
|
435
|
+
const stored = getOpenSet();
|
|
436
|
+
if (!stored.has(node.slug)) {
|
|
437
|
+
stored.add(node.slug);
|
|
438
|
+
saveOpenSet(stored);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}, [open, node.slug]);
|
|
442
|
+
|
|
443
|
+
const toggle = useCallback(() => {
|
|
444
|
+
setOpen((prev) => {
|
|
445
|
+
const next = !prev;
|
|
446
|
+
const stored = getOpenSet();
|
|
447
|
+
if (next) {
|
|
448
|
+
stored.add(node.slug);
|
|
449
|
+
} else {
|
|
450
|
+
stored.delete(node.slug);
|
|
451
|
+
}
|
|
452
|
+
saveOpenSet(stored);
|
|
453
|
+
return next;
|
|
454
|
+
});
|
|
455
|
+
}, [node.slug]);
|
|
456
|
+
|
|
457
|
+
const isExpanded = forceOpen || open;
|
|
458
|
+
const paddingLeft = padLeft(depth, true);
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div className={`${depth === 0 ? "border-t border-muted" : ""} ${depth >= 1 && !isLast ? "relative" : ""}`}>
|
|
462
|
+
{depth >= 1 && !isLast && isExpanded && (
|
|
463
|
+
<div
|
|
464
|
+
className="absolute border-l border-solid border-muted z-local-1"
|
|
465
|
+
style={{
|
|
466
|
+
left: connectorLeft(depth),
|
|
467
|
+
top: 0,
|
|
468
|
+
bottom: 0,
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
)}
|
|
472
|
+
<div className="relative">
|
|
473
|
+
<ConnectorLines depth={depth} isLast={isLast} topPad="calc(0.15rem + var(--spacing-vsp-xs))" />
|
|
474
|
+
{node.href ? (
|
|
475
|
+
<div
|
|
476
|
+
className={`flex w-full items-center text-small font-semibold pt-[0.15rem] ${isActive ? "bg-fg text-bg" : "text-fg"}`}
|
|
477
|
+
>
|
|
478
|
+
<a
|
|
479
|
+
href={node.href}
|
|
480
|
+
aria-current={isActive ? "page" : undefined}
|
|
481
|
+
className={`flex-1 flex items-start gap-hsp-xs py-vsp-xs hover:underline focus:underline break-words ${isActive ? "text-bg" : "text-fg"}`}
|
|
482
|
+
style={{ paddingLeft }}
|
|
483
|
+
>
|
|
484
|
+
{depth === 0 && (
|
|
485
|
+
<span className="flex h-[1lh] items-center">
|
|
486
|
+
<CategoryLinkIcon className={`w-[14px] ${isActive ? "text-bg" : ""}`} />
|
|
487
|
+
</span>
|
|
488
|
+
)}
|
|
489
|
+
<span dangerouslySetInnerHTML={{ __html: labelHtml }} />
|
|
490
|
+
</a>
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
onClick={toggle}
|
|
494
|
+
className={`aspect-square flex items-center justify-center w-[1.5rem] border-y border-l hover:underline focus:underline ${isActive ? "border-bg/30" : "border-muted"}`}
|
|
495
|
+
aria-expanded={isExpanded}
|
|
496
|
+
aria-label={isExpanded ? `Collapse ${node.label}` : `Expand ${node.label}`}
|
|
497
|
+
>
|
|
498
|
+
<ToggleChevron isExpanded={isExpanded} className={isActive ? "text-bg" : "text-muted"} />
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
) : (
|
|
502
|
+
<button
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={toggle}
|
|
505
|
+
className={`flex w-full items-center gap-hsp-md text-left text-small font-semibold py-vsp-xs text-fg hover:underline focus:underline break-words`}
|
|
506
|
+
style={{ paddingLeft }}
|
|
507
|
+
aria-expanded={isExpanded}
|
|
508
|
+
aria-label={isExpanded ? `Collapse ${node.label}` : `Expand ${node.label}`}
|
|
509
|
+
>
|
|
510
|
+
<span className="aspect-square flex items-center justify-center w-[1.5rem] shrink-0 border border-muted">
|
|
511
|
+
<ToggleChevron isExpanded={isExpanded} className="text-muted" />
|
|
512
|
+
</span>
|
|
513
|
+
<span dangerouslySetInnerHTML={{ __html: labelHtml }} />
|
|
514
|
+
</button>
|
|
515
|
+
)}
|
|
516
|
+
</div>
|
|
517
|
+
{isExpanded && (
|
|
518
|
+
<div>
|
|
519
|
+
<NodeList
|
|
520
|
+
nodes={node.children}
|
|
521
|
+
currentSlug={currentSlug}
|
|
522
|
+
depth={depth + 1}
|
|
523
|
+
forceOpen={forceOpen}
|
|
524
|
+
/>
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// LeafNode is memo-wrapped and labelHtml is memoised so pure leaf rows are
|
|
532
|
+
// skipped entirely during filter-query re-renders when their props are stable.
|
|
533
|
+
const LeafNode = memo(function LeafNode({
|
|
534
|
+
node,
|
|
535
|
+
currentSlug,
|
|
536
|
+
depth,
|
|
537
|
+
isLast,
|
|
538
|
+
}: {
|
|
539
|
+
node: SidebarNavNode;
|
|
540
|
+
currentSlug?: string;
|
|
541
|
+
depth: number;
|
|
542
|
+
isLast: boolean;
|
|
543
|
+
}) {
|
|
544
|
+
const labelHtml = useMemo(() => smartBreakToHtml(node.label), [node.label]);
|
|
545
|
+
if (!node.href) return null;
|
|
546
|
+
const isActive = node.slug === currentSlug;
|
|
547
|
+
const isRoot = depth === 0;
|
|
548
|
+
const paddingLeft = padLeft(depth, isRoot);
|
|
549
|
+
|
|
550
|
+
const outerClass = isRoot
|
|
551
|
+
? "border-t border-muted"
|
|
552
|
+
: !isRoot && isLast
|
|
553
|
+
? "pb-vsp-md"
|
|
554
|
+
: "";
|
|
555
|
+
|
|
556
|
+
const topPad = isRoot
|
|
557
|
+
? "calc(var(--spacing-vsp-xs) + 0.15rem)"
|
|
558
|
+
: "var(--spacing-vsp-2xs)";
|
|
559
|
+
|
|
560
|
+
return (
|
|
561
|
+
<div className={outerClass}>
|
|
562
|
+
<div className="relative">
|
|
563
|
+
<ConnectorLines depth={depth} isLast={isLast} topPad={topPad} />
|
|
564
|
+
<a
|
|
565
|
+
href={node.href}
|
|
566
|
+
aria-current={isActive ? "page" : undefined}
|
|
567
|
+
className={isRoot
|
|
568
|
+
? `flex items-start gap-hsp-xs py-[calc(var(--spacing-vsp-xs)+0.15rem)] pr-[4px] text-small font-semibold break-words ${
|
|
569
|
+
isActive ? "bg-fg text-bg" : "text-fg hover:underline focus:underline"
|
|
570
|
+
}`
|
|
571
|
+
: `block py-vsp-2xs pr-[4px] text-small break-words ${
|
|
572
|
+
isActive
|
|
573
|
+
? "bg-fg font-medium text-bg"
|
|
574
|
+
: "text-muted hover:underline focus:underline"
|
|
575
|
+
}`
|
|
576
|
+
}
|
|
577
|
+
style={{ paddingLeft }}
|
|
578
|
+
>
|
|
579
|
+
{isRoot && (
|
|
580
|
+
<span className="flex h-[1lh] items-center">
|
|
581
|
+
<CategoryLinkIcon className={`w-[14px] ${isActive ? "text-bg" : ""}`} />
|
|
582
|
+
</span>
|
|
583
|
+
)}
|
|
584
|
+
<span dangerouslySetInnerHTML={{ __html: labelHtml }} />
|
|
585
|
+
</a>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
);
|
|
589
|
+
});
|