@djangocfg/layouts 2.1.249 → 2.1.252

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.
@@ -1,155 +1,198 @@
1
1
  /**
2
- * Private Layout Sidebar
3
- *
4
- * Sidebar navigation component for PrivateLayout
2
+ * Private sidebar: header (brand only when expanded; icon mode shows expand trigger only),
3
+ * nav groups, account footer. Nav: muted inactive rows, pill active; density scales with item count.
5
4
  */
6
5
 
7
6
  'use client';
8
7
 
9
8
  import Link from 'next/link';
10
- import { usePathname } from 'next/navigation';
9
+ import { usePathname as useNextPathname } from 'next/navigation';
11
10
  import React from 'react';
12
11
 
13
12
  import {
14
- Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
15
- SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, useSidebar
13
+ Sidebar,
14
+ SidebarContent,
15
+ SidebarFooter,
16
+ SidebarGroup,
17
+ SidebarGroupContent,
18
+ SidebarGroupLabel,
19
+ SidebarHeader,
20
+ SidebarMenu,
21
+ SidebarMenuBadge,
22
+ SidebarMenuButton,
23
+ SidebarMenuItem,
24
+ SidebarTrigger,
25
+ useSidebar,
16
26
  } from '@djangocfg/ui-nextjs/components';
17
27
  import { cn } from '@djangocfg/ui-core/lib';
18
28
 
29
+ import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
19
30
  import { LucideIcon } from '../../../components';
20
31
 
21
- import type { SidebarItem, SidebarConfig } from '../PrivateLayout';
32
+ import type { I18nLayoutConfig } from '../../AppLayout/AppLayout';
33
+ import type { HeaderConfig, SidebarItem, SidebarConfig } from '../PrivateLayout';
34
+
35
+ type NavDensity = 'comfortable' | 'default' | 'compact';
36
+
37
+ function navDensityFromCount(n: number): NavDensity {
38
+ if (n <= 6) return 'comfortable';
39
+ if (n <= 14) return 'default';
40
+ return 'compact';
41
+ }
42
+
43
+ /**
44
+ * Nav rows: inactive muted. Light active = neutral pill (readable on white); dark = dark chip.
45
+ * Avoids `sidebar-accent` in light — project tokens often map to near-black and wash out labels.
46
+ */
47
+ const navItemClass = cn(
48
+ 'border-0 font-normal shadow-none transition-colors',
49
+ 'text-muted-foreground',
50
+ 'data-[active=true]:font-medium',
51
+ 'data-[active=true]:bg-zinc-200/90 data-[active=true]:text-zinc-900',
52
+ 'dark:data-[active=true]:bg-[#1a1a1a] dark:data-[active=true]:text-zinc-50',
53
+ 'hover:bg-zinc-100/90 hover:text-foreground dark:hover:bg-white/[0.06]',
54
+ 'data-[active=true]:hover:bg-zinc-200 dark:data-[active=true]:hover:bg-[#1a1a1a]',
55
+ '[&>svg]:shrink-0 [&>svg]:text-muted-foreground [&>svg]:opacity-85',
56
+ 'data-[active=true]:[&>svg]:text-zinc-800 data-[active=true]:[&>svg]:opacity-100',
57
+ 'dark:data-[active=true]:[&>svg]:text-zinc-50',
58
+ );
59
+
60
+ const DENSITY = {
61
+ comfortable: {
62
+ menu: 'gap-1.5',
63
+ group: 'gap-3',
64
+ label:
65
+ 'h-7 uppercase text-[10px] font-light leading-none tracking-[0.14em] text-muted-foreground/40',
66
+ buttonSize: 'lg' as const,
67
+ iconClass: 'h-5 w-5',
68
+ extraButton: 'rounded-lg !px-3',
69
+ },
70
+ default: {
71
+ menu: 'gap-1',
72
+ group: 'gap-2',
73
+ label:
74
+ 'uppercase text-[9px] font-light leading-none tracking-[0.12em] text-muted-foreground/50',
75
+ buttonSize: 'default' as const,
76
+ iconClass: 'h-4 w-4',
77
+ extraButton: 'rounded-lg',
78
+ },
79
+ compact: {
80
+ menu: 'gap-0.5',
81
+ group: 'gap-1',
82
+ label:
83
+ 'h-6 uppercase text-[8px] font-light leading-none tracking-[0.1em] text-muted-foreground/40',
84
+ buttonSize: 'sm' as const,
85
+ iconClass: 'h-3.5 w-3.5',
86
+ extraButton: 'rounded-md !px-2',
87
+ },
88
+ } as const;
22
89
 
23
90
  interface PrivateSidebarProps {
24
91
  sidebar: SidebarConfig;
92
+ header?: HeaderConfig;
93
+ i18n?: I18nLayoutConfig;
94
+ pathname?: string;
25
95
  }
26
96
 
27
- export function PrivateSidebar({ sidebar }: PrivateSidebarProps) {
28
- const pathname = usePathname();
29
- const { state, isMobile } = useSidebar();
97
+ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }: PrivateSidebarProps) {
98
+ const pathnameFromNext = useNextPathname();
99
+ const pathname = pathnameProp ?? pathnameFromNext;
100
+ const { state, isMobile, setOpenMobile } = useSidebar();
30
101
  const homeHref = sidebar.homeHref || '/';
31
102
 
32
- // Get all items for active detection
33
- const allItems = React.useMemo(() => {
34
- return sidebar.groups.flatMap((g) => g.items);
35
- }, [sidebar.groups]);
103
+ React.useEffect(() => {
104
+ if (isMobile) setOpenMobile(false);
105
+ }, [pathname, isMobile, setOpenMobile]);
106
+ const brandTitle = header?.title?.trim() || 'Dashboard';
107
+
108
+ const allItems = React.useMemo(
109
+ () => sidebar.groups.flatMap((g) => g.items),
110
+ [sidebar.groups],
111
+ );
112
+
113
+ const density = React.useMemo(() => navDensityFromCount(allItems.length), [allItems.length]);
114
+ const d = DENSITY[density];
36
115
 
37
116
  const isActive = (href: string) => {
38
- const matches = pathname === href || pathname.startsWith(href + '/');
117
+ const matches = pathname === href || pathname.startsWith(`${href}/`);
39
118
  if (!matches) return false;
40
-
41
- // Check if there's a more specific (longer) path that also matches
42
119
  return !allItems.some(
43
- (otherItem) =>
44
- otherItem.href !== href &&
45
- otherItem.href.startsWith(href + '/') &&
46
- (pathname === otherItem.href ||
47
- pathname.startsWith(otherItem.href + '/'))
48
- );
49
- };
50
-
51
- // Render a single menu item
52
- const renderMenuItem = (item: SidebarItem) => {
53
- const active = isActive(item.href);
54
-
55
- return (
56
- <SidebarMenuItem key={item.href}>
57
- <SidebarMenuButton
58
- asChild
59
- isActive={active}
60
- tooltip={item.label}
61
- size={isMobile ? 'lg' : 'default'}
62
- >
63
- <Link href={item.href}>
64
- {item.icon && (
65
- <LucideIcon
66
- icon={typeof item.icon === 'string' ? item.icon : item.icon}
67
- className={isMobile ? 'h-5 w-5' : 'h-4 w-4'}
68
- />
69
- )}
70
- <span className={isMobile ? 'text-base' : ''}>{item.label}</span>
71
- {item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
72
- </Link>
73
- </SidebarMenuButton>
74
- </SidebarMenuItem>
120
+ (other) =>
121
+ other.href !== href &&
122
+ other.href.startsWith(`${href}/`) &&
123
+ (pathname === other.href || pathname.startsWith(`${other.href}/`)),
75
124
  );
76
125
  };
77
126
 
78
- // Render groups
79
- const renderContent = () => {
80
- return sidebar.groups.map((group) => {
81
- // Skip dynamic groups with no items
82
- if (group.dynamic && group.items.length === 0) {
83
- return null;
84
- }
85
-
86
- return (
87
- <SidebarGroup key={group.label}>
88
- <SidebarGroupLabel className="font-medium text-[10px]">{group.label}</SidebarGroupLabel>
89
- <SidebarGroupContent>
90
- <SidebarMenu>{group.items.map(renderMenuItem)}</SidebarMenu>
91
- </SidebarGroupContent>
92
- </SidebarGroup>
93
- );
94
- });
95
- };
127
+ const expanded = state === 'expanded';
96
128
 
97
129
  return (
98
130
  <Sidebar collapsible="icon">
99
- <SidebarHeader>
100
- <div
101
- className="flex items-center gap-3"
102
- style={
103
- state === 'collapsed'
104
- ? {
105
- paddingLeft: '7px',
106
- paddingTop: '0.5rem',
107
- paddingBottom: '0.5rem',
108
- transition: 'padding 200ms ease-in-out',
109
- }
110
- : {
111
- padding: '0.5rem',
112
- transition: 'padding 200ms ease-in-out',
113
- }
114
- }
115
- >
116
- <Link href={homeHref}>
117
- <div className="flex items-center gap-3">
118
- <div
119
- className={cn(
120
- 'bg-primary rounded-sm flex items-center justify-center flex-shrink-0',
121
- isMobile ? 'h-10 w-10' : 'h-8 w-8'
122
- )}
123
- >
124
- <span className="text-primary-foreground font-bold text-sm">
131
+ <SidebarHeader className="px-2 py-1.5">
132
+ {expanded ? (
133
+ <div className="flex items-center gap-2">
134
+ <Link
135
+ href={homeHref}
136
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
137
+ >
138
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary">
139
+ <span className="text-[11px] font-bold leading-none tracking-tight text-primary-foreground">
125
140
  D
126
141
  </span>
127
142
  </div>
128
- {state !== 'collapsed' && (
129
- <span
130
- className={cn(
131
- 'font-semibold text-foreground truncate',
132
- isMobile && 'text-base'
133
- )}
134
- style={{ whiteSpace: 'nowrap' }}
135
- >
136
- Dashboard
137
- </span>
138
- )}
139
- </div>
140
- </Link>
141
- </div>
143
+ <span className="truncate text-sm font-semibold tracking-tight text-foreground">
144
+ {brandTitle}
145
+ </span>
146
+ </Link>
147
+ <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />
148
+ </div>
149
+ ) : (
150
+ <div className="flex justify-center py-0.5">
151
+ <SidebarTrigger aria-label="Expand sidebar" />
152
+ </div>
153
+ )}
142
154
  </SidebarHeader>
143
155
 
144
- <SidebarContent>{renderContent()}</SidebarContent>
145
-
146
- {/* Footer */}
147
- {sidebar.footer && (
148
- <SidebarFooter>
149
- {sidebar.footer}
150
- </SidebarFooter>
151
- )}
156
+ <SidebarContent className={cn('gap-2', d.group)}>
157
+ {sidebar.groups.map((group) => {
158
+ if (group.dynamic && group.items.length === 0) return null;
159
+ return (
160
+ <SidebarGroup key={group.label} className="gap-0">
161
+ <SidebarGroupLabel className={cn('px-2', d.label)}>{group.label}</SidebarGroupLabel>
162
+ <SidebarGroupContent>
163
+ <SidebarMenu className={d.menu}>
164
+ {group.items.map((item: SidebarItem) => (
165
+ <SidebarMenuItem key={item.href}>
166
+ <SidebarMenuButton
167
+ asChild
168
+ isActive={isActive(item.href)}
169
+ size={d.buttonSize}
170
+ className={cn(navItemClass, d.extraButton)}
171
+ >
172
+ <Link href={item.href}>
173
+ {item.icon && (
174
+ <LucideIcon
175
+ icon={typeof item.icon === 'string' ? item.icon : item.icon}
176
+ className={d.iconClass}
177
+ />
178
+ )}
179
+ <span>{item.label}</span>
180
+ {item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
181
+ </Link>
182
+ </SidebarMenuButton>
183
+ </SidebarMenuItem>
184
+ ))}
185
+ </SidebarMenu>
186
+ </SidebarGroupContent>
187
+ </SidebarGroup>
188
+ );
189
+ })}
190
+ </SidebarContent>
191
+
192
+ <SidebarFooter className="p-2">
193
+ {sidebar.footer && <div className="mb-2">{sidebar.footer}</div>}
194
+ <PrivateSidebarAccount header={header} i18n={i18n} />
195
+ </SidebarFooter>
152
196
  </Sidebar>
153
197
  );
154
198
  }
155
-
@@ -3,6 +3,4 @@
3
3
  */
4
4
 
5
5
  export { PrivateSidebar } from './PrivateSidebar';
6
- export { PrivateHeader } from './PrivateHeader';
7
6
  export { PrivateContent } from './PrivateContent';
8
-
@@ -11,15 +11,19 @@
11
11
  *
12
12
  * @example
13
13
  * ```tsx
14
- * import { PublicLayout } from '@djangocfg/layouts';
14
+ * import { PublicLayout, PublicNavbar } from '@djangocfg/layouts';
15
15
  *
16
16
  * <PublicLayout
17
- * logo="/logo.svg"
18
- * siteName="My App"
19
- * navigation={[
20
- * { label: 'Home', href: '/' },
21
- * { label: 'Docs', href: '/docs' }
22
- * ]}
17
+ * navbar={
18
+ * <PublicNavbar
19
+ * logo="/logo.svg"
20
+ * siteName="My App"
21
+ * navigation={[
22
+ * { label: 'Home', href: '/' },
23
+ * { label: 'Docs', href: '/docs' }
24
+ * ]}
25
+ * />
26
+ * }
23
27
  * >
24
28
  * {children}
25
29
  * </PublicLayout>
@@ -31,36 +35,25 @@
31
35
  import { usePathname } from 'next/navigation';
32
36
  import { ReactNode, useEffect, useMemo, useState } from 'react';
33
37
 
34
- import { PublicMobileDrawer, PublicNavigation } from './components';
35
38
  import { PublicLayoutProvider } from './context';
36
39
 
37
- import type { NavigationItem, UserMenuConfig } from '../types';
38
- import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
39
-
40
40
  export interface PublicLayoutProps {
41
41
  children: ReactNode;
42
- /** Logo path or URL */
43
- logo?: string;
44
- /** Site name */
45
- siteName?: string;
46
- /** Navigation items */
47
- navigation?: NavigationItem[];
48
- /** User menu configuration (optional, uses useAuth() for authentication state) */
49
- userMenu?: UserMenuConfig;
50
- /** i18n configuration for locale switching */
51
- i18n?: I18nLayoutConfig;
52
- /** Custom className for navbar container (e.g. "max-w-7xl mx-auto") */
53
- navbarContainerClassName?: string;
42
+
43
+ /**
44
+ * Slots (advanced).
45
+ *
46
+ * Slots-only API: this layout does not render any default navbar/drawer/footer.
47
+ * Pass `navbar`, `mobileDrawer`, `footer` explicitly (or keep them empty).
48
+ */
49
+ navbar?: ReactNode;
50
+ footer?: ReactNode;
54
51
  }
55
52
 
56
53
  export function PublicLayout({
57
54
  children,
58
- logo,
59
- siteName = 'App',
60
- navigation = [],
61
- userMenu,
62
- i18n,
63
- navbarContainerClassName,
55
+ navbar,
56
+ footer,
64
57
  }: PublicLayoutProps) {
65
58
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
66
59
  const pathname = usePathname();
@@ -71,30 +64,22 @@ export function PublicLayout({
71
64
  }, [pathname]);
72
65
 
73
66
  const contextValue = useMemo(() => ({
74
- logo,
75
- siteName,
76
- navigation,
77
- userMenu,
78
- i18n,
79
- containerClassName: navbarContainerClassName,
80
67
  mobileMenuOpen,
81
68
  toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
82
69
  closeMobileMenu: () => setMobileMenuOpen(false),
83
- }), [logo, siteName, navigation, userMenu, i18n, navbarContainerClassName, mobileMenuOpen]);
70
+ }), [
71
+ mobileMenuOpen,
72
+ ]);
84
73
 
85
74
  return (
86
75
  <PublicLayoutProvider value={contextValue}>
87
76
  <div className="min-h-screen flex flex-col">
88
- {/* Navigation */}
89
- <PublicNavigation />
90
-
91
- {/* Mobile Drawer */}
92
- <PublicMobileDrawer />
77
+ {navbar ?? null}
93
78
 
94
79
  {/* Main Content */}
95
80
  <main className="flex-1">{children}</main>
96
81
 
97
- {/* Footer - Add your own custom footer component here if needed */}
82
+ {footer ?? null}
98
83
  </div>
99
84
  </PublicLayoutProvider>
100
85
  );
@@ -11,13 +11,24 @@ import type { FooterMenuSection } from './types';
11
11
 
12
12
  export interface FooterMenuSectionsProps {
13
13
  menuSections: FooterMenuSection[];
14
+ minColumnWidth?: number;
15
+ maxColumns?: number;
14
16
  }
15
17
 
16
- export function FooterMenuSections({ menuSections }: FooterMenuSectionsProps) {
18
+ export function FooterMenuSections({
19
+ menuSections,
20
+ minColumnWidth = 180,
21
+ maxColumns = 5,
22
+ }: FooterMenuSectionsProps) {
17
23
  if (menuSections.length === 0) return null;
18
24
 
25
+ const effectiveColumns = Math.max(1, Math.min(maxColumns, menuSections.length));
26
+
19
27
  return (
20
- <div className="w-full grid grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-x-12">
28
+ <div
29
+ className="w-full grid gap-8 lg:gap-x-12"
30
+ style={{ gridTemplateColumns: `repeat(${effectiveColumns}, minmax(${minColumnWidth}px, 1fr))` }}
31
+ >
21
32
  {menuSections.map((section) => (
22
33
  <div key={section.title} className="min-w-0">
23
34
  <h3 className="text-xs font-medium text-muted-foreground mb-3">
@@ -13,7 +13,7 @@ import type { LucideIcon } from 'lucide-react';
13
13
  import type { FooterSocialLinks } from './types';
14
14
 
15
15
  export interface FooterProjectInfoProps {
16
- siteName: string;
16
+ siteName?: string;
17
17
  description?: string;
18
18
  logo?: string;
19
19
  badge?: {
@@ -41,16 +41,18 @@ export function FooterProjectInfo({
41
41
  <div className={isMobile ? 'w-6 h-6 flex items-center justify-center' : 'w-8 h-8 flex items-center justify-center'}>
42
42
  <img
43
43
  src={logo}
44
- alt={`${siteName} Logo`}
44
+ alt={`${siteName || 'Project'} Logo`}
45
45
  className="w-full h-full object-contain"
46
46
  />
47
47
  </div>
48
48
  ) : (
49
49
  <DjangoCFGLogo size={isMobile ? 24 : 32} className="text-foreground" />
50
50
  )}
51
- <span className={isMobile ? 'text-lg font-bold text-foreground' : 'text-lg font-semibold text-foreground'}>
52
- {siteName}
53
- </span>
51
+ {siteName && (
52
+ <span className={isMobile ? 'text-lg font-bold text-foreground' : 'text-lg font-semibold text-foreground'}>
53
+ {siteName}
54
+ </span>
55
+ )}
54
56
  </div>
55
57
 
56
58
  {description && (