@djangocfg/layouts 2.1.275 → 2.1.277
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 +52 -180
- package/package.json +18 -18
- package/src/layouts/AppLayout/AppLayout.tsx +14 -14
- package/src/layouts/PublicLayout/README.md +144 -0
- package/src/layouts/PublicLayout/{components/PublicFooter/PublicFooter.tsx → footers/DefaultFooter/DefaultFooter.tsx} +21 -15
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/DjangoCFGLogo.tsx +0 -6
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterBottom.tsx +3 -7
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterMenuSections.tsx +4 -7
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterProjectInfo.tsx +0 -4
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterSocialLinks.tsx +0 -5
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/index.ts +2 -12
- package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/types.ts +21 -26
- package/src/layouts/PublicLayout/footers/index.ts +1 -0
- package/src/layouts/PublicLayout/hooks/index.ts +1 -0
- package/src/layouts/PublicLayout/hooks/useResponsiveOverflow.ts +140 -0
- package/src/layouts/PublicLayout/index.ts +38 -20
- package/src/layouts/PublicLayout/navbarTypes.ts +27 -4
- package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingMobileDrawer.tsx +29 -0
- package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingNavbar.tsx +127 -0
- package/src/layouts/PublicLayout/navbars/FloatingNavbar/index.ts +3 -0
- package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushMobileDrawer.tsx +19 -0
- package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushNavbar.tsx +122 -0
- package/src/layouts/PublicLayout/navbars/FlushNavbar/index.ts +3 -0
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalMobileDrawer.tsx +19 -0
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +180 -0
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +3 -0
- package/src/layouts/PublicLayout/navbars/index.ts +3 -0
- package/src/layouts/PublicLayout/primitives/ExternalPrefixesContext.tsx +69 -0
- package/src/layouts/PublicLayout/primitives/NavActionItem.tsx +95 -0
- package/src/layouts/PublicLayout/{components → primitives}/NavActions.tsx +26 -1
- package/src/layouts/PublicLayout/{components → primitives}/NavBrand.tsx +4 -3
- package/src/layouts/PublicLayout/{components → primitives}/NavDesktopItems.tsx +105 -61
- package/src/layouts/PublicLayout/primitives/SmartNavLink.tsx +81 -0
- package/src/layouts/PublicLayout/{components → primitives}/ThemeBrandMark.tsx +0 -8
- package/src/layouts/PublicLayout/primitives/index.ts +18 -0
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +205 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +295 -0
- package/src/layouts/PublicLayout/shared/index.ts +4 -0
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +0 -211
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +0 -99
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +0 -287
- package/src/layouts/PublicLayout/components/index.ts +0 -11
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Menu, X } from 'lucide-react';
|
|
4
|
-
import React from 'react';
|
|
4
|
+
import React, { type ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
import { Button } from '@djangocfg/ui-core/components';
|
|
7
7
|
|
|
8
8
|
import { UserMenu } from '../../_components/UserMenu';
|
|
9
9
|
import type { UserMenuConfig } from '../../types';
|
|
10
10
|
|
|
11
|
+
import { NavActionItem, type NavAction } from './NavActionItem';
|
|
12
|
+
|
|
11
13
|
interface NavActionsProps {
|
|
12
14
|
userMenu?: UserMenuConfig;
|
|
13
15
|
mobileMenuOpen: boolean;
|
|
@@ -15,6 +17,12 @@ interface NavActionsProps {
|
|
|
15
17
|
toggleMobileLabel: string;
|
|
16
18
|
/** When true, mobile trigger is always visible (not hidden on lg+). Used for `split` layout. */
|
|
17
19
|
forceShowMobileTrigger?: boolean;
|
|
20
|
+
/** Typed CTA pills rendered before UserMenu (desktop only). */
|
|
21
|
+
actions?: NavAction[];
|
|
22
|
+
/** Arbitrary slot rendered between actions and UserMenu (desktop only). */
|
|
23
|
+
leadingSlot?: ReactNode;
|
|
24
|
+
/** Arbitrary slot rendered after the mobile toggle (desktop + mobile). */
|
|
25
|
+
trailingSlot?: ReactNode;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export function NavActions({
|
|
@@ -23,9 +31,24 @@ export function NavActions({
|
|
|
23
31
|
onMobileMenuToggle,
|
|
24
32
|
toggleMobileLabel,
|
|
25
33
|
forceShowMobileTrigger = false,
|
|
34
|
+
actions,
|
|
35
|
+
leadingSlot,
|
|
36
|
+
trailingSlot,
|
|
26
37
|
}: NavActionsProps) {
|
|
38
|
+
const hasActions = actions && actions.length > 0;
|
|
39
|
+
|
|
27
40
|
return (
|
|
28
41
|
<div className="flex items-center gap-4">
|
|
42
|
+
{hasActions && (
|
|
43
|
+
<div className="hidden lg:flex shrink-0 items-center gap-1.5">
|
|
44
|
+
{actions!.map((a) => (
|
|
45
|
+
<NavActionItem key={`${a.label}-${a.href}`} action={a} />
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{leadingSlot && <div className="hidden lg:flex shrink-0 items-center">{leadingSlot}</div>}
|
|
51
|
+
|
|
29
52
|
<div className="hidden lg:flex">
|
|
30
53
|
<UserMenu
|
|
31
54
|
variant="desktop"
|
|
@@ -45,6 +68,8 @@ export function NavActions({
|
|
|
45
68
|
>
|
|
46
69
|
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
47
70
|
</Button>
|
|
71
|
+
|
|
72
|
+
{trailingSlot && <div className="shrink-0 flex items-center">{trailingSlot}</div>}
|
|
48
73
|
</div>
|
|
49
74
|
);
|
|
50
75
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import Link from 'next/link';
|
|
4
3
|
import React, { type ReactNode } from 'react';
|
|
5
4
|
|
|
5
|
+
import { SmartNavLink } from './SmartNavLink';
|
|
6
|
+
|
|
6
7
|
interface NavBrandProps {
|
|
7
8
|
brand?: ReactNode;
|
|
8
9
|
brandHref?: string;
|
|
@@ -13,12 +14,12 @@ export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
|
|
|
13
14
|
|
|
14
15
|
if (typeof brand === 'string') {
|
|
15
16
|
return (
|
|
16
|
-
<
|
|
17
|
+
<SmartNavLink
|
|
17
18
|
href={brandHref}
|
|
18
19
|
className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
|
|
19
20
|
>
|
|
20
21
|
{brand}
|
|
21
|
-
</
|
|
22
|
+
</SmartNavLink>
|
|
22
23
|
);
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { ChevronDown } from 'lucide-react';
|
|
4
|
-
import Link from 'next/link';
|
|
5
4
|
import React from 'react';
|
|
6
5
|
|
|
7
6
|
import { Button } from '@djangocfg/ui-core/components';
|
|
@@ -9,11 +8,15 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
9
8
|
|
|
10
9
|
import type { NavigationItem } from '../../types';
|
|
11
10
|
import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
|
|
12
|
-
import
|
|
11
|
+
import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
|
|
12
|
+
import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
|
|
13
|
+
import { SmartNavLink } from './SmartNavLink';
|
|
13
14
|
|
|
14
15
|
interface NavDesktopItemsProps {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
/** Full ordered item list; overflow is computed responsively. */
|
|
17
|
+
items: NavigationItem[];
|
|
18
|
+
/** Optional hard cap — measurer will never show more than this. */
|
|
19
|
+
maxVisible?: number;
|
|
17
20
|
isActivePath: (href: string) => boolean;
|
|
18
21
|
isGroupActive: (item: NavigationItem) => boolean;
|
|
19
22
|
dropdown: UseDropdownMenuReturn;
|
|
@@ -29,7 +32,7 @@ const navItemCls = cn(
|
|
|
29
32
|
);
|
|
30
33
|
|
|
31
34
|
const navItemActiveCls =
|
|
32
|
-
'border-0 bg-accent font-semibold text-foreground shadow-sm dark:
|
|
35
|
+
'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted dark:shadow-none';
|
|
33
36
|
|
|
34
37
|
const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
|
|
35
38
|
|
|
@@ -38,7 +41,7 @@ function subMenuLinkCls(active: boolean) {
|
|
|
38
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',
|
|
39
42
|
'hover:bg-accent/55',
|
|
40
43
|
active
|
|
41
|
-
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:
|
|
44
|
+
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted/90 dark:shadow-none'
|
|
42
45
|
: 'border-0 text-foreground/90',
|
|
43
46
|
);
|
|
44
47
|
}
|
|
@@ -47,8 +50,8 @@ const popoverCls =
|
|
|
47
50
|
'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)]';
|
|
48
51
|
|
|
49
52
|
export function NavDesktopItems({
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
items,
|
|
54
|
+
maxVisible,
|
|
52
55
|
isActivePath,
|
|
53
56
|
isGroupActive,
|
|
54
57
|
dropdown,
|
|
@@ -56,6 +59,16 @@ export function NavDesktopItems({
|
|
|
56
59
|
}: NavDesktopItemsProps) {
|
|
57
60
|
const { openDropdownKey, scheduleOpen, scheduleClose, closeDropdown } = dropdown;
|
|
58
61
|
|
|
62
|
+
const { visibleCount, containerRef, itemRef, measured } = useResponsiveOverflow({
|
|
63
|
+
total: items.length,
|
|
64
|
+
moreWidth: 88,
|
|
65
|
+
gap: 4,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const effectiveCount = Math.min(visibleCount, maxVisible ?? visibleCount);
|
|
69
|
+
const primaryItems = items.slice(0, effectiveCount);
|
|
70
|
+
const overflowItems = items.slice(effectiveCount);
|
|
71
|
+
|
|
59
72
|
const renderItem = (item: NavigationItem) => {
|
|
60
73
|
if (item.items && item.items.length > 0) {
|
|
61
74
|
const key = `${item.label}-${item.href}`;
|
|
@@ -78,9 +91,9 @@ export function NavDesktopItems({
|
|
|
78
91
|
<span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
|
|
79
92
|
</a>
|
|
80
93
|
) : (
|
|
81
|
-
<
|
|
94
|
+
<SmartNavLink href={sub.href} className={subMenuLinkCls(subActive)}>
|
|
82
95
|
<span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
|
|
83
|
-
</
|
|
96
|
+
</SmartNavLink>
|
|
84
97
|
)}
|
|
85
98
|
</div>
|
|
86
99
|
);
|
|
@@ -112,7 +125,6 @@ export function NavDesktopItems({
|
|
|
112
125
|
'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
|
|
113
126
|
navItemCls,
|
|
114
127
|
(isOpen || isActive) && navItemActiveCls,
|
|
115
|
-
isOpen && 'border-0 dark:border dark:border-border',
|
|
116
128
|
)}
|
|
117
129
|
>
|
|
118
130
|
<span className={labelCls} title={item.label}>{item.label}</span>
|
|
@@ -137,13 +149,13 @@ export function NavDesktopItems({
|
|
|
137
149
|
|
|
138
150
|
const active = isActivePath(item.href);
|
|
139
151
|
return (
|
|
140
|
-
<
|
|
152
|
+
<SmartNavLink
|
|
141
153
|
key={item.href}
|
|
142
154
|
href={item.href}
|
|
143
155
|
className={cn(navItemCls, active && navItemActiveCls)}
|
|
144
156
|
>
|
|
145
157
|
<span className={labelCls} title={item.label}>{item.label}</span>
|
|
146
|
-
</
|
|
158
|
+
</SmartNavLink>
|
|
147
159
|
);
|
|
148
160
|
};
|
|
149
161
|
|
|
@@ -152,56 +164,88 @@ export function NavDesktopItems({
|
|
|
152
164
|
const moreActive = isMoreOpen || overflowItems.some((i) => isGroupActive(i));
|
|
153
165
|
|
|
154
166
|
return (
|
|
155
|
-
|
|
156
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
moreActive && navItemActiveCls,
|
|
171
|
-
isMoreOpen && 'border-0 dark:border dark:border-border',
|
|
172
|
-
)}
|
|
167
|
+
<div
|
|
168
|
+
ref={containerRef as React.Ref<HTMLDivElement>}
|
|
169
|
+
className="relative flex min-w-0 flex-1 items-center justify-center gap-1"
|
|
170
|
+
>
|
|
171
|
+
{/* Measurement layer: invisible, absolutely positioned, holds width-only copies. */}
|
|
172
|
+
<div
|
|
173
|
+
aria-hidden
|
|
174
|
+
className="pointer-events-none absolute inset-y-0 left-0 flex items-center gap-1 opacity-0"
|
|
175
|
+
style={{ visibility: 'hidden', whiteSpace: 'nowrap' }}
|
|
176
|
+
>
|
|
177
|
+
{items.map((item, i) => (
|
|
178
|
+
<span
|
|
179
|
+
key={`m-${item.label}-${item.href}`}
|
|
180
|
+
ref={itemRef(i) as React.Ref<HTMLSpanElement>}
|
|
181
|
+
className={cn(navItemCls, 'pointer-events-none')}
|
|
173
182
|
>
|
|
174
|
-
<span className={labelCls}>
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
className=
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
<span className={labelCls}>{item.label}</span>
|
|
184
|
+
{item.items && item.items.length > 0 && (
|
|
185
|
+
<span className="inline-flex size-3.5 shrink-0 items-center justify-center">
|
|
186
|
+
<ChevronDown className="size-3.5" />
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</span>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Live row — only renders items that fit (hidden until first measurement). */}
|
|
194
|
+
<div
|
|
195
|
+
className={cn(
|
|
196
|
+
'flex min-w-0 items-center gap-1',
|
|
197
|
+
!measured && 'invisible',
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
{primaryItems.map(renderItem)}
|
|
184
201
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
202
|
+
{hasOverflow && (
|
|
203
|
+
<div
|
|
204
|
+
className="relative"
|
|
205
|
+
onMouseEnter={() => scheduleOpen('__overflow-more')}
|
|
206
|
+
onMouseLeave={() => scheduleClose('__overflow-more')}
|
|
207
|
+
>
|
|
208
|
+
<Button
|
|
209
|
+
variant="ghost"
|
|
210
|
+
size="sm"
|
|
211
|
+
className={cn(
|
|
212
|
+
'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
|
|
213
|
+
navItemCls,
|
|
214
|
+
moreActive && navItemActiveCls,
|
|
215
|
+
)}
|
|
190
216
|
>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
217
|
+
<span className={labelCls}>More</span>
|
|
218
|
+
<span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
|
|
219
|
+
<ChevronDown
|
|
220
|
+
className={cn(
|
|
221
|
+
'size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform',
|
|
222
|
+
isMoreOpen && 'rotate-180',
|
|
223
|
+
)}
|
|
224
|
+
/>
|
|
225
|
+
</span>
|
|
226
|
+
</Button>
|
|
227
|
+
|
|
228
|
+
{isMoreOpen && (
|
|
229
|
+
<div
|
|
230
|
+
className={cn(popoverCls, 'left-auto right-0')}
|
|
231
|
+
onMouseEnter={() => { scheduleOpen('__overflow-more'); }}
|
|
232
|
+
onMouseLeave={() => scheduleClose('__overflow-more')}
|
|
233
|
+
>
|
|
234
|
+
{overflowItems.map((item) => {
|
|
235
|
+
const active = isGroupActive(item);
|
|
236
|
+
return (
|
|
237
|
+
<div key={`overflow-${item.href}`} className="rounded-full">
|
|
238
|
+
<SmartNavLink href={item.href} className={subMenuLinkCls(active)}>
|
|
239
|
+
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
|
240
|
+
</SmartNavLink>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
})}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
206
250
|
);
|
|
207
251
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SmartNavLink
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for `next/link` used inside PublicLayout navbars/footers.
|
|
5
|
+
*
|
|
6
|
+
* If the `href` is a string that starts with one of the prefixes configured
|
|
7
|
+
* via `ExternalPrefixesProvider`, renders a plain `<a>` (full page navigation).
|
|
8
|
+
* This is required for routes owned by a catch-all handler outside App Router
|
|
9
|
+
* (e.g. Nextra `/docs/*`), where `next/link` client navigation asks for an
|
|
10
|
+
* RSC payload the route cannot produce — resulting in a hard error banner.
|
|
11
|
+
*
|
|
12
|
+
* Otherwise falls back to `next/link` so existing behaviour is preserved.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use client';
|
|
16
|
+
|
|
17
|
+
import Link, { type LinkProps } from 'next/link';
|
|
18
|
+
import React, { forwardRef, type AnchorHTMLAttributes, type ReactNode } from 'react';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
isExternalPrefixHref,
|
|
22
|
+
useExternalPrefixes,
|
|
23
|
+
type ExternalPrefixes,
|
|
24
|
+
} from './ExternalPrefixesContext';
|
|
25
|
+
|
|
26
|
+
type AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
|
|
27
|
+
|
|
28
|
+
export interface SmartNavLinkProps
|
|
29
|
+
extends Omit<LinkProps, 'href' | 'passHref' | 'legacyBehavior'>,
|
|
30
|
+
AnchorProps {
|
|
31
|
+
href: LinkProps['href'];
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
/** Optional override — falls back to context value. */
|
|
34
|
+
externalPrefixes?: ExternalPrefixes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const SmartNavLink = forwardRef<HTMLAnchorElement, SmartNavLinkProps>(
|
|
38
|
+
function SmartNavLink(props, ref) {
|
|
39
|
+
const {
|
|
40
|
+
href,
|
|
41
|
+
children,
|
|
42
|
+
externalPrefixes,
|
|
43
|
+
// LinkProps-specific fields we should NOT forward to a plain <a>.
|
|
44
|
+
prefetch,
|
|
45
|
+
replace,
|
|
46
|
+
scroll,
|
|
47
|
+
shallow,
|
|
48
|
+
locale,
|
|
49
|
+
// Rest are anchor-compatible attributes.
|
|
50
|
+
...anchorProps
|
|
51
|
+
} = props;
|
|
52
|
+
|
|
53
|
+
const contextPrefixes = useExternalPrefixes();
|
|
54
|
+
const prefixes = externalPrefixes ?? contextPrefixes;
|
|
55
|
+
|
|
56
|
+
const shouldUseAnchor = isExternalPrefixHref(href, prefixes);
|
|
57
|
+
|
|
58
|
+
if (shouldUseAnchor) {
|
|
59
|
+
return (
|
|
60
|
+
<a ref={ref} href={href} {...anchorProps}>
|
|
61
|
+
{children}
|
|
62
|
+
</a>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Link
|
|
68
|
+
ref={ref}
|
|
69
|
+
href={href}
|
|
70
|
+
prefetch={prefetch}
|
|
71
|
+
replace={replace}
|
|
72
|
+
scroll={scroll}
|
|
73
|
+
shallow={shallow}
|
|
74
|
+
locale={locale}
|
|
75
|
+
{...anchorProps}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</Link>
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
@@ -31,11 +31,6 @@ export interface ThemeBrandMarkProps {
|
|
|
31
31
|
'aria-label'?: string;
|
|
32
32
|
}
|
|
33
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
34
|
export function ThemeBrandMark({ light, dark, className, 'aria-label': ariaLabel }: ThemeBrandMarkProps) {
|
|
40
35
|
return (
|
|
41
36
|
<span
|
|
@@ -61,9 +56,6 @@ export interface ThemeBrandMarkImgProps {
|
|
|
61
56
|
'aria-label'?: string;
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
/**
|
|
65
|
-
* Convenience wrapper around {@link ThemeBrandMark} for the common “two raster/SVG URLs” case.
|
|
66
|
-
*/
|
|
67
59
|
export function ThemeBrandMarkImg({
|
|
68
60
|
srcLight,
|
|
69
61
|
srcDark,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { NavBrand } from './NavBrand';
|
|
2
|
+
export { NavActions } from './NavActions';
|
|
3
|
+
export { NavActionItem } from './NavActionItem';
|
|
4
|
+
export type { NavAction } from './NavActionItem';
|
|
5
|
+
export { NavDesktopItems } from './NavDesktopItems';
|
|
6
|
+
export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
|
|
7
|
+
export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
|
|
8
|
+
export {
|
|
9
|
+
ExternalPrefixesProvider,
|
|
10
|
+
useExternalPrefixes,
|
|
11
|
+
isExternalPrefixHref,
|
|
12
|
+
} from './ExternalPrefixesContext';
|
|
13
|
+
export type {
|
|
14
|
+
ExternalPrefixes,
|
|
15
|
+
ExternalPrefixesProviderProps,
|
|
16
|
+
} from './ExternalPrefixesContext';
|
|
17
|
+
export { SmartNavLink } from './SmartNavLink';
|
|
18
|
+
export type { SmartNavLinkProps } from './SmartNavLink';
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MobileDrawerShell — shared drawer body used by every navbar variant.
|
|
3
|
+
*
|
|
4
|
+
* Variants differ only in `panelClassName` (rounded/flush, shadow, border) and
|
|
5
|
+
* optional outer container class.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import { ArrowRight } from 'lucide-react';
|
|
11
|
+
import React, { useMemo } from 'react';
|
|
12
|
+
|
|
13
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
14
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
15
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
16
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
17
|
+
|
|
18
|
+
import { usePathnameWithoutLocale } from '../../../hooks';
|
|
19
|
+
import { UserMenu } from '../../_components/UserMenu';
|
|
20
|
+
import { usePublicLayoutOptional } from '../context';
|
|
21
|
+
import { useMobileNavPanel } from '../hooks';
|
|
22
|
+
import { SmartNavLink } from '../primitives/SmartNavLink';
|
|
23
|
+
|
|
24
|
+
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
25
|
+
|
|
26
|
+
export interface MobileDrawerShellProps {
|
|
27
|
+
isOpen?: boolean;
|
|
28
|
+
onClose?: () => void;
|
|
29
|
+
navigation?: NavigationItem[];
|
|
30
|
+
userMenu?: UserMenuConfig;
|
|
31
|
+
/** Wrapper around the panel — controls horizontal padding / max width. */
|
|
32
|
+
outerClassName?: string;
|
|
33
|
+
/** Panel surface (bg, border, rounding, shadow). */
|
|
34
|
+
panelClassName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function MobileDrawerShell(props: MobileDrawerShellProps) {
|
|
38
|
+
const context = usePublicLayoutOptional();
|
|
39
|
+
const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
|
|
40
|
+
const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
|
|
41
|
+
const navigation = props.navigation ?? [];
|
|
42
|
+
const userMenu = props.userMenu;
|
|
43
|
+
|
|
44
|
+
const { isAuthenticated, user } = useAuth();
|
|
45
|
+
const pathname = usePathnameWithoutLocale();
|
|
46
|
+
const t = useAppT();
|
|
47
|
+
const { mounted, visible } = useMobileNavPanel({
|
|
48
|
+
isOpen: mobileMenuOpen,
|
|
49
|
+
onClose: closeMobileMenu,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const labels = useMemo(() => ({
|
|
53
|
+
menu: t('layouts.navigation.menu'),
|
|
54
|
+
quickActions: 'Actions',
|
|
55
|
+
signIn: t('layouts.profile.login'),
|
|
56
|
+
}), [t]);
|
|
57
|
+
|
|
58
|
+
const mobileNavigation = useMemo(() => {
|
|
59
|
+
const hasHome = navigation.some((item) => item.href === '/');
|
|
60
|
+
if (hasHome) return navigation;
|
|
61
|
+
return [{ label: 'Home', href: '/' }, ...navigation];
|
|
62
|
+
}, [navigation]);
|
|
63
|
+
|
|
64
|
+
const isActivePath = (href: string) => {
|
|
65
|
+
if (href === '/') return pathname === '/';
|
|
66
|
+
return pathname === href || pathname.startsWith(`${href}/`);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (!mounted) return null;
|
|
70
|
+
|
|
71
|
+
const hasSessionUser = Boolean(isAuthenticated && user);
|
|
72
|
+
const showSignInFooter = !hasSessionUser;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
{mobileMenuOpen && (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
aria-label={t('layouts.mobile.closeMenu')}
|
|
80
|
+
className="fixed inset-0 z-[998] lg:hidden bg-black/35 transition-opacity duration-200"
|
|
81
|
+
onClick={closeMobileMenu}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
'pointer-events-none fixed inset-x-0 z-1000 lg:hidden px-4 pb-3 sm:px-6 sm:pb-3 lg:px-8',
|
|
87
|
+
)}
|
|
88
|
+
style={{
|
|
89
|
+
top: 'var(--public-navbar-mobile-drawer-top, 5rem)',
|
|
90
|
+
bottom: 0,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
className={cn(
|
|
95
|
+
'mx-auto flex h-full min-h-0 max-h-full w-full flex-col overflow-hidden bg-background/72 backdrop-blur-[10px] dark:bg-card/80 transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out',
|
|
96
|
+
props.panelClassName,
|
|
97
|
+
props.outerClassName,
|
|
98
|
+
visible
|
|
99
|
+
? 'pointer-events-auto opacity-100 translate-y-0 scale-100'
|
|
100
|
+
: 'pointer-events-none opacity-0 -translate-y-2 scale-[0.985]',
|
|
101
|
+
)}
|
|
102
|
+
style={{
|
|
103
|
+
maxHeight: 'min(var(--public-navbar-mobile-drawer-max-height, calc(100dvh - 5rem - 12px)), calc(100dvh - 12px))',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 pb-10 space-y-5">
|
|
107
|
+
{hasSessionUser && (
|
|
108
|
+
<div className="px-2">
|
|
109
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
110
|
+
{labels.quickActions}
|
|
111
|
+
</h3>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{hasSessionUser && (
|
|
116
|
+
<UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} i18n={userMenu?.i18n} />
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
<div className="space-y-2">
|
|
120
|
+
<div className="px-2">
|
|
121
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
122
|
+
{labels.menu}
|
|
123
|
+
</h3>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="space-y-1">
|
|
126
|
+
{mobileNavigation.map((item) => {
|
|
127
|
+
const childItems = item.items ?? [];
|
|
128
|
+
const hasChildNav = childItems.length > 0;
|
|
129
|
+
const anyChildActive = hasChildNav && childItems.some((sub) => isActivePath(sub.href));
|
|
130
|
+
const parentPageActive = hasChildNav
|
|
131
|
+
? isActivePath(item.href) && !anyChildActive
|
|
132
|
+
: isActivePath(item.href);
|
|
133
|
+
const parentOnlySectionOpen = hasChildNav && anyChildActive;
|
|
134
|
+
return (
|
|
135
|
+
<div key={item.href}>
|
|
136
|
+
<SmartNavLink
|
|
137
|
+
href={item.href}
|
|
138
|
+
onClick={closeMobileMenu}
|
|
139
|
+
title={item.label}
|
|
140
|
+
className={cn(
|
|
141
|
+
'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',
|
|
142
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
|
143
|
+
parentPageActive
|
|
144
|
+
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none'
|
|
145
|
+
: parentOnlySectionOpen
|
|
146
|
+
? 'font-semibold text-foreground'
|
|
147
|
+
: 'text-foreground hover:bg-accent/60 hover:text-accent-foreground',
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{item.label}
|
|
151
|
+
</SmartNavLink>
|
|
152
|
+
{hasChildNav && (
|
|
153
|
+
<div className="ml-3 mt-1.5 space-y-1 border-l border-border/40 pl-3">
|
|
154
|
+
{childItems.map((subItem) => {
|
|
155
|
+
const subActive = isActivePath(subItem.href);
|
|
156
|
+
return (
|
|
157
|
+
<SmartNavLink
|
|
158
|
+
key={`${item.href}-${subItem.href}`}
|
|
159
|
+
href={subItem.href}
|
|
160
|
+
onClick={closeMobileMenu}
|
|
161
|
+
title={subItem.label}
|
|
162
|
+
className={cn(
|
|
163
|
+
'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',
|
|
164
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
|
165
|
+
subActive
|
|
166
|
+
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
|
|
167
|
+
: 'border-0 text-muted-foreground hover:bg-accent/55 hover:text-foreground',
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{subItem.label}
|
|
171
|
+
</SmartNavLink>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{showSignInFooter && (
|
|
184
|
+
<div className="shrink-0 border-t border-border/50 p-4">
|
|
185
|
+
<SmartNavLink
|
|
186
|
+
href={userMenu?.authPath || '/auth'}
|
|
187
|
+
onClick={closeMobileMenu}
|
|
188
|
+
className="block"
|
|
189
|
+
>
|
|
190
|
+
<Button className="relative w-full justify-center rounded-full h-11 px-6 pr-12">
|
|
191
|
+
{labels.signIn}
|
|
192
|
+
<ArrowRight
|
|
193
|
+
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 shrink-0"
|
|
194
|
+
aria-hidden
|
|
195
|
+
/>
|
|
196
|
+
</Button>
|
|
197
|
+
</SmartNavLink>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|