@c-rex/components 0.3.0-build.31 → 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.31",
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"));
@@ -79,7 +89,8 @@ const asLiteralArray = (value: unknown): LiteralModel[] => {
79
89
  const buildMetadataDisplayRows = (
80
90
  data: CommonItemsModel,
81
91
  uiLanguage: string,
82
- includeProperties: InformationUnitPropertyKey[]
92
+ includeProperties: InformationUnitPropertyKey[],
93
+ overrides?: MetadataFacetLabelOverrides
83
94
  ): MetadataDisplayRow[] => {
84
95
  const preferredTitle = resolveLiteralLabel(data.titles || [], uiLanguage) || resolveLiteralLabel(data.labels || [], uiLanguage);
85
96
  const rows: MetadataDisplayRow[] = [];
@@ -177,23 +188,37 @@ const buildMetadataDisplayRows = (
177
188
  const refs = asObjectRefArray(value);
178
189
  if (refs.length === 0) continue;
179
190
 
180
- const values = Array.from(
181
- new Set(
182
- refs
183
- .map((ref) => resolveObjectRefLabel(ref, uiLanguage))
184
- .filter((label): label is string => Boolean(label))
185
- )
186
- ).sort((a, b) => a.localeCompare(b));
191
+ const groupedValues = new Map<string, Set<string>>();
187
192
 
188
- if (values.length === 0) continue;
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;
189
198
 
190
- rows.push({
191
- key,
192
- label: key,
193
- labelSource: "translationKey",
194
- values,
195
- valueType: "text",
199
+ const sectionLabel = config.metadataDisplay.sectionStrategy === "none"
200
+ ? key
201
+ : override?.sectionLabel || resolveObjectRefClassLabel(ref, uiLanguage) || key;
202
+
203
+ const existing = groupedValues.get(sectionLabel) || new Set<string>();
204
+ existing.add(valueLabel);
205
+ groupedValues.set(sectionLabel, existing);
196
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
+ });
197
222
  continue;
198
223
  }
199
224
 
@@ -244,6 +269,7 @@ const AvailableInRow = ({
244
269
  versionOfShortId?: string | null;
245
270
  currentShortId?: string | null;
246
271
  linkPattern: string;
272
+ availableInVersions?: AvailableLanguageVersion[];
247
273
  }) => {
248
274
  const t = useTranslations();
249
275
  const locale = useLocale();
@@ -299,6 +325,8 @@ export const InformationUnitMetadataGridClient = ({
299
325
  metadataExcludeProperties,
300
326
  showBookmarkButton = false,
301
327
  showFileRenditions = true,
328
+ metadataLabelOverrides,
329
+ availableInVersions,
302
330
  }: Props) => {
303
331
  const t = useTranslations();
304
332
  const locale = useLocale();
@@ -311,8 +339,8 @@ export const InformationUnitMetadataGridClient = ({
311
339
  : [...(metadataExcludeProperties || []), "renditions"],
312
340
  });
313
341
 
314
- return buildMetadataDisplayRows(data, locale, includeProperties);
315
- }, [data, locale, metadataExcludeProperties, metadataIncludeProperties, showFileRenditions]);
342
+ return buildMetadataDisplayRows(data, locale, includeProperties, metadataLabelOverrides);
343
+ }, [data, locale, metadataExcludeProperties, metadataIncludeProperties, metadataLabelOverrides, showFileRenditions]);
316
344
 
317
345
  const cardContent = (
318
346
  <CardContent className="space-y-3 !p-0">
@@ -342,11 +370,37 @@ export const InformationUnitMetadataGridClient = ({
342
370
  </TableRow>
343
371
  ))}
344
372
 
345
- <AvailableInRow
346
- versionOfShortId={data.versionOf?.shortId}
347
- currentShortId={data.shortId}
348
- linkPattern={linkPattern}
349
- />
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
+ )}
350
404
  </TableBody>
351
405
  </Table>
352
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
+ };