@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.
Files changed (36) hide show
  1. package/README.md +101 -203
  2. package/package.json +18 -18
  3. package/src/index.ts +4 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +97 -8
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -0
  6. package/src/layouts/AppLayout/index.ts +6 -0
  7. package/src/layouts/PrivateLayout/PrivateLayout.tsx +3 -1
  8. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +15 -4
  9. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +3 -3
  10. package/src/layouts/PublicLayout/PublicLayout.tsx +82 -17
  11. package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +17 -24
  12. package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +79 -95
  13. package/src/layouts/PublicLayout/components/PublicFooter/index.ts +2 -0
  14. package/src/layouts/PublicLayout/components/PublicFooter/types.ts +41 -31
  15. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +84 -40
  16. package/src/layouts/PublicLayout/components/PublicNavbar.tsx +22 -35
  17. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +184 -98
  18. package/src/layouts/PublicLayout/components/ThemeBrandMark.tsx +83 -0
  19. package/src/layouts/PublicLayout/components/index.ts +2 -0
  20. package/src/layouts/PublicLayout/context.tsx +5 -0
  21. package/src/layouts/PublicLayout/hooks/index.ts +1 -1
  22. package/src/layouts/PublicLayout/hooks/useMobileNavPanel.ts +55 -0
  23. package/src/layouts/PublicLayout/index.ts +8 -0
  24. package/src/layouts/PublicLayout/navbarTypes.ts +20 -0
  25. package/src/layouts/PublicLayout/publicShellShadow.ts +12 -0
  26. package/src/layouts/_components/PrivateSidebarAccount.tsx +16 -3
  27. package/src/layouts/_components/UserMenu.tsx +133 -30
  28. package/src/layouts/types/index.ts +10 -1
  29. package/src/layouts/types/providers.types.ts +10 -0
  30. package/src/layouts/types/ui.types.ts +9 -0
  31. package/src/theme/ThemeStyleBridge.tsx +41 -0
  32. package/src/theme/buildThemeStyleSheet.ts +71 -0
  33. package/src/theme/index.ts +16 -0
  34. package/src/theme/themeStyle.types.ts +89 -0
  35. package/src/theme/themeStylePresets.ts +202 -0
  36. 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, { type ReactNode, useEffect, useMemo, useRef, useState } from '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
- // cn is reserved for future conditional styling
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 = 'floating' | 'flush';
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
- /** Custom brand node (full control over logo/text/link) */
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
- /** Brand link for default brand renderer */
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
- const desktopNavItemClass =
87
- 'inline-flex h-8 items-center rounded-md px-2 text-sm font-medium text-foreground/90 transition-colors hover:text-foreground hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50';
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
- const base =
180
- 'mx-auto w-full border border-border/40 dark:border-border/70';
181
-
182
- const visual =
183
- navbarVariant === 'floating'
184
- ? 'rounded-2xl shadow-[0_10px_28px_rgba(0,0,0,0.14)] dark:shadow-[0_10px_28px_rgba(0,0,0,0.22)]'
185
- : 'rounded-none border-x-0 border-t-0 shadow-none';
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
- const desktopPrimaryNavigation = navigation.slice(0, desktopMaxPrimaryItems);
203
- const desktopOverflowNavigation = navigation.slice(desktopMaxPrimaryItems);
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
- <div key={`${item.label}-${subItem.href}`} className="rounded-md">
212
- {subItem.external ? (
213
- <a
214
- href={subItem.href}
215
- target="_blank"
216
- rel="noopener noreferrer"
217
- className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
218
- >
219
- {subItem.label}
220
- </a>
221
- ) : (
222
- <Link
223
- href={subItem.href}
224
- className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
225
- >
226
- {subItem.label}
227
- </Link>
228
- )}
229
- </div>
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-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
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={`group ${desktopNavItemClass} ${isOpen || isActive ? 'bg-accent/50 text-foreground' : ''}`}
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
- <ChevronDown
263
- className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
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={`${desktopNavItemClass} ${isActivePath(item.href) ? 'bg-accent/50 text-foreground' : ''}`}
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={navInnerClassName}
298
- style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
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 px-3 sm:px-4 lg:px-6">
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
- {/* Logo */}
303
- {brand ? (
304
- brand
305
- ) : (
306
- <Link href={brandHref} className="flex items-center gap-1.5">
307
- {logo && (
308
- <img src={logo} alt={siteName} className="h-5.5 w-auto object-contain" />
309
- )}
310
- <span className="font-bold text-[15px]">{siteName}</span>
311
- </Link>
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-3">
393
+ <div className="hidden isolate lg:flex items-center gap-1">
316
394
  {desktopPrimaryNavigation.map(renderDesktopNavItem)}
317
- {desktopOverflowNavigation.length > 0 && (
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={`group ${desktopNavItemClass} ${
327
- openDropdownKey === '__overflow-more' || desktopOverflowNavigation.some((item) => isGroupActive(item))
328
- ? 'bg-accent/50 text-foreground'
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
- <ChevronDown
334
- className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
335
- openDropdownKey === '__overflow-more' ? 'rotate-180' : ''
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
- {openDropdownKey === '__overflow-more' && (
424
+ {isMoreMenuOpen && (
341
425
  <div
342
- className="absolute right-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
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((item) => (
350
- <div key={`overflow-${item.href}`} className="rounded-md">
351
- <Link
352
- href={item.href}
353
- className={`block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40 ${
354
- isGroupActive(item) ? 'bg-accent/45 text-foreground' : ''
355
- }`}
356
- >
357
- {item.label}
358
- </Link>
359
- </div>
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 { useFloatingPanel } from './useFloatingPanel';
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
+ }