@djangocfg/layouts 2.1.319 → 2.1.321
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/README.md +41 -102
- package/package.json +27 -18
- package/src/configurator/index.ts +14 -0
- package/src/configurator/private/index.ts +6 -0
- package/src/configurator/private/schema.ts +190 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +46 -1
- package/src/layouts/PrivateLayout/README.md +129 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +1 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +214 -47
- package/src/layouts/PrivateLayout/index.ts +10 -1
- package/src/layouts/_components/PrivateSidebarAccount.tsx +284 -146
- package/src/layouts/_components/SidebarFeatured.tsx +70 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# PrivateLayout
|
|
2
|
+
|
|
3
|
+
Authenticated app shell — collapsible sidebar (icon-rail vs expanded) + scrollable content area.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<PrivateLayout
|
|
7
|
+
sidebar={sidebar}
|
|
8
|
+
header={header}
|
|
9
|
+
pathname={usePathname()}
|
|
10
|
+
visual={{ variant: 'boxed', inset: 12, radius: '2xl', border: true }}
|
|
11
|
+
>
|
|
12
|
+
{children}
|
|
13
|
+
</PrivateLayout>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The auth guard redirects to `header.authPath` when there's no session. Pass `requireAuth={false}` to render without it (static showcases / playground).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Visual variants
|
|
21
|
+
|
|
22
|
+
`boxed` (default) — `<SidebarInset>` becomes a rounded card; the wrapper paints `bg-sidebar` so the brand colour bleeds to the viewport edges. Mobile (<md) degrades to full-bleed automatically.
|
|
23
|
+
|
|
24
|
+
`full-bleed` — content stretches edge-to-edge next to the sidebar (legacy look). Opt in with `visual={{ variant: 'full-bleed' }}`.
|
|
25
|
+
|
|
26
|
+
| Field | Type | Default | Notes |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| `variant` | `'full-bleed' \| 'boxed'` | `'boxed'` | Switch between the two shells. |
|
|
29
|
+
| `inset` | `number \| { x?: number; y?: number }` | `12` | Gap (px) between the card and the viewport edges (md+). |
|
|
30
|
+
| `radius` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | `'2xl'` | Card corner radius. |
|
|
31
|
+
| `background` | `'sidebar' \| 'muted' \| 'card' \| 'background'` | `'sidebar'` | Canvas colour painted *behind* the boxed card. |
|
|
32
|
+
| `border` | `boolean` | `true` | 1px border on the card. |
|
|
33
|
+
| `maxWidth` | `'none' \| '7xl' \| 'screen-xl' \| 'screen-2xl'` | `'none'` | Optional inner content width cap. |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Sidebar config
|
|
38
|
+
|
|
39
|
+
`SidebarConfig` (passed as `sidebar` prop):
|
|
40
|
+
|
|
41
|
+
| Field | Type | Notes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `groups` | `SidebarGroupConfig[]` | Nav items grouped under labels. Set `group.label = ''` to render flat (no header). |
|
|
44
|
+
| `homeHref` | `string` | Brand link target. |
|
|
45
|
+
| `menuStart` / `menuEnd` | `ReactNode` | Free slots above / below `groups` in the scrollable column. |
|
|
46
|
+
| `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` | Keep slots visible on the icon rail. |
|
|
47
|
+
| `footer` | `ReactNode` | Custom footer node above the account block. |
|
|
48
|
+
| `activeIndicator` | `'background' \| 'rail' \| 'both'` | Active item visual. Default `'background'` (legacy). `'rail'` paints a 2px primary stripe on the right edge (Vercel-style). |
|
|
49
|
+
| `groupLabelStyle` | `'uppercase' \| 'plain'` | Label typography. Default `'uppercase'` (legacy ultra-light caps). Collapsible groups always render `plain`. |
|
|
50
|
+
| `featured` | `SidebarFeaturedConfig` | Accent-tinted CTA tile rendered below groups (Mailersend-style). |
|
|
51
|
+
|
|
52
|
+
`SidebarGroupConfig`:
|
|
53
|
+
|
|
54
|
+
| Field | Type | Notes |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `label` | `string` | Group title. Empty string → flat group (no header). |
|
|
57
|
+
| `items` | `SidebarItem[]` | Nav links. |
|
|
58
|
+
| `dynamic` | `boolean` | Hide group when `items` is empty (for extension-driven groups). |
|
|
59
|
+
| `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active. Disabled on the icon rail. |
|
|
60
|
+
| `defaultOpen` | `boolean` | Initial open state for collapsible groups. |
|
|
61
|
+
| `icon` | `string \| LucideIcon` | Icon for the trigger (collapsible only). |
|
|
62
|
+
| `hideItemIcons` | `boolean` | Hide per-item icons inside the group. Defaults to `true` when `collapsible`. |
|
|
63
|
+
|
|
64
|
+
`SidebarItem`:
|
|
65
|
+
|
|
66
|
+
| Field | Type | Notes |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `label`, `href`, `icon`, `tooltip` | — | Standard nav item fields. |
|
|
69
|
+
| `badge` | `string \| number` | Trailing badge content. |
|
|
70
|
+
| `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
|
|
71
|
+
|
|
72
|
+
`SidebarFeaturedConfig`:
|
|
73
|
+
|
|
74
|
+
| Field | Type | Notes |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `icon`, `label`, `href` | — | Tile content. |
|
|
77
|
+
| `badge` | `string` | Inline pill (e.g. "lite"). |
|
|
78
|
+
| `accent` | `'green' \| 'blue' \| 'amber' \| 'primary'` | Tile tint. Default `'green'`. |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Header / account footer
|
|
83
|
+
|
|
84
|
+
`HeaderConfig` (passed as `header` prop):
|
|
85
|
+
|
|
86
|
+
| Field | Type | Notes |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `brand`, `title`, `brandIcon`, `brandLetter` | — | Sidebar header brand. |
|
|
89
|
+
| `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
|
|
90
|
+
| `authPath` | `string` | Sign-in redirect target. |
|
|
91
|
+
| `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
|
|
92
|
+
| `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
|
|
93
|
+
|
|
94
|
+
The footer button opens a popover (`DropdownMenu`, `side="top"`) with: email, account links, **Language** (opens fullscreen `LocaleSwitcherDialog` if `LayoutI18nProvider` is mounted), **Theme** (cycles `light → dark → system`), and **Log out**. In dev mode the footer renders a `Guest (dev)` placeholder when there's no authenticated user, so debug controls stay reachable; in production the block hides itself.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Sample
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
const sidebar: SidebarConfig = {
|
|
102
|
+
homeHref: '/',
|
|
103
|
+
activeIndicator: 'rail',
|
|
104
|
+
groupLabelStyle: 'plain',
|
|
105
|
+
groups: [
|
|
106
|
+
{
|
|
107
|
+
label: 'Email',
|
|
108
|
+
collapsible: true,
|
|
109
|
+
icon: 'Mail',
|
|
110
|
+
defaultOpen: true,
|
|
111
|
+
items: [
|
|
112
|
+
{ label: 'Domains', href: '/email/domains' },
|
|
113
|
+
{ label: 'Activity', href: '/email/activity' },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{ label: 'SMS', collapsible: true, icon: 'Smartphone', items: [...] },
|
|
117
|
+
],
|
|
118
|
+
featured: { icon: 'Sparkles', label: 'Marketing', href: '/marketing', badge: 'lite', accent: 'green' },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const header: HeaderConfig = {
|
|
122
|
+
title: 'Dashboard',
|
|
123
|
+
brandIcon: 'Layers',
|
|
124
|
+
authPath: '/auth',
|
|
125
|
+
userPlan: 'Pro plan',
|
|
126
|
+
footerSecondaryAction: { icon: 'Download', href: '/apps', ariaLabel: 'Get apps', pulse: true },
|
|
127
|
+
groups: [{ title: 'Account', items: [{ label: 'Settings', href: '/settings' }] }],
|
|
128
|
+
};
|
|
129
|
+
```
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React, { ReactNode } from 'react';
|
|
9
9
|
|
|
10
|
-
import { SidebarTrigger, useSidebar } from '@djangocfg/ui-
|
|
10
|
+
import { SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
|
|
13
13
|
import type { LayoutVisualConfig } from '../../types';
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
'use client';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
Collapsible,
|
|
10
|
+
CollapsibleContent,
|
|
11
|
+
CollapsibleTrigger,
|
|
12
|
+
Link,
|
|
13
|
+
} from '@djangocfg/ui-core/components';
|
|
14
|
+
import { ChevronDown } from 'lucide-react';
|
|
9
15
|
import { usePathname as useNextPathname } from 'next/navigation';
|
|
10
16
|
import React from 'react';
|
|
11
17
|
|
|
@@ -23,13 +29,21 @@ import {
|
|
|
23
29
|
SidebarMenuItem,
|
|
24
30
|
SidebarTrigger,
|
|
25
31
|
useSidebar,
|
|
26
|
-
} from '@djangocfg/ui-
|
|
32
|
+
} from '@djangocfg/ui-core/components';
|
|
27
33
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
28
34
|
|
|
29
35
|
import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
|
|
30
36
|
import { LucideIcon } from '../../../components';
|
|
31
37
|
|
|
32
|
-
import type {
|
|
38
|
+
import type {
|
|
39
|
+
HeaderConfig,
|
|
40
|
+
SidebarActiveIndicator,
|
|
41
|
+
SidebarConfig,
|
|
42
|
+
SidebarGroupConfig,
|
|
43
|
+
SidebarGroupLabelStyle,
|
|
44
|
+
SidebarItem,
|
|
45
|
+
} from '../PrivateLayout';
|
|
46
|
+
import { SidebarFeatured } from '../../_components/SidebarFeatured';
|
|
33
47
|
|
|
34
48
|
/** Few items → roomier rows; many items → tighter. Same breakpoints for demo, CarAPIS, etc. */
|
|
35
49
|
const DENSITY_COMFORTABLE_MAX = 6;
|
|
@@ -46,16 +60,36 @@ function navDensityFromCount(n: number): NavDensity {
|
|
|
46
60
|
/**
|
|
47
61
|
* Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
|
|
48
62
|
*/
|
|
49
|
-
const
|
|
50
|
-
'border-0 font-medium shadow-none transition-colors',
|
|
63
|
+
const navItemBaseClass = cn(
|
|
64
|
+
'group/nav relative border-0 font-medium shadow-none transition-colors',
|
|
51
65
|
'text-sidebar-foreground/80',
|
|
52
|
-
'data-[active=true]:font-semibold data-[active=true]:
|
|
66
|
+
'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
|
|
53
67
|
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
54
|
-
'data-[active=true]:hover:bg-sidebar-accent',
|
|
55
68
|
'[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
|
|
69
|
+
'[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
|
|
56
70
|
'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
|
|
57
71
|
);
|
|
58
72
|
|
|
73
|
+
const ACTIVE_INDICATOR_CLASS: Record<SidebarActiveIndicator, string> = {
|
|
74
|
+
background: cn(
|
|
75
|
+
'data-[active=true]:bg-sidebar-accent',
|
|
76
|
+
'data-[active=true]:hover:bg-sidebar-accent',
|
|
77
|
+
),
|
|
78
|
+
rail: cn(
|
|
79
|
+
'data-[active=true]:after:absolute data-[active=true]:after:right-0',
|
|
80
|
+
'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
|
|
81
|
+
'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
|
|
82
|
+
'data-[active=true]:after:bg-primary',
|
|
83
|
+
),
|
|
84
|
+
both: cn(
|
|
85
|
+
'data-[active=true]:bg-sidebar-accent data-[active=true]:hover:bg-sidebar-accent',
|
|
86
|
+
'data-[active=true]:after:absolute data-[active=true]:after:right-0',
|
|
87
|
+
'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
|
|
88
|
+
'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
|
|
89
|
+
'data-[active=true]:after:bg-primary',
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
|
|
59
93
|
const DENSITY = {
|
|
60
94
|
comfortable: {
|
|
61
95
|
menu: 'gap-1.5',
|
|
@@ -173,45 +207,39 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
173
207
|
|
|
174
208
|
const sidebarContentClass = cn('gap-2', menuNav.group);
|
|
175
209
|
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
|
|
210
|
-
</SidebarGroupContent>
|
|
211
|
-
</SidebarGroup>
|
|
212
|
-
);
|
|
213
|
-
});
|
|
214
|
-
}, [sidebar.groups, isActive, menuNav]);
|
|
210
|
+
const activeIndicator: SidebarActiveIndicator = sidebar.activeIndicator ?? 'background';
|
|
211
|
+
const groupLabelStyle: SidebarGroupLabelStyle = sidebar.groupLabelStyle ?? 'uppercase';
|
|
212
|
+
const navButtonClass = cn(navItemBaseClass, ACTIVE_INDICATOR_CLASS[activeIndicator], menuNav.extraButton);
|
|
213
|
+
const groupLabelUppercaseClass = cn('px-2', menuNav.label);
|
|
214
|
+
const groupLabelPlainClass = cn(
|
|
215
|
+
'px-2 text-sm font-semibold text-sidebar-foreground',
|
|
216
|
+
'h-7 leading-none',
|
|
217
|
+
);
|
|
218
|
+
const sidebarGroupClass = cn('gap-0', menuNav.groupPad);
|
|
219
|
+
|
|
220
|
+
const renderedGroups = sidebar.groups.map((group) => {
|
|
221
|
+
if (group.dynamic && group.items.length === 0) return null;
|
|
222
|
+
return (
|
|
223
|
+
<SidebarGroupRenderer
|
|
224
|
+
key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
|
|
225
|
+
group={group}
|
|
226
|
+
isActive={isActive}
|
|
227
|
+
navButtonClass={navButtonClass}
|
|
228
|
+
sidebarGroupClass={sidebarGroupClass}
|
|
229
|
+
groupLabelUppercaseClass={groupLabelUppercaseClass}
|
|
230
|
+
groupLabelPlainClass={groupLabelPlainClass}
|
|
231
|
+
groupLabelStyle={groupLabelStyle}
|
|
232
|
+
menuNav={menuNav}
|
|
233
|
+
collapsedRail={collapsedRail}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const featuredSlot = sidebar.featured && !collapsedRail ? (
|
|
239
|
+
<div className="w-full min-w-0 shrink-0 px-2">
|
|
240
|
+
<SidebarFeatured config={sidebar.featured} />
|
|
241
|
+
</div>
|
|
242
|
+
) : null;
|
|
215
243
|
|
|
216
244
|
const expandedHeader = (
|
|
217
245
|
<div className={headerRowClass}>
|
|
@@ -305,11 +333,16 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
305
333
|
const railExpandHintClass =
|
|
306
334
|
!isMobile && state === 'collapsed' ? 'cursor-pointer' : undefined;
|
|
307
335
|
|
|
336
|
+
const sidebarRootClass = cn(
|
|
337
|
+
railExpandHintClass,
|
|
338
|
+
'[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
|
|
339
|
+
);
|
|
340
|
+
|
|
308
341
|
return (
|
|
309
342
|
<Sidebar
|
|
310
343
|
collapsible="icon"
|
|
311
344
|
variant={variant}
|
|
312
|
-
className={
|
|
345
|
+
className={sidebarRootClass}
|
|
313
346
|
onClick={expandOnRailClick}
|
|
314
347
|
>
|
|
315
348
|
<SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
|
|
@@ -317,6 +350,7 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
317
350
|
<SidebarContent className={sidebarContentClass}>
|
|
318
351
|
{menuStartSlot}
|
|
319
352
|
{renderedGroups}
|
|
353
|
+
{featuredSlot}
|
|
320
354
|
{menuEndSlot}
|
|
321
355
|
</SidebarContent>
|
|
322
356
|
|
|
@@ -327,3 +361,136 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
327
361
|
</Sidebar>
|
|
328
362
|
);
|
|
329
363
|
}
|
|
364
|
+
|
|
365
|
+
interface SidebarGroupRendererProps {
|
|
366
|
+
group: SidebarGroupConfig;
|
|
367
|
+
isActive: (href: string) => boolean;
|
|
368
|
+
navButtonClass: string;
|
|
369
|
+
sidebarGroupClass: string;
|
|
370
|
+
groupLabelUppercaseClass: string;
|
|
371
|
+
groupLabelPlainClass: string;
|
|
372
|
+
groupLabelStyle: SidebarGroupLabelStyle;
|
|
373
|
+
menuNav: typeof DENSITY[keyof typeof DENSITY];
|
|
374
|
+
collapsedRail: boolean;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function SidebarGroupRenderer({
|
|
378
|
+
group,
|
|
379
|
+
isActive,
|
|
380
|
+
navButtonClass,
|
|
381
|
+
sidebarGroupClass,
|
|
382
|
+
groupLabelUppercaseClass,
|
|
383
|
+
groupLabelPlainClass,
|
|
384
|
+
groupLabelStyle,
|
|
385
|
+
menuNav,
|
|
386
|
+
collapsedRail,
|
|
387
|
+
}: SidebarGroupRendererProps) {
|
|
388
|
+
const hasLabel = Boolean(group.label && group.label.trim().length > 0);
|
|
389
|
+
const isCollapsible = Boolean(group.collapsible) && hasLabel && !collapsedRail;
|
|
390
|
+
const hideItemIcons = group.hideItemIcons ?? isCollapsible;
|
|
391
|
+
|
|
392
|
+
const hasActiveChild = React.useMemo(
|
|
393
|
+
() => group.items.some((item) => isActive(item.href)),
|
|
394
|
+
[group.items, isActive],
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const [open, setOpen] = React.useState<boolean>(
|
|
398
|
+
isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
React.useEffect(() => {
|
|
402
|
+
if (isCollapsible && hasActiveChild) setOpen(true);
|
|
403
|
+
}, [isCollapsible, hasActiveChild]);
|
|
404
|
+
|
|
405
|
+
const items = group.items.map((item: SidebarItem) => {
|
|
406
|
+
const tooltipText = item.tooltip ?? item.label;
|
|
407
|
+
const itemIcon = !hideItemIcons && item.icon ? (
|
|
408
|
+
<LucideIcon icon={item.icon} className={menuNav.iconClass} />
|
|
409
|
+
) : null;
|
|
410
|
+
const itemClassName = hideItemIcons
|
|
411
|
+
? cn(navButtonClass, 'pl-8')
|
|
412
|
+
: navButtonClass;
|
|
413
|
+
const badgeNode = item.badge ? (
|
|
414
|
+
<SidebarMenuBadge
|
|
415
|
+
className={item.badgeVariant === 'pill'
|
|
416
|
+
? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
|
|
417
|
+
: undefined}
|
|
418
|
+
>
|
|
419
|
+
{item.badge}
|
|
420
|
+
</SidebarMenuBadge>
|
|
421
|
+
) : null;
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<SidebarMenuItem key={item.href}>
|
|
425
|
+
<SidebarMenuButton
|
|
426
|
+
asChild
|
|
427
|
+
isActive={isActive(item.href)}
|
|
428
|
+
size={menuNav.buttonSize}
|
|
429
|
+
tooltip={tooltipText}
|
|
430
|
+
className={itemClassName}
|
|
431
|
+
>
|
|
432
|
+
<Link href={item.href}>
|
|
433
|
+
{itemIcon}
|
|
434
|
+
<span>{item.label}</span>
|
|
435
|
+
{badgeNode}
|
|
436
|
+
</Link>
|
|
437
|
+
</SidebarMenuButton>
|
|
438
|
+
</SidebarMenuItem>
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const labelClass = isCollapsible || groupLabelStyle === 'plain'
|
|
443
|
+
? groupLabelPlainClass
|
|
444
|
+
: groupLabelUppercaseClass;
|
|
445
|
+
|
|
446
|
+
if (isCollapsible) {
|
|
447
|
+
const triggerIcon = group.icon ? (
|
|
448
|
+
<LucideIcon icon={group.icon} className={cn(menuNav.iconClass, 'shrink-0 text-sidebar-foreground/70')} />
|
|
449
|
+
) : null;
|
|
450
|
+
return (
|
|
451
|
+
<SidebarGroup className={sidebarGroupClass}>
|
|
452
|
+
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
|
453
|
+
<CollapsibleTrigger asChild>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
className={cn(
|
|
457
|
+
'group/trig flex w-full items-center gap-2 rounded-md px-2 py-1.5',
|
|
458
|
+
'text-sm font-semibold text-sidebar-foreground',
|
|
459
|
+
'transition-colors hover:bg-sidebar-accent/40',
|
|
460
|
+
'data-[no-expand]', // marker so rail-expand click handler ignores it (pattern in PrivateSidebar)
|
|
461
|
+
)}
|
|
462
|
+
aria-expanded={open}
|
|
463
|
+
data-no-expand
|
|
464
|
+
>
|
|
465
|
+
{triggerIcon}
|
|
466
|
+
<span className="flex-1 truncate text-left">{group.label}</span>
|
|
467
|
+
<ChevronDown
|
|
468
|
+
className={cn(
|
|
469
|
+
'h-4 w-4 shrink-0 text-sidebar-foreground/55 transition-transform duration-200',
|
|
470
|
+
open && 'rotate-180',
|
|
471
|
+
)}
|
|
472
|
+
aria-hidden
|
|
473
|
+
/>
|
|
474
|
+
</button>
|
|
475
|
+
</CollapsibleTrigger>
|
|
476
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
|
477
|
+
<SidebarGroupContent>
|
|
478
|
+
<SidebarMenu className={cn(menuNav.menu, 'mt-1')}>{items}</SidebarMenu>
|
|
479
|
+
</SidebarGroupContent>
|
|
480
|
+
</CollapsibleContent>
|
|
481
|
+
</Collapsible>
|
|
482
|
+
</SidebarGroup>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<SidebarGroup className={sidebarGroupClass}>
|
|
488
|
+
{hasLabel ? (
|
|
489
|
+
<SidebarGroupLabel className={labelClass}>{group.label}</SidebarGroupLabel>
|
|
490
|
+
) : null}
|
|
491
|
+
<SidebarGroupContent>
|
|
492
|
+
<SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
|
|
493
|
+
</SidebarGroupContent>
|
|
494
|
+
</SidebarGroup>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
@@ -3,5 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { PrivateLayout } from './PrivateLayout';
|
|
6
|
-
export type {
|
|
6
|
+
export type {
|
|
7
|
+
PrivateLayoutProps,
|
|
8
|
+
SidebarItem,
|
|
9
|
+
SidebarGroupConfig,
|
|
10
|
+
SidebarConfig,
|
|
11
|
+
HeaderConfig,
|
|
12
|
+
SidebarActiveIndicator,
|
|
13
|
+
SidebarGroupLabelStyle,
|
|
14
|
+
SidebarFeaturedConfig,
|
|
15
|
+
} from './PrivateLayout';
|
|
7
16
|
|