@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.
@@ -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
+ });