@djangocfg/layouts 2.1.487 → 2.1.488

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.487",
3
+ "version": "2.1.488",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -89,12 +89,12 @@
89
89
  "check": "tsc --noEmit"
90
90
  },
91
91
  "peerDependencies": {
92
- "@djangocfg/analytics": "^2.1.487",
93
- "@djangocfg/api": "^2.1.487",
94
- "@djangocfg/centrifugo": "^2.1.487",
95
- "@djangocfg/devtools": "^2.1.487",
96
- "@djangocfg/i18n": "^2.1.487",
97
- "@djangocfg/ui-core": "^2.1.487",
92
+ "@djangocfg/analytics": "^2.1.488",
93
+ "@djangocfg/api": "^2.1.488",
94
+ "@djangocfg/centrifugo": "^2.1.488",
95
+ "@djangocfg/devtools": "^2.1.488",
96
+ "@djangocfg/i18n": "^2.1.488",
97
+ "@djangocfg/ui-core": "^2.1.488",
98
98
  "@hookform/resolvers": "^5.2.2",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -124,14 +124,14 @@
124
124
  "uuid": "^11.1.1"
125
125
  },
126
126
  "devDependencies": {
127
- "@djangocfg/analytics": "^2.1.487",
128
- "@djangocfg/api": "^2.1.487",
129
- "@djangocfg/centrifugo": "^2.1.487",
130
- "@djangocfg/devtools": "^2.1.487",
131
- "@djangocfg/i18n": "^2.1.487",
132
- "@djangocfg/typescript-config": "^2.1.487",
133
- "@djangocfg/ui-core": "^2.1.487",
134
- "@djangocfg/ui-tools": "^2.1.487",
127
+ "@djangocfg/analytics": "^2.1.488",
128
+ "@djangocfg/api": "^2.1.488",
129
+ "@djangocfg/centrifugo": "^2.1.488",
130
+ "@djangocfg/devtools": "^2.1.488",
131
+ "@djangocfg/i18n": "^2.1.488",
132
+ "@djangocfg/typescript-config": "^2.1.488",
133
+ "@djangocfg/ui-core": "^2.1.488",
134
+ "@djangocfg/ui-tools": "^2.1.488",
135
135
  "@types/node": "^25.9.5",
136
136
  "@types/react": "19.2.15",
137
137
  "@types/react-dom": "19.2.3",
@@ -130,7 +130,7 @@ export function UserMenu({
130
130
  <div className="pt-4 border-t border-border/50">
131
131
  <Link
132
132
  href={resolvedAuthPath}
133
- className="group flex items-center justify-between rounded-full px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
133
+ className="group flex items-center justify-between rounded-control px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
134
134
  >
135
135
  <span>{labels.signIn}</span>
136
136
  <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
@@ -162,7 +162,7 @@ export function UserMenu({
162
162
  if (variant === 'mobile') {
163
163
  return (
164
164
  <div className="space-y-3">
165
- <div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-accent/30">
165
+ <div className="flex items-center gap-3 px-4 py-3 rounded-[var(--radius-popover)] bg-accent/30">
166
166
  <UserAvatar src={userAvatar} name={displayName} size="md" />
167
167
  <div className="flex-1 min-w-0">
168
168
  <p className="text-sm font-semibold text-foreground truncate">
@@ -4,6 +4,7 @@ import { Menu, X } from 'lucide-react';
4
4
  import React, { memo, type ReactNode } from 'react';
5
5
 
6
6
  import { Button } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
7
8
 
8
9
  import { UserMenu } from '../components/UserMenu';
9
10
  import type { UserMenuConfig } from '../../types';
@@ -28,6 +29,12 @@ interface NavActionsProps {
28
29
  * Built by `NavbarShell` from `config.controls` + `config.i18n`.
29
30
  */
30
31
  controlsSlot?: ReactNode;
32
+ /**
33
+ * Rendered immediately before the burger, on mobile only. Holds the locale
34
+ * flag: language is a top-level choice, so it should not require opening the
35
+ * menu to reach.
36
+ */
37
+ mobileLeadingSlot?: ReactNode;
31
38
  }
32
39
 
33
40
  function NavActionsRaw({
@@ -40,6 +47,7 @@ function NavActionsRaw({
40
47
  leadingSlot,
41
48
  trailingSlot,
42
49
  controlsSlot,
50
+ mobileLeadingSlot,
43
51
  }: NavActionsProps) {
44
52
  const hasActions = actions && actions.length > 0;
45
53
 
@@ -66,16 +74,53 @@ function NavActionsRaw({
66
74
  />
67
75
  </div>
68
76
 
69
- <Button
70
- variant="ghost"
71
- size="icon"
72
- aria-label={toggleMobileLabel}
73
- data-mobile-menu-trigger="true"
74
- className={forceShowMobileTrigger ? 'rounded-full' : 'lg:hidden rounded-full'}
75
- onClick={onMobileMenuToggle}
76
- >
77
- {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
78
- </Button>
77
+ {/* Flag and burger are NOT peers: the burger opens navigation, the flag is
78
+ a rarely-touched setting. Sitting flush at equal size they read as one
79
+ pair of identical pills, so a divider separates them and the burger is
80
+ the larger, 44px touch target the platform guidelines ask for. */}
81
+ <div className="flex items-center gap-1">
82
+ {mobileLeadingSlot && (
83
+ <>
84
+ <div className={forceShowMobileTrigger ? 'shrink-0 flex items-center' : 'shrink-0 flex items-center lg:hidden'}>
85
+ {mobileLeadingSlot}
86
+ </div>
87
+ <span
88
+ aria-hidden
89
+ className={cn(
90
+ // `mx-1` on the rule itself (rather than a wider gap on the
91
+ // row) keeps the spacing symmetrical: a plain `gap` measures
92
+ // from the buttons' 44px boxes, which are wider than their
93
+ // glyphs, and the divider ended up hugging the burger.
94
+ // Full-strength border — at `/70` it vanished in light mode.
95
+ 'mx-1 h-6 w-px shrink-0 bg-border',
96
+ forceShowMobileTrigger ? undefined : 'lg:hidden',
97
+ )}
98
+ />
99
+ </>
100
+ )}
101
+
102
+ <Button
103
+ variant="ghost"
104
+ size="icon"
105
+ aria-label={toggleMobileLabel}
106
+ data-mobile-menu-trigger="true"
107
+ className={cn(
108
+ // Pill, matching every other BUTTON in the shell (CTAs, Login, the
109
+ // page's own actions). Corner style here is per-element-kind, not
110
+ // one global radius: buttons are pills, while nav rows and menu
111
+ // items use `rounded-control` (`--radius-control`).
112
+ //
113
+ // The 44px box stays (that is the touch target); only the glyph is
114
+ // back to the standard 20px — at 24px it read as an oversized icon
115
+ // rather than a normal control.
116
+ 'rounded-full h-11 w-11 [&_svg]:size-5',
117
+ !forceShowMobileTrigger && 'lg:hidden',
118
+ )}
119
+ onClick={onMobileMenuToggle}
120
+ >
121
+ {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
122
+ </Button>
123
+ </div>
79
124
 
80
125
  {trailingSlot && <div className="shrink-0 flex items-center">{trailingSlot}</div>}
81
126
  </div>
@@ -119,6 +164,7 @@ export const NavActions = memo(NavActionsRaw, (prev, next) => {
119
164
  prev.leadingSlot === next.leadingSlot &&
120
165
  prev.trailingSlot === next.trailingSlot &&
121
166
  prev.controlsSlot === next.controlsSlot &&
167
+ prev.mobileLeadingSlot === next.mobileLeadingSlot &&
122
168
  actionsEqual(prev.actions, next.actions)
123
169
  );
124
170
  });
@@ -38,7 +38,7 @@ function ThemeModeControl({ size }: { size: 'compact' | 'default' }) {
38
38
  const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
39
39
 
40
40
  return (
41
- <div className="inline-flex items-center gap-0.5 rounded-full border border-border/60 bg-muted/30 p-0.5">
41
+ <div className="inline-flex items-center gap-0.5 rounded-[var(--radius-popover)] border border-border/60 bg-muted/30 p-0.5">
42
42
  <Button
43
43
  type="button"
44
44
  variant="ghost"
@@ -23,8 +23,17 @@ interface NavDesktopItemsProps {
23
23
  renderDesktopDropdown?: PublicDesktopDropdownRenderer;
24
24
  }
25
25
 
26
+ // Corners come from ui-core's radius tokens, never raw Tailwind steps —
27
+ // presets (`dense`, `windows`) retune those variables, and a hardcoded
28
+ // `rounded-lg` would opt this navbar out of the active theme.
29
+ //
30
+ // Radius is chosen per element KIND, not unified across the bar: nav rows and
31
+ // menu items take `rounded-control` (--radius-control, the token documented for
32
+ // "nav items, inputs, chips"), their popover takes --radius-popover, while
33
+ // BUTTONS stay pills like every other button in the product. Making the rows
34
+ // pills too is what produced capsules floating inside a soft-cornered popover.
26
35
  const navItemCls = cn(
27
- 'inline-flex min-h-9 items-center justify-center gap-1 rounded-full border border-transparent px-4 py-1.5 text-sm !font-medium',
36
+ 'inline-flex min-h-9 items-center justify-center gap-1 rounded-control border border-transparent px-4 py-1.5 text-sm !font-medium',
28
37
  'ring-0 focus-visible:ring-0',
29
38
  'text-foreground/90 transition-colors',
30
39
  'surface-hover hover:text-foreground',
@@ -38,7 +47,7 @@ const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
38
47
 
39
48
  function subMenuLinkCls(active: boolean) {
40
49
  return cn(
41
- 'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-full border-0 px-4 py-2 text-sm font-medium transition-colors',
50
+ 'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-control border-0 px-3 py-2 text-sm font-medium transition-colors',
42
51
  'surface-hover',
43
52
  active
44
53
  ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted/90 dark:shadow-none'
@@ -50,10 +59,10 @@ function subMenuLinkCls(active: boolean) {
50
59
  // it. The package-level nav-dropdown-glass utility keeps the frosted effect
51
60
  // present even when the consuming app does not scan layout sources.
52
61
  const popoverCls =
53
- 'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 nav-dropdown-glass p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
62
+ 'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
54
63
 
55
64
  const portalPopoverCls =
56
- 'fixed z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 nav-dropdown-glass backdrop-blur-[5px] backdrop-saturate-150 p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
65
+ 'fixed z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass backdrop-blur-[5px] backdrop-saturate-150 p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
57
66
 
58
67
  function DesktopDropdownPortal({
59
68
  anchor,
@@ -130,7 +139,7 @@ function NavDesktopItemsRaw({
130
139
  {item.items.map((sub) => {
131
140
  const subActive = isActivePath(sub.href);
132
141
  return (
133
- <div key={`${item.label}-${sub.href}`} className="rounded-full">
142
+ <div key={`${item.label}-${sub.href}`} className="rounded-control">
134
143
  {sub.external ? (
135
144
  <a
136
145
  href={sub.href}
@@ -292,7 +301,7 @@ function NavDesktopItemsRaw({
292
301
  {overflowItems.map((item) => {
293
302
  const active = isGroupActive(item);
294
303
  return (
295
- <div key={`overflow-${item.href}`} className="rounded-full">
304
+ <div key={`overflow-${item.href}`} className="rounded-control">
296
305
  <Link href={item.href} className={subMenuLinkCls(active)}>
297
306
  <span className="min-w-0 truncate" title={item.label}>{item.label}</span>
298
307
  </Link>
@@ -58,7 +58,6 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
58
58
  useBodyScrollLock(mobileMenuOpen && isTabletOrBelow);
59
59
 
60
60
  const labels = useMemo(() => ({
61
- menu: t('layouts.navigation.menu'),
62
61
  quickActions: 'Actions',
63
62
  signIn: t('layouts.profile.login'),
64
63
  }), [t]);
@@ -79,8 +78,11 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
79
78
  const hasSessionUser = Boolean(isAuthenticated && user);
80
79
  const showSignInFooter = !hasSessionUser;
81
80
  const showThemeSwitcher = props.controls?.showThemeSwitcher === true;
82
- const showLocaleSwitcher = props.controls?.showLocaleSwitcher === true;
83
- const showControlsRow = showThemeSwitcher || showLocaleSwitcher;
81
+ // Locale is deliberately NOT rendered here even when enabled: on mobile it
82
+ // lives in the navbar beside the burger (see NavbarShell). The drawer footer
83
+ // is the worst possible anchor for a locale list — it sits at the bottom
84
+ // edge, so the list opened downward and spilled past the panel.
85
+ const showControlsRow = showThemeSwitcher;
84
86
 
85
87
  return (
86
88
  <>
@@ -121,7 +123,11 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
121
123
  maxHeight: 'min(var(--public-navbar-mobile-drawer-max-height, calc(100dvh - 5rem - 12px)), calc(100dvh - 12px))',
122
124
  }}
123
125
  >
124
- <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 pb-10 space-y-5">
126
+ {/* `p-2` is the panel's inner gutter. Rows carry their own `px-3`, so
127
+ a link's text sits at 20px from the panel edge while its hover /
128
+ active pill still has room to breathe inside the rounded corners —
129
+ at `px-4` the pill ran into them. */}
130
+ <div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-3">
125
131
  {hasSessionUser && (
126
132
  <div className="px-2">
127
133
  <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@@ -134,14 +140,13 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
134
140
  <UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} i18n={userMenu?.i18n} />
135
141
  )}
136
142
 
137
- <div className="space-y-2">
138
- <div className="px-2">
139
- <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
140
- {labels.menu}
141
- </h3>
142
- </div>
143
- <div className="space-y-1">
144
- {mobileNavigation.map((item) => {
143
+ {/* No "MENU" heading: an open drawer IS the menu, so the label only
144
+ cost a row of vertical space on the smallest screens.
145
+ No `space-y` either each row already carries its own height
146
+ (`min-h-11`), so an extra gap on top of it read as drift rather
147
+ than rhythm. Rows touch; their padding does the separating. */}
148
+ <div>
149
+ {mobileNavigation.map((item) => {
145
150
  const childItems = item.items ?? [];
146
151
  const hasChildNav = childItems.length > 0;
147
152
  const anyChildActive = hasChildNav && childItems.some((sub) => isActivePath(sub.href));
@@ -156,7 +161,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
156
161
  onClick={closeMobileMenu}
157
162
  title={item.label}
158
163
  className={cn(
159
- 'block min-h-11 min-w-0 max-w-full rounded-full border-0 px-5 py-3 text-[15px] font-medium transition-colors ring-0 truncate',
164
+ 'flex min-h-11 min-w-0 max-w-full items-center rounded-control border-0 px-3 text-[15px] font-medium transition-colors ring-0 truncate',
160
165
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
161
166
  parentPageActive
162
167
  ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none'
@@ -168,7 +173,16 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
168
173
  {item.label}
169
174
  </Link>
170
175
  {hasChildNav && (
171
- <div className="ml-3 mt-1.5 space-y-1 border-l border-border/40 pl-3">
176
+ // Children stay expanded (a short scroll beats an extra
177
+ // tap) but run denser than the parents: 36px rows, not
178
+ // 44px, so a section like "Products" no longer pushes
179
+ // every following section below the fold.
180
+ //
181
+ // The rule sits at `ml-[1.125rem]` — the centre of the
182
+ // parent's text inset (px-3 + half a character) — so the
183
+ // children hang off their parent instead of floating at
184
+ // an unrelated offset.
185
+ <div className="my-0.5 ml-[1.125rem] space-y-0 border-l border-border/50 pl-3">
172
186
  {childItems.map((subItem) => {
173
187
  const subActive = isActivePath(subItem.href);
174
188
  return (
@@ -178,7 +192,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
178
192
  onClick={closeMobileMenu}
179
193
  title={subItem.label}
180
194
  className={cn(
181
- 'flex min-h-11 min-w-0 max-w-full items-center rounded-full border-0 px-4 py-2.5 text-sm font-medium transition-colors ring-0 truncate',
195
+ 'flex min-h-9 min-w-0 max-w-full items-center rounded-control border-0 px-3 text-sm font-medium transition-colors ring-0 truncate',
182
196
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
183
197
  subActive
184
198
  ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
@@ -191,38 +205,47 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
191
205
  })}
192
206
  </div>
193
207
  )}
194
- </div>
195
- );
196
- })}
197
- </div>
208
+ </div>
209
+ );
210
+ })}
198
211
  </div>
199
212
  </div>
200
213
 
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
- showThemeSwitcher={showThemeSwitcher}
205
- showLocaleSwitcher={showLocaleSwitcher}
206
- size="default"
207
- />
208
- </div>
209
- )}
210
-
211
- {showSignInFooter && (
212
- <div className="shrink-0 border-t border-border/50 p-4">
213
- <Link
214
- href={userMenu?.authPath || routes.auth}
215
- onClick={closeMobileMenu}
216
- className="block"
217
- >
218
- <Button className="relative w-full justify-center rounded-full h-11 px-6 pr-12">
219
- {labels.signIn}
220
- <ArrowRight
221
- className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 shrink-0"
222
- aria-hidden
223
- />
224
- </Button>
225
- </Link>
214
+ {/* One footer row, not three stacked bands. Controls are utilities and
215
+ sit left at their natural width; Sign in is the single primary
216
+ action and sits right, sized to its label — a full-bleed button
217
+ here read as the loudest thing in the drawer while the language
218
+ picker above it burned a whole row of its own. */}
219
+ {/* `mx-2` insets the rule to the panel gutter so it reads as a divider
220
+ between two zones of one panel, not a seam across it. */}
221
+ {(showControlsRow || showSignInFooter) && (
222
+ <div className="mx-2 shrink-0 border-t border-border/60 px-1 py-2.5 flex items-center justify-between gap-3">
223
+ {showControlsRow ? (
224
+ // Squared off to match the navbar controls and the Login button
225
+ // beside it — `NavControls` itself stays pill-shaped for the
226
+ // site footer, which has no such neighbours.
227
+ <NavControls
228
+ className="[&>div]:rounded-[var(--radius-popover)] [&_button]:rounded-full"
229
+ showThemeSwitcher={showThemeSwitcher}
230
+ showLocaleSwitcher={false}
231
+ size="compact"
232
+ />
233
+ ) : (
234
+ <span />
235
+ )}
236
+
237
+ {showSignInFooter && (
238
+ <Link
239
+ href={userMenu?.authPath || routes.auth}
240
+ onClick={closeMobileMenu}
241
+ className="shrink-0"
242
+ >
243
+ <Button className="rounded-full h-10 gap-1.5 px-5">
244
+ {labels.signIn}
245
+ <ArrowRight className="h-4 w-4 shrink-0" aria-hidden />
246
+ </Button>
247
+ </Link>
248
+ )}
226
249
  </div>
227
250
  )}
228
251
  </div>
@@ -29,6 +29,8 @@ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
29
29
  import { cn } from '@djangocfg/ui-core/lib';
30
30
 
31
31
  import { usePathnameWithoutLocale } from '../../../hooks';
32
+ import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
33
+ import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
32
34
  import { usePublicLayoutOptional } from '../context';
33
35
  import {
34
36
  useDropdownMenu,
@@ -51,24 +53,69 @@ import { NavBrand } from '../primitives/NavBrand';
51
53
  import { NavControls } from '../primitives/NavControls';
52
54
  import { NavDesktopItems } from '../primitives/NavDesktopItems';
53
55
 
54
- const heightCls: Record<PublicNavbarHeight, string> = {
56
+ import { NavbarRowDesktop, NavbarRowMobile } from './navbarRows';
57
+
58
+ /**
59
+ * Vertical padding, per breakpoint. Two independent scales rather than one with
60
+ * `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
61
+ * so padding there is pure added height, while the desktop row needs the extra
62
+ * air to keep the link list off the panel edge.
63
+ */
64
+ const heightClsDesktop: Record<PublicNavbarHeight, string> = {
55
65
  sm: 'py-2',
56
66
  md: 'py-3.5',
57
67
  lg: 'py-5',
58
68
  };
59
69
 
60
- /** Token Tailwind class for the inner-row horizontal padding. */
61
- const insetXCls: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
70
+ const heightClsMobile: Record<PublicNavbarHeight, string> = {
71
+ sm: 'py-1.5',
72
+ md: 'py-2',
73
+ lg: 'py-3',
74
+ };
75
+
76
+ /**
77
+ * Token → Tailwind class for the inner-row horizontal padding.
78
+ *
79
+ * Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
80
+ * optical imbalance, not a measured one. On the left the gutter meets the
81
+ * brand's glyphs directly; on the right it meets a button — an icon square on
82
+ * phones, the Login pill on desktop — whose box carries its own internal
83
+ * padding around the glyph. Equal `px` therefore *looks* lopsided, with a
84
+ * conspicuous moat to the right of the last control.
85
+ *
86
+ * It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
87
+ * lg:px-6`), which is why the right side stays small in absolute terms: at
88
+ * `lg` the symmetric version put ~48px between the Login button and the
89
+ * viewport edge.
90
+ */
91
+ /** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
92
+ const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
93
+ none: '',
94
+ xs: 'lg:pl-4 lg:pr-1.5',
95
+ sm: 'lg:pl-6 lg:pr-2',
96
+ md: 'lg:pl-8 lg:pr-3',
97
+ lg: 'lg:pl-12 lg:pr-5',
98
+ };
99
+
100
+ /** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
101
+ const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
62
102
  none: '',
63
- xs: 'px-2 sm:px-3 lg:px-4',
64
- sm: 'px-3 sm:px-4 lg:px-6',
65
- md: 'px-4 sm:px-6 lg:px-8',
66
- lg: 'px-6 sm:px-8 lg:px-12',
103
+ xs: 'pl-3 pr-1',
104
+ sm: 'pl-4 pr-1.5',
105
+ md: 'pl-5 pr-2',
106
+ lg: 'pl-6 pr-3',
67
107
  };
68
108
 
109
+ /**
110
+ * Resolves the inset token into one class string covering both breakpoints.
111
+ *
112
+ * A caller may also pass raw Tailwind instead of a token; that escape hatch is
113
+ * applied verbatim to both rows, since we cannot know how to split it.
114
+ */
69
115
  function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
70
- if (!value) return insetXCls.sm;
71
- return value in insetXCls ? insetXCls[value as keyof typeof insetXCls] : value;
116
+ const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
117
+ if (!(token in insetXClsMobile)) return value as string;
118
+ return cn(insetXClsMobile[token], insetXClsDesktop[token]);
72
119
  }
73
120
 
74
121
  interface ShapeInputs {
@@ -190,6 +237,7 @@ function NavbarShellRaw(props: NavbarShellProps) {
190
237
  : undefined;
191
238
  const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
192
239
  const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
240
+ const closeMobileMenu = context?.closeMobileMenu ?? (() => {});
193
241
 
194
242
  const navOuterRef = useRef<HTMLDivElement | null>(null);
195
243
 
@@ -200,6 +248,9 @@ function NavbarShellRaw(props: NavbarShellProps) {
200
248
  const isTabletOrBelow = useIsTabletOrBelow();
201
249
  const t = useAppT();
202
250
  const pathname = usePathnameWithoutLocale();
251
+ // `LocaleSwitcher` throws without a provider, so the mobile flag is gated on
252
+ // this being present — same contract `NavControls` applies internally.
253
+ const i18n = useLayoutI18nOptional();
203
254
 
204
255
  // Close dropdowns when switching to mobile
205
256
  useEffect(() => {
@@ -273,6 +324,34 @@ function NavbarShellRaw(props: NavbarShellProps) {
273
324
  />
274
325
  ) : null;
275
326
 
327
+ // Locale sits in the navbar beside the burger, reachable without opening the
328
+ // menu. It stays the compact dropdown, NOT the fullscreen dialog: anchored at
329
+ // the TOP of the screen it has all the room it needs to open downward, so the
330
+ // overflow that plagued the drawer footer cannot happen here — and a modal is
331
+ // far too heavy a response to a mistap on a small target.
332
+ //
333
+ // Rendered directly rather than through `NavControls`: that wrapper hardcodes
334
+ // an outlined, tinted pill for the site footer, and overriding it from the
335
+ // outside produced exactly the mismatch this pairing must avoid — an outlined
336
+ // flag next to a ghost burger. Here both are ghost squircles of equal size.
337
+ const mobileLocaleNode = showLocaleSwitcher && i18n ? (
338
+ // Capture-phase close: the navbar (z-1001) stays clickable above an open
339
+ // drawer, so without this the drawer would sit open behind the menu.
340
+ <div
341
+ onClickCapture={() => {
342
+ if (mobileMenuOpen) closeMobileMenu();
343
+ }}
344
+ >
345
+ <LocaleSwitcher
346
+ variant="dropdown"
347
+ buttonVariant="ghost"
348
+ size="icon"
349
+ showTriggerLabel={false}
350
+ className="h-11 w-11 rounded-full"
351
+ />
352
+ </div>
353
+ ) : null;
354
+
276
355
  const actionsNode = props.renderActions ? (
277
356
  props.renderActions({
278
357
  userMenu,
@@ -293,58 +372,32 @@ function NavbarShellRaw(props: NavbarShellProps) {
293
372
  leadingSlot={props.actionsLeadingSlot}
294
373
  trailingSlot={props.actionsTrailingSlot}
295
374
  controlsSlot={controlsNode}
375
+ mobileLeadingSlot={mobileLocaleNode}
296
376
  />
297
377
  );
298
378
 
299
- const h = heightCls[navbarHeight];
300
-
301
- const row = (() => {
302
- switch (navLayout) {
303
- case 'brand-left':
304
- return (
305
- <div className={cn('flex items-center gap-4', h)}>
306
- <div className="min-w-0 shrink-0 flex items-center">{brandNode}</div>
307
- <div className="hidden isolate lg:flex min-w-0 flex-1 items-center gap-1">{desktopNavNode}</div>
308
- <div className="flex-1 lg:hidden" />
309
- <div className="flex shrink-0 items-center gap-4">{actionsNode}</div>
310
- </div>
311
- );
312
- case 'centered':
313
- return (
314
- <div className={cn('flex items-center gap-4', h)}>
315
- <div className="shrink-0">{brandNode}</div>
316
- <div className="hidden isolate lg:flex min-w-0 flex-1 items-center justify-center gap-1">{desktopNavNode}</div>
317
- <div className="flex-1 lg:hidden" />
318
- <div className="flex shrink-0 items-center">{actionsNode}</div>
319
- </div>
320
- );
321
- case 'split':
322
- return (
323
- <div className={cn('flex items-center justify-between', h)}>
324
- {brandNode}
325
- {actionsNode}
326
- </div>
327
- );
328
- default:
329
- return (
330
- <div className={cn('flex items-center gap-4', h)}>
331
- <div className="shrink-0">{brandNode}</div>
332
- <div className="hidden isolate lg:flex min-w-0 flex-1 items-center justify-center gap-1">
333
- {desktopNavNode}
334
- </div>
335
- <div className="flex-1 lg:hidden" />
336
- <div className="flex shrink-0 items-center">{actionsNode}</div>
337
- </div>
338
- );
339
- }
340
- })();
341
-
342
379
  const innerPaddingCls = innerPadding ?? cn('w-full', resolveInsetX(insetX));
343
380
 
344
381
  return (
345
382
  <div ref={navOuterRef} className={outerCls}>
346
383
  <nav className={cn(shapeClassName, surfaceCls)}>
347
- <div className={innerPaddingCls}>{row}</div>
384
+ <div className={innerPaddingCls}>
385
+ {/* Two rows, one config. Exactly one is visible at any width — see
386
+ the note in `navbarRows.tsx` for why this is CSS and not a
387
+ media-query hook. */}
388
+ <NavbarRowDesktop
389
+ brand={brandNode}
390
+ actions={actionsNode}
391
+ desktopNav={desktopNavNode}
392
+ navLayout={navLayout}
393
+ heightClassName={heightClsDesktop[navbarHeight]}
394
+ />
395
+ <NavbarRowMobile
396
+ brand={brandNode}
397
+ actions={actionsNode}
398
+ heightClassName={heightClsMobile[navbarHeight]}
399
+ />
400
+ </div>
348
401
  </nav>
349
402
  </div>
350
403
  );
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The navbar has TWO layouts, not one responsive layout.
3
+ *
4
+ * They are separate components on purpose. Previously a single row carried both
5
+ * behaviours and toggled parts with `lg:hidden` / `hidden lg:flex`, which meant
6
+ * every shared token — horizontal inset, row height, control size, corner
7
+ * radius — had to encode both breakpoints at once. Tuning the phone spacing
8
+ * risked the desktop bar and vice versa; the two designs were entangled in
9
+ * values that looked unrelated to either.
10
+ *
11
+ * Splitting them costs one thing worth stating plainly: both rows are rendered
12
+ * and one is hidden with CSS. That keeps rendering purely CSS-driven — no
13
+ * `useMediaQuery`, no layout shift on hydration, correct SSR output — at the
14
+ * price of the hidden row's markup existing in the DOM. For a navbar (a handful
15
+ * of links) that trade is right; it would not be for a heavy subtree.
16
+ *
17
+ * Config stays SINGLE — callers pass one `navigation`, one `brand`, one
18
+ * `userMenu`. Only the arrangement differs, so the two cannot drift apart.
19
+ */
20
+
21
+ 'use client';
22
+
23
+ import React, { type ReactNode } from 'react';
24
+
25
+ import { cn } from '@djangocfg/ui-core/lib';
26
+
27
+ import type { PublicNavLayout } from '../navbarTypes';
28
+
29
+ export interface NavbarRowProps {
30
+ brand: ReactNode;
31
+ actions: ReactNode;
32
+ /** Desktop-only: the horizontal link list. */
33
+ desktopNav?: ReactNode;
34
+ navLayout: PublicNavLayout;
35
+ /** Vertical padding classes for this breakpoint. */
36
+ heightClassName: string;
37
+ }
38
+
39
+ /**
40
+ * Desktop row (`lg` and up). Brand, the link list, then actions.
41
+ *
42
+ * `navLayout` only affects where the links sit, so it is read here and nowhere
43
+ * else — the mobile row has no link list to arrange.
44
+ */
45
+ export function NavbarRowDesktop({
46
+ brand,
47
+ actions,
48
+ desktopNav,
49
+ navLayout,
50
+ heightClassName,
51
+ }: NavbarRowProps) {
52
+ // `split` deliberately renders no link list — the caller wants brand and
53
+ // actions only, with navigation living in the drawer at every width.
54
+ const navAlignment =
55
+ navLayout === 'brand-left'
56
+ ? 'justify-start'
57
+ : 'justify-center';
58
+
59
+ return (
60
+ <div className={cn('hidden lg:flex items-center gap-6', heightClassName)}>
61
+ <div className="min-w-0 shrink-0 flex items-center">{brand}</div>
62
+
63
+ {navLayout !== 'split' && (
64
+ // `isolate` keeps the dropdown popovers' stacking contained to the nav
65
+ // list instead of competing with the actions column.
66
+ <div className={cn('isolate flex min-w-0 flex-1 items-center gap-1', navAlignment)}>
67
+ {desktopNav}
68
+ </div>
69
+ )}
70
+
71
+ {navLayout === 'split' && <div className="flex-1" />}
72
+
73
+ <div className="flex shrink-0 items-center gap-3">{actions}</div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Mobile row (below `lg`). Brand on the left, controls hard right — there is no
80
+ * link list, so it never needs the alignment logic above.
81
+ */
82
+ export function NavbarRowMobile({
83
+ brand,
84
+ actions,
85
+ heightClassName,
86
+ }: Omit<NavbarRowProps, 'desktopNav' | 'navLayout'>) {
87
+ return (
88
+ <div className={cn('flex lg:hidden items-center justify-between gap-3', heightClassName)}>
89
+ <div className="min-w-0 flex items-center">{brand}</div>
90
+ <div className="flex shrink-0 items-center">{actions}</div>
91
+ </div>
92
+ );
93
+ }
@@ -104,7 +104,7 @@ export function LocaleSwitcherDialog({
104
104
  The shell scrolls; the grid below uses overflow-hidden so the focus
105
105
  ring on the search input isn't clipped by an inner scroll edge.
106
106
  */}
107
- <div className="mx-auto flex h-full w-full max-w-5xl flex-col px-4 sm:px-6 lg:px-8">
107
+ <div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-4 sm:px-6 lg:px-8">
108
108
  <LocaleSwitcherDialogHeader brand={brand} labels={merged} />
109
109
 
110
110
  <div className="flex-1 min-h-0 pb-6 lg:pb-10">