@object-ui/layout 3.1.5 → 3.3.1

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +21 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +174 -152
  5. package/dist/index.umd.cjs +3 -3
  6. package/package.json +45 -10
  7. package/.turbo/turbo-build.log +0 -38
  8. package/src/AppSchemaRenderer.tsx +0 -480
  9. package/src/AppShell.tsx +0 -149
  10. package/src/NavigationRenderer.tsx +0 -746
  11. package/src/Page.tsx +0 -39
  12. package/src/PageCard.tsx +0 -12
  13. package/src/PageHeader.tsx +0 -35
  14. package/src/ResponsiveGrid.tsx +0 -118
  15. package/src/SidebarNav.tsx +0 -164
  16. package/src/__tests__/AppSchemaRenderer.test.tsx +0 -408
  17. package/src/__tests__/NavigationRenderer.test.tsx +0 -562
  18. package/src/index.ts +0 -96
  19. package/src/stories/AppShell.stories.tsx +0 -110
  20. package/src/stories/ResponsiveGrid.stories.tsx +0 -110
  21. package/src/stories/SidebarNav.stories.tsx +0 -223
  22. package/tsconfig.json +0 -9
  23. package/vite.config.ts +0 -38
  24. /package/dist/{layout → packages/layout}/src/AppSchemaRenderer.d.ts +0 -0
  25. /package/dist/{layout → packages/layout}/src/AppShell.d.ts +0 -0
  26. /package/dist/{layout → packages/layout}/src/NavigationRenderer.d.ts +0 -0
  27. /package/dist/{layout → packages/layout}/src/Page.d.ts +0 -0
  28. /package/dist/{layout → packages/layout}/src/PageCard.d.ts +0 -0
  29. /package/dist/{layout → packages/layout}/src/PageHeader.d.ts +0 -0
  30. /package/dist/{layout → packages/layout}/src/ResponsiveGrid.d.ts +0 -0
  31. /package/dist/{layout → packages/layout}/src/SidebarNav.d.ts +0 -0
  32. /package/dist/{layout → packages/layout}/src/index.d.ts +0 -0
@@ -1,746 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- /**
10
- * @object-ui/layout - Navigation Renderer
11
- *
12
- * Renders a `NavigationItem[]` tree from AppSchema JSON into a Shadcn sidebar.
13
- * Supports all 7 navigation item types: object, dashboard, page, report,
14
- * url, action, group — plus separators, badges, visibility expressions,
15
- * and RBAC permission guards.
16
- *
17
- * Enhanced with:
18
- * - Search filtering across navigation tree
19
- * - Pin/favorite navigation items (pinned items in "Favorites" section)
20
- * - Drag-to-reorder navigation items via @dnd-kit
21
- *
22
- * @module NavigationRenderer
23
- */
24
-
25
- import React, { useState, useMemo } from 'react';
26
- import { Link, useLocation } from 'react-router-dom';
27
- import * as LucideIcons from 'lucide-react';
28
- import {
29
- DndContext,
30
- closestCenter,
31
- KeyboardSensor,
32
- PointerSensor,
33
- useSensor,
34
- useSensors,
35
- type DragEndEvent,
36
- } from '@dnd-kit/core';
37
- import {
38
- SortableContext,
39
- verticalListSortingStrategy,
40
- useSortable,
41
- arrayMove,
42
- } from '@dnd-kit/sortable';
43
- import { CSS } from '@dnd-kit/utilities';
44
- import {
45
- SidebarGroup,
46
- SidebarGroupLabel,
47
- SidebarGroupContent,
48
- SidebarMenu,
49
- SidebarMenuItem,
50
- SidebarMenuButton,
51
- SidebarMenuAction,
52
- Collapsible,
53
- CollapsibleTrigger,
54
- CollapsibleContent,
55
- Badge,
56
- Separator,
57
- } from '@object-ui/components';
58
- import type { NavigationItem } from '@object-ui/types';
59
-
60
- // ---------------------------------------------------------------------------
61
- // Types
62
- // ---------------------------------------------------------------------------
63
-
64
- /**
65
- * Callback to evaluate a visibility expression.
66
- * Return `true` if the item should be visible.
67
- * When not provided, all items default to visible.
68
- */
69
- export type VisibilityEvaluator = (
70
- expression: string | boolean | undefined,
71
- ) => boolean;
72
-
73
- /**
74
- * Callback to check whether the current user satisfies **all** of the
75
- * given permission strings. Each string is opaque — the consumer decides
76
- * the format (e.g. `"object:action"` or a named role).
77
- * When not provided, all items default to permitted.
78
- */
79
- export type PermissionChecker = (permissions: string[]) => boolean;
80
-
81
- export interface NavigationRendererProps {
82
- /** Navigation items to render */
83
- items: NavigationItem[];
84
-
85
- /**
86
- * Base URL prefix prepended to generated hrefs.
87
- * @example "/apps/crm"
88
- */
89
- basePath?: string;
90
-
91
- /** Optional visibility evaluator for `visible` expressions */
92
- evaluateVisibility?: VisibilityEvaluator;
93
-
94
- /** Optional permission checker for `requiredPermissions` */
95
- checkPermission?: PermissionChecker;
96
-
97
- /** Called when an `action`-type item is clicked */
98
- onAction?: (item: NavigationItem) => void;
99
-
100
- // --- P1.7 Navigation Enhancements ---
101
-
102
- /** Search query to filter navigation items by label */
103
- searchQuery?: string;
104
-
105
- /** Enable pin/favorite toggle on navigation items */
106
- enablePinning?: boolean;
107
-
108
- /** Called when a navigation item is pinned or unpinned */
109
- onPinToggle?: (itemId: string, pinned: boolean) => void;
110
-
111
- /** Enable drag-to-reorder for navigation items */
112
- enableReorder?: boolean;
113
-
114
- /** Called when navigation items are reordered via drag */
115
- onReorder?: (reorderedItems: NavigationItem[]) => void;
116
-
117
- /**
118
- * Optional label resolver for object-type navigation items.
119
- * When provided, called with `(objectName, fallbackLabel)` for items
120
- * where `item.type === 'object'` and `item.label` is a plain string.
121
- * Enables convention-based i18n auto-resolution without coupling
122
- * the layout package to i18n.
123
- */
124
- resolveObjectLabel?: (objectName: string, fallbackLabel: string) => string;
125
-
126
- /**
127
- * Optional i18n translation function for resolving I18nLabel objects
128
- * (`{ key, defaultValue }`). When provided, labels are translated
129
- * through i18next; otherwise falls back to `defaultValue`.
130
- */
131
- t?: (key: string, options?: any) => string;
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Icon Helper
136
- // ---------------------------------------------------------------------------
137
-
138
- const iconCache = new Map<string, React.ComponentType<any>>();
139
-
140
- /**
141
- * Resolve a Lucide icon component by name string.
142
- * Supports PascalCase, camelCase, and kebab-case.
143
- */
144
- export function resolveIcon(name?: string): React.ComponentType<any> {
145
- if (!name) return LucideIcons.FileText;
146
-
147
- const cached = iconCache.get(name);
148
- if (cached) return cached;
149
-
150
- // Direct match
151
- if ((LucideIcons as any)[name]) {
152
- iconCache.set(name, (LucideIcons as any)[name]);
153
- return (LucideIcons as any)[name];
154
- }
155
-
156
- // kebab-case → PascalCase
157
- const pascal = name
158
- .split('-')
159
- .filter(Boolean)
160
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
161
- .join('');
162
- if ((LucideIcons as any)[pascal]) {
163
- iconCache.set(name, (LucideIcons as any)[pascal]);
164
- return (LucideIcons as any)[pascal];
165
- }
166
-
167
- return LucideIcons.FileText;
168
- }
169
-
170
- // ---------------------------------------------------------------------------
171
- // I18nLabel resolver
172
- // ---------------------------------------------------------------------------
173
-
174
- /**
175
- * Resolve a NavigationItem label to a plain string.
176
- * Handles both plain strings and I18nLabel objects { key, defaultValue }.
177
- * When a `t` function is provided, I18nLabel objects are translated via i18next.
178
- */
179
- export function resolveLabel(
180
- label: string | { key: string; defaultValue?: string; params?: Record<string, any> },
181
- t?: (key: string, options?: any) => string,
182
- ): string {
183
- if (typeof label === 'string') return label;
184
- if (t) {
185
- const result = t(label.key, { defaultValue: label.defaultValue, ...label.params });
186
- if (result && result !== label.key) return result;
187
- }
188
- return label.defaultValue || label.key;
189
- }
190
-
191
- /**
192
- * Resolve a navigation item label, applying:
193
- * 1. i18n translation for I18nLabel objects (when `t` is provided)
194
- * 2. Convention-based i18n for object-type items with plain string labels
195
- * (when `resolveObjectLabel` is provided)
196
- */
197
- function resolveItemLabel(
198
- item: NavigationItem,
199
- resolver?: (objectName: string, fallbackLabel: string) => string,
200
- t?: (key: string, options?: any) => string,
201
- ): string {
202
- const base = resolveLabel(item.label, t);
203
- // Only apply convention-based resolution for object-type items with plain string labels.
204
- // I18nLabel objects (with explicit key/defaultValue) already have their own translation keys.
205
- if (resolver && item.type === 'object' && item.objectName && typeof item.label === 'string') {
206
- return resolver(item.objectName, base);
207
- }
208
- return base;
209
- }
210
-
211
- // ---------------------------------------------------------------------------
212
- // Default evaluators (always-visible, always-permitted)
213
- // ---------------------------------------------------------------------------
214
-
215
- const defaultVisibility: VisibilityEvaluator = (expr) => {
216
- if (expr === false || expr === 'false') return false;
217
- return true;
218
- };
219
-
220
- const defaultPermission: PermissionChecker = () => true;
221
-
222
- // ---------------------------------------------------------------------------
223
- // Internal helper: resolve href from NavigationItem
224
- // ---------------------------------------------------------------------------
225
-
226
- function resolveHref(item: NavigationItem, basePath: string): { href: string; external: boolean } {
227
- switch (item.type) {
228
- case 'object': {
229
- const objectPath = `${basePath}/${item.objectName ?? ''}`;
230
- return { href: item.viewName ? `${objectPath}/view/${item.viewName}` : objectPath, external: false };
231
- }
232
- case 'dashboard':
233
- return { href: item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#', external: false };
234
- case 'page':
235
- return { href: item.pageName ? `${basePath}/page/${item.pageName}` : '#', external: false };
236
- case 'report':
237
- return { href: item.reportName ? `${basePath}/report/${item.reportName}` : '#', external: false };
238
- case 'url':
239
- return { href: item.url ?? '#', external: item.target === '_blank' };
240
- default:
241
- return { href: '#', external: false };
242
- }
243
- }
244
-
245
- // ---------------------------------------------------------------------------
246
- // Search filter helper
247
- // ---------------------------------------------------------------------------
248
-
249
- /**
250
- * Recursively filter navigation items by search query (case-insensitive label match).
251
- * Groups are kept if any child matches, with non-matching children pruned.
252
- */
253
- export function filterNavigationItems(
254
- items: NavigationItem[],
255
- query: string,
256
- ): NavigationItem[] {
257
- if (!query.trim()) return items;
258
- const lowerQuery = query.toLowerCase().trim();
259
-
260
- return items.reduce<NavigationItem[]>((acc, item) => {
261
- // Separators are excluded during search
262
- if (item.type === 'separator') return acc;
263
-
264
- // Groups: recursively filter children
265
- if (item.type === 'group' && item.children?.length) {
266
- const filteredChildren = filterNavigationItems(item.children, query);
267
- if (filteredChildren.length > 0) {
268
- acc.push({ ...item, children: filteredChildren });
269
- }
270
- return acc;
271
- }
272
-
273
- // Leaf items: match label
274
- if (resolveLabel(item.label).toLowerCase().includes(lowerQuery)) {
275
- acc.push(item);
276
- }
277
- return acc;
278
- }, []);
279
- }
280
-
281
- /** Minimum drag distance in pixels to activate reorder */
282
- const DRAG_ACTIVATION_DISTANCE = 5;
283
-
284
- // ---------------------------------------------------------------------------
285
- // SortableNavigationItem (drag-reorder wrapper)
286
- // ---------------------------------------------------------------------------
287
-
288
- function SortableNavigationItem({
289
- item,
290
- basePath,
291
- evalVis,
292
- checkPerm,
293
- onAction,
294
- enablePinning,
295
- onPinToggle,
296
- enableReorder,
297
- resolveObjectLabel,
298
- t: tProp,
299
- }: {
300
- item: NavigationItem;
301
- basePath: string;
302
- evalVis: VisibilityEvaluator;
303
- checkPerm: PermissionChecker;
304
- onAction?: (item: NavigationItem) => void;
305
- enablePinning?: boolean;
306
- onPinToggle?: (itemId: string, pinned: boolean) => void;
307
- enableReorder?: boolean;
308
- resolveObjectLabel?: (objectName: string, fallbackLabel: string) => string;
309
- t?: (key: string, options?: any) => string;
310
- }) {
311
- const {
312
- attributes,
313
- listeners,
314
- setNodeRef,
315
- transform,
316
- transition,
317
- isDragging,
318
- } = useSortable({ id: item.id, disabled: !enableReorder });
319
-
320
- const style: React.CSSProperties = {
321
- transform: CSS.Transform.toString(transform),
322
- transition,
323
- opacity: isDragging ? 0.5 : undefined,
324
- zIndex: isDragging ? 10 : undefined,
325
- };
326
-
327
- return (
328
- <div ref={setNodeRef} style={style} {...attributes}>
329
- <NavigationItemRenderer
330
- item={item}
331
- basePath={basePath}
332
- evalVis={evalVis}
333
- checkPerm={checkPerm}
334
- onAction={onAction}
335
- enablePinning={enablePinning}
336
- onPinToggle={onPinToggle}
337
- dragListeners={enableReorder ? listeners : undefined}
338
- resolveObjectLabel={resolveObjectLabel}
339
- t={tProp}
340
- />
341
- </div>
342
- );
343
- }
344
-
345
- // ---------------------------------------------------------------------------
346
- // NavigationItemRenderer (recursive)
347
- // ---------------------------------------------------------------------------
348
-
349
- function NavigationItemRenderer({
350
- item,
351
- basePath,
352
- evalVis,
353
- checkPerm,
354
- onAction,
355
- enablePinning,
356
- onPinToggle,
357
- dragListeners,
358
- resolveObjectLabel,
359
- t: tProp,
360
- }: {
361
- item: NavigationItem;
362
- basePath: string;
363
- evalVis: VisibilityEvaluator;
364
- checkPerm: PermissionChecker;
365
- onAction?: (item: NavigationItem) => void;
366
- enablePinning?: boolean;
367
- onPinToggle?: (itemId: string, pinned: boolean) => void;
368
- dragListeners?: Record<string, any>;
369
- resolveObjectLabel?: (objectName: string, fallbackLabel: string) => string;
370
- t?: (key: string, options?: any) => string;
371
- }) {
372
- const location = useLocation();
373
- const [isOpen, setIsOpen] = useState(item.defaultOpen !== false);
374
-
375
- // --- Visibility guard ---
376
- if (!evalVis(item.visible)) return null;
377
-
378
- // --- Permission guard ---
379
- if (item.requiredPermissions?.length && !checkPerm(item.requiredPermissions)) return null;
380
-
381
- // --- Separator ---
382
- if (item.type === 'separator') {
383
- return <Separator className="my-2" />;
384
- }
385
-
386
- // --- Group (collapsible) ---
387
- if (item.type === 'group') {
388
- const children = (item.children ?? [])
389
- .slice()
390
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
391
-
392
- return (
393
- <Collapsible open={isOpen} onOpenChange={setIsOpen}>
394
- <SidebarGroup>
395
- <SidebarGroupLabel asChild>
396
- <CollapsibleTrigger className="flex w-full items-center justify-between">
397
- {resolveLabel(item.label, tProp)}
398
- <LucideIcons.ChevronRight
399
- className={`ml-auto h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
400
- />
401
- </CollapsibleTrigger>
402
- </SidebarGroupLabel>
403
- <CollapsibleContent>
404
- <SidebarGroupContent>
405
- <SidebarMenu>
406
- {children.map((child) => (
407
- <NavigationItemRenderer
408
- key={child.id}
409
- item={child}
410
- basePath={basePath}
411
- evalVis={evalVis}
412
- checkPerm={checkPerm}
413
- onAction={onAction}
414
- enablePinning={enablePinning}
415
- onPinToggle={onPinToggle}
416
- resolveObjectLabel={resolveObjectLabel}
417
- t={tProp}
418
- />
419
- ))}
420
- </SidebarMenu>
421
- </SidebarGroupContent>
422
- </CollapsibleContent>
423
- </SidebarGroup>
424
- </Collapsible>
425
- );
426
- }
427
-
428
- // --- Action ---
429
- if (item.type === 'action') {
430
- const Icon = resolveIcon(item.icon);
431
- return (
432
- <SidebarMenuItem>
433
- {dragListeners && (
434
- <span className="absolute left-0.5 top-1/2 -translate-y-1/2 cursor-grab text-muted-foreground" aria-label="Drag to reorder" {...dragListeners}>
435
- <LucideIcons.GripVertical className="h-3.5 w-3.5" />
436
- </span>
437
- )}
438
- <SidebarMenuButton
439
- tooltip={resolveLabel(item.label, tProp)}
440
- onClick={() => onAction?.(item)}
441
- >
442
- <Icon className="h-4 w-4" />
443
- <span>{resolveLabel(item.label, tProp)}</span>
444
- {item.badge != null && (
445
- <Badge variant={item.badgeVariant ?? 'default'} className="ml-auto text-[10px] px-1.5 py-0">
446
- {item.badge}
447
- </Badge>
448
- )}
449
- </SidebarMenuButton>
450
- {enablePinning && onPinToggle && (
451
- <SidebarMenuAction
452
- showOnHover
453
- onClick={() => onPinToggle(item.id, !item.pinned)}
454
- aria-label={item.pinned ? `Unpin ${resolveLabel(item.label, tProp)}` : `Pin ${resolveLabel(item.label, tProp)}`}
455
- >
456
- {item.pinned ? (
457
- <LucideIcons.PinOff className="h-3.5 w-3.5" />
458
- ) : (
459
- <LucideIcons.Pin className="h-3.5 w-3.5" />
460
- )}
461
- </SidebarMenuAction>
462
- )}
463
- </SidebarMenuItem>
464
- );
465
- }
466
-
467
- // --- Leaf items (object / dashboard / page / report / url) ---
468
- const Icon = resolveIcon(item.icon);
469
- const { href, external } = resolveHref(item, basePath);
470
- const isActive = href !== '#' && location.pathname.startsWith(href);
471
- const itemLabel = resolveItemLabel(item, resolveObjectLabel, tProp);
472
-
473
- const content = (
474
- <>
475
- <Icon className="h-4 w-4" />
476
- <span>{itemLabel}</span>
477
- {item.badge != null && (
478
- <Badge variant={item.badgeVariant ?? 'default'} className="ml-auto text-[10px] px-1.5 py-0">
479
- {item.badge}
480
- </Badge>
481
- )}
482
- </>
483
- );
484
-
485
- return (
486
- <SidebarMenuItem>
487
- {dragListeners && (
488
- <span className="absolute left-0.5 top-1/2 -translate-y-1/2 cursor-grab text-muted-foreground" aria-label="Drag to reorder" {...dragListeners}>
489
- <LucideIcons.GripVertical className="h-3.5 w-3.5" />
490
- </span>
491
- )}
492
- <SidebarMenuButton asChild isActive={isActive} tooltip={itemLabel}>
493
- {external ? (
494
- <a href={href} target="_blank" rel="noopener noreferrer">
495
- {content}
496
- </a>
497
- ) : (
498
- <Link to={href}>
499
- {content}
500
- </Link>
501
- )}
502
- </SidebarMenuButton>
503
- {enablePinning && onPinToggle && (
504
- <SidebarMenuAction
505
- showOnHover
506
- onClick={() => onPinToggle(item.id, !item.pinned)}
507
- aria-label={item.pinned ? `Unpin ${itemLabel}` : `Pin ${itemLabel}`}
508
- >
509
- {item.pinned ? (
510
- <LucideIcons.PinOff className="h-3.5 w-3.5" />
511
- ) : (
512
- <LucideIcons.Pin className="h-3.5 w-3.5" />
513
- )}
514
- </SidebarMenuAction>
515
- )}
516
- </SidebarMenuItem>
517
- );
518
- }
519
-
520
- // ---------------------------------------------------------------------------
521
- // NavigationRenderer (main export)
522
- // ---------------------------------------------------------------------------
523
-
524
- /**
525
- * Renders a `NavigationItem[]` tree into Shadcn Sidebar components.
526
- *
527
- * Features:
528
- * - 7 navigation item types + separators
529
- * - Nested collapsible groups
530
- * - Badge indicators
531
- * - Visibility expression evaluation
532
- * - RBAC permission guards
533
- * - Active-route highlighting
534
- * - Search filtering across navigation tree
535
- * - Pin/favorite items with dedicated "Favorites" section
536
- * - Drag-to-reorder navigation items
537
- *
538
- * @example
539
- * ```tsx
540
- * <NavigationRenderer
541
- * items={appSchema.navigation}
542
- * basePath="/apps/crm"
543
- * evaluateVisibility={(expr) => evaluateVisibility(expr, evaluator)}
544
- * checkPermission={(perms) => perms.every(p => can(p))}
545
- * searchQuery={searchTerm}
546
- * enablePinning
547
- * onPinToggle={(id, pinned) => updatePin(id, pinned)}
548
- * enableReorder
549
- * onReorder={(items) => saveOrder(items)}
550
- * />
551
- * ```
552
- */
553
- export function NavigationRenderer({
554
- items,
555
- basePath = '',
556
- evaluateVisibility: evalVis = defaultVisibility,
557
- checkPermission: checkPerm = defaultPermission,
558
- onAction,
559
- searchQuery,
560
- enablePinning,
561
- onPinToggle,
562
- enableReorder,
563
- onReorder,
564
- resolveObjectLabel,
565
- t: tProp,
566
- }: NavigationRendererProps) {
567
- // --- Search filtering ---
568
- const filteredItems = useMemo(
569
- () => (searchQuery ? filterNavigationItems(items, searchQuery) : items),
570
- [items, searchQuery],
571
- );
572
-
573
- // --- Pinned items (favorites section) ---
574
- const pinnedItems = useMemo(
575
- () => collectPinnedItems(filteredItems),
576
- [filteredItems],
577
- );
578
-
579
- // --- Sort top-level items by order ---
580
- const sorted = filteredItems.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
581
-
582
- // --- Drag-reorder sensors ---
583
- const sensors = useSensors(
584
- useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
585
- useSensor(KeyboardSensor),
586
- );
587
-
588
- const handleDragEnd = (event: DragEndEvent) => {
589
- const { active, over } = event;
590
- if (!over || active.id === over.id || !onReorder) return;
591
-
592
- const oldIndex = sorted.findIndex((i) => i.id === active.id);
593
- const newIndex = sorted.findIndex((i) => i.id === over.id);
594
- if (oldIndex === -1 || newIndex === -1) return;
595
-
596
- const reordered = arrayMove(sorted, oldIndex, newIndex).map((item, idx) => ({
597
- ...item,
598
- order: idx,
599
- }));
600
- onReorder(reordered);
601
- };
602
-
603
- // --- Shared renderer props ---
604
- const itemProps = {
605
- basePath,
606
- evalVis,
607
- checkPerm,
608
- onAction,
609
- enablePinning,
610
- onPinToggle,
611
- resolveObjectLabel,
612
- t: tProp,
613
- };
614
-
615
- const hasGroups = sorted.some((i) => i.type === 'group');
616
-
617
- // --- Favorites section (pinned items) ---
618
- const favoritesSection = pinnedItems.length > 0 && enablePinning ? (
619
- <SidebarGroup>
620
- <SidebarGroupLabel className="flex items-center gap-1.5">
621
- <LucideIcons.Star className="h-3.5 w-3.5" />
622
- Favorites
623
- </SidebarGroupLabel>
624
- <SidebarGroupContent>
625
- <SidebarMenu>
626
- {pinnedItems.map((item) => (
627
- <NavigationItemRenderer
628
- key={`fav-${item.id}`}
629
- item={item}
630
- {...itemProps}
631
- />
632
- ))}
633
- </SidebarMenu>
634
- </SidebarGroupContent>
635
- </SidebarGroup>
636
- ) : null;
637
-
638
- // --- No explicit groups → wrap in a single SidebarGroup ---
639
- if (!hasGroups) {
640
- const topLevelIds = sorted.filter((i) => i.type !== 'group').map((i) => i.id);
641
-
642
- const menuContent = enableReorder ? (
643
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
644
- <SortableContext items={topLevelIds} strategy={verticalListSortingStrategy}>
645
- <SidebarMenu>
646
- {sorted.map((item) => (
647
- <SortableNavigationItem
648
- key={item.id}
649
- item={item}
650
- enableReorder={enableReorder}
651
- {...itemProps}
652
- />
653
- ))}
654
- </SidebarMenu>
655
- </SortableContext>
656
- </DndContext>
657
- ) : (
658
- <SidebarMenu>
659
- {sorted.map((item) => (
660
- <NavigationItemRenderer
661
- key={item.id}
662
- item={item}
663
- {...itemProps}
664
- />
665
- ))}
666
- </SidebarMenu>
667
- );
668
-
669
- return (
670
- <>
671
- {favoritesSection}
672
- <SidebarGroup>
673
- <SidebarGroupContent>
674
- {menuContent}
675
- </SidebarGroupContent>
676
- </SidebarGroup>
677
- </>
678
- );
679
- }
680
-
681
- // Mixed content: render groups inline, wrap consecutive leaf items
682
- const fragments: React.ReactNode[] = [];
683
- let leafBuffer: NavigationItem[] = [];
684
-
685
- const flushLeaves = (key: string) => {
686
- if (leafBuffer.length === 0) return;
687
- const leaves = leafBuffer;
688
- leafBuffer = [];
689
- fragments.push(
690
- <SidebarGroup key={key}>
691
- <SidebarGroupContent>
692
- <SidebarMenu>
693
- {leaves.map((item) => (
694
- <NavigationItemRenderer
695
- key={item.id}
696
- item={item}
697
- {...itemProps}
698
- />
699
- ))}
700
- </SidebarMenu>
701
- </SidebarGroupContent>
702
- </SidebarGroup>,
703
- );
704
- };
705
-
706
- sorted.forEach((item, idx) => {
707
- if (item.type === 'group') {
708
- flushLeaves(`leaf-${idx}`);
709
- fragments.push(
710
- <NavigationItemRenderer
711
- key={item.id}
712
- item={item}
713
- {...itemProps}
714
- />,
715
- );
716
- } else {
717
- leafBuffer.push(item);
718
- }
719
- });
720
-
721
- flushLeaves('leaf-end');
722
-
723
- return (
724
- <>
725
- {favoritesSection}
726
- {fragments}
727
- </>
728
- );
729
- }
730
-
731
- // ---------------------------------------------------------------------------
732
- // Helper: collect all pinned items (leaf-only) from a navigation tree
733
- // ---------------------------------------------------------------------------
734
-
735
- function collectPinnedItems(items: NavigationItem[]): NavigationItem[] {
736
- const pinned: NavigationItem[] = [];
737
- for (const item of items) {
738
- if (item.pinned && item.type !== 'group' && item.type !== 'separator') {
739
- pinned.push(item);
740
- }
741
- if (item.children?.length) {
742
- pinned.push(...collectPinnedItems(item.children));
743
- }
744
- }
745
- return pinned;
746
- }