@djangocfg/layouts 2.1.255 → 2.1.257

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.255",
3
+ "version": "2.1.257",
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.255",
78
- "@djangocfg/centrifugo": "^2.1.255",
79
- "@djangocfg/i18n": "^2.1.255",
80
- "@djangocfg/monitor": "^2.1.255",
81
- "@djangocfg/debuger": "^2.1.255",
82
- "@djangocfg/ui-core": "^2.1.255",
83
- "@djangocfg/ui-nextjs": "^2.1.255",
84
- "@djangocfg/ui-tools": "^2.1.255",
77
+ "@djangocfg/api": "^2.1.257",
78
+ "@djangocfg/centrifugo": "^2.1.257",
79
+ "@djangocfg/i18n": "^2.1.257",
80
+ "@djangocfg/monitor": "^2.1.257",
81
+ "@djangocfg/debuger": "^2.1.257",
82
+ "@djangocfg/ui-core": "^2.1.257",
83
+ "@djangocfg/ui-nextjs": "^2.1.257",
84
+ "@djangocfg/ui-tools": "^2.1.257",
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.255",
113
- "@djangocfg/i18n": "^2.1.255",
114
- "@djangocfg/centrifugo": "^2.1.255",
115
- "@djangocfg/monitor": "^2.1.255",
116
- "@djangocfg/debuger": "^2.1.255",
117
- "@djangocfg/typescript-config": "^2.1.255",
118
- "@djangocfg/ui-core": "^2.1.255",
119
- "@djangocfg/ui-nextjs": "^2.1.255",
120
- "@djangocfg/ui-tools": "^2.1.255",
112
+ "@djangocfg/api": "^2.1.257",
113
+ "@djangocfg/i18n": "^2.1.257",
114
+ "@djangocfg/centrifugo": "^2.1.257",
115
+ "@djangocfg/monitor": "^2.1.257",
116
+ "@djangocfg/debuger": "^2.1.257",
117
+ "@djangocfg/typescript-config": "^2.1.257",
118
+ "@djangocfg/ui-core": "^2.1.257",
119
+ "@djangocfg/ui-nextjs": "^2.1.257",
120
+ "@djangocfg/ui-tools": "^2.1.257",
121
121
  "@types/node": "^24.7.2",
122
122
  "@types/react": "^19.1.0",
123
123
  "@types/react-dom": "^19.1.0",
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Private layout main column — optional mobile menu strip (`SidebarTrigger`) + scrollable area.
3
- * On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Sheet` from ui-nextjs sidebar.
2
+ * Private layout main column — on narrow viewports a fixed menu FAB (`SidebarTrigger`) + scrollable area.
3
+ * On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from ui-nextjs sidebar.
4
4
  */
5
5
 
6
6
  'use client';
7
7
 
8
8
  import React, { ReactNode } from 'react';
9
9
 
10
- import { SidebarTrigger } from '@djangocfg/ui-nextjs/components';
10
+ import { SidebarTrigger, useSidebar } from '@djangocfg/ui-nextjs/components';
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
 
13
13
  interface PrivateContentProps {
@@ -22,24 +22,42 @@ export function PrivateContent({
22
22
  padding = 'default',
23
23
  hasSidebar = true,
24
24
  }: PrivateContentProps) {
25
- const mobileToolbarClass = cn(
26
- 'sticky top-0 z-40 flex shrink-0 items-center gap-2 border-b border-border/50 bg-background/95 py-2 pl-2 pr-3 backdrop-blur-md supports-[backdrop-filter]:bg-background/80',
27
- 'md:hidden',
28
- );
25
+ const { isMobile, openMobile } = useSidebar();
26
+
27
+ /**
28
+ * Space for fixed FAB + safe area so content does not sit under the button.
29
+ * Tighter when the mobile drawer is closed (FAB only). When the drawer is open, a bit more
30
+ * room avoids the first line sitting under the overlay edge.
31
+ */
32
+ const mobileFabClearance =
33
+ hasSidebar &&
34
+ (isMobile && !openMobile
35
+ ? 'max-md:pt-[max(3.75rem,calc(3rem+env(safe-area-inset-top,0px)))]'
36
+ : 'max-md:pt-[max(4rem,calc(3.25rem+env(safe-area-inset-top,0px)))]');
37
+
29
38
  const scrollAreaClass = cn(
30
39
  'min-h-0 flex-1 overflow-y-auto',
31
40
  padding === 'default' && 'p-4 sm:p-6 lg:p-8',
41
+ mobileFabClearance,
32
42
  );
33
43
 
34
- const mobileToolbar = hasSidebar ? (
35
- <div className={mobileToolbarClass}>
36
- <SidebarTrigger className="shrink-0" aria-label="Open menu" />
37
- </div>
44
+ const mobileMenuFab = hasSidebar ? (
45
+ <SidebarTrigger
46
+ variant="secondary"
47
+ className={cn(
48
+ 'fixed z-40 md:hidden',
49
+ 'left-3 top-[max(0.75rem,env(safe-area-inset-top,0px))]',
50
+ 'h-12 w-12 rounded-xl',
51
+ 'border border-border shadow-md',
52
+ '[&_svg]:!h-6 [&_svg]:!w-6',
53
+ 'touch-manipulation',
54
+ )}
55
+ />
38
56
  ) : null;
39
57
 
40
58
  return (
41
59
  <div className="flex min-h-0 min-w-0 flex-1 flex-col">
42
- {mobileToolbar}
60
+ {mobileMenuFab}
43
61
  <div className={scrollAreaClass}>{children}</div>
44
62
  </div>
45
63
  );
@@ -48,9 +48,9 @@ function navDensityFromCount(n: number): NavDensity {
48
48
  * Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
49
49
  */
50
50
  const navItemClass = cn(
51
- 'border-0 font-normal shadow-none transition-colors',
52
- 'text-sidebar-foreground/70',
53
- 'data-[active=true]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
51
+ 'border-0 font-medium shadow-none transition-colors',
52
+ 'text-sidebar-foreground/80',
53
+ 'data-[active=true]:font-semibold data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
54
54
  'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
55
55
  'data-[active=true]:hover:bg-sidebar-accent',
56
56
  '[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
@@ -210,7 +210,7 @@ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }
210
210
  <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
211
211
  <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
212
212
  </Link>
213
- <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />
213
+ {!isMobile && <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />}
214
214
  </div>
215
215
  );
216
216
 
@@ -220,12 +220,32 @@ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }
220
220
  </div>
221
221
  );
222
222
 
223
- const sidebarHeaderContent = expanded ? expandedHeader : collapsedHeader;
223
+ /** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
224
+ const mobileHeader = (
225
+ <div className="flex items-center gap-3">
226
+ <Link
227
+ href={homeHref}
228
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
229
+ >
230
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
231
+ <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
232
+ </Link>
233
+ </div>
234
+ );
235
+
236
+ const sidebarHeaderContent = isMobile ? mobileHeader : expanded ? expandedHeader : collapsedHeader;
224
237
  const footerExtra = sidebar.footer ? <div className="mb-2">{sidebar.footer}</div> : null;
225
238
 
239
+ const sidebarHeaderClass = cn(
240
+ 'pb-2',
241
+ isMobile
242
+ ? 'px-4 pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
243
+ : 'px-2 pt-3.5',
244
+ );
245
+
226
246
  return (
227
247
  <Sidebar collapsible="icon">
228
- <SidebarHeader className="px-2 pt-3.5 pb-2">{sidebarHeaderContent}</SidebarHeader>
248
+ <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
229
249
 
230
250
  <SidebarContent className={sidebarContentClass}>
231
251
  {menuStartSlot}
@@ -33,9 +33,12 @@
33
33
  'use client';
34
34
 
35
35
  import { usePathname } from 'next/navigation';
36
- import { ReactNode, useEffect, useMemo, useState } from 'react';
36
+ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
37
37
 
38
- import { PublicLayoutProvider } from './context';
38
+ import { cn } from '@djangocfg/ui-core/lib';
39
+
40
+ import { PublicLayoutProvider, usePublicLayoutOptional } from './context';
41
+ import type { PublicNavbarSurface } from './navbarTypes';
39
42
 
40
43
  export interface PublicLayoutProps {
41
44
  children: ReactNode;
@@ -48,36 +51,75 @@ export interface PublicLayoutProps {
48
51
  */
49
52
  navbar?: ReactNode;
50
53
  footer?: ReactNode;
54
+ /**
55
+ * When `auto` (default), `<main>` gets a small top offset from `PublicNavigation` surface
56
+ * (`floating` vs `flush`). Set `none` if the page controls spacing itself.
57
+ */
58
+ contentTopSpacing?: 'auto' | 'none';
59
+ }
60
+
61
+ function PublicMain({
62
+ children,
63
+ contentTopSpacing,
64
+ }: {
65
+ children: ReactNode;
66
+ contentTopSpacing: 'auto' | 'none';
67
+ }) {
68
+ const ctx = usePublicLayoutOptional();
69
+ const variant = ctx?.navbarSurface?.variant;
70
+
71
+ const topClass =
72
+ contentTopSpacing === 'none'
73
+ ? undefined
74
+ : !variant
75
+ ? 'pt-4 sm:pt-5'
76
+ : variant === 'floating'
77
+ ? 'pt-2 sm:pt-3 lg:pt-4'
78
+ : 'pt-1 sm:pt-2 lg:pt-3';
79
+
80
+ return (
81
+ <main className={cn('flex-1', topClass)}>
82
+ {children}
83
+ </main>
84
+ );
51
85
  }
52
86
 
53
87
  export function PublicLayout({
54
88
  children,
55
89
  navbar,
56
90
  footer,
91
+ contentTopSpacing = 'auto',
57
92
  }: PublicLayoutProps) {
58
93
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
94
+ const [navbarSurface, setNavbarSurfaceState] = useState<PublicNavbarSurface | null>(null);
59
95
  const pathname = usePathname();
60
96
 
97
+ const setNavbarSurface = useCallback((surface: PublicNavbarSurface | null) => {
98
+ setNavbarSurfaceState(surface);
99
+ }, []);
100
+
61
101
  // Close mobile menu on route change
62
102
  useEffect(() => {
63
103
  setMobileMenuOpen(false);
64
104
  }, [pathname]);
65
105
 
66
- const contextValue = useMemo(() => ({
67
- mobileMenuOpen,
68
- toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
69
- closeMobileMenu: () => setMobileMenuOpen(false),
70
- }), [
71
- mobileMenuOpen,
72
- ]);
106
+ const contextValue = useMemo(
107
+ () => ({
108
+ mobileMenuOpen,
109
+ toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
110
+ closeMobileMenu: () => setMobileMenuOpen(false),
111
+ navbarSurface,
112
+ setNavbarSurface,
113
+ }),
114
+ [mobileMenuOpen, navbarSurface, setNavbarSurface],
115
+ );
73
116
 
74
117
  return (
75
118
  <PublicLayoutProvider value={contextValue}>
76
119
  <div className="min-h-screen flex flex-col">
77
120
  {navbar ?? null}
78
121
 
79
- {/* Main Content */}
80
- <main className="flex-1">{children}</main>
122
+ <PublicMain contentTopSpacing={contentTopSpacing}>{children}</PublicMain>
81
123
 
82
124
  {footer ?? null}
83
125
  </div>
@@ -17,7 +17,7 @@ import { usePathnameWithoutLocale } from '../../../hooks';
17
17
 
18
18
  import { UserMenu } from '../../_components/UserMenu';
19
19
  import { usePublicLayoutOptional } from '../context';
20
- import { useFloatingPanel } from '../hooks';
20
+ import { useMobileNavPanel } from '../hooks';
21
21
 
22
22
  import type { NavigationItem, UserMenuConfig } from '../../types';
23
23
 
@@ -39,7 +39,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
39
39
  const { isAuthenticated } = useAuth();
40
40
  const pathname = usePathnameWithoutLocale();
41
41
  const t = useAppT();
42
- const { isRendered, isActive, onTransitionEnd } = useFloatingPanel({
42
+ const { mounted, visible } = useMobileNavPanel({
43
43
  isOpen: mobileMenuOpen,
44
44
  onClose: closeMobileMenu,
45
45
  });
@@ -61,7 +61,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
61
61
  return pathname === href || pathname.startsWith(`${href}/`);
62
62
  };
63
63
 
64
- if (!isRendered) return null;
64
+ if (!mounted) return null;
65
65
 
66
66
  return (
67
67
  <>
@@ -73,16 +73,17 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
73
73
  onClick={closeMobileMenu}
74
74
  />
75
75
  )}
76
+ {/* Outer shell must not capture taps when the panel is closed: with pointer-events-none on the
77
+ inner panel, events would otherwise hit this transparent fixed layer (z-1000) and block the page. */}
76
78
  <div
77
- className="fixed inset-x-0 z-1000 lg:hidden px-4 sm:px-6 lg:px-8"
79
+ className="pointer-events-none fixed inset-x-0 z-1000 lg:hidden px-4 sm:px-6 lg:px-8"
78
80
  style={{ top: 'var(--public-navbar-mobile-drawer-top, 5rem)' }}
79
81
  >
80
82
  <div
81
- onTransitionEnd={onTransitionEnd}
82
83
  className={`mx-auto w-full 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 || ''} ${
83
- isActive
84
- ? 'opacity-100 translate-y-0 scale-100'
85
- : 'opacity-0 -translate-y-2 scale-[0.985] pointer-events-none'
84
+ visible
85
+ ? 'pointer-events-auto opacity-100 translate-y-0 scale-100'
86
+ : 'pointer-events-none opacity-0 -translate-y-2 scale-[0.985]'
86
87
  }`}
87
88
  style={{
88
89
  maxHeight: 'min(var(--public-navbar-mobile-drawer-max-height, calc(100dvh - 5rem - 12px)), calc(100dvh - 12px))',
@@ -101,7 +102,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
101
102
  )}
102
103
 
103
104
  {isAuthenticated && (
104
- <UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} />
105
+ <UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} i18n={userMenu?.i18n} />
105
106
  )}
106
107
 
107
108
  {/* Navigation Items */}
@@ -112,7 +113,10 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
112
113
  </h3>
113
114
  </div>
114
115
  <div className="space-y-1">
115
- {mobileNavigation.map((item) => (
116
+ {mobileNavigation.map((item) => {
117
+ const childItems = item.items ?? [];
118
+ const hasChildNav = childItems.length > 0;
119
+ return (
116
120
  <div key={item.href}>
117
121
  <Link
118
122
  href={item.href}
@@ -125,9 +129,9 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
125
129
  >
126
130
  {item.label}
127
131
  </Link>
128
- {item.items && item.items.length > 0 && (
132
+ {hasChildNav && (
129
133
  <div className="ml-3 mt-1 space-y-1 border-l border-border/40 pl-3">
130
- {item.items.map((subItem) => (
134
+ {childItems.map((subItem) => (
131
135
  <Link
132
136
  key={`${item.href}-${subItem.href}`}
133
137
  href={subItem.href}
@@ -140,7 +144,8 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
140
144
  </div>
141
145
  )}
142
146
  </div>
143
- ))}
147
+ );
148
+ })}
144
149
  </div>
145
150
  </div>
146
151
  </div>
@@ -6,11 +6,8 @@ import { PublicMobileDrawer } from './PublicMobileDrawer';
6
6
  import { PublicNavigation } from './PublicNavigation';
7
7
 
8
8
  import type { NavigationItem, UserMenuConfig } from '../../types';
9
- import type {
10
- PublicDesktopDropdownRenderer,
11
- PublicNavbarPosition,
12
- PublicNavbarVariant,
13
- } from './PublicNavigation';
9
+ import type { PublicDesktopDropdownRenderer } from './PublicNavigation';
10
+ import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
14
11
 
15
12
  export interface PublicNavbarConfig {
16
13
  brand?: React.ReactNode;
@@ -8,7 +8,14 @@
8
8
 
9
9
  import { ChevronDown, Menu, X } from 'lucide-react';
10
10
  import Link from 'next/link';
11
- import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
11
+ import React, {
12
+ type ReactNode,
13
+ useEffect,
14
+ useLayoutEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ } from 'react';
12
19
 
13
20
  import { useAuth } from '@djangocfg/api/auth';
14
21
  import { useAppT } from '@djangocfg/i18n';
@@ -22,11 +29,11 @@ import { usePathnameWithoutLocale } from '../../../hooks';
22
29
 
23
30
  import { UserMenu } from '../../_components/UserMenu';
24
31
  import { usePublicLayoutOptional } from '../context';
32
+ import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
25
33
 
26
34
  import type { NavigationItem, UserMenuConfig } from '../../types';
27
35
 
28
- export type PublicNavbarVariant = 'floating' | 'flush';
29
- export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
36
+ export type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
30
37
 
31
38
  export interface PublicDesktopDropdownRenderProps {
32
39
  item: NavigationItem;
@@ -131,6 +138,16 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
131
138
  }
132
139
  }, [isTabletOrBelow]);
133
140
 
141
+ const setNavbarSurface = context?.setNavbarSurface;
142
+
143
+ useLayoutEffect(() => {
144
+ if (!setNavbarSurface) return;
145
+ setNavbarSurface({ variant: navbarVariant, position: navbarPosition });
146
+ return () => {
147
+ setNavbarSurface(null);
148
+ };
149
+ }, [setNavbarSurface, navbarVariant, navbarPosition]);
150
+
134
151
  useEffect(() => {
135
152
  const updateDrawerViewportVars = () => {
136
153
  const root = document.documentElement;
@@ -297,7 +314,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
297
314
  className={navInnerClassName}
298
315
  style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
299
316
  >
300
- <div className="w-full px-3 sm:px-4 lg:px-6">
317
+ <div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
301
318
  <div className="flex items-center justify-between py-3.5">
302
319
  {/* Logo */}
303
320
  {brand ? (
@@ -373,6 +390,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
373
390
  variant="desktop"
374
391
  groups={userMenu?.groups}
375
392
  authPath={userMenu?.authPath}
393
+ i18n={userMenu?.i18n}
376
394
  />
377
395
  </>
378
396
  </div>
@@ -2,10 +2,15 @@
2
2
 
3
3
  import React, { createContext, useContext } from 'react';
4
4
 
5
+ import type { PublicNavbarSurface } from './navbarTypes';
6
+
5
7
  export interface PublicLayoutContextValue {
6
8
  mobileMenuOpen: boolean;
7
9
  toggleMobileMenu: () => void;
8
10
  closeMobileMenu: () => void;
11
+ /** Filled by `<PublicNavigation />` — drives default `main` top spacing. */
12
+ navbarSurface: PublicNavbarSurface | null;
13
+ setNavbarSurface: (surface: PublicNavbarSurface | null) => void;
9
14
  }
10
15
 
11
16
  const PublicLayoutContext = createContext<PublicLayoutContextValue | null>(null);
@@ -1,2 +1,2 @@
1
- export { useFloatingPanel } from './useFloatingPanel';
1
+ export { useMobileNavPanel } from './useMobileNavPanel';
2
2
 
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /** Slightly above `transition-[transform,opacity] duration-[220ms]` in PublicMobileDrawer */
6
+ const CLOSE_UNMOUNT_MS = 230;
7
+
8
+ /**
9
+ * Keeps the mobile nav panel mounted briefly after `isOpen` becomes false so CSS can run the close transition,
10
+ * then unmounts — no reliance on `transitionend` (fragile with SPA navigation / Safari).
11
+ */
12
+ export function useMobileNavPanel(options: { isOpen: boolean; onClose: () => void }) {
13
+ const { isOpen, onClose } = options;
14
+ const [mounted, setMounted] = useState(isOpen);
15
+ const [visible, setVisible] = useState(isOpen);
16
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
17
+
18
+ useEffect(() => {
19
+ if (isOpen) {
20
+ if (closeTimerRef.current) {
21
+ clearTimeout(closeTimerRef.current);
22
+ closeTimerRef.current = null;
23
+ }
24
+ setMounted(true);
25
+ const id = requestAnimationFrame(() => {
26
+ requestAnimationFrame(() => setVisible(true));
27
+ });
28
+ return () => cancelAnimationFrame(id);
29
+ }
30
+
31
+ setVisible(false);
32
+ closeTimerRef.current = setTimeout(() => {
33
+ setMounted((m) => (m ? false : m));
34
+ closeTimerRef.current = null;
35
+ }, CLOSE_UNMOUNT_MS);
36
+
37
+ return () => {
38
+ if (closeTimerRef.current) {
39
+ clearTimeout(closeTimerRef.current);
40
+ closeTimerRef.current = null;
41
+ }
42
+ };
43
+ }, [isOpen]);
44
+
45
+ useEffect(() => {
46
+ if (!isOpen) return;
47
+ const onKeyDown = (event: KeyboardEvent) => {
48
+ if (event.key === 'Escape') onClose();
49
+ };
50
+ window.addEventListener('keydown', onKeyDown);
51
+ return () => window.removeEventListener('keydown', onKeyDown);
52
+ }, [isOpen, onClose]);
53
+
54
+ return { mounted, visible };
55
+ }
@@ -6,8 +6,11 @@ export { PublicLayout } from './PublicLayout';
6
6
  export type { PublicLayoutProps } from './PublicLayout';
7
7
  export { PublicNavigation, PublicMobileDrawer, PublicNavbar } from './components';
8
8
  export type {
9
+ PublicNavbarSurface,
9
10
  PublicNavbarVariant,
10
11
  PublicNavbarPosition,
12
+ } from './navbarTypes';
13
+ export type {
11
14
  PublicDesktopDropdownRenderer,
12
15
  PublicDesktopDropdownRenderProps,
13
16
  } from './components/PublicNavigation';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared navbar surface types — used by PublicNavigation (registration) and PublicLayout (main offset).
3
+ */
4
+
5
+ export type PublicNavbarVariant = 'floating' | 'flush';
6
+
7
+ export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
8
+
9
+ export interface PublicNavbarSurface {
10
+ variant: PublicNavbarVariant;
11
+ position: PublicNavbarPosition;
12
+ }
@@ -30,6 +30,17 @@ import { LocaleSwitcher } from './LocaleSwitcher';
30
30
  import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
31
31
  import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
32
32
 
33
+ /** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
34
+ function isPointerFromRadixOverlay(target: EventTarget | null): boolean {
35
+ if (!target || !(target instanceof Element)) return false;
36
+ return Boolean(
37
+ target.closest('[data-radix-popper-content-wrapper]') ||
38
+ target.closest('[data-radix-dropdown-menu-content]') ||
39
+ target.closest('[data-radix-select-content]') ||
40
+ target.closest('[data-radix-popover-content]'),
41
+ );
42
+ }
43
+
33
44
  interface PrivateSidebarAccountProps {
34
45
  header?: HeaderConfig;
35
46
  i18n?: I18nLayoutConfig;
@@ -53,9 +64,11 @@ export function PrivateSidebarAccount({ header, i18n }: PrivateSidebarAccountPro
53
64
 
54
65
  const handlePointerDown = (event: PointerEvent) => {
55
66
  const root = accountRootRef.current;
56
- if (root && !root.contains(event.target as Node)) {
57
- setAccountOpen(false);
58
- }
67
+ const target = event.target;
68
+ if (!(target instanceof Node)) return;
69
+ if (root?.contains(target)) return;
70
+ if (isPointerFromRadixOverlay(target)) return;
71
+ setAccountOpen(false);
59
72
  };
60
73
 
61
74
  document.addEventListener('pointerdown', handlePointerDown);
@@ -30,7 +30,7 @@
30
30
 
31
31
  'use client';
32
32
 
33
- import { ArrowRight, LogOut } from 'lucide-react';
33
+ import { ArrowRight, Globe, LogOut } from 'lucide-react';
34
34
  import Link from 'next/link';
35
35
  import React, { useMemo } from 'react';
36
36
 
@@ -39,12 +39,26 @@ import { useAppT } from '@djangocfg/i18n';
39
39
 
40
40
  import { useLogout } from '../../hooks';
41
41
  import {
42
- Avatar, AvatarFallback, AvatarImage, Button, DropdownMenu, DropdownMenuContent,
43
- DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator,
44
- DropdownMenuTrigger
42
+ Avatar,
43
+ AvatarFallback,
44
+ AvatarImage,
45
+ Button,
46
+ DropdownMenu,
47
+ DropdownMenuContent,
48
+ DropdownMenuGroup,
49
+ DropdownMenuItem,
50
+ DropdownMenuLabel,
51
+ DropdownMenuSeparator,
52
+ DropdownMenuSub,
53
+ DropdownMenuSubContent,
54
+ DropdownMenuSubTrigger,
55
+ DropdownMenuTrigger,
56
+ getLanguageFlag,
45
57
  } from '@djangocfg/ui-core/components';
46
58
 
47
- import type { UserMenuGroup } from '../types';
59
+ import { LOCALE_LABELS } from './LocaleSwitcher';
60
+
61
+ import type { UserMenuGroup, UserMenuLocaleConfig } from '../types';
48
62
 
49
63
  export interface UserMenuProps {
50
64
  /** Display variant */
@@ -53,12 +67,18 @@ export interface UserMenuProps {
53
67
  groups?: UserMenuGroup[];
54
68
  /** Auth page path (for sign in button) */
55
69
  authPath?: string;
70
+ /**
71
+ * Language switching inside this menu (desktop: submenu; mobile: button row).
72
+ * Prefer this over nesting `<LocaleSwitcher />` in the same dropdown — nested menus close each other.
73
+ */
74
+ i18n?: UserMenuLocaleConfig;
56
75
  }
57
76
 
58
77
  export function UserMenu({
59
78
  variant = 'desktop',
60
79
  groups,
61
80
  authPath = '/auth',
81
+ i18n,
62
82
  }: UserMenuProps) {
63
83
  const { user, isAuthenticated } = useAuth();
64
84
  const handleLogout = useLogout();
@@ -69,36 +89,43 @@ export function UserMenu({
69
89
  signIn: t('layouts.profile.login'),
70
90
  signOut: t('layouts.profile.signOut'),
71
91
  userMenu: t('layouts.profile.userMenu'),
92
+ language: t('layouts.profile.language'),
72
93
  }), [t]);
73
94
 
74
95
  React.useEffect(() => {
75
96
  setMounted(true);
76
97
  }, []);
77
98
 
78
- // Prepare menu groups
79
- // Must be before early return to maintain hook order
80
- const menuGroups: UserMenuGroup[] = React.useMemo(() => {
81
- const allGroups: UserMenuGroup[] = [];
99
+ /** Profile links only; sign out rendered separately so we can insert locale UI between. */
100
+ const profileGroups: UserMenuGroup[] = React.useMemo(() => {
101
+ if (groups && groups.length > 0) return [...groups];
102
+ return [];
103
+ }, [groups]);
82
104
 
83
- // Add custom groups if provided
84
- if (groups && groups.length > 0) {
85
- allGroups.push(...groups);
86
- }
105
+ const signOutItem = React.useMemo(
106
+ () => ({
107
+ label: labels.signOut,
108
+ onClick: handleLogout,
109
+ icon: LogOut,
110
+ variant: 'destructive' as const,
111
+ }),
112
+ [handleLogout, labels.signOut],
113
+ );
87
114
 
88
- // Always add Sign Out at the end
89
- allGroups.push({
90
- items: [
91
- {
92
- label: labels.signOut,
93
- onClick: handleLogout,
94
- icon: LogOut,
95
- variant: 'destructive',
96
- },
97
- ],
98
- });
115
+ /** Prepared locale UI data avoid `i18n && i18n.locales.length` chains in JSX. */
116
+ const localeMenu = React.useMemo(() => {
117
+ const codes = i18n?.locales;
118
+ if (!i18n || !codes?.length) return null;
119
+ return {
120
+ codes,
121
+ current: i18n.locale,
122
+ onChange: i18n.onLocaleChange,
123
+ };
124
+ }, [i18n]);
99
125
 
100
- return allGroups;
101
- }, [groups, handleLogout, labels.signOut]);
126
+ const hasProfileGroups = profileGroups.length > 0;
127
+
128
+ const localeLabel = (code: string) => LOCALE_LABELS[code] || code.toUpperCase();
102
129
 
103
130
  if (!mounted) {
104
131
  return null;
@@ -151,7 +178,7 @@ export function UserMenu({
151
178
  </div>
152
179
  </div>
153
180
  <div className="space-y-1">
154
- {menuGroups.map((group, groupIndex) => (
181
+ {profileGroups.map((group, groupIndex) => (
155
182
  <div key={groupIndex}>
156
183
  {group.title && (
157
184
  <div className="px-4 py-2">
@@ -195,6 +222,44 @@ export function UserMenu({
195
222
  })}
196
223
  </div>
197
224
  ))}
225
+ {localeMenu && (
226
+ <div className="border-t border-border/50 px-4 pt-3">
227
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
228
+ {labels.language}
229
+ </p>
230
+ <div className="mt-2 flex flex-wrap gap-2">
231
+ {localeMenu.codes.map((code) => {
232
+ const flag = getLanguageFlag(code);
233
+ const active = code === localeMenu.current;
234
+ return (
235
+ <button
236
+ key={code}
237
+ type="button"
238
+ onClick={() => localeMenu.onChange(code)}
239
+ className={`rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
240
+ active
241
+ ? 'border-primary bg-accent text-foreground'
242
+ : 'border-border/60 text-muted-foreground hover:bg-accent hover:text-foreground'
243
+ }`}
244
+ >
245
+ {flag ? <span className="mr-1.5">{flag}</span> : null}
246
+ {localeLabel(code)}
247
+ </button>
248
+ );
249
+ })}
250
+ </div>
251
+ </div>
252
+ )}
253
+ <div>
254
+ <button
255
+ type="button"
256
+ onClick={signOutItem.onClick}
257
+ className="flex w-full items-center gap-3 rounded-sm px-4 py-3 text-left text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
258
+ >
259
+ <LogOut className="h-4 w-4" />
260
+ {signOutItem.label}
261
+ </button>
262
+ </div>
198
263
  </div>
199
264
  </div>
200
265
  );
@@ -222,7 +287,7 @@ export function UserMenu({
222
287
  </div>
223
288
  </DropdownMenuLabel>
224
289
  <DropdownMenuSeparator />
225
- {menuGroups.map((group, groupIndex) => (
290
+ {profileGroups.map((group, groupIndex) => (
226
291
  <React.Fragment key={groupIndex}>
227
292
  {groupIndex > 0 && <DropdownMenuSeparator />}
228
293
  <DropdownMenuGroup>
@@ -267,6 +332,44 @@ export function UserMenu({
267
332
  </DropdownMenuGroup>
268
333
  </React.Fragment>
269
334
  ))}
335
+ {localeMenu && (
336
+ <>
337
+ {hasProfileGroups && <DropdownMenuSeparator />}
338
+ <DropdownMenuSub>
339
+ <DropdownMenuSubTrigger className="cursor-default">
340
+ <Globe className="mr-2 h-4 w-4" />
341
+ <span>{labels.language}</span>
342
+ </DropdownMenuSubTrigger>
343
+ <DropdownMenuSubContent>
344
+ {localeMenu.codes.map((code) => {
345
+ const flag = getLanguageFlag(code);
346
+ return (
347
+ <DropdownMenuItem
348
+ key={code}
349
+ onSelect={() => {
350
+ localeMenu.onChange(code);
351
+ }}
352
+ className={code === localeMenu.current ? 'bg-accent' : ''}
353
+ >
354
+ {flag ? <span className="mr-2">{flag}</span> : null}
355
+ {localeLabel(code)}
356
+ </DropdownMenuItem>
357
+ );
358
+ })}
359
+ </DropdownMenuSubContent>
360
+ </DropdownMenuSub>
361
+ </>
362
+ )}
363
+ <DropdownMenuSeparator />
364
+ <DropdownMenuGroup>
365
+ <DropdownMenuItem
366
+ onClick={signOutItem.onClick}
367
+ className="text-destructive focus:text-destructive"
368
+ >
369
+ <LogOut className="mr-2 h-4 w-4" />
370
+ <span>{signOutItem.label}</span>
371
+ </DropdownMenuItem>
372
+ </DropdownMenuGroup>
270
373
  </DropdownMenuContent>
271
374
  </DropdownMenu>
272
375
  );
@@ -36,6 +36,7 @@ export type {
36
36
  FooterConfig,
37
37
  UserMenuItem,
38
38
  UserMenuGroup,
39
+ UserMenuLocaleConfig,
39
40
  UserMenuConfig,
40
41
  } from './ui.types';
41
42
 
@@ -97,9 +97,18 @@ export interface UserMenuGroup {
97
97
  items: UserMenuItem[];
98
98
  }
99
99
 
100
+ /** Optional locale switching inside the user menu (submenu on desktop; avoids nested `DropdownMenu` + portal issues). */
101
+ export interface UserMenuLocaleConfig {
102
+ locale: string;
103
+ locales: string[];
104
+ onLocaleChange: (locale: string) => void;
105
+ }
106
+
100
107
  export interface UserMenuConfig {
101
108
  /** Menu groups for authenticated users */
102
109
  groups?: UserMenuGroup[];
103
110
  /** Auth page path (for sign in button) */
104
111
  authPath?: string;
112
+ /** When set, language is rendered inside the user menu (not as a separate nested dropdown). */
113
+ i18n?: UserMenuLocaleConfig;
105
114
  }
@@ -1,61 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useRef, useState } from 'react';
4
-
5
- interface UseFloatingPanelOptions {
6
- isOpen: boolean;
7
- onClose: () => void;
8
- }
9
-
10
- export function useFloatingPanel({
11
- isOpen,
12
- onClose,
13
- }: UseFloatingPanelOptions) {
14
- const [isRendered, setIsRendered] = useState(isOpen);
15
- const [isActive, setIsActive] = useState(isOpen);
16
- const rafRef = useRef<number | null>(null);
17
-
18
- useEffect(() => {
19
- if (isOpen) {
20
- setIsRendered(true);
21
- if (rafRef.current) window.cancelAnimationFrame(rafRef.current);
22
- rafRef.current = window.requestAnimationFrame(() => {
23
- setIsActive(true);
24
- });
25
- return;
26
- }
27
-
28
- if (!isRendered) return;
29
- setIsActive(false);
30
- }, [isOpen, isRendered]);
31
-
32
- useEffect(() => {
33
- return () => {
34
- if (rafRef.current) window.cancelAnimationFrame(rafRef.current);
35
- };
36
- }, []);
37
-
38
- useEffect(() => {
39
- if (!isOpen) return;
40
- const onKeyDown = (event: KeyboardEvent) => {
41
- if (event.key === 'Escape') onClose();
42
- };
43
- window.addEventListener('keydown', onKeyDown);
44
- return () => window.removeEventListener('keydown', onKeyDown);
45
- }, [isOpen, onClose]);
46
-
47
- const onTransitionEnd = (event: React.TransitionEvent<HTMLElement>) => {
48
- if (event.target !== event.currentTarget) return;
49
- if (event.propertyName !== 'transform') return;
50
- if (!isOpen && !isActive) {
51
- setIsRendered(false);
52
- }
53
- };
54
-
55
- return {
56
- isRendered,
57
- isActive,
58
- onTransitionEnd,
59
- };
60
- }
61
-