@djangocfg/layouts 2.1.248 → 2.1.249

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.248",
3
+ "version": "2.1.249",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,14 +74,14 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.248",
78
- "@djangocfg/centrifugo": "^2.1.248",
79
- "@djangocfg/i18n": "^2.1.248",
80
- "@djangocfg/monitor": "^2.1.248",
81
- "@djangocfg/debuger": "^2.1.248",
82
- "@djangocfg/ui-core": "^2.1.248",
83
- "@djangocfg/ui-nextjs": "^2.1.248",
84
- "@djangocfg/ui-tools": "^2.1.248",
77
+ "@djangocfg/api": "^2.1.249",
78
+ "@djangocfg/centrifugo": "^2.1.249",
79
+ "@djangocfg/i18n": "^2.1.249",
80
+ "@djangocfg/monitor": "^2.1.249",
81
+ "@djangocfg/debuger": "^2.1.249",
82
+ "@djangocfg/ui-core": "^2.1.249",
83
+ "@djangocfg/ui-nextjs": "^2.1.249",
84
+ "@djangocfg/ui-tools": "^2.1.249",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -109,15 +109,15 @@
109
109
  "uuid": "^11.1.0"
110
110
  },
111
111
  "devDependencies": {
112
- "@djangocfg/api": "^2.1.248",
113
- "@djangocfg/i18n": "^2.1.248",
114
- "@djangocfg/centrifugo": "^2.1.248",
115
- "@djangocfg/monitor": "^2.1.248",
116
- "@djangocfg/debuger": "^2.1.248",
117
- "@djangocfg/typescript-config": "^2.1.248",
118
- "@djangocfg/ui-core": "^2.1.248",
119
- "@djangocfg/ui-nextjs": "^2.1.248",
120
- "@djangocfg/ui-tools": "^2.1.248",
112
+ "@djangocfg/api": "^2.1.249",
113
+ "@djangocfg/i18n": "^2.1.249",
114
+ "@djangocfg/centrifugo": "^2.1.249",
115
+ "@djangocfg/monitor": "^2.1.249",
116
+ "@djangocfg/debuger": "^2.1.249",
117
+ "@djangocfg/typescript-config": "^2.1.249",
118
+ "@djangocfg/ui-core": "^2.1.249",
119
+ "@djangocfg/ui-nextjs": "^2.1.249",
120
+ "@djangocfg/ui-tools": "^2.1.249",
121
121
  "@types/node": "^24.7.2",
122
122
  "@types/react": "^19.1.0",
123
123
  "@types/react-dom": "^19.1.0",
@@ -15,17 +15,26 @@ import { useAppT } from '@djangocfg/i18n';
15
15
  import { Button } from '@djangocfg/ui-core/components';
16
16
 
17
17
  import { UserMenu } from '../../_components/UserMenu';
18
- import { usePublicLayout } from '../context';
18
+ import { usePublicLayoutOptional } from '../context';
19
19
  import { useFloatingPanel } from '../hooks';
20
20
 
21
- export function PublicMobileDrawer() {
22
- const {
23
- mobileMenuOpen,
24
- closeMobileMenu,
25
- navigation,
26
- userMenu,
27
- containerClassName,
28
- } = usePublicLayout();
21
+ import type { NavigationItem, UserMenuConfig } from '../../types';
22
+
23
+ interface PublicMobileDrawerProps {
24
+ isOpen?: boolean;
25
+ onClose?: () => void;
26
+ navigation?: NavigationItem[];
27
+ userMenu?: UserMenuConfig;
28
+ containerClassName?: string;
29
+ }
30
+
31
+ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
32
+ const context = usePublicLayoutOptional();
33
+ const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
34
+ const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
35
+ const navigation = props.navigation ?? context?.navigation ?? [];
36
+ const userMenu = props.userMenu ?? context?.userMenu;
37
+ const containerClassName = props.containerClassName ?? context?.containerClassName;
29
38
  const { isAuthenticated } = useAuth();
30
39
  const t = useAppT();
31
40
  const { isRendered, isActive, onTransitionEnd } = useFloatingPanel({
@@ -54,15 +63,15 @@ export function PublicMobileDrawer() {
54
63
  <div className="fixed inset-x-0 top-20 z-1000 lg:hidden px-4 sm:px-6 lg:px-8">
55
64
  <div
56
65
  onTransitionEnd={onTransitionEnd}
57
- className={`mx-auto w-full rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
66
+ className={`mx-auto w-full max-h-[80vh] rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden flex flex-col transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
58
67
  isActive
59
68
  ? 'opacity-100 translate-y-0 scale-100'
60
69
  : 'opacity-0 -translate-y-2 scale-[0.985] pointer-events-none'
61
70
  }`}
62
71
  style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
63
72
  >
64
- {/* Content */}
65
- <div className="max-h-[min(72vh,560px)] overflow-y-auto px-4 py-4 space-y-5">
73
+ {/* Scrollable content */}
74
+ <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
66
75
  {isAuthenticated && (
67
76
  <div className="px-2">
68
77
  <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@@ -84,33 +93,48 @@ export function PublicMobileDrawer() {
84
93
  </div>
85
94
  <div className="space-y-1">
86
95
  {navigation.map((item) => (
87
- <Link
88
- key={item.href}
89
- href={item.href}
90
- onClick={closeMobileMenu}
91
- className="block px-3 py-2.5 rounded-lg text-[15px] font-medium transition-colors text-foreground hover:bg-accent/70 hover:text-accent-foreground"
92
- >
93
- {item.label}
94
- </Link>
96
+ <div key={item.href}>
97
+ <Link
98
+ href={item.href}
99
+ onClick={closeMobileMenu}
100
+ className="block px-3 py-2.5 rounded-lg text-[15px] font-medium transition-colors text-foreground hover:bg-accent/70 hover:text-accent-foreground"
101
+ >
102
+ {item.label}
103
+ </Link>
104
+ {item.items && item.items.length > 0 && (
105
+ <div className="ml-3 mt-1 space-y-1 border-l border-border/40 pl-3">
106
+ {item.items.map((subItem) => (
107
+ <Link
108
+ key={`${item.href}-${subItem.href}`}
109
+ href={subItem.href}
110
+ onClick={closeMobileMenu}
111
+ className="block px-2 py-2 rounded-md text-sm text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
112
+ >
113
+ {subItem.label}
114
+ </Link>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
95
119
  ))}
96
120
  </div>
97
121
  </div>
98
-
99
- {!isAuthenticated && (
100
- <div className="pt-4 border-t border-border/50">
101
- <Link
102
- href={userMenu?.authPath || '/auth'}
103
- onClick={closeMobileMenu}
104
- className="block"
105
- >
106
- <Button className="w-full justify-between rounded-lg h-11">
107
- <span>{labels.signIn}</span>
108
- <ArrowRight className="h-4 w-4" />
109
- </Button>
110
- </Link>
111
- </div>
112
- )}
113
122
  </div>
123
+
124
+ {!isAuthenticated && (
125
+ <div className="shrink-0 border-t border-border/50 p-4">
126
+ <Link
127
+ href={userMenu?.authPath || '/auth'}
128
+ onClick={closeMobileMenu}
129
+ className="block"
130
+ >
131
+ <Button className="w-full justify-between rounded-lg h-11">
132
+ <span>{labels.signIn}</span>
133
+ <ArrowRight className="h-4 w-4" />
134
+ </Button>
135
+ </Link>
136
+ </div>
137
+ )}
114
138
  </div>
115
139
  </div>
116
140
  </>
@@ -6,36 +6,91 @@
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, { 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';
15
+ import {
16
+ Button,
17
+ } from '@djangocfg/ui-core/components';
16
18
  // useIsMobile is used for conditional rendering
17
19
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
18
20
  // cn is reserved for future conditional styling
19
21
  import { cn as _cn } from '@djangocfg/ui-core/lib';
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
+ interface PublicNavigationProps {
29
+ logo?: string;
30
+ siteName?: string;
31
+ navigation?: NavigationItem[];
32
+ userMenu?: UserMenuConfig;
33
+ containerClassName?: string;
34
+ mobileMenuOpen?: boolean;
35
+ onMobileMenuToggle?: () => void;
36
+ }
37
+
38
+ export function PublicNavigation(props: PublicNavigationProps = {}) {
39
+ const context = usePublicLayoutOptional();
40
+ const logo = props.logo ?? context?.logo;
41
+ const siteName = props.siteName ?? context?.siteName ?? 'App';
42
+ const navigation = props.navigation ?? context?.navigation ?? [];
43
+ const userMenu = props.userMenu ?? context?.userMenu;
44
+ const containerClassName = props.containerClassName ?? context?.containerClassName;
45
+ const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
46
+ const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
34
47
  const { isAuthenticated: _isAuthenticated } = useAuth();
35
48
  const isMobile = useIsMobile();
36
49
  const t = useAppT();
50
+ const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
51
+ const openTimerRef = useRef<number | null>(null);
52
+ const closeTimerRef = useRef<number | null>(null);
37
53
 
38
54
  const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
55
+ const desktopNavItemClass =
56
+ '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';
57
+
58
+ const clearOpenTimer = () => {
59
+ if (openTimerRef.current) {
60
+ window.clearTimeout(openTimerRef.current);
61
+ openTimerRef.current = null;
62
+ }
63
+ };
64
+
65
+ const clearCloseTimer = () => {
66
+ if (closeTimerRef.current) {
67
+ window.clearTimeout(closeTimerRef.current);
68
+ closeTimerRef.current = null;
69
+ }
70
+ };
71
+
72
+ const scheduleOpen = (key: string) => {
73
+ clearOpenTimer();
74
+ clearCloseTimer();
75
+ openTimerRef.current = window.setTimeout(() => {
76
+ setOpenDropdownKey(key);
77
+ }, 80);
78
+ };
79
+
80
+ const scheduleClose = (key: string) => {
81
+ clearOpenTimer();
82
+ clearCloseTimer();
83
+ closeTimerRef.current = window.setTimeout(() => {
84
+ setOpenDropdownKey((prev) => (prev === key ? null : prev));
85
+ }, 120);
86
+ };
87
+
88
+ useEffect(() => {
89
+ return () => {
90
+ clearOpenTimer();
91
+ clearCloseTimer();
92
+ };
93
+ }, []);
39
94
 
40
95
  const navClass = 'sticky top-3 z-50 px-4 sm:px-6 lg:px-8';
41
96
 
@@ -65,28 +120,86 @@ export function PublicNavigation() {
65
120
  {logo && (
66
121
  <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
67
122
  )}
68
- <span className="font-bold text-lg">{siteName}</span>
123
+ <span className="font-bold text-base">{siteName}</span>
69
124
  </div>
70
125
  ) : (
71
126
  <Link href="/" className="flex items-center gap-2">
72
127
  {logo && (
73
128
  <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
74
129
  )}
75
- <span className="font-bold text-lg">{siteName}</span>
130
+ <span className="font-bold text-base">{siteName}</span>
76
131
  </Link>
77
132
  )}
78
133
 
79
134
  {/* 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"
86
- >
87
- {item.label}
88
- </Link>
89
- ))}
135
+ <div className="hidden md:flex items-center gap-3">
136
+ {navigation.map((item) => {
137
+ if (item.items && item.items.length > 0) {
138
+ const dropdownKey = `${item.label}-${item.href}`;
139
+ return (
140
+ <div
141
+ key={dropdownKey}
142
+ className="relative"
143
+ onMouseEnter={() => scheduleOpen(dropdownKey)}
144
+ onMouseLeave={() => scheduleClose(dropdownKey)}
145
+ >
146
+ <Button
147
+ variant="ghost"
148
+ size="sm"
149
+ className={`group ${desktopNavItemClass} ${openDropdownKey === dropdownKey ? 'bg-accent/50 text-foreground' : ''}`}
150
+ >
151
+ <span>{item.label}</span>
152
+ <ChevronDown
153
+ className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${openDropdownKey === dropdownKey ? 'rotate-180' : ''}`}
154
+ />
155
+ </Button>
156
+
157
+ {openDropdownKey === dropdownKey && (
158
+ <div
159
+ 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)]"
160
+ onMouseEnter={() => {
161
+ clearOpenTimer();
162
+ clearCloseTimer();
163
+ }}
164
+ onMouseLeave={() => scheduleClose(dropdownKey)}
165
+ >
166
+ {item.items.map((subItem) => (
167
+ <div key={`${item.label}-${subItem.href}`} className="rounded-md">
168
+ {subItem.external ? (
169
+ <a
170
+ href={subItem.href}
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
174
+ >
175
+ {subItem.label}
176
+ </a>
177
+ ) : (
178
+ <Link
179
+ href={subItem.href}
180
+ className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
181
+ >
182
+ {subItem.label}
183
+ </Link>
184
+ )}
185
+ </div>
186
+ ))}
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ return (
194
+ <Link
195
+ key={item.href}
196
+ href={item.href}
197
+ className={desktopNavItemClass}
198
+ >
199
+ {item.label}
200
+ </Link>
201
+ );
202
+ })}
90
203
  </div>
91
204
 
92
205
  {/* User Menu / Actions */}
@@ -37,3 +37,7 @@ export function usePublicLayout() {
37
37
  return context;
38
38
  }
39
39
 
40
+ export function usePublicLayoutOptional() {
41
+ return useContext(PublicLayoutContext);
42
+ }
43
+
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { PublicLayout } from './PublicLayout';
6
6
  export type { PublicLayoutProps } from './PublicLayout';
7
+ export { PublicNavigation, PublicMobileDrawer } from './components';
7
8
  export {
8
9
  PublicFooter,
9
10
  FooterProjectInfo,
@@ -15,4 +16,5 @@ export {
15
16
  export type {
16
17
  PublicFooterProps,
17
18
  } from './components/PublicFooter';
19
+ export { PublicLayoutProvider, usePublicLayout } from './context';
18
20
 
@@ -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 {