@djangocfg/layouts 2.1.248 → 2.1.251

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.
@@ -6,92 +6,367 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import { Menu, X } from 'lucide-react';
9
+ import { ChevronDown, Menu, X } from 'lucide-react';
10
10
  import Link from 'next/link';
11
- import React, { useMemo } from 'react';
11
+ import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
12
12
 
13
13
  import { useAuth } from '@djangocfg/api/auth';
14
14
  import { useAppT } from '@djangocfg/i18n';
15
- import { Button } from '@djangocfg/ui-core/components';
16
- // useIsMobile is used for conditional rendering
17
- import { useIsMobile } from '@djangocfg/ui-core/hooks';
15
+ import {
16
+ Button,
17
+ } from '@djangocfg/ui-core/components';
18
+ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
18
19
  // cn is reserved for future conditional styling
19
20
  import { cn as _cn } from '@djangocfg/ui-core/lib';
21
+ import { usePathnameWithoutLocale } from '../../../hooks';
20
22
 
21
23
  import { UserMenu } from '../../_components/UserMenu';
22
- import { usePublicLayout } from '../context';
23
-
24
- export function PublicNavigation() {
25
- const {
26
- logo,
27
- siteName,
28
- navigation,
29
- userMenu,
30
- containerClassName,
31
- mobileMenuOpen,
32
- toggleMobileMenu,
33
- } = usePublicLayout();
24
+ import { usePublicLayoutOptional } from '../context';
25
+
26
+ import type { NavigationItem, UserMenuConfig } from '../../types';
27
+
28
+ export type PublicNavbarVariant = 'floating' | 'flush';
29
+ export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
30
+
31
+ export interface PublicDesktopDropdownRenderProps {
32
+ item: NavigationItem;
33
+ isOpen: boolean;
34
+ isActive: boolean;
35
+ close: () => void;
36
+ defaultPopover: React.ReactNode;
37
+ defaultItems: React.ReactNode;
38
+ }
39
+
40
+ export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => React.ReactNode;
41
+
42
+ interface PublicNavigationProps {
43
+ /** Custom brand node (full control over logo/text/link) */
44
+ brand?: ReactNode;
45
+ /** Brand link for default brand renderer */
46
+ brandHref?: string;
47
+ logo?: string;
48
+ siteName?: string;
49
+ navigation?: NavigationItem[];
50
+ userMenu?: UserMenuConfig;
51
+ containerClassName?: string;
52
+ navbarVariant?: PublicNavbarVariant;
53
+ navbarPosition?: PublicNavbarPosition;
54
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
55
+ /** Max visible top-level desktop items before collapsing into "More" */
56
+ desktopMaxPrimaryItems?: number;
57
+ mobileMenuOpen?: boolean;
58
+ onMobileMenuToggle?: () => void;
59
+ }
60
+
61
+ export function PublicNavigation(props: PublicNavigationProps = {}) {
62
+ const context = usePublicLayoutOptional();
63
+ const brand = props.brand;
64
+ const brandHref = props.brandHref ?? '/';
65
+ const logo = props.logo;
66
+ const siteName = props.siteName ?? 'App';
67
+ const navigation = props.navigation ?? [];
68
+ const userMenu = props.userMenu;
69
+ const containerClassName = props.containerClassName;
70
+ const navbarVariant = props.navbarVariant ?? 'floating';
71
+ const navbarPosition = props.navbarPosition ?? 'sticky';
72
+ const renderDesktopDropdown = props.renderDesktopDropdown;
73
+ const desktopMaxPrimaryItems = Math.max(1, props.desktopMaxPrimaryItems ?? 7);
74
+ const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
75
+ const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
34
76
  const { isAuthenticated: _isAuthenticated } = useAuth();
35
- const isMobile = useIsMobile();
77
+ const isTabletOrBelow = useIsTabletOrBelow();
78
+ const pathname = usePathnameWithoutLocale();
36
79
  const t = useAppT();
80
+ const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
81
+ const openTimerRef = useRef<number | null>(null);
82
+ const closeTimerRef = useRef<number | null>(null);
83
+ const navOuterRef = useRef<HTMLDivElement | null>(null);
37
84
 
38
85
  const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
86
+ const desktopNavItemClass =
87
+ 'inline-flex h-8 items-center rounded-md px-2 text-sm font-medium text-foreground/90 transition-colors hover:text-foreground hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50';
88
+
89
+ const clearOpenTimer = () => {
90
+ if (openTimerRef.current) {
91
+ window.clearTimeout(openTimerRef.current);
92
+ openTimerRef.current = null;
93
+ }
94
+ };
95
+
96
+ const clearCloseTimer = () => {
97
+ if (closeTimerRef.current) {
98
+ window.clearTimeout(closeTimerRef.current);
99
+ closeTimerRef.current = null;
100
+ }
101
+ };
102
+
103
+ const scheduleOpen = (key: string) => {
104
+ clearOpenTimer();
105
+ clearCloseTimer();
106
+ openTimerRef.current = window.setTimeout(() => {
107
+ setOpenDropdownKey(key);
108
+ }, 80);
109
+ };
110
+
111
+ const scheduleClose = (key: string) => {
112
+ clearOpenTimer();
113
+ clearCloseTimer();
114
+ closeTimerRef.current = window.setTimeout(() => {
115
+ setOpenDropdownKey((prev) => (prev === key ? null : prev));
116
+ }, 120);
117
+ };
118
+
119
+ useEffect(() => {
120
+ return () => {
121
+ clearOpenTimer();
122
+ clearCloseTimer();
123
+ };
124
+ }, []);
125
+
126
+ useEffect(() => {
127
+ if (isTabletOrBelow) {
128
+ setOpenDropdownKey(null);
129
+ clearOpenTimer();
130
+ clearCloseTimer();
131
+ }
132
+ }, [isTabletOrBelow]);
133
+
134
+ useEffect(() => {
135
+ const updateDrawerViewportVars = () => {
136
+ const root = document.documentElement;
137
+ const navEl = navOuterRef.current;
138
+ if (!navEl) return;
139
+
140
+ const rect = navEl.getBoundingClientRect();
141
+ const top = Math.max(0, Math.round(rect.bottom + 8));
142
+ const viewportHeight = window.innerHeight;
143
+ const maxHeight = Math.max(240, viewportHeight - top - 12);
144
+
145
+ root.style.setProperty('--public-navbar-mobile-drawer-top', `${top}px`);
146
+ root.style.setProperty('--public-navbar-mobile-drawer-max-height', `${maxHeight}px`);
147
+ };
148
+
149
+ updateDrawerViewportVars();
150
+ const navEl = navOuterRef.current;
151
+ const observer = navEl ? new ResizeObserver(updateDrawerViewportVars) : null;
152
+ if (navEl && observer) observer.observe(navEl);
153
+ window.addEventListener('resize', updateDrawerViewportVars);
154
+ window.addEventListener('scroll', updateDrawerViewportVars, { passive: true });
155
+
156
+ return () => {
157
+ if (navEl && observer) observer.unobserve(navEl);
158
+ observer?.disconnect();
159
+ window.removeEventListener('resize', updateDrawerViewportVars);
160
+ window.removeEventListener('scroll', updateDrawerViewportVars);
161
+ };
162
+ }, [navbarPosition, navbarVariant, containerClassName]);
163
+
164
+ const navOuterClassName = (() => {
165
+ const positionClass =
166
+ navbarPosition === 'fixed'
167
+ ? 'fixed'
168
+ : navbarPosition === 'static'
169
+ ? 'static'
170
+ : 'sticky';
171
+
172
+ const topClass = navbarVariant === 'floating' ? 'top-3' : 'top-0';
173
+ const insetPaddingClass = navbarVariant === 'floating' ? 'px-3 sm:px-4 lg:px-6' : '';
174
+
175
+ return `${positionClass} ${topClass} inset-x-0 z-50 ${insetPaddingClass}`.trim().replace(/\s+/g, ' ');
176
+ })();
177
+
178
+ const navInnerClassName = (() => {
179
+ const base =
180
+ 'mx-auto w-full border border-border/40 dark:border-border/70';
181
+
182
+ const visual =
183
+ navbarVariant === 'floating'
184
+ ? 'rounded-2xl shadow-[0_10px_28px_rgba(0,0,0,0.14)] dark:shadow-[0_10px_28px_rgba(0,0,0,0.22)]'
185
+ : 'rounded-none border-x-0 border-t-0 shadow-none';
186
+
187
+ return `${base} ${visual} ${containerClassName || ''}`.trim().replace(/\s+/g, ' ');
188
+ })();
189
+
190
+ const isActivePath = (href: string) => {
191
+ if (href === '/') return pathname === '/';
192
+ return pathname === href || pathname.startsWith(`${href}/`);
193
+ };
194
+
195
+ const isGroupActive = (item: NavigationItem) => {
196
+ if (isActivePath(item.href)) return true;
197
+ if (!item.items) return false;
198
+ return item.items.some((subItem) => isActivePath(subItem.href));
199
+ };
39
200
 
40
- const navClass = 'sticky top-3 z-50 px-4 sm:px-6 lg:px-8';
201
+ const closeDropdown = () => setOpenDropdownKey(null);
202
+ const desktopPrimaryNavigation = navigation.slice(0, desktopMaxPrimaryItems);
203
+ const desktopOverflowNavigation = navigation.slice(desktopMaxPrimaryItems);
204
+
205
+ const renderDesktopNavItem = (item: NavigationItem) => {
206
+ if (item.items && item.items.length > 0) {
207
+ const dropdownKey = `${item.label}-${item.href}`;
208
+ const defaultItems = (
209
+ <>
210
+ {item.items.map((subItem) => (
211
+ <div key={`${item.label}-${subItem.href}`} className="rounded-md">
212
+ {subItem.external ? (
213
+ <a
214
+ href={subItem.href}
215
+ target="_blank"
216
+ rel="noopener noreferrer"
217
+ className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
218
+ >
219
+ {subItem.label}
220
+ </a>
221
+ ) : (
222
+ <Link
223
+ href={subItem.href}
224
+ className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
225
+ >
226
+ {subItem.label}
227
+ </Link>
228
+ )}
229
+ </div>
230
+ ))}
231
+ </>
232
+ );
233
+
234
+ const defaultPopover = (
235
+ <div
236
+ className="absolute left-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
237
+ onMouseEnter={() => {
238
+ clearOpenTimer();
239
+ clearCloseTimer();
240
+ }}
241
+ onMouseLeave={() => scheduleClose(dropdownKey)}
242
+ >
243
+ {defaultItems}
244
+ </div>
245
+ );
246
+
247
+ const isOpen = openDropdownKey === dropdownKey;
248
+ const isActive = isGroupActive(item);
249
+ return (
250
+ <div
251
+ key={dropdownKey}
252
+ className="relative"
253
+ onMouseEnter={() => scheduleOpen(dropdownKey)}
254
+ onMouseLeave={() => scheduleClose(dropdownKey)}
255
+ >
256
+ <Button
257
+ variant="ghost"
258
+ size="sm"
259
+ className={`group ${desktopNavItemClass} ${isOpen || isActive ? 'bg-accent/50 text-foreground' : ''}`}
260
+ >
261
+ <span>{item.label}</span>
262
+ <ChevronDown
263
+ className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
264
+ />
265
+ </Button>
266
+
267
+ {isOpen && (
268
+ renderDesktopDropdown
269
+ ? renderDesktopDropdown({
270
+ item,
271
+ isOpen,
272
+ isActive,
273
+ close: closeDropdown,
274
+ defaultPopover,
275
+ defaultItems,
276
+ })
277
+ : defaultPopover
278
+ )}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ return (
284
+ <Link
285
+ key={item.href}
286
+ href={item.href}
287
+ className={`${desktopNavItemClass} ${isActivePath(item.href) ? 'bg-accent/50 text-foreground' : ''}`}
288
+ >
289
+ {item.label}
290
+ </Link>
291
+ );
292
+ };
41
293
 
42
294
  return (
43
- <div className={navClass}>
295
+ <div ref={navOuterRef} className={navOuterClassName}>
44
296
  <nav
45
- className={`mx-auto w-full rounded-2xl border border-border/40 dark:border-border/70 shadow-[0_10px_28px_rgba(0,0,0,0.14)] dark:shadow-[0_10px_28px_rgba(0,0,0,0.22)] ${containerClassName || ''}`}
297
+ className={navInnerClassName}
46
298
  style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
47
299
  >
48
- <div className="w-full px-4 sm:px-6 lg:px-8">
49
- <div
50
- className="flex items-center justify-between py-3.5"
51
- onClick={isMobile ? toggleMobileMenu : undefined}
52
- onKeyDown={isMobile ? (event) => {
53
- if (event.key === 'Enter' || event.key === ' ') {
54
- event.preventDefault();
55
- toggleMobileMenu();
56
- }
57
- } : undefined}
58
- role={isMobile ? 'button' : undefined}
59
- tabIndex={isMobile ? 0 : undefined}
60
- aria-label={isMobile ? toggleMobileLabel : undefined}
61
- >
300
+ <div className="w-full px-3 sm:px-4 lg:px-6">
301
+ <div className="flex items-center justify-between py-3.5">
62
302
  {/* Logo */}
63
- {isMobile ? (
64
- <div className="flex items-center gap-2">
65
- {logo && (
66
- <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
67
- )}
68
- <span className="font-bold text-lg">{siteName}</span>
69
- </div>
303
+ {brand ? (
304
+ brand
70
305
  ) : (
71
- <Link href="/" className="flex items-center gap-2">
306
+ <Link href={brandHref} className="flex items-center gap-1.5">
72
307
  {logo && (
73
- <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
308
+ <img src={logo} alt={siteName} className="h-5.5 w-auto object-contain" />
74
309
  )}
75
- <span className="font-bold text-lg">{siteName}</span>
310
+ <span className="font-bold text-[15px]">{siteName}</span>
76
311
  </Link>
77
312
  )}
78
313
 
79
314
  {/* Desktop Navigation */}
80
- <div className="hidden md:flex items-center gap-6">
81
- {navigation.map((item) => (
82
- <Link
83
- key={item.href}
84
- href={item.href}
85
- className="text-sm font-medium hover:text-primary transition-colors"
315
+ <div className="hidden lg:flex items-center gap-3">
316
+ {desktopPrimaryNavigation.map(renderDesktopNavItem)}
317
+ {desktopOverflowNavigation.length > 0 && (
318
+ <div
319
+ className="relative"
320
+ onMouseEnter={() => scheduleOpen('__overflow-more')}
321
+ onMouseLeave={() => scheduleClose('__overflow-more')}
86
322
  >
87
- {item.label}
88
- </Link>
89
- ))}
323
+ <Button
324
+ variant="ghost"
325
+ size="sm"
326
+ className={`group ${desktopNavItemClass} ${
327
+ openDropdownKey === '__overflow-more' || desktopOverflowNavigation.some((item) => isGroupActive(item))
328
+ ? 'bg-accent/50 text-foreground'
329
+ : ''
330
+ }`}
331
+ >
332
+ <span>More</span>
333
+ <ChevronDown
334
+ className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
335
+ openDropdownKey === '__overflow-more' ? 'rotate-180' : ''
336
+ }`}
337
+ />
338
+ </Button>
339
+
340
+ {openDropdownKey === '__overflow-more' && (
341
+ <div
342
+ className="absolute right-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
343
+ onMouseEnter={() => {
344
+ clearOpenTimer();
345
+ clearCloseTimer();
346
+ }}
347
+ onMouseLeave={() => scheduleClose('__overflow-more')}
348
+ >
349
+ {desktopOverflowNavigation.map((item) => (
350
+ <div key={`overflow-${item.href}`} className="rounded-md">
351
+ <Link
352
+ href={item.href}
353
+ className={`block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40 ${
354
+ isGroupActive(item) ? 'bg-accent/45 text-foreground' : ''
355
+ }`}
356
+ >
357
+ {item.label}
358
+ </Link>
359
+ </div>
360
+ ))}
361
+ </div>
362
+ )}
363
+ </div>
364
+ )}
90
365
  </div>
91
366
 
92
367
  {/* User Menu / Actions */}
93
368
  <div className="flex items-center gap-4">
94
- {!isMobile && (
369
+ <div className="hidden lg:flex">
95
370
  <>
96
371
  {/* User Menu */}
97
372
  <UserMenu
@@ -100,20 +375,19 @@ export function PublicNavigation() {
100
375
  authPath={userMenu?.authPath}
101
376
  />
102
377
  </>
103
- )}
378
+ </div>
104
379
 
105
380
  {/* Mobile Menu Button */}
106
- {isMobile && (
107
- <Button
108
- variant="ghost"
109
- size="icon"
110
- aria-label={toggleMobileLabel}
111
- data-mobile-menu-trigger="true"
112
- className="pointer-events-none"
113
- >
114
- {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
115
- </Button>
116
- )}
381
+ <Button
382
+ variant="ghost"
383
+ size="icon"
384
+ aria-label={toggleMobileLabel}
385
+ data-mobile-menu-trigger="true"
386
+ className="lg:hidden"
387
+ onClick={toggleMobileMenu}
388
+ >
389
+ {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
390
+ </Button>
117
391
  </div>
118
392
  </div>
119
393
  </div>
@@ -4,5 +4,6 @@
4
4
 
5
5
  export { PublicNavigation } from './PublicNavigation';
6
6
  export { PublicMobileDrawer } from './PublicMobileDrawer';
7
+ export { PublicNavbar } from './PublicNavbar';
7
8
  export { PublicFooter } from './PublicFooter';
8
9
 
@@ -2,16 +2,7 @@
2
2
 
3
3
  import React, { createContext, useContext } from 'react';
4
4
 
5
- import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
6
- import type { NavigationItem, UserMenuConfig } from '../types';
7
-
8
5
  export interface PublicLayoutContextValue {
9
- logo?: string;
10
- siteName: string;
11
- navigation: NavigationItem[];
12
- userMenu?: UserMenuConfig;
13
- i18n?: I18nLayoutConfig;
14
- containerClassName?: string;
15
6
  mobileMenuOpen: boolean;
16
7
  toggleMobileMenu: () => void;
17
8
  closeMobileMenu: () => void;
@@ -37,3 +28,7 @@ export function usePublicLayout() {
37
28
  return context;
38
29
  }
39
30
 
31
+ export function usePublicLayoutOptional() {
32
+ return useContext(PublicLayoutContext);
33
+ }
34
+
@@ -4,6 +4,14 @@
4
4
 
5
5
  export { PublicLayout } from './PublicLayout';
6
6
  export type { PublicLayoutProps } from './PublicLayout';
7
+ export { PublicNavigation, PublicMobileDrawer, PublicNavbar } from './components';
8
+ export type {
9
+ PublicNavbarVariant,
10
+ PublicNavbarPosition,
11
+ PublicDesktopDropdownRenderer,
12
+ PublicDesktopDropdownRenderProps,
13
+ } from './components/PublicNavigation';
14
+ export type { PublicNavbarConfig, PublicNavbarProps } from './components/PublicNavbar';
7
15
  export {
8
16
  PublicFooter,
9
17
  FooterProjectInfo,
@@ -15,4 +23,5 @@ export {
15
23
  export type {
16
24
  PublicFooterProps,
17
25
  } from './components/PublicFooter';
26
+ export { PublicLayoutProvider, usePublicLayout } from './context';
18
27
 
@@ -16,6 +16,8 @@ export interface NavigationItem {
16
16
  icon?: LucideIcon | string;
17
17
  badge?: string | number;
18
18
  external?: boolean;
19
+ /** Optional nested items for desktop dropdown navigation */
20
+ items?: NavigationItem[];
19
21
  }
20
22
 
21
23
  export interface NavigationSection {