@djangocfg/layouts 2.1.280 → 2.1.282

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/README.md CHANGED
@@ -87,7 +87,7 @@ Wraps `BaseApp` and picks **admin → private → public** layout by path (`matc
87
87
 
88
88
  | Component | Use |
89
89
  |---|---|
90
- | **`PublicLayout`** | Marketing / docs. Slots for navbar + footer. Supports `LinkComponentProvider` to inject an i18n-aware `Link` (e.g. `next-intl`) so every anchor picks up the locale prefix. **[See PublicLayout README](./src/layouts/PublicLayout/README.md)** for full props, navbar variants (`FloatingNavbar` / `FlushNavbar` / `MinimalNavbar`), `DefaultFooter`, `NavAction`, and hooks. |
90
+ | **`PublicLayout`** | Marketing / docs. Slots for navbar + footer. Supports `LinkComponentProvider` to inject an i18n-aware `Link` (e.g. `next-intl`) so every anchor picks up the locale prefix. All three navbars accept `controls` + `i18n` to show theme / locale switchers next to UserMenu (same shape as `DefaultFooter.controls`). **[See PublicLayout README](./src/layouts/PublicLayout/README.md)** for full props, navbar variants (`FloatingNavbar` / `FlushNavbar` / `MinimalNavbar`), `DefaultFooter`, `NavAction`, `NavControls`, and hooks. |
91
91
  | **`PrivateLayout`** | App shell — sidebar + header. |
92
92
  | **`AuthLayout`** | Sign-in flows. |
93
93
  | **`AdminLayout`** | Admin console. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.280",
3
+ "version": "2.1.282",
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.280",
78
- "@djangocfg/centrifugo": "^2.1.280",
79
- "@djangocfg/debuger": "^2.1.280",
80
- "@djangocfg/i18n": "^2.1.280",
81
- "@djangocfg/monitor": "^2.1.280",
82
- "@djangocfg/ui-core": "^2.1.280",
83
- "@djangocfg/ui-nextjs": "^2.1.280",
84
- "@djangocfg/ui-tools": "^2.1.280",
77
+ "@djangocfg/api": "^2.1.282",
78
+ "@djangocfg/centrifugo": "^2.1.282",
79
+ "@djangocfg/debuger": "^2.1.282",
80
+ "@djangocfg/i18n": "^2.1.282",
81
+ "@djangocfg/monitor": "^2.1.282",
82
+ "@djangocfg/ui-core": "^2.1.282",
83
+ "@djangocfg/ui-nextjs": "^2.1.282",
84
+ "@djangocfg/ui-tools": "^2.1.282",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -110,15 +110,15 @@
110
110
  "uuid": "^11.1.0"
111
111
  },
112
112
  "devDependencies": {
113
- "@djangocfg/api": "^2.1.280",
114
- "@djangocfg/centrifugo": "^2.1.280",
115
- "@djangocfg/debuger": "^2.1.280",
116
- "@djangocfg/i18n": "^2.1.280",
117
- "@djangocfg/monitor": "^2.1.280",
118
- "@djangocfg/typescript-config": "^2.1.280",
119
- "@djangocfg/ui-core": "^2.1.280",
120
- "@djangocfg/ui-nextjs": "^2.1.280",
121
- "@djangocfg/ui-tools": "^2.1.280",
113
+ "@djangocfg/api": "^2.1.282",
114
+ "@djangocfg/centrifugo": "^2.1.282",
115
+ "@djangocfg/debuger": "^2.1.282",
116
+ "@djangocfg/i18n": "^2.1.282",
117
+ "@djangocfg/monitor": "^2.1.282",
118
+ "@djangocfg/typescript-config": "^2.1.282",
119
+ "@djangocfg/ui-core": "^2.1.282",
120
+ "@djangocfg/ui-nextjs": "^2.1.282",
121
+ "@djangocfg/ui-tools": "^2.1.282",
122
122
  "@types/node": "^24.7.2",
123
123
  "@types/react": "^19.1.0",
124
124
  "@types/react-dom": "^19.1.0",
@@ -48,7 +48,9 @@ import type {
48
48
  SWRConfigOptions,
49
49
  PwaInstallConfig,
50
50
  DebugConfig,
51
+ I18nLayoutConfig,
51
52
  } from '../types';
53
+ export type { I18nLayoutConfig } from '../types';
52
54
  import type { AuthConfig } from '@djangocfg/api/auth';
53
55
  import type { MonitorConfig } from '@djangocfg/monitor';
54
56
 
@@ -152,16 +154,6 @@ function determineLayoutMode(
152
154
  return 'public';
153
155
  }
154
156
 
155
- /** i18n configuration for locale switching */
156
- export interface I18nLayoutConfig {
157
- /** Current locale */
158
- locale: string;
159
- /** Available locales */
160
- locales: string[];
161
- /** Callback when locale changes */
162
- onLocaleChange: (locale: string) => void;
163
- }
164
-
165
157
  /**
166
158
  * Props passed to every layout component (`public` / `private` / `admin`).
167
159
  * Use `publicChrome` to pass defaults for `FloatingNavbar` / `DefaultFooter` from `AppLayout`.
@@ -15,7 +15,8 @@ import { useAuth } from '@djangocfg/api/auth';
15
15
  import { Preloader } from '@djangocfg/ui-core/components';
16
16
  import { SidebarInset, SidebarProvider } from '@djangocfg/ui-nextjs/components';
17
17
 
18
- import type { AppLayoutPublicChrome, I18nLayoutConfig } from '../AppLayout/AppLayout';
18
+ import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
19
+ import type { I18nLayoutConfig } from '../types';
19
20
  import { UserMenuConfig } from '../types';
20
21
  import { PrivateContent, PrivateSidebar } from './components';
21
22
 
@@ -29,7 +29,7 @@ import { cn } from '@djangocfg/ui-core/lib';
29
29
  import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
30
30
  import { LucideIcon } from '../../../components';
31
31
 
32
- import type { I18nLayoutConfig } from '../../AppLayout/AppLayout';
32
+ import type { I18nLayoutConfig } from '../../types';
33
33
  import type { HeaderConfig, SidebarItem, SidebarConfig } from '../PrivateLayout';
34
34
 
35
35
  /** Few items → roomier rows; many items → tighter. Same breakpoints for demo, CarAPIS, etc. */
@@ -76,6 +76,8 @@ Three variants. All share the same core props (below); only the chrome differs.
76
76
  | `transparentThreshold` | `number` | `40` | Px past which the nav becomes opaque. |
77
77
  | `desktopMaxPrimaryItems` | `number` | auto | Hard cap for primary items before overflow. |
78
78
  | `renderDesktopDropdown` | `(ctx) => ReactNode` | — | Replace default popover per-item. |
79
+ | `controls` | `{ showThemeSwitcher?; showLocaleSwitcher? }` | — | Compact theme + locale pills next to UserMenu. See below. |
80
+ | `i18n` | `{ locale, locales, onLocaleChange }` | — | Required for the locale switcher (same shape as `DefaultFooter.i18n`). |
79
81
 
80
82
  ### Variant-only
81
83
 
@@ -92,6 +94,48 @@ Three variants. All share the same core props (below); only the chrome differs.
92
94
  - `centered` — all three groups centered in one row.
93
95
  - `split` — brand left, actions right, **no desktop nav** (drawer only).
94
96
 
97
+ ## Theme + locale controls (navbar)
98
+
99
+ All three navbars accept an optional `controls` block and `i18n` config that
100
+ mirrors `DefaultFooter`. When enabled, a compact `NavControls` pill is rendered
101
+ on desktop right before `UserMenu`, and an equivalent row appears in the mobile
102
+ drawer footer.
103
+
104
+ ```tsx
105
+ <FloatingNavbar
106
+ config={{
107
+ brand: <BrandLogo />,
108
+ navigation,
109
+ userMenu: { authPath: '/auth' },
110
+ controls: { showThemeSwitcher: true, showLocaleSwitcher: true },
111
+ i18n: { locale, locales, onLocaleChange: changeLocale },
112
+ }}
113
+ />
114
+ ```
115
+
116
+ | Control | Required | Notes |
117
+ |---|---|---|
118
+ | `controls.showThemeSwitcher` | — | Light / system / dark pill (uses `useThemeContext`). |
119
+ | `controls.showLocaleSwitcher` | `i18n` | Globe dropdown. Silently hidden when `i18n` is omitted. |
120
+
121
+ Same shape works for `FlushNavbar` and `MinimalNavbar`. If you build a custom
122
+ navbar with `NavbarShell`, the controls node is pre-built and delivered to
123
+ `renderActions(ctx)` as `ctx.controls`.
124
+
125
+ Need to render the pills somewhere custom (e.g. your own navbar)? Import
126
+ `NavControls` directly:
127
+
128
+ ```tsx
129
+ import { NavControls } from '@djangocfg/layouts';
130
+
131
+ <NavControls
132
+ showThemeSwitcher
133
+ showLocaleSwitcher
134
+ i18n={{ locale, locales, onLocaleChange }}
135
+ size="compact" // 'compact' (navbar) | 'default' (footer)
136
+ />
137
+ ```
138
+
95
139
  ## `NavAction`
96
140
 
97
141
  Typed pill used by every navbar's `actions`.
@@ -155,7 +199,7 @@ Three variants: `full` (default) with brand column + menus + controls; `compact`
155
199
 
156
200
  ## Primitives
157
201
 
158
- `NavBrand`, `NavActions`, `NavActionItem`, `NavDesktopItems`, `ThemeBrandMark`, `ThemeBrandMarkImg`, `LinkComponentProvider`, `useLinkComponent`, `PublicLayoutProvider`, `usePublicLayout`.
202
+ `NavBrand`, `NavActions`, `NavActionItem`, `NavControls`, `NavDesktopItems`, `ThemeBrandMark`, `ThemeBrandMarkImg`, `LinkComponentProvider`, `useLinkComponent`, `PublicLayoutProvider`, `usePublicLayout`.
159
203
 
160
204
  ## Context
161
205
 
@@ -5,8 +5,7 @@
5
5
  import type { LucideIcon } from 'lucide-react';
6
6
  import type { ReactNode } from 'react';
7
7
 
8
- import type { I18nLayoutConfig } from '../../../AppLayout/AppLayout';
9
- import type { FooterLink, FooterMenuSection, FooterSocialLinks } from '../../../types';
8
+ import type { FooterLink, FooterMenuSection, FooterSocialLinks, I18nLayoutConfig } from '../../../types';
10
9
 
11
10
  export type { FooterLink, FooterMenuSection, FooterSocialLinks };
12
11
 
@@ -33,6 +33,7 @@ export {
33
33
  NavBrand,
34
34
  NavActions,
35
35
  NavActionItem,
36
+ NavControls,
36
37
  NavDesktopItems,
37
38
  ThemeBrandMark,
38
39
  ThemeBrandMarkImg,
@@ -41,6 +42,7 @@ export {
41
42
  } from './primitives';
42
43
  export type {
43
44
  NavAction,
45
+ NavControlsProps,
44
46
  ThemeBrandMarkProps,
45
47
  ThemeBrandMarkImgProps,
46
48
  LinkComponent,
@@ -20,7 +20,7 @@ import type {
20
20
  PublicNavbarShellConfig,
21
21
  PublicNavLayout,
22
22
  } from '../../navbarTypes';
23
- import type { NavigationItem, UserMenuConfig } from '../../../types';
23
+ import type { I18nLayoutConfig, NavigationItem, UserMenuConfig } from '../../../types';
24
24
 
25
25
  import { FloatingMobileDrawer } from './FloatingMobileDrawer';
26
26
 
@@ -51,6 +51,19 @@ export interface FloatingNavbarConfig {
51
51
  actionsLeadingSlot?: React.ReactNode;
52
52
  /** Arbitrary ReactNode after the mobile toggle. */
53
53
  actionsTrailingSlot?: React.ReactNode;
54
+ /**
55
+ * Optional theme + locale controls rendered next to UserMenu on desktop
56
+ * and as a footer row in the mobile drawer. Locale switcher requires
57
+ * `i18n`. Mirrors `DefaultFooter.controls`.
58
+ */
59
+ controls?: {
60
+ /** Light / system / dark pill. @default false */
61
+ showThemeSwitcher?: boolean;
62
+ /** Locale dropdown. Requires `i18n`. @default false */
63
+ showLocaleSwitcher?: boolean;
64
+ };
65
+ /** i18n config (current locale + locales + onLocaleChange). */
66
+ i18n?: I18nLayoutConfig;
54
67
  }
55
68
 
56
69
  export interface FloatingNavbarProps {
@@ -95,6 +108,8 @@ export function FloatingNavbar({ config }: FloatingNavbarProps) {
95
108
  actions={config.actions}
96
109
  actionsLeadingSlot={config.actionsLeadingSlot}
97
110
  actionsTrailingSlot={config.actionsTrailingSlot}
111
+ controls={config.controls}
112
+ i18n={config.i18n}
98
113
  outerClassName={outerClassName}
99
114
  shapeClassName={shapeClassName}
100
115
  shapeForState={({ scrolled, transparent }) =>
@@ -111,6 +126,8 @@ export function FloatingNavbar({ config }: FloatingNavbarProps) {
111
126
  userMenu={config.userMenu}
112
127
  containerClassName={containerClassName}
113
128
  rounding={rounding}
129
+ controls={config.controls}
130
+ i18n={config.i18n}
114
131
  />
115
132
  </>
116
133
  );
@@ -19,7 +19,7 @@ import type {
19
19
  PublicNavbarShellConfig,
20
20
  PublicNavLayout,
21
21
  } from '../../navbarTypes';
22
- import type { NavigationItem, UserMenuConfig } from '../../../types';
22
+ import type { I18nLayoutConfig, NavigationItem, UserMenuConfig } from '../../../types';
23
23
 
24
24
  import { FlushMobileDrawer } from './FlushMobileDrawer';
25
25
 
@@ -50,6 +50,19 @@ export interface FlushNavbarConfig {
50
50
  actionsLeadingSlot?: React.ReactNode;
51
51
  /** Arbitrary ReactNode after the mobile toggle. */
52
52
  actionsTrailingSlot?: React.ReactNode;
53
+ /**
54
+ * Optional theme + locale controls rendered next to UserMenu on desktop
55
+ * and as a footer row in the mobile drawer. Locale switcher requires
56
+ * `i18n`. Mirrors `DefaultFooter.controls`.
57
+ */
58
+ controls?: {
59
+ /** Light / system / dark pill. @default false */
60
+ showThemeSwitcher?: boolean;
61
+ /** Locale dropdown. Requires `i18n`. @default false */
62
+ showLocaleSwitcher?: boolean;
63
+ };
64
+ /** i18n config (current locale + locales + onLocaleChange). */
65
+ i18n?: I18nLayoutConfig;
53
66
  }
54
67
 
55
68
  export interface FlushNavbarProps {
@@ -91,6 +104,8 @@ export function FlushNavbar({ config }: FlushNavbarProps) {
91
104
  actions={config.actions}
92
105
  actionsLeadingSlot={config.actionsLeadingSlot}
93
106
  actionsTrailingSlot={config.actionsTrailingSlot}
107
+ controls={config.controls}
108
+ i18n={config.i18n}
94
109
  outerClassName={outerClassName}
95
110
  shapeClassName={shapeClassName}
96
111
  shapeForState={({ scrolled, transparent }) =>
@@ -106,6 +121,8 @@ export function FlushNavbar({ config }: FlushNavbarProps) {
106
121
  navigation={navigation}
107
122
  userMenu={config.userMenu}
108
123
  containerClassName={containerClassName}
124
+ controls={config.controls}
125
+ i18n={config.i18n}
109
126
  />
110
127
  </>
111
128
  );
@@ -25,7 +25,7 @@ import type {
25
25
  PublicNavbarPosition,
26
26
  PublicNavLayout,
27
27
  } from '../../navbarTypes';
28
- import type { NavigationItem, UserMenuConfig } from '../../../types';
28
+ import type { I18nLayoutConfig, NavigationItem, UserMenuConfig } from '../../../types';
29
29
 
30
30
  import { MinimalMobileDrawer } from './MinimalMobileDrawer';
31
31
 
@@ -67,6 +67,19 @@ export interface MinimalNavbarConfig {
67
67
  * @default 'mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-10'
68
68
  */
69
69
  containerClassName?: string;
70
+ /**
71
+ * Optional theme + locale controls rendered next to UserMenu on desktop
72
+ * and as a footer row in the mobile drawer. Locale switcher requires
73
+ * `i18n`. Mirrors `DefaultFooter.controls`.
74
+ */
75
+ controls?: {
76
+ /** Light / system / dark pill. @default false */
77
+ showThemeSwitcher?: boolean;
78
+ /** Locale dropdown. Requires `i18n`. @default false */
79
+ showLocaleSwitcher?: boolean;
80
+ };
81
+ /** i18n config (current locale + locales + onLocaleChange). */
82
+ i18n?: I18nLayoutConfig;
70
83
  }
71
84
 
72
85
  export interface MinimalNavbarProps {
@@ -90,6 +103,10 @@ function MinimalActions({
90
103
  </div>
91
104
  )}
92
105
 
106
+ {ctx.controls && (
107
+ <div className="hidden lg:flex shrink-0 items-center">{ctx.controls}</div>
108
+ )}
109
+
93
110
  <div className="hidden lg:flex">
94
111
  <UserMenu
95
112
  variant="desktop"
@@ -146,6 +163,8 @@ export function MinimalNavbar({ config }: MinimalNavbarProps) {
146
163
  hideNavOnScroll={config.hideNavOnScroll}
147
164
  transparent={transparent}
148
165
  transparentThreshold={config.transparentThreshold}
166
+ controls={config.controls}
167
+ i18n={config.i18n}
149
168
  outerClassName={outerClassName}
150
169
  shapeClassName={shapeClassName}
151
170
  innerPadding={containerClassName}
@@ -163,6 +182,8 @@ export function MinimalNavbar({ config }: MinimalNavbarProps) {
163
182
  navigation={navigation}
164
183
  userMenu={config.userMenu}
165
184
  containerClassName={containerClassName}
185
+ controls={config.controls}
186
+ i18n={config.i18n}
166
187
  />
167
188
  </>
168
189
  );
@@ -23,6 +23,11 @@ interface NavActionsProps {
23
23
  leadingSlot?: ReactNode;
24
24
  /** Arbitrary slot rendered after the mobile toggle (desktop + mobile). */
25
25
  trailingSlot?: ReactNode;
26
+ /**
27
+ * Theme / locale controls rendered right before UserMenu (desktop only).
28
+ * Built by `NavbarShell` from `config.controls` + `config.i18n`.
29
+ */
30
+ controlsSlot?: ReactNode;
26
31
  }
27
32
 
28
33
  export function NavActions({
@@ -34,6 +39,7 @@ export function NavActions({
34
39
  actions,
35
40
  leadingSlot,
36
41
  trailingSlot,
42
+ controlsSlot,
37
43
  }: NavActionsProps) {
38
44
  const hasActions = actions && actions.length > 0;
39
45
 
@@ -49,6 +55,8 @@ export function NavActions({
49
55
 
50
56
  {leadingSlot && <div className="hidden lg:flex shrink-0 items-center">{leadingSlot}</div>}
51
57
 
58
+ {controlsSlot && <div className="hidden lg:flex shrink-0 items-center">{controlsSlot}</div>}
59
+
52
60
  <div className="hidden lg:flex">
53
61
  <UserMenu
54
62
  variant="desktop"
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ import { Laptop, Moon, Sun } from 'lucide-react';
4
+ import React, { useEffect, useState } from 'react';
5
+
6
+ import { Button } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+ import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
9
+
10
+ import type { I18nLayoutConfig } from '../../types';
11
+ import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
12
+
13
+ export interface NavControlsProps {
14
+ /** Optional i18n config. Required to render the locale switcher. */
15
+ i18n?: I18nLayoutConfig;
16
+ /** Show the theme (light / system / dark) pill. @default false */
17
+ showThemeSwitcher?: boolean;
18
+ /** Show the locale dropdown. Requires `i18n`. @default false */
19
+ showLocaleSwitcher?: boolean;
20
+ /** Visual size. `compact` matches navbar row, `default` matches footer. @default 'compact' */
21
+ size?: 'compact' | 'default';
22
+ /** Extra classes for the outer container. */
23
+ className?: string;
24
+ }
25
+
26
+ function ThemeModeControl({ size }: { size: 'compact' | 'default' }) {
27
+ const { theme, setTheme } = useThemeContext();
28
+ const [mounted, setMounted] = useState(false);
29
+
30
+ useEffect(() => {
31
+ setMounted(true);
32
+ }, []);
33
+
34
+ const currentTheme = mounted ? (theme || 'system') : 'system';
35
+ const isActive = (value: 'system' | 'light' | 'dark') => currentTheme === value;
36
+
37
+ const btnSize = size === 'compact' ? 'h-7 w-7' : 'h-8 w-8';
38
+ const iconSize = size === 'compact' ? 'h-3.5 w-3.5' : 'h-4 w-4';
39
+ const baseItemClass = `${btnSize} rounded-full p-0 text-muted-foreground hover:text-foreground`;
40
+ const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
41
+
42
+ return (
43
+ <div className="inline-flex items-center gap-0.5 rounded-full border border-border/60 bg-muted/30 p-0.5">
44
+ <Button
45
+ type="button"
46
+ variant="ghost"
47
+ size="icon"
48
+ className={cn(baseItemClass, isActive('system') && activeItemClass)}
49
+ onClick={() => setTheme('system')}
50
+ aria-label="Use system theme"
51
+ >
52
+ <Laptop className={iconSize} />
53
+ </Button>
54
+ <Button
55
+ type="button"
56
+ variant="ghost"
57
+ size="icon"
58
+ className={cn(baseItemClass, isActive('light') && activeItemClass)}
59
+ onClick={() => setTheme('light')}
60
+ aria-label="Use light theme"
61
+ >
62
+ <Sun className={iconSize} />
63
+ </Button>
64
+ <Button
65
+ type="button"
66
+ variant="ghost"
67
+ size="icon"
68
+ className={cn(baseItemClass, isActive('dark') && activeItemClass)}
69
+ onClick={() => setTheme('dark')}
70
+ aria-label="Use dark theme"
71
+ >
72
+ <Moon className={iconSize} />
73
+ </Button>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Navbar-sized theme + locale controls. Mirrors `DefaultFooter` controls but
80
+ * tuned for a navbar row. Rendered by `NavActions` when `controls` are passed
81
+ * to a navbar config.
82
+ */
83
+ export function NavControls({
84
+ i18n,
85
+ showThemeSwitcher = false,
86
+ showLocaleSwitcher = false,
87
+ size = 'compact',
88
+ className,
89
+ }: NavControlsProps) {
90
+ const renderLocale = showLocaleSwitcher && Boolean(i18n);
91
+ if (!showThemeSwitcher && !renderLocale) return null;
92
+
93
+ const localeBtnClass =
94
+ size === 'compact'
95
+ ? 'h-8 rounded-full border-border/60 bg-muted/30 px-2.5 text-xs hover:bg-muted/40'
96
+ : 'h-9 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40';
97
+
98
+ return (
99
+ <div className={cn('inline-flex items-center gap-1.5', className)}>
100
+ {showThemeSwitcher && <ThemeModeControl size={size} />}
101
+ {renderLocale && i18n && (
102
+ <LocaleSwitcher
103
+ locale={i18n.locale}
104
+ locales={i18n.locales}
105
+ onChange={i18n.onLocaleChange}
106
+ variant="outline"
107
+ size={size === 'compact' ? 'sm' : 'default'}
108
+ showTriggerLabel={false}
109
+ className={localeBtnClass}
110
+ />
111
+ )}
112
+ </div>
113
+ );
114
+ }
@@ -66,9 +66,11 @@ export function NavDesktopItems({
66
66
  gap: 4,
67
67
  });
68
68
 
69
- const effectiveCount = Math.min(visibleCount, maxVisible ?? visibleCount);
69
+ const effectiveCount = measured
70
+ ? Math.min(visibleCount, maxVisible ?? visibleCount)
71
+ : (maxVisible ?? items.length);
70
72
  const primaryItems = items.slice(0, effectiveCount);
71
- const overflowItems = items.slice(effectiveCount);
73
+ const overflowItems = measured ? items.slice(effectiveCount) : [];
72
74
 
73
75
  const renderItem = (item: NavigationItem) => {
74
76
  if (item.items && item.items.length > 0) {
@@ -191,13 +193,8 @@ export function NavDesktopItems({
191
193
  ))}
192
194
  </div>
193
195
 
194
- {/* Live row — only renders items that fit (hidden until first measurement). */}
195
- <div
196
- className={cn(
197
- 'flex min-w-0 items-center gap-1',
198
- !measured && 'invisible',
199
- )}
200
- >
196
+ {/* Live row — renders all items on SSR, overflow splits after measurement. */}
197
+ <div className="flex min-w-0 items-center gap-1">
201
198
  {primaryItems.map(renderItem)}
202
199
 
203
200
  {hasOverflow && (
@@ -2,6 +2,8 @@ export { NavBrand } from './NavBrand';
2
2
  export { NavActions } from './NavActions';
3
3
  export { NavActionItem } from './NavActionItem';
4
4
  export type { NavAction } from './NavActionItem';
5
+ export { NavControls } from './NavControls';
6
+ export type { NavControlsProps } from './NavControls';
5
7
  export { NavDesktopItems } from './NavDesktopItems';
6
8
  export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
7
9
  export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
@@ -13,6 +13,7 @@ import React, { useMemo } from 'react';
13
13
  import { useAuth } from '@djangocfg/api/auth';
14
14
  import { useAppT } from '@djangocfg/i18n';
15
15
  import { Button } from '@djangocfg/ui-core/components';
16
+ import { useBodyScrollLock, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
16
17
  import { cn } from '@djangocfg/ui-core/lib';
17
18
 
18
19
  import { usePathnameWithoutLocale } from '../../../hooks';
@@ -20,8 +21,9 @@ import { UserMenu } from '../../_components/UserMenu';
20
21
  import { usePublicLayoutOptional } from '../context';
21
22
  import { useMobileNavPanel } from '../hooks';
22
23
  import { useLinkComponent } from '../primitives/LinkComponentContext';
24
+ import { NavControls } from '../primitives/NavControls';
23
25
 
24
- import type { NavigationItem, UserMenuConfig } from '../../types';
26
+ import type { I18nLayoutConfig, NavigationItem, UserMenuConfig } from '../../types';
25
27
 
26
28
  export interface MobileDrawerShellProps {
27
29
  isOpen?: boolean;
@@ -32,6 +34,13 @@ export interface MobileDrawerShellProps {
32
34
  outerClassName?: string;
33
35
  /** Panel surface (bg, border, rounding, shadow). */
34
36
  panelClassName?: string;
37
+ /** Optional theme/locale controls shown as a row inside the drawer footer. */
38
+ controls?: {
39
+ showThemeSwitcher?: boolean;
40
+ showLocaleSwitcher?: boolean;
41
+ };
42
+ /** i18n config — required for the locale switcher row. */
43
+ i18n?: I18nLayoutConfig;
35
44
  }
36
45
 
37
46
  export function MobileDrawerShell(props: MobileDrawerShellProps) {
@@ -49,6 +58,8 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
49
58
  isOpen: mobileMenuOpen,
50
59
  onClose: closeMobileMenu,
51
60
  });
61
+ const isTabletOrBelow = useIsTabletOrBelow();
62
+ useBodyScrollLock(mobileMenuOpen && isTabletOrBelow);
52
63
 
53
64
  const labels = useMemo(() => ({
54
65
  menu: t('layouts.navigation.menu'),
@@ -71,6 +82,10 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
71
82
 
72
83
  const hasSessionUser = Boolean(isAuthenticated && user);
73
84
  const showSignInFooter = !hasSessionUser;
85
+ const showThemeSwitcher = props.controls?.showThemeSwitcher === true;
86
+ const showLocaleSwitcher =
87
+ props.controls?.showLocaleSwitcher === true && Boolean(props.i18n);
88
+ const showControlsRow = showThemeSwitcher || showLocaleSwitcher;
74
89
 
75
90
  return (
76
91
  <>
@@ -84,7 +99,9 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
84
99
  )}
85
100
  <div
86
101
  className={cn(
87
- 'pointer-events-none fixed inset-x-0 z-1000 lg:hidden px-4 pb-3 sm:px-6 sm:pb-3 lg:px-8',
102
+ // Horizontal padding mirrors the navbar outer wrapper so the drawer
103
+ // panel aligns edge-to-edge with the navbar shell.
104
+ 'pointer-events-none fixed inset-x-0 z-1000 lg:hidden px-3 pb-3 sm:px-4 sm:pb-3 lg:px-6',
88
105
  )}
89
106
  style={{
90
107
  top: 'var(--public-navbar-mobile-drawer-top, 5rem)',
@@ -181,6 +198,17 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
181
198
  </div>
182
199
  </div>
183
200
 
201
+ {showControlsRow && (
202
+ <div className="shrink-0 border-t border-border/50 px-4 py-3 flex items-center justify-center gap-2">
203
+ <NavControls
204
+ i18n={props.i18n}
205
+ showThemeSwitcher={showThemeSwitcher}
206
+ showLocaleSwitcher={showLocaleSwitcher}
207
+ size="default"
208
+ />
209
+ </div>
210
+ )}
211
+
184
212
  {showSignInFooter && (
185
213
  <div className="shrink-0 border-t border-border/50 p-4">
186
214
  <Link
@@ -46,8 +46,11 @@ import type { NavigationItem, UserMenuConfig } from '../../types';
46
46
  import { NavActions } from '../primitives/NavActions';
47
47
  import type { NavAction } from '../primitives/NavActionItem';
48
48
  import { NavBrand } from '../primitives/NavBrand';
49
+ import { NavControls } from '../primitives/NavControls';
49
50
  import { NavDesktopItems } from '../primitives/NavDesktopItems';
50
51
 
52
+ import type { I18nLayoutConfig } from '../../types';
53
+
51
54
  const heightCls: Record<PublicNavbarHeight, string> = {
52
55
  sm: 'py-2',
53
56
  md: 'py-3.5',
@@ -113,6 +116,17 @@ export interface NavbarShellProps {
113
116
  /** Arbitrary ReactNode after the mobile toggle. */
114
117
  actionsTrailingSlot?: ReactNode;
115
118
 
119
+ // ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
120
+ /** i18n config — enables the locale switcher. Same type as `DefaultFooter`. */
121
+ i18n?: I18nLayoutConfig;
122
+ /** Toggle individual controls. Locale switcher also requires `i18n`. */
123
+ controls?: {
124
+ /** Light / system / dark pill. @default false */
125
+ showThemeSwitcher?: boolean;
126
+ /** Locale dropdown. Requires `i18n`. @default false */
127
+ showLocaleSwitcher?: boolean;
128
+ };
129
+
116
130
  // ── Props override (used by variants that proxy ctx differently) ──────────
117
131
  mobileMenuOpen?: boolean;
118
132
  onMobileMenuToggle?: () => void;
@@ -124,6 +138,11 @@ export interface NavbarActionsContext {
124
138
  toggleMobileMenu: () => void;
125
139
  toggleMobileLabel: string;
126
140
  navLayout: PublicNavLayout;
141
+ /**
142
+ * Pre-built theme/locale controls node. `null` when both toggles are off.
143
+ * Variants with a custom `renderActions` can choose where to place it.
144
+ */
145
+ controls: ReactNode | null;
127
146
  }
128
147
 
129
148
  export function NavbarShell(props: NavbarShellProps) {
@@ -200,7 +219,10 @@ export function NavbarShell(props: NavbarShellProps) {
200
219
 
201
220
  const outerCls = cn(
202
221
  outerClassName,
203
- 'inset-x-0 z-50',
222
+ 'inset-x-0',
223
+ // Stay above the mobile drawer backdrop (z-[998]) and drawer (z-[1000])
224
+ // so the brand + close button remain visible and clickable.
225
+ mobileMenuOpen ? 'z-[1001]' : 'z-50',
204
226
  hideNavOnScroll && 'transition-transform duration-300 ease-in-out will-change-transform',
205
227
  hideNavOnScroll && hidden && !mobileMenuOpen && '-translate-y-full',
206
228
  );
@@ -224,6 +246,18 @@ export function NavbarShell(props: NavbarShellProps) {
224
246
  />
225
247
  ) : null;
226
248
 
249
+ const showThemeSwitcher = props.controls?.showThemeSwitcher === true;
250
+ const showLocaleSwitcher =
251
+ props.controls?.showLocaleSwitcher === true && Boolean(props.i18n);
252
+ const hasControls = showThemeSwitcher || showLocaleSwitcher;
253
+ const controlsNode = hasControls ? (
254
+ <NavControls
255
+ i18n={props.i18n}
256
+ showThemeSwitcher={showThemeSwitcher}
257
+ showLocaleSwitcher={showLocaleSwitcher}
258
+ />
259
+ ) : null;
260
+
227
261
  const actionsNode = props.renderActions ? (
228
262
  props.renderActions({
229
263
  userMenu,
@@ -231,6 +265,7 @@ export function NavbarShell(props: NavbarShellProps) {
231
265
  toggleMobileMenu,
232
266
  toggleMobileLabel,
233
267
  navLayout,
268
+ controls: controlsNode,
234
269
  })
235
270
  ) : (
236
271
  <NavActions
@@ -242,6 +277,7 @@ export function NavbarShell(props: NavbarShellProps) {
242
277
  actions={props.actions}
243
278
  leadingSlot={props.actionsLeadingSlot}
244
279
  trailingSlot={props.actionsTrailingSlot}
280
+ controlsSlot={controlsNode}
245
281
  />
246
282
  );
247
283
 
@@ -254,6 +290,7 @@ export function NavbarShell(props: NavbarShellProps) {
254
290
  <div className={cn('flex items-center gap-4', h)}>
255
291
  <div className="min-w-0 shrink-0 flex items-center">{brandNode}</div>
256
292
  <div className="hidden isolate lg:flex min-w-0 flex-1 items-center gap-1">{desktopNavNode}</div>
293
+ <div className="flex-1 lg:hidden" />
257
294
  <div className="flex shrink-0 items-center gap-4">{actionsNode}</div>
258
295
  </div>
259
296
  );
@@ -262,6 +299,7 @@ export function NavbarShell(props: NavbarShellProps) {
262
299
  <div className={cn('flex items-center gap-4', h)}>
263
300
  <div className="shrink-0">{brandNode}</div>
264
301
  <div className="hidden isolate lg:flex min-w-0 flex-1 items-center justify-center gap-1">{desktopNavNode}</div>
302
+ <div className="flex-1 lg:hidden" />
265
303
  <div className="flex shrink-0 items-center">{actionsNode}</div>
266
304
  </div>
267
305
  );
@@ -279,6 +317,7 @@ export function NavbarShell(props: NavbarShellProps) {
279
317
  <div className="hidden isolate lg:flex min-w-0 flex-1 items-center justify-center gap-1">
280
318
  {desktopNavNode}
281
319
  </div>
320
+ <div className="flex-1 lg:hidden" />
282
321
  <div className="flex shrink-0 items-center">{actionsNode}</div>
283
322
  </div>
284
323
  );
@@ -27,7 +27,7 @@ import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
27
27
  import { useLogout } from '../../hooks';
28
28
  import { LocaleSwitcher } from './LocaleSwitcher';
29
29
 
30
- import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
30
+ import type { I18nLayoutConfig } from '../types';
31
31
  import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
32
32
 
33
33
  /** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
@@ -52,4 +52,4 @@ export type {
52
52
  // Layout Types
53
53
  // ============================================================================
54
54
 
55
- export type { BaseLayoutProps, DebugConfig } from './layout.types';
55
+ export type { BaseLayoutProps, DebugConfig, I18nLayoutConfig } from './layout.types';
@@ -70,3 +70,16 @@ export interface DebugConfig extends DebugButtonProps {
70
70
  enabled?: boolean;
71
71
  }
72
72
 
73
+ /**
74
+ * i18n configuration consumed by layouts, navbars, footer controls, and the
75
+ * UserMenu locale row. Same shape as `@djangocfg/nextjs`'s `useLocaleSwitcher`.
76
+ */
77
+ export interface I18nLayoutConfig {
78
+ /** Current locale (e.g. `"en"`, `"ru"`, `"pt-BR"`). */
79
+ locale: string;
80
+ /** Available locales. */
81
+ locales: string[];
82
+ /** Called when the user picks a new locale. */
83
+ onLocaleChange: (locale: string) => void;
84
+ }
85
+