@djangocfg/layouts 2.1.266 → 2.1.267

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.
@@ -1,42 +1,45 @@
1
1
  /**
2
- * Public Layout Navigation
2
+ * Public Layout Navigation — orchestrator.
3
3
  *
4
- * Navigation component for PublicLayout with mobile drawer support.
5
- *
6
- * Colors: use Tailwind semantic utilities backed by `@djangocfg/ui-core` theme
7
- * (`packages/ui-core/src/styles/theme/light.css`, `dark.css`, `tokens.css`).
4
+ * Logic lives in hooks; rendering lives in NavBrand / NavDesktopItems / NavActions.
5
+ * This file wires them together and switches on navLayout.
8
6
  */
9
7
 
10
8
  'use client';
11
9
 
12
- import { ChevronDown, Menu, X } from 'lucide-react';
13
- import Link from 'next/link';
14
10
  import React, {
15
11
  type ReactNode,
16
12
  useEffect,
17
13
  useLayoutEffect,
18
14
  useMemo,
19
15
  useRef,
20
- useState,
21
16
  } from 'react';
22
17
 
23
- import { useAuth } from '@djangocfg/api/auth';
24
18
  import { useAppT } from '@djangocfg/i18n';
25
- import {
26
- Button,
27
- } from '@djangocfg/ui-core/components';
28
19
  import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
29
20
  import { cn } from '@djangocfg/ui-core/lib';
30
- import { usePathnameWithoutLocale } from '../../../hooks';
31
21
 
32
- import { UserMenu } from '../../_components/UserMenu';
22
+ import { usePathnameWithoutLocale } from '../../../hooks';
33
23
  import { usePublicLayoutOptional } from '../context';
24
+ import {
25
+ useDropdownMenu,
26
+ useNavbarScroll,
27
+ useNavbarViewportVars,
28
+ } from '../hooks';
29
+ import type {
30
+ PublicNavbarHeight,
31
+ PublicNavbarPosition,
32
+ PublicNavbarVariant,
33
+ PublicNavLayout,
34
+ } from '../navbarTypes';
34
35
  import { publicFloatingChromeClassName } from '../publicShellShadow';
35
- import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
36
-
37
36
  import type { NavigationItem, UserMenuConfig } from '../../types';
38
37
 
39
- export type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
38
+ import { NavActions } from './NavActions';
39
+ import { NavBrand } from './NavBrand';
40
+ import { NavDesktopItems } from './NavDesktopItems';
41
+
42
+ // ─── Public types (re-exported so PublicNavbar.tsx can use them) ──────────────
40
43
 
41
44
  export interface PublicDesktopDropdownRenderProps {
42
45
  item: NavigationItem;
@@ -47,14 +50,15 @@ export interface PublicDesktopDropdownRenderProps {
47
50
  defaultItems: React.ReactNode;
48
51
  }
49
52
 
50
- export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => React.ReactNode;
53
+ export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => ReactNode;
54
+
55
+ export type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
56
+
57
+ // ─── Props ────────────────────────────────────────────────────────────────────
51
58
 
52
59
  interface PublicNavigationProps {
53
- /**
54
- * Brand area: any React node, or a plain string (wrapped in `<Link href={brandHref}>` as the title).
55
- */
56
60
  brand?: ReactNode;
57
- /** Used when `brand` is a string; also for deduping a lone nav link that matches this href (desktop). @default '/' */
61
+ /** @default '/' */
58
62
  brandHref?: string;
59
63
  navigation?: NavigationItem[];
60
64
  userMenu?: UserMenuConfig;
@@ -62,423 +66,222 @@ interface PublicNavigationProps {
62
66
  navbarVariant?: PublicNavbarVariant;
63
67
  navbarPosition?: PublicNavbarPosition;
64
68
  renderDesktopDropdown?: PublicDesktopDropdownRenderer;
65
- /** Max visible top-level desktop items before collapsing into "More" */
69
+ /** Max visible top-level desktop items before collapsing into "More". @default 7 */
66
70
  desktopMaxPrimaryItems?: number;
67
- /**
68
- * Tailwind rounding for the floating navbar shell (e.g. `rounded-3xl`). Defaults to `rounded-2xl`.
69
- * Prefer `PublicNavbar` `config.shell.rounding` (or `AppLayout` `publicChrome.navbar.shell.rounding`).
70
- */
71
+ /** Tailwind rounding for floating variant shell. @default 'rounded-2xl' */
71
72
  rounding?: string;
72
73
  mobileMenuOpen?: boolean;
73
74
  onMobileMenuToggle?: () => void;
75
+ /** Desktop nav arrangement. @default 'default' */
76
+ navLayout?: PublicNavLayout;
77
+ /** Navbar vertical padding / height. @default 'md' */
78
+ navbarHeight?: PublicNavbarHeight;
79
+ /** Slide navbar off-screen on scroll-down; restore on scroll-up. @default false */
80
+ hideNavOnScroll?: boolean;
81
+ /** Transparent at page top, opaque after scrolling past threshold. @default false */
82
+ transparent?: boolean;
83
+ /** scrollY threshold for transparent → opaque transition. @default 40 */
84
+ transparentThreshold?: number;
74
85
  }
75
86
 
87
+ // ─── Height map ───────────────────────────────────────────────────────────────
88
+
89
+ const heightCls: Record<PublicNavbarHeight, string> = {
90
+ sm: 'py-2',
91
+ md: 'py-3.5',
92
+ lg: 'py-5',
93
+ };
94
+
95
+ // ─── Component ────────────────────────────────────────────────────────────────
96
+
76
97
  export function PublicNavigation(props: PublicNavigationProps = {}) {
77
98
  const context = usePublicLayoutOptional();
78
- const brand = props.brand;
79
- const brandHref = props.brandHref ?? '/';
80
- const navigation = props.navigation ?? [];
81
- const userMenu = props.userMenu;
82
- const containerClassName = props.containerClassName;
83
- const rounding = props.rounding;
84
- const navbarVariant = props.navbarVariant ?? 'floating';
85
- const navbarPosition = props.navbarPosition ?? 'sticky';
99
+
100
+ // ── Resolve props (context fills missing values) ──────────────────────────
101
+ const brand = props.brand;
102
+ const brandHref = props.brandHref ?? '/';
103
+ const navigation = props.navigation ?? [];
104
+ const userMenu = props.userMenu;
105
+ const containerClassName = props.containerClassName;
106
+ const rounding = props.rounding;
107
+ const navbarVariant = props.navbarVariant ?? 'floating';
108
+ const navbarPosition = props.navbarPosition ?? 'sticky';
86
109
  const renderDesktopDropdown = props.renderDesktopDropdown;
87
110
  const desktopMaxPrimaryItems = Math.max(1, props.desktopMaxPrimaryItems ?? 7);
88
- const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
111
+ const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
89
112
  const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
90
- const { isAuthenticated: _isAuthenticated } = useAuth();
91
- const isTabletOrBelow = useIsTabletOrBelow();
92
- const pathname = usePathnameWithoutLocale();
93
- const t = useAppT();
94
- const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
95
- const openTimerRef = useRef<number | null>(null);
96
- const closeTimerRef = useRef<number | null>(null);
97
- const navOuterRef = useRef<HTMLDivElement | null>(null);
113
+ const navLayout = props.navLayout ?? 'default';
114
+ const navbarHeight = props.navbarHeight ?? 'md';
115
+ const hideNavOnScroll = props.hideNavOnScroll ?? false;
116
+ const transparent = props.transparent ?? false;
117
+ const transparentThreshold = props.transparentThreshold ?? 40;
98
118
 
99
- const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
100
-
101
- /** Long i18n labels: ellipsis instead of stretching the bar (desktop top-level + “More”). */
102
- const desktopNavLabelClass = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
103
- /** Dropdown / overflow menu rows — a bit wider than top-level pills. */
104
- /** Top-level desktop nav control — larger hit target, works in light/dark. */
105
- const desktopNavItemClass = cn(
106
- // No `border-transparent` (1px “ghost” stroke still paints); use `border-0` and only add stroke in dark when needed.
107
- 'inline-flex min-h-9 items-center justify-center gap-1 rounded-full border-0 px-4 py-1.5 text-sm font-medium',
108
- 'ring-0 focus-visible:ring-0',
109
- 'text-foreground/90 transition-colors',
110
- 'hover:bg-accent/55 hover:text-foreground',
111
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
112
- );
113
- /** Light: fill only. Dark: optional hairline + muted fill. */
114
- const desktopNavItemActiveClass =
115
- 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none';
116
-
117
- const subMenuLinkClass = (active: boolean) =>
118
- cn(
119
- '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',
120
- 'hover:bg-accent/55',
121
- active
122
- ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
123
- : 'border-0 text-foreground/90',
124
- );
125
-
126
- const clearOpenTimer = () => {
127
- if (openTimerRef.current) {
128
- window.clearTimeout(openTimerRef.current);
129
- openTimerRef.current = null;
130
- }
131
- };
132
-
133
- const clearCloseTimer = () => {
134
- if (closeTimerRef.current) {
135
- window.clearTimeout(closeTimerRef.current);
136
- closeTimerRef.current = null;
137
- }
138
- };
119
+ // ── Refs ──────────────────────────────────────────────────────────────────
120
+ const navOuterRef = useRef<HTMLDivElement | null>(null);
139
121
 
140
- const scheduleOpen = (key: string) => {
141
- clearOpenTimer();
142
- clearCloseTimer();
143
- openTimerRef.current = window.setTimeout(() => {
144
- setOpenDropdownKey(key);
145
- }, 80);
146
- };
122
+ // ── Hooks ─────────────────────────────────────────────────────────────────
123
+ const { hidden, scrolled } = useNavbarScroll({ hideNavOnScroll, transparent, transparentThreshold });
124
+ const dropdown = useDropdownMenu();
125
+ useNavbarViewportVars(navOuterRef, [navbarPosition, navbarVariant, containerClassName] as const);
147
126
 
148
- const scheduleClose = (key: string) => {
149
- clearOpenTimer();
150
- clearCloseTimer();
151
- closeTimerRef.current = window.setTimeout(() => {
152
- setOpenDropdownKey((prev) => (prev === key ? null : prev));
153
- }, 120);
154
- };
127
+ const isTabletOrBelow = useIsTabletOrBelow();
128
+ const t = useAppT();
129
+ const pathname = usePathnameWithoutLocale();
155
130
 
131
+ // Close dropdowns when switching to mobile
156
132
  useEffect(() => {
157
- return () => {
158
- clearOpenTimer();
159
- clearCloseTimer();
160
- };
161
- }, []);
162
-
163
- useEffect(() => {
164
- if (isTabletOrBelow) {
165
- setOpenDropdownKey(null);
166
- clearOpenTimer();
167
- clearCloseTimer();
168
- }
169
- }, [isTabletOrBelow]);
133
+ if (isTabletOrBelow) dropdown.closeDropdown();
134
+ }, [isTabletOrBelow, dropdown.closeDropdown]);
170
135
 
136
+ // Sync navbar surface into context
171
137
  const setNavbarSurface = context?.setNavbarSurface;
172
-
173
138
  useLayoutEffect(() => {
174
139
  if (!setNavbarSurface) return;
175
140
  setNavbarSurface({ variant: navbarVariant, position: navbarPosition });
176
- return () => {
177
- setNavbarSurface(null);
178
- };
141
+ return () => setNavbarSurface(null);
179
142
  }, [setNavbarSurface, navbarVariant, navbarPosition]);
180
143
 
181
- useEffect(() => {
182
- const updateDrawerViewportVars = () => {
183
- const root = document.documentElement;
184
- const navEl = navOuterRef.current;
185
- if (!navEl) return;
186
-
187
- const rect = navEl.getBoundingClientRect();
188
- const top = Math.max(0, Math.round(rect.bottom + 8));
189
- const viewportHeight = window.innerHeight;
190
- const maxHeight = Math.max(240, viewportHeight - top - 12);
191
-
192
- root.style.setProperty('--public-navbar-mobile-drawer-top', `${top}px`);
193
- root.style.setProperty('--public-navbar-mobile-drawer-max-height', `${maxHeight}px`);
194
- };
195
-
196
- updateDrawerViewportVars();
197
- const navEl = navOuterRef.current;
198
- const observer = navEl ? new ResizeObserver(updateDrawerViewportVars) : null;
199
- if (navEl && observer) observer.observe(navEl);
200
- window.addEventListener('resize', updateDrawerViewportVars);
201
- window.addEventListener('scroll', updateDrawerViewportVars, { passive: true });
202
-
203
- return () => {
204
- if (navEl && observer) observer.unobserve(navEl);
205
- observer?.disconnect();
206
- window.removeEventListener('resize', updateDrawerViewportVars);
207
- window.removeEventListener('scroll', updateDrawerViewportVars);
208
- };
209
- }, [navbarPosition, navbarVariant, containerClassName]);
210
-
211
- const navOuterClassName = (() => {
212
- const positionClass =
213
- navbarPosition === 'fixed'
214
- ? 'fixed'
215
- : navbarPosition === 'static'
216
- ? 'static'
217
- : 'sticky';
218
-
219
- const topClass = navbarVariant === 'floating' ? 'top-3' : 'top-0';
220
- const insetPaddingClass = navbarVariant === 'floating' ? 'px-3 sm:px-4 lg:px-6' : '';
221
-
222
- return `${positionClass} ${topClass} inset-x-0 z-50 ${insetPaddingClass}`.trim().replace(/\s+/g, ' ');
223
- })();
224
-
225
- const navInnerClassName = cn(
144
+ // ── Derived values ────────────────────────────────────────────────────────
145
+ const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
146
+
147
+ const isActivePath = useMemo(() => (href: string) => {
148
+ if (href === '/') return pathname === '/';
149
+ return pathname === href || pathname.startsWith(`${href}/`);
150
+ }, [pathname]);
151
+
152
+ const isGroupActive = useMemo(() => (item: NavigationItem): boolean => {
153
+ if (isActivePath(item.href)) return true;
154
+ return item.items?.some((sub) => isActivePath(sub.href)) ?? false;
155
+ }, [isActivePath]);
156
+
157
+ // Desktop: filter lone link duplicating brand (keeps full list in drawer)
158
+ const desktopNavItems = useMemo(
159
+ () => navigation.filter((item) => item.items?.length || item.href !== brandHref),
160
+ [navigation, brandHref],
161
+ );
162
+
163
+ const primaryItems = desktopNavItems.slice(0, desktopMaxPrimaryItems);
164
+ const overflowItems = desktopNavItems.slice(desktopMaxPrimaryItems);
165
+
166
+ // ── Class names ───────────────────────────────────────────────────────────
167
+
168
+ const navOuterClassName = cn(
169
+ navbarPosition === 'fixed'
170
+ ? 'fixed'
171
+ : navbarPosition === 'static'
172
+ ? 'static'
173
+ : 'sticky',
174
+ navbarVariant === 'floating' ? 'top-3' : 'top-0',
175
+ navbarVariant === 'floating' ? 'px-3 sm:px-4 lg:px-6' : '',
176
+ 'inset-x-0 z-50',
177
+ hideNavOnScroll && 'transition-transform duration-300 ease-in-out will-change-transform',
178
+ // Keep visible when mobile menu is open even if scrolling
179
+ hideNavOnScroll && hidden && !mobileMenuOpen && '-translate-y-full',
180
+ );
181
+
182
+ const navShapeClassName = cn(
226
183
  'mx-auto w-full',
227
184
  navbarVariant === 'floating'
228
185
  ? cn(rounding ?? 'rounded-2xl', publicFloatingChromeClassName)
229
186
  : 'rounded-none border-x-0 border-t-0 border-b border-border/40 dark:border-border/70 shadow-none',
230
187
  containerClassName,
231
- // Last: guarantee shell has no stroke on light; `containerClassName` cannot reintroduce a border.
232
188
  navbarVariant === 'floating' && '!border-0 dark:!border dark:!border-border/75',
233
189
  );
234
190
 
235
- const isActivePath = (href: string) => {
236
- if (href === '/') return pathname === '/';
237
- return pathname === href || pathname.startsWith(`${href}/`);
238
- };
191
+ const navSurfaceClassName = cn(
192
+ transparent && 'transition-[background-color,backdrop-filter] duration-200 ease-out',
193
+ !transparent || scrolled
194
+ ? 'bg-background/72 backdrop-blur-[10px] dark:bg-card/80'
195
+ : 'bg-transparent backdrop-blur-0 dark:bg-transparent',
196
+ );
239
197
 
240
- const isGroupActive = (item: NavigationItem) => {
241
- if (isActivePath(item.href)) return true;
242
- if (!item.items) return false;
243
- return item.items.some((subItem) => isActivePath(subItem.href));
244
- };
198
+ // ── Sub-components ────────────────────────────────────────────────────────
245
199
 
246
- const closeDropdown = () => setOpenDropdownKey(null);
247
-
248
- /** Desktop: omit a lone top-level link that duplicates the brand link — saves space; drawer keeps full list. */
249
- const desktopNavItems = useMemo(() => {
250
- return navigation.filter((item) => {
251
- if (item.items && item.items.length > 0) return true;
252
- return item.href !== brandHref;
253
- });
254
- }, [navigation, brandHref]);
255
-
256
- const desktopPrimaryNavigation = desktopNavItems.slice(0, desktopMaxPrimaryItems);
257
- const desktopOverflowNavigation = desktopNavItems.slice(desktopMaxPrimaryItems);
258
-
259
- const renderDesktopNavItem = (item: NavigationItem) => {
260
- if (item.items && item.items.length > 0) {
261
- const dropdownKey = `${item.label}-${item.href}`;
262
- const defaultItems = (
263
- <>
264
- {item.items.map((subItem) => {
265
- const subActive = isActivePath(subItem.href);
266
- return (
267
- <div key={`${item.label}-${subItem.href}`} className="rounded-full">
268
- {subItem.external ? (
269
- <a
270
- href={subItem.href}
271
- target="_blank"
272
- rel="noopener noreferrer"
273
- className={subMenuLinkClass(subActive)}
274
- >
275
- <span className="min-w-0 truncate" title={subItem.label}>{subItem.label}</span>
276
- </a>
277
- ) : (
278
- <Link href={subItem.href} className={subMenuLinkClass(subActive)}>
279
- <span className="min-w-0 truncate" title={subItem.label}>{subItem.label}</span>
280
- </Link>
281
- )}
282
- </div>
283
- );
284
- })}
285
- </>
286
- );
287
-
288
- const defaultPopover = (
289
- <div
290
- className="absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
291
- onMouseEnter={() => {
292
- clearOpenTimer();
293
- clearCloseTimer();
294
- }}
295
- onMouseLeave={() => scheduleClose(dropdownKey)}
296
- >
297
- {defaultItems}
298
- </div>
299
- );
300
-
301
- const isOpen = openDropdownKey === dropdownKey;
302
- const isActive = isGroupActive(item);
303
- return (
304
- <div
305
- key={dropdownKey}
306
- className="relative"
307
- onMouseEnter={() => scheduleOpen(dropdownKey)}
308
- onMouseLeave={() => scheduleClose(dropdownKey)}
309
- >
310
- <Button
311
- variant="ghost"
312
- size="sm"
313
- className={cn(
314
- // Override Button base [&_svg]:size-16px so hover/layout doesn’t fight icon size; fixed chevron box avoids neighbor jitter when rotating.
315
- 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
316
- desktopNavItemClass,
317
- (isOpen || isActive) && desktopNavItemActiveClass,
318
- isOpen && 'border-0 dark:border dark:border-border',
319
- )}
320
- >
321
- <span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
322
- <span
323
- className="inline-flex size-3.5 shrink-0 items-center justify-center"
324
- aria-hidden
325
- >
326
- <ChevronDown
327
- className={`size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform ${isOpen ? 'rotate-180' : ''}`}
328
- />
329
- </span>
330
- </Button>
331
-
332
- {isOpen && (
333
- renderDesktopDropdown
334
- ? renderDesktopDropdown({
335
- item,
336
- isOpen,
337
- isActive,
338
- close: closeDropdown,
339
- defaultPopover,
340
- defaultItems,
341
- })
342
- : defaultPopover
343
- )}
344
- </div>
345
- );
346
- }
200
+ const brandNode = (
201
+ <div className="min-w-0 shrink-0 flex items-center">
202
+ <NavBrand brand={brand} brandHref={brandHref} />
203
+ </div>
204
+ );
347
205
 
348
- const linkActive = isActivePath(item.href);
349
- return (
350
- <Link
351
- key={item.href}
352
- href={item.href}
353
- className={cn(desktopNavItemClass, linkActive && desktopNavItemActiveClass)}
354
- >
355
- <span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
356
- </Link>
357
- );
358
- };
206
+ const desktopNavNode = navLayout !== 'split' ? (
207
+ <NavDesktopItems
208
+ primaryItems={primaryItems}
209
+ overflowItems={overflowItems}
210
+ isActivePath={isActivePath}
211
+ isGroupActive={isGroupActive}
212
+ dropdown={dropdown}
213
+ renderDesktopDropdown={renderDesktopDropdown}
214
+ />
215
+ ) : null;
216
+
217
+ const actionsNode = (
218
+ <NavActions
219
+ userMenu={userMenu}
220
+ mobileMenuOpen={mobileMenuOpen}
221
+ onMobileMenuToggle={toggleMobileMenu}
222
+ toggleMobileLabel={toggleMobileLabel}
223
+ forceShowMobileTrigger={navLayout === 'split'}
224
+ />
225
+ );
359
226
 
360
- const hasDesktopOverflowNav = desktopOverflowNavigation.length > 0;
361
- const isMoreMenuOpen = openDropdownKey === '__overflow-more';
362
- const moreMenuButtonActive =
363
- isMoreMenuOpen || desktopOverflowNavigation.some((navItem) => isGroupActive(navItem));
227
+ const h = heightCls[navbarHeight];
364
228
 
365
- return (
366
- <div ref={navOuterRef} className={navOuterClassName}>
367
- <nav
368
- className={cn(
369
- navInnerClassName,
370
- // Light: glass over page bg. Dark: slightly lifted vs --background (see ui-core theme/dark.css --card).
371
- 'bg-background/72 backdrop-blur-[10px] dark:bg-card/80',
372
- )}
373
- >
374
- <div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
375
- <div className="flex items-center justify-between py-3.5">
376
- {/* Brand */}
377
- <div className="min-w-0 shrink-0 flex items-center">
378
- {brand != null && brand !== '' && brand !== false
379
- ? typeof brand === 'string'
380
- ? (
381
- <Link
382
- href={brandHref}
383
- className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
384
- >
385
- {brand}
386
- </Link>
387
- )
388
- : brand
389
- : null}
390
- </div>
229
+ // ── Layout variants ───────────────────────────────────────────────────────
391
230
 
392
- {/* Desktop Navigation */}
393
- <div className="hidden isolate lg:flex items-center gap-1">
394
- {desktopPrimaryNavigation.map(renderDesktopNavItem)}
395
- {hasDesktopOverflowNav && (
396
- <div
397
- className="relative"
398
- onMouseEnter={() => scheduleOpen('__overflow-more')}
399
- onMouseLeave={() => scheduleClose('__overflow-more')}
400
- >
401
- <Button
402
- variant="ghost"
403
- size="sm"
404
- className={cn(
405
- 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
406
- desktopNavItemClass,
407
- moreMenuButtonActive && desktopNavItemActiveClass,
408
- isMoreMenuOpen && 'border-0 dark:border dark:border-border',
409
- )}
410
- >
411
- <span className={desktopNavLabelClass}>More</span>
412
- <span
413
- className="inline-flex size-3.5 shrink-0 items-center justify-center"
414
- aria-hidden
415
- >
416
- <ChevronDown
417
- className={`size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform ${
418
- isMoreMenuOpen ? 'rotate-180' : ''
419
- }`}
420
- />
421
- </span>
422
- </Button>
423
-
424
- {isMoreMenuOpen && (
425
- <div
426
- className="absolute right-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
427
- onMouseEnter={() => {
428
- clearOpenTimer();
429
- clearCloseTimer();
430
- }}
431
- onMouseLeave={() => scheduleClose('__overflow-more')}
432
- >
433
- {desktopOverflowNavigation.map((navItem) => {
434
- const overflowActive = isGroupActive(navItem);
435
- return (
436
- <div key={`overflow-${navItem.href}`} className="rounded-full">
437
- <Link
438
- href={navItem.href}
439
- className={subMenuLinkClass(overflowActive)}
440
- >
441
- <span className="min-w-0 truncate" title={navItem.label}>{navItem.label}</span>
442
- </Link>
443
- </div>
444
- );
445
- })}
446
- </div>
447
- )}
448
- </div>
449
- )}
231
+ const renderRow = () => {
232
+ switch (navLayout) {
233
+ case 'brand-left':
234
+ return (
235
+ <div className={cn('flex items-center gap-1', h)}>
236
+ <div className="min-w-0 shrink-0 flex items-center mr-4">{brandNode}</div>
237
+ <div className="hidden isolate lg:flex items-center gap-1">
238
+ {desktopNavNode}
239
+ </div>
240
+ <div className="ml-auto flex items-center gap-4">{actionsNode}</div>
450
241
  </div>
451
-
452
- {/* User Menu / Actions */}
453
- <div className="flex items-center gap-4">
454
- <div className="hidden lg:flex">
455
- <>
456
- {/* User Menu */}
457
- <UserMenu
458
- variant="desktop"
459
- groups={userMenu?.groups}
460
- authPath={userMenu?.authPath}
461
- i18n={userMenu?.i18n}
462
- />
463
- </>
242
+ );
243
+
244
+ case 'centered':
245
+ return (
246
+ <div className={cn('flex items-center justify-center gap-4', h)}>
247
+ {brandNode}
248
+ <div className="hidden isolate lg:flex items-center gap-1">
249
+ {desktopNavNode}
464
250
  </div>
251
+ {actionsNode}
252
+ </div>
253
+ );
465
254
 
466
- {/* Mobile Menu Button */}
467
- <Button
468
- variant="ghost"
469
- size="icon"
470
- aria-label={toggleMobileLabel}
471
- data-mobile-menu-trigger="true"
472
- className="lg:hidden rounded-full"
473
- onClick={toggleMobileMenu}
474
- >
475
- {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
476
- </Button>
255
+ case 'split':
256
+ return (
257
+ <div className={cn('flex items-center justify-between', h)}>
258
+ {brandNode}
259
+ {actionsNode}
477
260
  </div>
261
+ );
262
+
263
+ default: // 'default' — brand left, nav truly centered (absolute), actions right
264
+ return (
265
+ <div className={cn('relative flex items-center justify-between', h)}>
266
+ {brandNode}
267
+ <div className="hidden isolate lg:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
268
+ {desktopNavNode}
269
+ </div>
270
+ {actionsNode}
271
+ </div>
272
+ );
273
+ }
274
+ };
275
+
276
+ // ── Render ────────────────────────────────────────────────────────────────
277
+
278
+ return (
279
+ <div ref={navOuterRef} className={navOuterClassName}>
280
+ <nav className={cn(navShapeClassName, navSurfaceClassName)}>
281
+ <div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
282
+ {renderRow()}
478
283
  </div>
479
- </div>
480
284
  </nav>
481
285
  </div>
482
286
  );
483
287
  }
484
-