@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.
Files changed (42) hide show
  1. package/README.md +52 -180
  2. package/package.json +18 -18
  3. package/src/layouts/AppLayout/AppLayout.tsx +14 -14
  4. package/src/layouts/PublicLayout/README.md +144 -0
  5. package/src/layouts/PublicLayout/{components/PublicFooter/PublicFooter.tsx → footers/DefaultFooter/DefaultFooter.tsx} +21 -15
  6. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/DjangoCFGLogo.tsx +0 -6
  7. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterBottom.tsx +3 -7
  8. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterMenuSections.tsx +4 -7
  9. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterProjectInfo.tsx +0 -4
  10. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/FooterSocialLinks.tsx +0 -5
  11. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/index.ts +2 -12
  12. package/src/layouts/PublicLayout/{components/PublicFooter → footers/DefaultFooter}/types.ts +21 -26
  13. package/src/layouts/PublicLayout/footers/index.ts +1 -0
  14. package/src/layouts/PublicLayout/hooks/index.ts +1 -0
  15. package/src/layouts/PublicLayout/hooks/useResponsiveOverflow.ts +140 -0
  16. package/src/layouts/PublicLayout/index.ts +38 -20
  17. package/src/layouts/PublicLayout/navbarTypes.ts +27 -4
  18. package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingMobileDrawer.tsx +29 -0
  19. package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingNavbar.tsx +127 -0
  20. package/src/layouts/PublicLayout/navbars/FloatingNavbar/index.ts +3 -0
  21. package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushMobileDrawer.tsx +19 -0
  22. package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushNavbar.tsx +122 -0
  23. package/src/layouts/PublicLayout/navbars/FlushNavbar/index.ts +3 -0
  24. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalMobileDrawer.tsx +19 -0
  25. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +180 -0
  26. package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +3 -0
  27. package/src/layouts/PublicLayout/navbars/index.ts +3 -0
  28. package/src/layouts/PublicLayout/primitives/ExternalPrefixesContext.tsx +69 -0
  29. package/src/layouts/PublicLayout/primitives/NavActionItem.tsx +95 -0
  30. package/src/layouts/PublicLayout/{components → primitives}/NavActions.tsx +26 -1
  31. package/src/layouts/PublicLayout/{components → primitives}/NavBrand.tsx +4 -3
  32. package/src/layouts/PublicLayout/{components → primitives}/NavDesktopItems.tsx +105 -61
  33. package/src/layouts/PublicLayout/primitives/SmartNavLink.tsx +81 -0
  34. package/src/layouts/PublicLayout/{components → primitives}/ThemeBrandMark.tsx +0 -8
  35. package/src/layouts/PublicLayout/primitives/index.ts +18 -0
  36. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +205 -0
  37. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +295 -0
  38. package/src/layouts/PublicLayout/shared/index.ts +4 -0
  39. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +0 -211
  40. package/src/layouts/PublicLayout/components/PublicNavbar.tsx +0 -99
  41. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +0 -287
  42. 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
- <Link
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
- </Link>
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 type { PublicDesktopDropdownRenderer } from './PublicNavigation';
11
+ import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
12
+ import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
13
+ import { SmartNavLink } from './SmartNavLink';
13
14
 
14
15
  interface NavDesktopItemsProps {
15
- primaryItems: NavigationItem[];
16
- overflowItems: NavigationItem[];
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:border dark:border-border dark:bg-muted dark:shadow-none';
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:border dark:border-border dark:bg-muted/90 dark:shadow-none'
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
- primaryItems,
51
- overflowItems,
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
- <Link href={sub.href} className={subMenuLinkCls(subActive)}>
94
+ <SmartNavLink href={sub.href} className={subMenuLinkCls(subActive)}>
82
95
  <span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
83
- </Link>
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
- <Link
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
- </Link>
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
- {primaryItems.map(renderItem)}
157
-
158
- {hasOverflow && (
159
- <div
160
- className="relative"
161
- onMouseEnter={() => scheduleOpen('__overflow-more')}
162
- onMouseLeave={() => scheduleClose('__overflow-more')}
163
- >
164
- <Button
165
- variant="ghost"
166
- size="sm"
167
- className={cn(
168
- 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
169
- navItemCls,
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}>More</span>
175
- <span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
176
- <ChevronDown
177
- className={cn(
178
- 'size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform',
179
- isMoreOpen && 'rotate-180',
180
- )}
181
- />
182
- </span>
183
- </Button>
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
- {isMoreOpen && (
186
- <div
187
- className={cn(popoverCls, 'left-auto right-0')}
188
- onMouseEnter={() => { scheduleOpen('__overflow-more'); }}
189
- onMouseLeave={() => scheduleClose('__overflow-more')}
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
- {overflowItems.map((item) => {
192
- const active = isGroupActive(item);
193
- return (
194
- <div key={`overflow-${item.href}`} className="rounded-full">
195
- <Link href={item.href} className={subMenuLinkCls(active)}>
196
- <span className="min-w-0 truncate" title={item.label}>{item.label}</span>
197
- </Link>
198
- </div>
199
- );
200
- })}
201
- </div>
202
- )}
203
- </div>
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
+