@djangocfg/layouts 2.1.256 → 2.1.259
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 +101 -203
- package/package.json +18 -18
- package/src/index.ts +4 -1
- package/src/layouts/AppLayout/AppLayout.tsx +97 -8
- package/src/layouts/AppLayout/BaseApp.tsx +2 -0
- package/src/layouts/AppLayout/index.ts +6 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +3 -1
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +15 -4
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +3 -3
- package/src/layouts/PublicLayout/PublicLayout.tsx +82 -17
- package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +17 -24
- package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +79 -95
- package/src/layouts/PublicLayout/components/PublicFooter/index.ts +2 -0
- package/src/layouts/PublicLayout/components/PublicFooter/types.ts +41 -31
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +84 -40
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +22 -35
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +184 -98
- package/src/layouts/PublicLayout/components/ThemeBrandMark.tsx +83 -0
- package/src/layouts/PublicLayout/components/index.ts +2 -0
- package/src/layouts/PublicLayout/context.tsx +5 -0
- package/src/layouts/PublicLayout/hooks/index.ts +1 -1
- package/src/layouts/PublicLayout/hooks/useMobileNavPanel.ts +55 -0
- package/src/layouts/PublicLayout/index.ts +8 -0
- package/src/layouts/PublicLayout/navbarTypes.ts +20 -0
- package/src/layouts/PublicLayout/publicShellShadow.ts +12 -0
- package/src/layouts/_components/PrivateSidebarAccount.tsx +16 -3
- package/src/layouts/_components/UserMenu.tsx +133 -30
- package/src/layouts/types/index.ts +10 -1
- package/src/layouts/types/providers.types.ts +10 -0
- package/src/layouts/types/ui.types.ts +9 -0
- package/src/theme/ThemeStyleBridge.tsx +41 -0
- package/src/theme/buildThemeStyleSheet.ts +71 -0
- package/src/theme/index.ts +16 -0
- package/src/theme/themeStyle.types.ts +89 -0
- package/src/theme/themeStylePresets.ts +202 -0
- package/src/layouts/PublicLayout/hooks/useFloatingPanel.ts +0 -61
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public Layout Navigation
|
|
3
3
|
*
|
|
4
|
-
* Navigation component for PublicLayout with mobile drawer support
|
|
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`).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
'use client';
|
|
8
11
|
|
|
9
12
|
import { ChevronDown, Menu, X } from 'lucide-react';
|
|
10
13
|
import Link from 'next/link';
|
|
11
|
-
import React, {
|
|
14
|
+
import React, {
|
|
15
|
+
type ReactNode,
|
|
16
|
+
useEffect,
|
|
17
|
+
useLayoutEffect,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from 'react';
|
|
12
22
|
|
|
13
23
|
import { useAuth } from '@djangocfg/api/auth';
|
|
14
24
|
import { useAppT } from '@djangocfg/i18n';
|
|
@@ -16,17 +26,17 @@ import {
|
|
|
16
26
|
Button,
|
|
17
27
|
} from '@djangocfg/ui-core/components';
|
|
18
28
|
import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
|
|
19
|
-
|
|
20
|
-
import { cn as _cn } from '@djangocfg/ui-core/lib';
|
|
29
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
21
30
|
import { usePathnameWithoutLocale } from '../../../hooks';
|
|
22
31
|
|
|
23
32
|
import { UserMenu } from '../../_components/UserMenu';
|
|
24
33
|
import { usePublicLayoutOptional } from '../context';
|
|
34
|
+
import { publicFloatingChromeClassName } from '../publicShellShadow';
|
|
35
|
+
import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
|
|
25
36
|
|
|
26
37
|
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
27
38
|
|
|
28
|
-
export type PublicNavbarVariant
|
|
29
|
-
export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
|
|
39
|
+
export type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
|
|
30
40
|
|
|
31
41
|
export interface PublicDesktopDropdownRenderProps {
|
|
32
42
|
item: NavigationItem;
|
|
@@ -40,12 +50,12 @@ export interface PublicDesktopDropdownRenderProps {
|
|
|
40
50
|
export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => React.ReactNode;
|
|
41
51
|
|
|
42
52
|
interface PublicNavigationProps {
|
|
43
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Brand area: any React node, or a plain string (wrapped in `<Link href={brandHref}>` as the title).
|
|
55
|
+
*/
|
|
44
56
|
brand?: ReactNode;
|
|
45
|
-
/**
|
|
57
|
+
/** Used when `brand` is a string; also for deduping a lone nav link that matches this href (desktop). @default '/' */
|
|
46
58
|
brandHref?: string;
|
|
47
|
-
logo?: string;
|
|
48
|
-
siteName?: string;
|
|
49
59
|
navigation?: NavigationItem[];
|
|
50
60
|
userMenu?: UserMenuConfig;
|
|
51
61
|
containerClassName?: string;
|
|
@@ -54,6 +64,11 @@ interface PublicNavigationProps {
|
|
|
54
64
|
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
55
65
|
/** Max visible top-level desktop items before collapsing into "More" */
|
|
56
66
|
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
|
+
rounding?: string;
|
|
57
72
|
mobileMenuOpen?: boolean;
|
|
58
73
|
onMobileMenuToggle?: () => void;
|
|
59
74
|
}
|
|
@@ -62,11 +77,10 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
62
77
|
const context = usePublicLayoutOptional();
|
|
63
78
|
const brand = props.brand;
|
|
64
79
|
const brandHref = props.brandHref ?? '/';
|
|
65
|
-
const logo = props.logo;
|
|
66
|
-
const siteName = props.siteName ?? 'App';
|
|
67
80
|
const navigation = props.navigation ?? [];
|
|
68
81
|
const userMenu = props.userMenu;
|
|
69
82
|
const containerClassName = props.containerClassName;
|
|
83
|
+
const rounding = props.rounding;
|
|
70
84
|
const navbarVariant = props.navbarVariant ?? 'floating';
|
|
71
85
|
const navbarPosition = props.navbarPosition ?? 'sticky';
|
|
72
86
|
const renderDesktopDropdown = props.renderDesktopDropdown;
|
|
@@ -83,8 +97,31 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
83
97
|
const navOuterRef = useRef<HTMLDivElement | null>(null);
|
|
84
98
|
|
|
85
99
|
const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
);
|
|
88
125
|
|
|
89
126
|
const clearOpenTimer = () => {
|
|
90
127
|
if (openTimerRef.current) {
|
|
@@ -131,6 +168,16 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
131
168
|
}
|
|
132
169
|
}, [isTabletOrBelow]);
|
|
133
170
|
|
|
171
|
+
const setNavbarSurface = context?.setNavbarSurface;
|
|
172
|
+
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
if (!setNavbarSurface) return;
|
|
175
|
+
setNavbarSurface({ variant: navbarVariant, position: navbarPosition });
|
|
176
|
+
return () => {
|
|
177
|
+
setNavbarSurface(null);
|
|
178
|
+
};
|
|
179
|
+
}, [setNavbarSurface, navbarVariant, navbarPosition]);
|
|
180
|
+
|
|
134
181
|
useEffect(() => {
|
|
135
182
|
const updateDrawerViewportVars = () => {
|
|
136
183
|
const root = document.documentElement;
|
|
@@ -175,17 +222,15 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
175
222
|
return `${positionClass} ${topClass} inset-x-0 z-50 ${insetPaddingClass}`.trim().replace(/\s+/g, ' ');
|
|
176
223
|
})();
|
|
177
224
|
|
|
178
|
-
const navInnerClassName = (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return `${base} ${visual} ${containerClassName || ''}`.trim().replace(/\s+/g, ' ');
|
|
188
|
-
})();
|
|
225
|
+
const navInnerClassName = cn(
|
|
226
|
+
'mx-auto w-full',
|
|
227
|
+
navbarVariant === 'floating'
|
|
228
|
+
? cn(rounding ?? 'rounded-2xl', publicFloatingChromeClassName)
|
|
229
|
+
: 'rounded-none border-x-0 border-t-0 border-b border-border/40 dark:border-border/70 shadow-none',
|
|
230
|
+
containerClassName,
|
|
231
|
+
// Last: guarantee shell has no stroke on light; `containerClassName` cannot reintroduce a border.
|
|
232
|
+
navbarVariant === 'floating' && '!border-0 dark:!border dark:!border-border/75',
|
|
233
|
+
);
|
|
189
234
|
|
|
190
235
|
const isActivePath = (href: string) => {
|
|
191
236
|
if (href === '/') return pathname === '/';
|
|
@@ -199,41 +244,50 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
199
244
|
};
|
|
200
245
|
|
|
201
246
|
const closeDropdown = () => setOpenDropdownKey(null);
|
|
202
|
-
|
|
203
|
-
|
|
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);
|
|
204
258
|
|
|
205
259
|
const renderDesktopNavItem = (item: NavigationItem) => {
|
|
206
260
|
if (item.items && item.items.length > 0) {
|
|
207
261
|
const dropdownKey = `${item.label}-${item.href}`;
|
|
208
262
|
const defaultItems = (
|
|
209
263
|
<>
|
|
210
|
-
{item.items.map((subItem) =>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
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
|
+
})}
|
|
231
285
|
</>
|
|
232
286
|
);
|
|
233
287
|
|
|
234
288
|
const defaultPopover = (
|
|
235
289
|
<div
|
|
236
|
-
className="absolute left-0 top-full mt-1 z-[1200] min-w-
|
|
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)]"
|
|
237
291
|
onMouseEnter={() => {
|
|
238
292
|
clearOpenTimer();
|
|
239
293
|
clearCloseTimer();
|
|
@@ -256,12 +310,23 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
256
310
|
<Button
|
|
257
311
|
variant="ghost"
|
|
258
312
|
size="sm"
|
|
259
|
-
className={
|
|
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
|
+
)}
|
|
260
320
|
>
|
|
261
|
-
<span>{item.label}</span>
|
|
262
|
-
<
|
|
263
|
-
className=
|
|
264
|
-
|
|
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>
|
|
265
330
|
</Button>
|
|
266
331
|
|
|
267
332
|
{isOpen && (
|
|
@@ -280,41 +345,54 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
280
345
|
);
|
|
281
346
|
}
|
|
282
347
|
|
|
348
|
+
const linkActive = isActivePath(item.href);
|
|
283
349
|
return (
|
|
284
350
|
<Link
|
|
285
351
|
key={item.href}
|
|
286
352
|
href={item.href}
|
|
287
|
-
className={
|
|
353
|
+
className={cn(desktopNavItemClass, linkActive && desktopNavItemActiveClass)}
|
|
288
354
|
>
|
|
289
|
-
{item.label}
|
|
355
|
+
<span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
|
|
290
356
|
</Link>
|
|
291
357
|
);
|
|
292
358
|
};
|
|
293
359
|
|
|
360
|
+
const hasDesktopOverflowNav = desktopOverflowNavigation.length > 0;
|
|
361
|
+
const isMoreMenuOpen = openDropdownKey === '__overflow-more';
|
|
362
|
+
const moreMenuButtonActive =
|
|
363
|
+
isMoreMenuOpen || desktopOverflowNavigation.some((navItem) => isGroupActive(navItem));
|
|
364
|
+
|
|
294
365
|
return (
|
|
295
366
|
<div ref={navOuterRef} className={navOuterClassName}>
|
|
296
367
|
<nav
|
|
297
|
-
className={
|
|
298
|
-
|
|
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
|
+
)}
|
|
299
373
|
>
|
|
300
|
-
<div className="w-full
|
|
374
|
+
<div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
|
|
301
375
|
<div className="flex items-center justify-between py-3.5">
|
|
302
|
-
{/*
|
|
303
|
-
|
|
304
|
-
brand
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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>
|
|
313
391
|
|
|
314
392
|
{/* Desktop Navigation */}
|
|
315
|
-
<div className="hidden lg:flex items-center gap-
|
|
393
|
+
<div className="hidden isolate lg:flex items-center gap-1">
|
|
316
394
|
{desktopPrimaryNavigation.map(renderDesktopNavItem)}
|
|
317
|
-
{
|
|
395
|
+
{hasDesktopOverflowNav && (
|
|
318
396
|
<div
|
|
319
397
|
className="relative"
|
|
320
398
|
onMouseEnter={() => scheduleOpen('__overflow-more')}
|
|
@@ -323,41 +401,48 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
323
401
|
<Button
|
|
324
402
|
variant="ghost"
|
|
325
403
|
size="sm"
|
|
326
|
-
className={
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
)}
|
|
331
410
|
>
|
|
332
|
-
<span>More</span>
|
|
333
|
-
<
|
|
334
|
-
className=
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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>
|
|
338
422
|
</Button>
|
|
339
423
|
|
|
340
|
-
{
|
|
424
|
+
{isMoreMenuOpen && (
|
|
341
425
|
<div
|
|
342
|
-
className="absolute right-0 top-full mt-1 z-[1200] min-w-
|
|
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)]"
|
|
343
427
|
onMouseEnter={() => {
|
|
344
428
|
clearOpenTimer();
|
|
345
429
|
clearCloseTimer();
|
|
346
430
|
}}
|
|
347
431
|
onMouseLeave={() => scheduleClose('__overflow-more')}
|
|
348
432
|
>
|
|
349
|
-
{desktopOverflowNavigation.map((
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
})}
|
|
361
446
|
</div>
|
|
362
447
|
)}
|
|
363
448
|
</div>
|
|
@@ -373,6 +458,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
373
458
|
variant="desktop"
|
|
374
459
|
groups={userMenu?.groups}
|
|
375
460
|
authPath={userMenu?.authPath}
|
|
461
|
+
i18n={userMenu?.i18n}
|
|
376
462
|
/>
|
|
377
463
|
</>
|
|
378
464
|
</div>
|
|
@@ -383,7 +469,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
383
469
|
size="icon"
|
|
384
470
|
aria-label={toggleMobileLabel}
|
|
385
471
|
data-mobile-menu-trigger="true"
|
|
386
|
-
className="lg:hidden"
|
|
472
|
+
className="lg:hidden rounded-full"
|
|
387
473
|
onClick={toggleMobileMenu}
|
|
388
474
|
>
|
|
389
475
|
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-aware brand mark for public chrome (navbar, footer).
|
|
3
|
+
*
|
|
4
|
+
* Uses two layers and Tailwind `dark:` so the correct asset is chosen with **no JS theme hook**
|
|
5
|
+
* and **no hydration mismatch** — the same thing `next-themes` does for `html.dark` / class strategy.
|
|
6
|
+
*
|
|
7
|
+
* Prefer this over `useTheme().resolvedTheme` + one `<img>` unless you have a strong reason
|
|
8
|
+
* (single-DOM node only).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use client';
|
|
12
|
+
|
|
13
|
+
import React, { type ReactNode } from 'react';
|
|
14
|
+
|
|
15
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
16
|
+
|
|
17
|
+
export interface ThemeBrandMarkProps {
|
|
18
|
+
/**
|
|
19
|
+
* Mark when the app is in **light** appearance (`html` without `.dark`).
|
|
20
|
+
* Usually a dark-colored logo on a light bar.
|
|
21
|
+
*/
|
|
22
|
+
light: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Mark when the app is in **dark** appearance (`html.dark`).
|
|
25
|
+
* Usually a light-colored logo on a dark bar.
|
|
26
|
+
*/
|
|
27
|
+
dark: ReactNode;
|
|
28
|
+
/** Outer wrapper — keep sizing here (e.g. `h-5.5 w-auto`). */
|
|
29
|
+
className?: string;
|
|
30
|
+
/** `role="img"` label when children are decorative. */
|
|
31
|
+
'aria-label'?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Renders two branches; only one is visible. Same idea as pairing
|
|
36
|
+
* `className="dark:hidden"` / `className="hidden dark:block"` on `<img>`, but works with
|
|
37
|
+
* any node (SVG component, picture, etc.).
|
|
38
|
+
*/
|
|
39
|
+
export function ThemeBrandMark({ light, dark, className, 'aria-label': ariaLabel }: ThemeBrandMarkProps) {
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
className={cn('inline-flex shrink-0 items-center justify-center', className)}
|
|
43
|
+
role={ariaLabel ? 'img' : undefined}
|
|
44
|
+
aria-label={ariaLabel}
|
|
45
|
+
>
|
|
46
|
+
<span className="flex items-center justify-center dark:hidden">{light}</span>
|
|
47
|
+
<span className="hidden items-center justify-center dark:flex">{dark}</span>
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ThemeBrandMarkImgProps {
|
|
53
|
+
/** Logo URL for light UI (no `html.dark`). */
|
|
54
|
+
srcLight: string;
|
|
55
|
+
/** Logo URL for dark UI (`html.dark`). */
|
|
56
|
+
srcDark: string;
|
|
57
|
+
alt?: string;
|
|
58
|
+
/** Applied to both images (e.g. `h-5.5 w-auto object-contain`). */
|
|
59
|
+
className?: string;
|
|
60
|
+
wrapperClassName?: string;
|
|
61
|
+
'aria-label'?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convenience wrapper around {@link ThemeBrandMark} for the common “two raster/SVG URLs” case.
|
|
66
|
+
*/
|
|
67
|
+
export function ThemeBrandMarkImg({
|
|
68
|
+
srcLight,
|
|
69
|
+
srcDark,
|
|
70
|
+
alt = '',
|
|
71
|
+
className,
|
|
72
|
+
wrapperClassName,
|
|
73
|
+
'aria-label': ariaLabel,
|
|
74
|
+
}: ThemeBrandMarkImgProps) {
|
|
75
|
+
return (
|
|
76
|
+
<ThemeBrandMark
|
|
77
|
+
className={wrapperClassName}
|
|
78
|
+
aria-label={ariaLabel}
|
|
79
|
+
light={<img src={srcLight} alt={alt} className={className} />}
|
|
80
|
+
dark={<img src={srcDark} alt={alt} className={className} />}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -6,4 +6,6 @@ export { PublicNavigation } from './PublicNavigation';
|
|
|
6
6
|
export { PublicMobileDrawer } from './PublicMobileDrawer';
|
|
7
7
|
export { PublicNavbar } from './PublicNavbar';
|
|
8
8
|
export { PublicFooter } from './PublicFooter';
|
|
9
|
+
export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
|
|
10
|
+
export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
|
|
9
11
|
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useContext } from 'react';
|
|
4
4
|
|
|
5
|
+
import type { PublicNavbarSurface } from './navbarTypes';
|
|
6
|
+
|
|
5
7
|
export interface PublicLayoutContextValue {
|
|
6
8
|
mobileMenuOpen: boolean;
|
|
7
9
|
toggleMobileMenu: () => void;
|
|
8
10
|
closeMobileMenu: () => void;
|
|
11
|
+
/** Filled by `<PublicNavigation />` — drives default `main` top spacing. */
|
|
12
|
+
navbarSurface: PublicNavbarSurface | null;
|
|
13
|
+
setNavbarSurface: (surface: PublicNavbarSurface | null) => void;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
const PublicLayoutContext = createContext<PublicLayoutContextValue | null>(null);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { useMobileNavPanel } from './useMobileNavPanel';
|
|
2
2
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Slightly above `transition-[transform,opacity] duration-[220ms]` in PublicMobileDrawer */
|
|
6
|
+
const CLOSE_UNMOUNT_MS = 230;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keeps the mobile nav panel mounted briefly after `isOpen` becomes false so CSS can run the close transition,
|
|
10
|
+
* then unmounts — no reliance on `transitionend` (fragile with SPA navigation / Safari).
|
|
11
|
+
*/
|
|
12
|
+
export function useMobileNavPanel(options: { isOpen: boolean; onClose: () => void }) {
|
|
13
|
+
const { isOpen, onClose } = options;
|
|
14
|
+
const [mounted, setMounted] = useState(isOpen);
|
|
15
|
+
const [visible, setVisible] = useState(isOpen);
|
|
16
|
+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (isOpen) {
|
|
20
|
+
if (closeTimerRef.current) {
|
|
21
|
+
clearTimeout(closeTimerRef.current);
|
|
22
|
+
closeTimerRef.current = null;
|
|
23
|
+
}
|
|
24
|
+
setMounted(true);
|
|
25
|
+
const id = requestAnimationFrame(() => {
|
|
26
|
+
requestAnimationFrame(() => setVisible(true));
|
|
27
|
+
});
|
|
28
|
+
return () => cancelAnimationFrame(id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setVisible(false);
|
|
32
|
+
closeTimerRef.current = setTimeout(() => {
|
|
33
|
+
setMounted((m) => (m ? false : m));
|
|
34
|
+
closeTimerRef.current = null;
|
|
35
|
+
}, CLOSE_UNMOUNT_MS);
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
if (closeTimerRef.current) {
|
|
39
|
+
clearTimeout(closeTimerRef.current);
|
|
40
|
+
closeTimerRef.current = null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}, [isOpen]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isOpen) return;
|
|
47
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
48
|
+
if (event.key === 'Escape') onClose();
|
|
49
|
+
};
|
|
50
|
+
window.addEventListener('keydown', onKeyDown);
|
|
51
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
52
|
+
}, [isOpen, onClose]);
|
|
53
|
+
|
|
54
|
+
return { mounted, visible };
|
|
55
|
+
}
|
|
@@ -6,8 +6,12 @@ export { PublicLayout } from './PublicLayout';
|
|
|
6
6
|
export type { PublicLayoutProps } from './PublicLayout';
|
|
7
7
|
export { PublicNavigation, PublicMobileDrawer, PublicNavbar } from './components';
|
|
8
8
|
export type {
|
|
9
|
+
PublicNavbarSurface,
|
|
9
10
|
PublicNavbarVariant,
|
|
10
11
|
PublicNavbarPosition,
|
|
12
|
+
PublicNavbarShellConfig,
|
|
13
|
+
} from './navbarTypes';
|
|
14
|
+
export type {
|
|
11
15
|
PublicDesktopDropdownRenderer,
|
|
12
16
|
PublicDesktopDropdownRenderProps,
|
|
13
17
|
} from './components/PublicNavigation';
|
|
@@ -20,8 +24,12 @@ export {
|
|
|
20
24
|
FooterSocialLinksComponent,
|
|
21
25
|
DjangoCFGLogo,
|
|
22
26
|
} from './components/PublicFooter';
|
|
27
|
+
export { ThemeBrandMark, ThemeBrandMarkImg } from './components/ThemeBrandMark';
|
|
28
|
+
export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './components/ThemeBrandMark';
|
|
23
29
|
export type {
|
|
24
30
|
PublicFooterProps,
|
|
31
|
+
PublicFooterConfig,
|
|
32
|
+
FooterProjectInfoProps,
|
|
25
33
|
} from './components/PublicFooter';
|
|
26
34
|
export { PublicLayoutProvider, usePublicLayout } from './context';
|
|
27
35
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared navbar surface types — used by PublicNavigation (registration) and PublicLayout (main offset).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type PublicNavbarVariant = 'floating' | 'flush';
|
|
6
|
+
|
|
7
|
+
export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
|
|
8
|
+
|
|
9
|
+
export interface PublicNavbarSurface {
|
|
10
|
+
variant: PublicNavbarVariant;
|
|
11
|
+
position: PublicNavbarPosition;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Floating bar + mobile drawer shell (rounding, width / centering). */
|
|
15
|
+
export interface PublicNavbarShellConfig {
|
|
16
|
+
/** Tailwind rounding class (e.g. `rounded-3xl`). */
|
|
17
|
+
rounding?: string;
|
|
18
|
+
/** Strip + drawer wrapper (e.g. `mx-auto max-w-7xl`). */
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|