@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.
@@ -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-nextjs/components';
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 { Link } from '@djangocfg/ui-core/components';
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-nextjs/components';
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 { HeaderConfig, SidebarItem, SidebarConfig } from '../PrivateLayout';
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 navItemClass = cn(
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]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
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 renderedGroups = React.useMemo(() => {
177
- const navButtonClass = cn(navItemClass, menuNav.extraButton);
178
- const groupLabelClass = cn('px-2', menuNav.label);
179
- const sidebarGroupClass = cn('gap-0', menuNav.groupPad);
180
-
181
- return sidebar.groups.map((group) => {
182
- if (group.dynamic && group.items.length === 0) return null;
183
- const items = group.items.map((item: SidebarItem) => {
184
- const iconProp = typeof item.icon === 'string' ? item.icon : item.icon;
185
- const tooltipText = item.tooltip ?? item.label;
186
- return (
187
- <SidebarMenuItem key={item.href}>
188
- <SidebarMenuButton
189
- asChild
190
- isActive={isActive(item.href)}
191
- size={menuNav.buttonSize}
192
- tooltip={tooltipText}
193
- className={navButtonClass}
194
- >
195
- <Link href={item.href}>
196
- {item.icon ? <LucideIcon icon={iconProp} className={menuNav.iconClass} /> : null}
197
- <span>{item.label}</span>
198
- {item.badge ? <SidebarMenuBadge>{item.badge}</SidebarMenuBadge> : null}
199
- </Link>
200
- </SidebarMenuButton>
201
- </SidebarMenuItem>
202
- );
203
- });
204
-
205
- return (
206
- <SidebarGroup key={group.label} className={sidebarGroupClass}>
207
- <SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
208
- <SidebarGroupContent>
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={railExpandHintClass}
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 { PrivateLayoutProps, SidebarItem, SidebarGroupConfig, SidebarConfig, HeaderConfig } from './PrivateLayout';
6
+ export type {
7
+ PrivateLayoutProps,
8
+ SidebarItem,
9
+ SidebarGroupConfig,
10
+ SidebarConfig,
11
+ HeaderConfig,
12
+ SidebarActiveIndicator,
13
+ SidebarGroupLabelStyle,
14
+ SidebarFeaturedConfig,
15
+ } from './PrivateLayout';
7
16