@djangocfg/layouts 2.1.266 → 2.1.268
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 +73 -3
- package/package.json +18 -18
- package/src/hooks/index.ts +1 -1
- package/src/hooks/usePathnameWithoutLocale.ts +35 -19
- package/src/layouts/AppLayout/AppLayout.tsx +15 -4
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +1 -1
- package/src/layouts/AuthLayout/styles/auth.css +7 -0
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +24 -13
- package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +22 -106
- package/src/layouts/ProfileLayout/components/TwoFactorSection.tsx +1 -1
- package/src/layouts/ProfileLayout/context.tsx +2 -10
- package/src/layouts/PublicLayout/PublicLayout.tsx +18 -0
- package/src/layouts/PublicLayout/components/NavActions.tsx +50 -0
- package/src/layouts/PublicLayout/components/NavBrand.tsx +26 -0
- package/src/layouts/PublicLayout/components/NavDesktopItems.tsx +207 -0
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +44 -6
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +199 -396
- package/src/layouts/PublicLayout/hooks/index.ts +5 -1
- package/src/layouts/PublicLayout/hooks/useDropdownMenu.ts +58 -0
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +61 -0
- package/src/layouts/PublicLayout/hooks/useNavbarViewportVars.ts +46 -0
- package/src/layouts/PublicLayout/index.ts +4 -0
- package/src/layouts/PublicLayout/navbarTypes.ts +17 -0
- package/src/utils/pathMatcher.ts +6 -3
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public Layout Navigation
|
|
2
|
+
* Public Layout Navigation — orchestrator.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
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
|
|
111
|
+
const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
|
|
89
112
|
const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
361
|
-
const isMoreMenuOpen = openDropdownKey === '__overflow-more';
|
|
362
|
-
const moreMenuButtonActive =
|
|
363
|
-
isMoreMenuOpen || desktopOverflowNavigation.some((navItem) => isGroupActive(navItem));
|
|
227
|
+
const h = heightCls[navbarHeight];
|
|
364
228
|
|
|
365
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|