@c-rex/components 0.3.0-build.32 → 0.3.0-build.33

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": "@c-rex/components",
3
- "version": "0.3.0-build.32",
3
+ "version": "0.3.0-build.33",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -73,6 +73,22 @@
73
73
  "types": "./src/article/article-action-bar.tsx",
74
74
  "import": "./src/article/article-action-bar.tsx"
75
75
  },
76
+ "./toc-browse-controls": {
77
+ "types": "./src/toc/toc-browse-controls.tsx",
78
+ "import": "./src/toc/toc-browse-controls.tsx"
79
+ },
80
+ "./toc-tree-panel": {
81
+ "types": "./src/toc/toc-tree-panel.tsx",
82
+ "import": "./src/toc/toc-tree-panel.tsx"
83
+ },
84
+ "./toc-breadcrumb": {
85
+ "types": "./src/toc/toc-breadcrumb.tsx",
86
+ "import": "./src/toc/toc-breadcrumb.tsx"
87
+ },
88
+ "./toc-types": {
89
+ "types": "./src/toc/types.ts",
90
+ "import": "./src/toc/types.ts"
91
+ },
76
92
  "./html-rendition": {
77
93
  "types": "./src/renditions/html.tsx",
78
94
  "import": "./src/renditions/html.tsx"
@@ -20,6 +20,7 @@ import {
20
20
  INFORMATION_UNIT_PROPERTY_PRESENTATION,
21
21
  type InformationUnitPropertyKey,
22
22
  } from "@c-rex/services/metadata-presentation-config";
23
+ import type { AvailableLanguageVersion, MetadataFacetLabelOverrides } from "@c-rex/services/read-models";
23
24
  import { resolveMetadataDisplayProperties } from "@c-rex/services/metadata-view-profile";
24
25
  import {
25
26
  extractCountryCodeFromLanguage,
@@ -46,6 +47,8 @@ type Props = {
46
47
  showBookmarkButton?: boolean;
47
48
  showFileRenditions?: boolean;
48
49
  embedded?: boolean;
50
+ metadataLabelOverrides?: MetadataFacetLabelOverrides;
51
+ availableInVersions?: AvailableLanguageVersion[];
49
52
  };
50
53
 
51
54
  const resolveLiteralLabel = (labels?: LiteralModel[] | null, uiLanguage?: string): string | undefined => {
@@ -66,6 +69,13 @@ const resolveObjectRefLabel = (item: ObjectRefModel, uiLanguage: string): string
66
69
  return resolveLiteralLabel(item.labels || [], uiLanguage) || item.shortId || item.id || undefined;
67
70
  };
68
71
 
72
+ const resolveObjectRefClassLabel = (item: ObjectRefModel, uiLanguage: string): string | undefined => {
73
+ return resolveLiteralLabel(item.class?.labels || [], uiLanguage)
74
+ || item.class?.shortId
75
+ || item.class?.id
76
+ || undefined;
77
+ };
78
+
69
79
  const asObjectRefArray = (value: unknown): ObjectRefModel[] => {
70
80
  if (!Array.isArray(value)) return [];
71
81
  return value.filter((item): item is ObjectRefModel => Boolean(item && typeof item === "object"));
@@ -76,14 +86,11 @@ const asLiteralArray = (value: unknown): LiteralModel[] => {
76
86
  return value.filter((item): item is LiteralModel => Boolean(item && typeof item === "object"));
77
87
  };
78
88
 
79
- const resolveObjectRefClassLabel = (item: ObjectRefModel, uiLanguage: string): string | undefined => {
80
- return resolveLiteralLabel(item.class?.labels || [], uiLanguage);
81
- };
82
-
83
89
  const buildMetadataDisplayRows = (
84
90
  data: CommonItemsModel,
85
91
  uiLanguage: string,
86
- includeProperties: InformationUnitPropertyKey[]
92
+ includeProperties: InformationUnitPropertyKey[],
93
+ overrides?: MetadataFacetLabelOverrides
87
94
  ): MetadataDisplayRow[] => {
88
95
  const preferredTitle = resolveLiteralLabel(data.titles || [], uiLanguage) || resolveLiteralLabel(data.labels || [], uiLanguage);
89
96
  const rows: MetadataDisplayRow[] = [];
@@ -181,55 +188,37 @@ const buildMetadataDisplayRows = (
181
188
  const refs = asObjectRefArray(value);
182
189
  if (refs.length === 0) continue;
183
190
 
184
- if (config.metadataDisplay.sectionStrategy === "class") {
185
- const groupedValues = new Map<string, Set<string>>();
186
-
187
- refs.forEach((ref) => {
188
- const sectionLabel = resolveObjectRefClassLabel(ref, uiLanguage);
189
- const valueLabel = resolveObjectRefLabel(ref, uiLanguage);
190
- if (!sectionLabel || !valueLabel) return;
191
+ const groupedValues = new Map<string, Set<string>>();
191
192
 
192
- const existing = groupedValues.get(sectionLabel) || new Set<string>();
193
- existing.add(valueLabel);
194
- groupedValues.set(sectionLabel, existing);
195
- });
196
-
197
- if (groupedValues.size > 0) {
198
- Array.from(groupedValues.entries())
199
- .sort(([left], [right]) => left.localeCompare(right))
200
- .forEach(([sectionLabel, valueSet]) => {
201
- const values = Array.from(valueSet).sort((a, b) => a.localeCompare(b));
202
- if (values.length === 0) return;
203
-
204
- rows.push({
205
- key,
206
- label: sectionLabel,
207
- labelSource: "direct",
208
- values,
209
- valueType: "text",
210
- });
211
- });
212
- continue;
213
- }
214
- }
193
+ refs.forEach((ref) => {
194
+ const shortId = ref.shortId || "";
195
+ const override = shortId ? overrides?.[key]?.[shortId] : undefined;
196
+ const valueLabel = override?.label || resolveObjectRefLabel(ref, uiLanguage);
197
+ if (!valueLabel) return;
215
198
 
216
- const values = Array.from(
217
- new Set(
218
- refs
219
- .map((ref) => resolveObjectRefLabel(ref, uiLanguage))
220
- .filter((label): label is string => Boolean(label))
221
- )
222
- ).sort((a, b) => a.localeCompare(b));
199
+ const sectionLabel = config.metadataDisplay.sectionStrategy === "none"
200
+ ? key
201
+ : override?.sectionLabel || resolveObjectRefClassLabel(ref, uiLanguage) || key;
223
202
 
224
- if (values.length === 0) continue;
225
-
226
- rows.push({
227
- key,
228
- label: key,
229
- labelSource: "translationKey",
230
- values,
231
- valueType: "text",
203
+ const existing = groupedValues.get(sectionLabel) || new Set<string>();
204
+ existing.add(valueLabel);
205
+ groupedValues.set(sectionLabel, existing);
232
206
  });
207
+
208
+ Array.from(groupedValues.entries())
209
+ .sort(([left], [right]) => left.localeCompare(right))
210
+ .forEach(([sectionLabel, valueSet]) => {
211
+ const values = Array.from(valueSet).sort((a, b) => a.localeCompare(b));
212
+ if (values.length === 0) return;
213
+
214
+ rows.push({
215
+ key,
216
+ label: sectionLabel,
217
+ labelSource: sectionLabel === key ? "translationKey" : "direct",
218
+ values,
219
+ valueType: "text",
220
+ });
221
+ });
233
222
  continue;
234
223
  }
235
224
 
@@ -280,6 +269,7 @@ const AvailableInRow = ({
280
269
  versionOfShortId?: string | null;
281
270
  currentShortId?: string | null;
282
271
  linkPattern: string;
272
+ availableInVersions?: AvailableLanguageVersion[];
283
273
  }) => {
284
274
  const t = useTranslations();
285
275
  const locale = useLocale();
@@ -335,6 +325,8 @@ export const InformationUnitMetadataGridClient = ({
335
325
  metadataExcludeProperties,
336
326
  showBookmarkButton = false,
337
327
  showFileRenditions = true,
328
+ metadataLabelOverrides,
329
+ availableInVersions,
338
330
  }: Props) => {
339
331
  const t = useTranslations();
340
332
  const locale = useLocale();
@@ -347,8 +339,8 @@ export const InformationUnitMetadataGridClient = ({
347
339
  : [...(metadataExcludeProperties || []), "renditions"],
348
340
  });
349
341
 
350
- return buildMetadataDisplayRows(data, locale, includeProperties);
351
- }, [data, locale, metadataExcludeProperties, metadataIncludeProperties, showFileRenditions]);
342
+ return buildMetadataDisplayRows(data, locale, includeProperties, metadataLabelOverrides);
343
+ }, [data, locale, metadataExcludeProperties, metadataIncludeProperties, metadataLabelOverrides, showFileRenditions]);
352
344
 
353
345
  const cardContent = (
354
346
  <CardContent className="space-y-3 !p-0">
@@ -378,11 +370,37 @@ export const InformationUnitMetadataGridClient = ({
378
370
  </TableRow>
379
371
  ))}
380
372
 
381
- <AvailableInRow
382
- versionOfShortId={data.versionOf?.shortId}
383
- currentShortId={data.shortId}
384
- linkPattern={linkPattern}
385
- />
373
+ {availableInVersions ? (
374
+ availableInVersions
375
+ .filter((item) => item.shortId !== data.shortId)
376
+ .length > 0 ? (
377
+ <TableRow className="min-h-12">
378
+ <TableCell className="font-medium w-28 pl-4">
379
+ <h4 className="text-sm font-medium">{t("availableIn")}</h4>
380
+ </TableCell>
381
+ <TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
382
+ {availableInVersions
383
+ .filter((item) => item.shortId !== data.shortId)
384
+ .map((item) => (
385
+ <span className="w-8 block border" key={item.shortId}>
386
+ <Link
387
+ href={linkPattern.replace("{shortId}", item.shortId)}
388
+ title={item.language}
389
+ >
390
+ <Flag countryCode={extractCountryCodeFromLanguage(item.language)} />
391
+ </Link>
392
+ </span>
393
+ ))}
394
+ </TableCell>
395
+ </TableRow>
396
+ ) : null
397
+ ) : (
398
+ <AvailableInRow
399
+ versionOfShortId={data.versionOf?.shortId}
400
+ currentShortId={data.shortId}
401
+ linkPattern={linkPattern}
402
+ />
403
+ )}
386
404
  </TableBody>
387
405
  </Table>
388
406
  </CardContent>
@@ -0,0 +1,60 @@
1
+ import Link from "next/link";
2
+ import type { FC } from "react";
3
+ import {
4
+ Breadcrumb as BreadcrumbComponent,
5
+ BreadcrumbItem,
6
+ BreadcrumbList,
7
+ BreadcrumbPage,
8
+ BreadcrumbSeparator,
9
+ } from "@c-rex/ui/breadcrumb";
10
+ import type { TocBreadcrumbItem } from "./types";
11
+
12
+ type TocBreadcrumbProps = {
13
+ lang: string;
14
+ homeLabel: string;
15
+ homeHref?: string;
16
+ items: TocBreadcrumbItem[];
17
+ className?: string;
18
+ };
19
+
20
+ export const TocBreadcrumb: FC<TocBreadcrumbProps> = ({
21
+ lang,
22
+ homeLabel,
23
+ homeHref = "/",
24
+ items,
25
+ className = "hidden sm:block",
26
+ }) => {
27
+ if (items.length === 0) {
28
+ return null;
29
+ }
30
+
31
+ return (
32
+ <BreadcrumbComponent lang={lang} className={className}>
33
+ <BreadcrumbList>
34
+ <BreadcrumbItem>
35
+ <Link href={homeHref}>{homeLabel}</Link>
36
+ </BreadcrumbItem>
37
+ <BreadcrumbSeparator />
38
+
39
+ {items.map((item, index) => {
40
+ const isLast = item.isCurrent ?? index === items.length - 1;
41
+
42
+ return (
43
+ <span key={`${item.id}-${index}`} className="contents">
44
+ <BreadcrumbItem>
45
+ {isLast ? (
46
+ <BreadcrumbPage>{item.label}</BreadcrumbPage>
47
+ ) : item.href ? (
48
+ <Link href={item.href}>{item.label}</Link>
49
+ ) : (
50
+ <span>{item.label}</span>
51
+ )}
52
+ </BreadcrumbItem>
53
+ {!isLast && <BreadcrumbSeparator />}
54
+ </span>
55
+ );
56
+ })}
57
+ </BreadcrumbList>
58
+ </BreadcrumbComponent>
59
+ );
60
+ };
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { Button } from "@c-rex/ui/button";
6
+ import { cn } from "@c-rex/utils";
7
+
8
+ export type TocBrowseLink = {
9
+ href: string;
10
+ label: string;
11
+ };
12
+
13
+ type TocBrowseControlsProps = {
14
+ previous?: TocBrowseLink;
15
+ next?: TocBrowseLink;
16
+ className?: string;
17
+ showLabels?: boolean;
18
+ justify?: "between" | "end";
19
+ buttonGapClassName?: string;
20
+ buttonClassName?: string;
21
+ hideContainerBorder?: boolean;
22
+ };
23
+
24
+ export const TocBrowseControls = ({
25
+ previous,
26
+ next,
27
+ className,
28
+ showLabels = true,
29
+ justify = "between",
30
+ buttonGapClassName = "gap-2",
31
+ buttonClassName,
32
+ hideContainerBorder = false,
33
+ }: TocBrowseControlsProps) => {
34
+ if (!previous && !next) {
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <div
40
+ className={cn(
41
+ "flex items-center gap-2 rounded-md px-3 py-2",
42
+ hideContainerBorder ? "border-0 bg-transparent" : "border",
43
+ justify === "between" ? "justify-between" : "justify-end",
44
+ className
45
+ )}
46
+ >
47
+ <div className={cn("min-w-0", justify === "between" ? "flex-1" : "flex-none")}>
48
+ {previous ? (
49
+ <Button asChild variant="ghost" className={cn("max-w-full justify-start px-0", buttonClassName)}>
50
+ <Link href={previous.href} title={previous.label} className={cn("inline-flex min-w-0 items-center", buttonGapClassName)}>
51
+ <ChevronLeft className="h-4 w-4 shrink-0" />
52
+ {showLabels ? <span className="truncate">{previous.label}</span> : null}
53
+ </Link>
54
+ </Button>
55
+ ) : (
56
+ justify === "between" ? <span className="text-sm text-muted-foreground">&nbsp;</span> : null
57
+ )}
58
+ </div>
59
+
60
+ <div className={cn("min-w-0 text-right", justify === "between" ? "flex-1" : "flex-none")}>
61
+ {next ? (
62
+ <Button asChild variant="ghost" className={cn("max-w-full justify-end px-0", buttonClassName)}>
63
+ <Link href={next.href} title={next.label} className={cn("inline-flex min-w-0 items-center", buttonGapClassName)}>
64
+ {showLabels ? <span className="truncate">{next.label}</span> : null}
65
+ <ChevronRight className="h-4 w-4 shrink-0" />
66
+ </Link>
67
+ </Button>
68
+ ) : (
69
+ justify === "between" ? <span className="text-sm text-muted-foreground">&nbsp;</span> : null
70
+ )}
71
+ </div>
72
+ </div>
73
+ );
74
+ };
@@ -0,0 +1,278 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type FC, type ReactNode } from "react";
4
+ import Link from "next/link";
5
+ import { ChevronDown, ChevronRight } from "lucide-react";
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarGroup,
10
+ SidebarHeader,
11
+ SidebarMenu,
12
+ SidebarMenuButton,
13
+ SidebarMenuItem,
14
+ SidebarMenuSub,
15
+ } from "@c-rex/ui/sidebar";
16
+ import { Skeleton } from "@c-rex/ui/skeleton";
17
+ import type { TocNodeSummary } from "./types";
18
+
19
+ type TocTreePanelProps = {
20
+ lang: string;
21
+ rootNodeId?: string;
22
+ activePathIds?: string[];
23
+ initialChildrenByParentId?: Record<string, TocNodeSummary[]>;
24
+ placeholder?: ReactNode;
25
+ headerContent?: ReactNode;
26
+ headerTitle?: ReactNode;
27
+ loadChildren: (parentDirectoryNodeShortId: string) => Promise<TocNodeSummary[]>;
28
+ buildNodeHref?: (node: TocNodeSummary) => string | undefined;
29
+ defaultWidthPx?: number;
30
+ minWidthPx?: number;
31
+ maxWidthPx?: number;
32
+ };
33
+
34
+ const DEFAULT_TOC_WIDTH_PX = 336;
35
+ const MIN_TOC_WIDTH_PX = 260;
36
+ const MAX_TOC_WIDTH_PX = 640;
37
+
38
+ const loadingSkeleton = (
39
+ <div className="pt-4 space-y-2">
40
+ <Skeleton className="w-auto h-10 mb-2" />
41
+ <Skeleton className="w-auto h-10 mb-2" />
42
+ <Skeleton className="w-auto h-10 mb-2 ml-8" />
43
+ <Skeleton className="w-auto h-10 mb-2 ml-8" />
44
+ <div className="px-2 pt-1 text-xs text-muted-foreground">Inhaltsverzeichnis wird geladen...</div>
45
+ </div>
46
+ );
47
+
48
+ export const TocTreePanel: FC<TocTreePanelProps> = ({
49
+ lang,
50
+ rootNodeId,
51
+ activePathIds = [],
52
+ initialChildrenByParentId = {},
53
+ placeholder,
54
+ headerContent,
55
+ headerTitle = "Inhaltsverzeichnis",
56
+ loadChildren,
57
+ buildNodeHref,
58
+ defaultWidthPx = DEFAULT_TOC_WIDTH_PX,
59
+ minWidthPx = MIN_TOC_WIDTH_PX,
60
+ maxWidthPx = MAX_TOC_WIDTH_PX,
61
+ }) => {
62
+ const [childrenByParentId, setChildrenByParentId] = useState<Record<string, TocNodeSummary[]>>(initialChildrenByParentId);
63
+ const [loadingParentIds, setLoadingParentIds] = useState<Set<string>>(new Set());
64
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(activePathIds));
65
+ const [tocWidthPx, setTocWidthPx] = useState(defaultWidthPx);
66
+ const resizeStartRef = useRef<{ startX: number; startWidth: number } | null>(null);
67
+ const pendingChildrenRequestsRef = useRef<Map<string, Promise<void>>>(new Map());
68
+
69
+ const activeIdSet = useMemo(() => new Set(activePathIds), [activePathIds]);
70
+
71
+ useEffect(() => {
72
+ setChildrenByParentId((prev) => ({
73
+ ...prev,
74
+ ...initialChildrenByParentId,
75
+ }));
76
+ }, [initialChildrenByParentId]);
77
+
78
+ useEffect(() => {
79
+ setExpandedIds(new Set(activePathIds));
80
+ }, [activePathIds]);
81
+
82
+ useEffect(() => {
83
+ const knownNodesById = new Map(
84
+ Object.values(childrenByParentId)
85
+ .flat()
86
+ .map((node) => [node.id, node] as const)
87
+ );
88
+
89
+ expandedIds.forEach((nodeId) => {
90
+ const node = knownNodesById.get(nodeId);
91
+ if (node?.hasChildren && !childrenByParentId[nodeId] && !loadingParentIds.has(nodeId)) {
92
+ void ensureChildrenLoaded(nodeId);
93
+ }
94
+ });
95
+ }, [childrenByParentId, expandedIds, loadingParentIds]);
96
+
97
+ useEffect(() => {
98
+ const handleMove = (event: MouseEvent) => {
99
+ if (!resizeStartRef.current) return;
100
+ const deltaX = event.clientX - resizeStartRef.current.startX;
101
+ const nextWidth = Math.max(
102
+ minWidthPx,
103
+ Math.min(maxWidthPx, resizeStartRef.current.startWidth + deltaX)
104
+ );
105
+ setTocWidthPx(nextWidth);
106
+ };
107
+
108
+ const handleUp = () => {
109
+ resizeStartRef.current = null;
110
+ };
111
+
112
+ window.addEventListener("mousemove", handleMove);
113
+ window.addEventListener("mouseup", handleUp);
114
+
115
+ return () => {
116
+ window.removeEventListener("mousemove", handleMove);
117
+ window.removeEventListener("mouseup", handleUp);
118
+ };
119
+ }, [maxWidthPx, minWidthPx]);
120
+
121
+ const ensureChildrenLoaded = async (parentDirectoryNodeShortId: string) => {
122
+ if (!parentDirectoryNodeShortId || childrenByParentId[parentDirectoryNodeShortId] || loadingParentIds.has(parentDirectoryNodeShortId)) {
123
+ return;
124
+ }
125
+
126
+ const existingRequest = pendingChildrenRequestsRef.current.get(parentDirectoryNodeShortId);
127
+ if (existingRequest) {
128
+ return existingRequest;
129
+ }
130
+
131
+ const requestPromise = (async () => {
132
+ setLoadingParentIds((prev) => new Set(prev).add(parentDirectoryNodeShortId));
133
+ try {
134
+ const children = await loadChildren(parentDirectoryNodeShortId);
135
+ setChildrenByParentId((prev) => ({
136
+ ...prev,
137
+ [parentDirectoryNodeShortId]: children || [],
138
+ }));
139
+ } catch (error) {
140
+ console.error("[TocTreePanel] Failed to load children", error);
141
+ setChildrenByParentId((prev) => ({
142
+ ...prev,
143
+ [parentDirectoryNodeShortId]: [],
144
+ }));
145
+ } finally {
146
+ pendingChildrenRequestsRef.current.delete(parentDirectoryNodeShortId);
147
+ setLoadingParentIds((prev) => {
148
+ const next = new Set(prev);
149
+ next.delete(parentDirectoryNodeShortId);
150
+ return next;
151
+ });
152
+ }
153
+ })();
154
+
155
+ pendingChildrenRequestsRef.current.set(parentDirectoryNodeShortId, requestPromise);
156
+ return requestPromise;
157
+ };
158
+
159
+ const toggleExpanded = (nodeId: string, hasChildren: boolean) => {
160
+ setExpandedIds((prev) => {
161
+ const next = new Set(prev);
162
+ if (next.has(nodeId)) {
163
+ next.delete(nodeId);
164
+ return next;
165
+ }
166
+ next.add(nodeId);
167
+ return next;
168
+ });
169
+
170
+ if (hasChildren) {
171
+ void ensureChildrenLoaded(nodeId);
172
+ }
173
+ };
174
+
175
+ const renderNode = (node: TocNodeSummary): ReactNode => {
176
+ const isExpanded = expandedIds.has(node.id);
177
+ const children = childrenByParentId[node.id] || [];
178
+ const isLoadingChildren = loadingParentIds.has(node.id);
179
+ const href = buildNodeHref?.(node);
180
+
181
+ return (
182
+ <SidebarMenuItem key={node.id}>
183
+ <div className="flex min-w-0 items-center gap-1">
184
+ {node.hasChildren ? (
185
+ <button
186
+ type="button"
187
+ aria-label={isExpanded ? "Collapse section" : "Expand section"}
188
+ className="h-8 w-8 inline-flex items-center justify-center text-muted-foreground hover:text-foreground shrink-0"
189
+ onClick={() => toggleExpanded(node.id, node.hasChildren)}
190
+ >
191
+ {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
192
+ </button>
193
+ ) : (
194
+ <span className="h-8 w-8 shrink-0" aria-hidden="true" />
195
+ )}
196
+
197
+ {href ? (
198
+ <SidebarMenuButton asChild isActive={activeIdSet.has(node.id)} className="flex-1 min-w-0 whitespace-nowrap">
199
+ <Link href={href} title={node.label} className="w-full min-w-0 overflow-hidden text-left justify-start">
200
+ <span className="truncate">{node.label}</span>
201
+ </Link>
202
+ </SidebarMenuButton>
203
+ ) : (
204
+ <SidebarMenuButton
205
+ type="button"
206
+ isActive={activeIdSet.has(node.id)}
207
+ className="flex-1 min-w-0 whitespace-nowrap"
208
+ onClick={() => node.hasChildren && toggleExpanded(node.id, node.hasChildren)}
209
+ title={node.label}
210
+ >
211
+ <span className="truncate">{node.label}</span>
212
+ </SidebarMenuButton>
213
+ )}
214
+ </div>
215
+
216
+ {node.hasChildren && isExpanded ? (
217
+ <SidebarMenuSub>
218
+ {isLoadingChildren && children.length === 0 ? (
219
+ <div className="space-y-2 py-2">
220
+ <Skeleton className="h-8 w-full" />
221
+ <Skeleton className="h-8 w-[85%]" />
222
+ </div>
223
+ ) : children.map((child) => renderNode(child))}
224
+ </SidebarMenuSub>
225
+ ) : null}
226
+ </SidebarMenuItem>
227
+ );
228
+ };
229
+
230
+ const rootChildren = rootNodeId ? (childrenByParentId[rootNodeId] || []) : [];
231
+ const sidebarStyle = {
232
+ "--sidebar-width": `${tocWidthPx}px`,
233
+ } as CSSProperties;
234
+
235
+ return (
236
+ <div className="relative shrink-0" style={sidebarStyle}>
237
+ <Sidebar side="left">
238
+ <SidebarHeader className="border-b px-2 py-2">
239
+ <div className="flex min-w-0 w-full items-center justify-between gap-2">
240
+ <div className="min-w-0 overflow-hidden pl-9 text-sm font-medium text-foreground" title={typeof headerTitle === "string" ? headerTitle : undefined}>
241
+ <span className="truncate">{headerTitle}</span>
242
+ </div>
243
+ <div className="ml-auto flex shrink-0 items-center">
244
+ {headerContent}
245
+ </div>
246
+ </div>
247
+ </SidebarHeader>
248
+ <SidebarContent className="toc-scrollbar overflow-y-auto overflow-x-hidden">
249
+ <SidebarGroup lang={lang} className="min-w-0">
250
+ {!rootNodeId ? (
251
+ placeholder || null
252
+ ) : rootChildren.length === 0 && loadingParentIds.size === 0 ? (
253
+ placeholder || loadingSkeleton
254
+ ) : (
255
+ <div className="min-w-0">
256
+ <SidebarMenu className="min-w-0">
257
+ {rootChildren.map((item) => renderNode(item))}
258
+ </SidebarMenu>
259
+ </div>
260
+ )}
261
+ </SidebarGroup>
262
+ </SidebarContent>
263
+ </Sidebar>
264
+ <button
265
+ type="button"
266
+ aria-label="Resize table of contents"
267
+ title="Resize table of contents"
268
+ className="absolute inset-y-0 -right-2 z-20 hidden w-4 cursor-ew-resize lg:block"
269
+ onMouseDown={(event) => {
270
+ resizeStartRef.current = { startX: event.clientX, startWidth: tocWidthPx };
271
+ }}
272
+ onDoubleClick={() => setTocWidthPx(defaultWidthPx)}
273
+ >
274
+ <span className="pointer-events-none absolute inset-y-4 left-1/2 w-px -translate-x-1/2 rounded-full bg-border" />
275
+ </button>
276
+ </div>
277
+ );
278
+ };
@@ -0,0 +1,27 @@
1
+ export type TocNodeSummary = {
2
+ id: string;
3
+ label: string;
4
+ topicId?: string;
5
+ hasChildren: boolean;
6
+ isNavigable: boolean;
7
+ };
8
+
9
+ export type TocPathEntry = {
10
+ id: string;
11
+ label: string;
12
+ topicId?: string;
13
+ isNavigable: boolean;
14
+ };
15
+
16
+ export type TocBrowseTarget = {
17
+ directoryNodeShortId: string;
18
+ topicId: string;
19
+ label: string;
20
+ };
21
+
22
+ export type TocBreadcrumbItem = {
23
+ id: string;
24
+ label: string;
25
+ href?: string;
26
+ isCurrent?: boolean;
27
+ };