@djangocfg/layouts 2.1.591 → 2.1.593

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 (100) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  14. package/src/layouts/PrivateLayout/README.md +20 -12
  15. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  16. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  17. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
  18. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  19. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  20. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  21. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  22. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  23. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  24. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  25. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  26. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  27. package/src/layouts/PrivateLayout/context.tsx +10 -115
  28. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  29. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  30. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  31. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  32. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  33. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  34. package/src/layouts/PrivateLayout/types.ts +13 -20
  35. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  36. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  37. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  38. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  39. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  40. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  41. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  42. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  43. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  44. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  45. package/src/layouts/SettingsLayout/README.md +23 -10
  46. package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
  47. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  48. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  49. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  50. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  51. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  52. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  53. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  54. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  55. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  56. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  57. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
  58. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  59. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  60. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  61. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  62. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  63. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  64. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  65. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  66. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  67. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  68. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  69. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  70. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  71. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  72. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  73. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  74. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  75. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  76. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  77. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  78. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  79. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  80. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  81. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  82. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  83. package/src/layouts/SettingsLayout/index.ts +3 -1
  84. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  85. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  86. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  87. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
  88. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  89. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  90. package/src/layouts/SettingsLayout/store.ts +12 -0
  91. package/src/layouts/SettingsLayout/types.ts +12 -4
  92. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  93. package/src/layouts/types/layout.types.ts +5 -6
  94. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
  95. package/src/styles/dashboard.css +10 -0
  96. package/src/layouts/PrivateLayout/density.ts +0 -48
  97. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  98. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  99. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  100. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -88,26 +88,24 @@ export interface SidebarConfig {
88
88
  * (above `footer` + account block).
89
89
  */
90
90
  menuEnd?: ReactNode;
91
- /**
92
- * Keep `menuStart` visible when the desktop sidebar is collapsed to the
93
- * icon rail. Default `false` — most slot content is full-width and looks
94
- * broken at ~56px. Set `true` only when the slot renders well in compact mode.
95
- */
91
+ /** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail. */
96
92
  menuStartShowOnCollapsed?: boolean;
97
- /** Same as `menuStartShowOnCollapsed`, but for `menuEnd`. Default `false`. */
93
+ /** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail. */
98
94
  menuEndShowOnCollapsed?: boolean;
95
+ /**
96
+ * Let the user drag the sidebar's right edge (14–24rem, remembered; double-click
97
+ * resets). Default `true`.
98
+ */
99
+ resizable?: boolean;
99
100
  /** Custom footer component rendered at the bottom of the sidebar */
100
101
  footer?: ReactNode;
101
- /** Active-state visual on nav items. Default `'background'` (legacy). */
102
+ /** @deprecated No effect: the active item is always a surface. */
102
103
  activeIndicator?: SidebarActiveIndicator;
103
- /** Style of group labels. Default `'uppercase'` (legacy). Collapsible groups always use `plain`. */
104
+ /** @deprecated No effect: group labels are always sentence case. */
104
105
  groupLabelStyle?: SidebarGroupLabelStyle;
105
106
  /** Featured CTA tile rendered below all groups, above `menuEnd`. */
106
107
  featured?: SidebarFeaturedConfig;
107
- /**
108
- * Override the auto-calculated nav density. When omitted density is derived
109
- * from total item count (comfortable ≤5, default ≤9, compact >9).
110
- */
108
+ /** @deprecated No effect: rows are one desktop size and one 44px touch size. */
111
109
  density?: 'comfortable' | 'default' | 'compact';
112
110
  }
113
111
 
@@ -167,11 +165,7 @@ export interface HeaderConfig {
167
165
  brandLetter?: string;
168
166
  /** User menu groups (account panel in the sidebar footer) */
169
167
  groups?: UserMenuConfig['groups'];
170
- /**
171
- * Behaviour of the footer account button.
172
- * - `'menu'` (default) — opens a DropdownMenu with account links, locale/theme controls, and sign-out.
173
- * - `'dialog'` — opens the global ProfileDialog (managed via Zustand store).
174
- */
168
+ /** @deprecated No effect: the footer account button always opens the account menu. */
175
169
  accountAction?: 'menu' | 'dialog';
176
170
  /** Auth page path (for sign in button) */
177
171
  authPath?: string;
@@ -213,9 +207,8 @@ export interface PrivateLayoutProps {
213
207
  */
214
208
  contentScroll?: 'auto' | 'hidden';
215
209
  /**
216
- * Visual style of the shell. Defaults to `'boxed'` (inset rounded card on a
217
- * sidebar-coloured canvas). Pass `{ variant: 'full-bleed' }` for the legacy
218
- * edge-to-edge layout.
210
+ * Visual style of the shell. Defaults to `'full-bleed'` (flat, Claude-style:
211
+ * sidebar + border + page). Pass `{ variant: 'boxed' }` for the inset rounded card.
219
212
  */
220
213
  visual?: LayoutVisualConfig;
221
214
  /**
@@ -6,104 +6,18 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import React, { memo, useEffect, useState } from 'react';
10
- import { Laptop, Moon, Sun } from 'lucide-react';
11
-
12
- import { Button } from '@djangocfg/ui-core/components';
13
- import { useForcedTheme, useThemeContext } from '@djangocfg/ui-core/theme';
9
+ import React, { memo } from 'react';
14
10
 
15
11
  import { Link } from '@djangocfg/ui-core/components';
16
12
 
17
13
  import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
18
- import { useLayoutI18nOptional } from '../../../AppLayout/LayoutI18nProvider';
14
+ import { useLayoutI18nOptional } from '../../../../shared/LayoutI18nProvider';
15
+ import { ThemeModeControl } from './ThemeModeControl';
19
16
  import { FooterMenuSections } from './FooterMenuSections';
20
17
  import { FooterProjectInfo } from './FooterProjectInfo';
21
18
 
22
19
  import type { DefaultFooterProps } from './types';
23
20
 
24
- interface ThemeModeControlProps {
25
- /** What to do when the route forces a theme via `ThemeOverride`. */
26
- lockedBehavior?: 'disable' | 'hide';
27
- /** Tooltip shown when locked. */
28
- lockedTitle?: string;
29
- }
30
-
31
- function ThemeModeControl({
32
- lockedBehavior = 'disable',
33
- lockedTitle = 'Theme is set for this page',
34
- }: ThemeModeControlProps) {
35
- const { theme, setTheme } = useThemeContext();
36
- const forcedTheme = useForcedTheme();
37
- const [mounted, setMounted] = useState(false);
38
-
39
- useEffect(() => {
40
- setMounted(true);
41
- }, []);
42
-
43
- const isLocked = Boolean(forcedTheme);
44
- // When locked, the active item reflects the forced theme — not the user's pick.
45
- const currentTheme: 'system' | 'light' | 'dark' = isLocked
46
- ? forcedTheme!
47
- : mounted
48
- ? ((theme as 'system' | 'light' | 'dark' | undefined) || 'system')
49
- : 'system';
50
-
51
- // Prepare item descriptors above JSX — no inline conditionals in the tree.
52
- const items: Array<{
53
- key: 'system' | 'light' | 'dark';
54
- icon: typeof Laptop;
55
- label: string;
56
- }> = [
57
- { key: 'system', icon: Laptop, label: 'Use system theme' },
58
- { key: 'light', icon: Sun, label: 'Use light theme' },
59
- { key: 'dark', icon: Moon, label: 'Use dark theme' },
60
- ];
61
-
62
- const baseItemClass = 'h-8 w-8 rounded-full p-0 text-muted-foreground hover:text-foreground';
63
- const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
64
- const lockedItemClass = 'cursor-not-allowed opacity-60 hover:text-muted-foreground';
65
-
66
- if (isLocked && lockedBehavior === 'hide') {
67
- return null;
68
- }
69
-
70
- const containerClass = [
71
- 'inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/30 p-1',
72
- isLocked ? 'cursor-not-allowed' : '',
73
- ].filter(Boolean).join(' ');
74
- const containerTitle = isLocked ? lockedTitle : undefined;
75
-
76
- return (
77
- <div className={containerClass} title={containerTitle} aria-disabled={isLocked}>
78
- {items.map((item) => {
79
- const Icon = item.icon;
80
- const isActive = currentTheme === item.key;
81
- const className = [
82
- baseItemClass,
83
- isActive ? activeItemClass : '',
84
- isLocked ? lockedItemClass : '',
85
- ].filter(Boolean).join(' ');
86
- const handleClick = isLocked ? undefined : () => setTheme(item.key);
87
- return (
88
- <Button
89
- key={item.key}
90
- type="button"
91
- variant="ghost"
92
- size="icon"
93
- className={className}
94
- onClick={handleClick}
95
- disabled={isLocked}
96
- aria-label={item.label}
97
- title={isLocked ? lockedTitle : item.label}
98
- >
99
- <Icon className="h-4 w-4" />
100
- </Button>
101
- );
102
- })}
103
- </div>
104
- );
105
- }
106
-
107
21
  function DefaultFooterRaw({ config }: DefaultFooterProps) {
108
22
  const variant = config.variant ?? 'full';
109
23
  const shellClass = config.shell?.className;
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { Laptop, Moon, Sun } from 'lucide-react';
4
+ import React, { useEffect, useState } from 'react';
5
+
6
+ import { Button } from '@djangocfg/ui-core/components';
7
+ import { useForcedTheme, useThemeContext } from '@djangocfg/ui-core/theme';
8
+
9
+ interface ThemeModeControlProps {
10
+ /** What to do when the route forces a theme via `ThemeOverride`. */
11
+ lockedBehavior?: 'disable' | 'hide';
12
+ /** Tooltip shown when locked. */
13
+ lockedTitle?: string;
14
+ }
15
+
16
+ export function ThemeModeControl({
17
+ lockedBehavior = 'disable',
18
+ lockedTitle = 'Theme is set for this page',
19
+ }: ThemeModeControlProps) {
20
+ const { theme, setTheme } = useThemeContext();
21
+ const forcedTheme = useForcedTheme();
22
+ const [mounted, setMounted] = useState(false);
23
+
24
+ useEffect(() => {
25
+ setMounted(true);
26
+ }, []);
27
+
28
+ const isLocked = Boolean(forcedTheme);
29
+ // When locked, the active item reflects the forced theme — not the user's pick.
30
+ const currentTheme: 'system' | 'light' | 'dark' = isLocked
31
+ ? forcedTheme!
32
+ : mounted
33
+ ? ((theme as 'system' | 'light' | 'dark' | undefined) || 'system')
34
+ : 'system';
35
+
36
+ // Prepare item descriptors above JSX — no inline conditionals in the tree.
37
+ const items: Array<{
38
+ key: 'system' | 'light' | 'dark';
39
+ icon: typeof Laptop;
40
+ label: string;
41
+ }> = [
42
+ { key: 'system', icon: Laptop, label: 'Use system theme' },
43
+ { key: 'light', icon: Sun, label: 'Use light theme' },
44
+ { key: 'dark', icon: Moon, label: 'Use dark theme' },
45
+ ];
46
+
47
+ const baseItemClass = 'h-8 w-8 rounded-full p-0 text-muted-foreground hover:text-foreground';
48
+ const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
49
+ const lockedItemClass = 'cursor-not-allowed opacity-60 hover:text-muted-foreground';
50
+
51
+ if (isLocked && lockedBehavior === 'hide') {
52
+ return null;
53
+ }
54
+
55
+ const containerClass = [
56
+ 'inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/30 p-1',
57
+ isLocked ? 'cursor-not-allowed' : '',
58
+ ].filter(Boolean).join(' ');
59
+ const containerTitle = isLocked ? lockedTitle : undefined;
60
+
61
+ return (
62
+ <div className={containerClass} title={containerTitle} aria-disabled={isLocked}>
63
+ {items.map((item) => {
64
+ const Icon = item.icon;
65
+ const isActive = currentTheme === item.key;
66
+ const className = [
67
+ baseItemClass,
68
+ isActive ? activeItemClass : '',
69
+ isLocked ? lockedItemClass : '',
70
+ ].filter(Boolean).join(' ');
71
+ const handleClick = isLocked ? undefined : () => setTheme(item.key);
72
+ return (
73
+ <Button
74
+ key={item.key}
75
+ type="button"
76
+ variant="ghost"
77
+ size="icon"
78
+ className={className}
79
+ onClick={handleClick}
80
+ disabled={isLocked}
81
+ aria-label={item.label}
82
+ title={isLocked ? lockedTitle : item.label}
83
+ >
84
+ <Icon className="h-4 w-4" />
85
+ </Button>
86
+ );
87
+ })}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ import { portalPopoverCls } from './navDesktopStyles';
7
+
8
+ export function DesktopDropdownPortal({
9
+ anchor,
10
+ children,
11
+ onMouseEnter,
12
+ onMouseLeave,
13
+ }: {
14
+ anchor: HTMLElement;
15
+ children: React.ReactNode;
16
+ onMouseEnter: () => void;
17
+ onMouseLeave: () => void;
18
+ }) {
19
+ const [position, setPosition] = React.useState({ left: 0, top: 0 });
20
+
21
+ React.useLayoutEffect(() => {
22
+ const updatePosition = () => {
23
+ const rect = anchor.getBoundingClientRect();
24
+ setPosition({ left: rect.left, top: rect.bottom + 4 });
25
+ };
26
+
27
+ updatePosition();
28
+ window.addEventListener('resize', updatePosition);
29
+ window.addEventListener('scroll', updatePosition, true);
30
+ return () => {
31
+ window.removeEventListener('resize', updatePosition);
32
+ window.removeEventListener('scroll', updatePosition, true);
33
+ };
34
+ }, [anchor]);
35
+
36
+ return createPortal(
37
+ <div
38
+ className={portalPopoverCls}
39
+ style={position}
40
+ onMouseEnter={onMouseEnter}
41
+ onMouseLeave={onMouseLeave}
42
+ >
43
+ {children}
44
+ </div>,
45
+ document.body,
46
+ );
47
+ }
@@ -8,7 +8,7 @@ import { cn } from '@djangocfg/ui-core/lib';
8
8
  import { useThemeContext } from '@djangocfg/ui-core/theme';
9
9
 
10
10
  import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
11
- import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
11
+ import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
12
12
 
13
13
  export interface NavControlsProps {
14
14
  /** Show the theme (light / system / dark) pill. @default false */
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { ChevronDown } from 'lucide-react';
4
4
  import React, { memo } from 'react';
5
- import { createPortal } from 'react-dom';
6
5
 
7
6
  import { Button, Link } from '@djangocfg/ui-core/components';
8
7
  import { cn } from '@djangocfg/ui-core/lib';
@@ -12,6 +11,8 @@ import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
12
11
  import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
13
12
  import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
14
13
  import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
14
+ import { DesktopDropdownPortal } from './DesktopDropdownPortal';
15
+ import { labelCls, navItemActiveCls, navItemCls, popoverCls, subMenuLinkCls } from './navDesktopStyles';
15
16
 
16
17
  interface NavDesktopItemsProps {
17
18
  /** Full ordered item list; overflow is computed responsively. */
@@ -24,88 +25,6 @@ interface NavDesktopItemsProps {
24
25
  renderDesktopDropdown?: PublicDesktopDropdownRenderer;
25
26
  }
26
27
 
27
- // Corners come from ui-core's radius tokens, never raw Tailwind steps —
28
- // presets (`dense`, `windows`) retune those variables, and a hardcoded
29
- // `rounded-lg` would opt this navbar out of the active theme.
30
- //
31
- // Radius is chosen per element KIND, not unified across the bar: nav rows and
32
- // menu items take `rounded-control` (--radius-control, the token documented for
33
- // "nav items, inputs, chips"), their popover takes --radius-popover, while
34
- // BUTTONS stay pills like every other button in the product. Making the rows
35
- // pills too is what produced capsules floating inside a soft-cornered popover.
36
- const navItemCls = cn(
37
- 'inline-flex min-h-9 items-center justify-center gap-1 rounded-control border border-transparent px-4 py-1.5 text-sm !font-medium',
38
- 'ring-0 focus-visible:ring-0',
39
- 'text-foreground/90 transition-colors',
40
- 'surface-hover hover:text-foreground',
41
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
42
- );
43
-
44
- const navItemActiveCls =
45
- 'border-transparent bg-accent text-foreground shadow-sm dark:bg-muted dark:shadow-none';
46
-
47
- const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
48
-
49
- function subMenuLinkCls(active: boolean) {
50
- return cn(
51
- 'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-control border-0 px-3 py-2 text-sm font-medium transition-colors',
52
- 'surface-hover',
53
- active
54
- ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted/90 dark:shadow-none'
55
- : 'border-0 text-foreground/90',
56
- );
57
- }
58
-
59
- // Real glass: low-opacity surface so the blur actually shows the page behind
60
- // it. The package-level nav-dropdown-glass utility keeps the frosted effect
61
- // present even when the consuming app does not scan layout sources.
62
- const popoverCls =
63
- 'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
64
-
65
- const portalPopoverCls =
66
- 'fixed z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass backdrop-blur-[5px] backdrop-saturate-150 p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
67
-
68
- function DesktopDropdownPortal({
69
- anchor,
70
- children,
71
- onMouseEnter,
72
- onMouseLeave,
73
- }: {
74
- anchor: HTMLElement;
75
- children: React.ReactNode;
76
- onMouseEnter: () => void;
77
- onMouseLeave: () => void;
78
- }) {
79
- const [position, setPosition] = React.useState({ left: 0, top: 0 });
80
-
81
- React.useLayoutEffect(() => {
82
- const updatePosition = () => {
83
- const rect = anchor.getBoundingClientRect();
84
- setPosition({ left: rect.left, top: rect.bottom + 4 });
85
- };
86
-
87
- updatePosition();
88
- window.addEventListener('resize', updatePosition);
89
- window.addEventListener('scroll', updatePosition, true);
90
- return () => {
91
- window.removeEventListener('resize', updatePosition);
92
- window.removeEventListener('scroll', updatePosition, true);
93
- };
94
- }, [anchor]);
95
-
96
- return createPortal(
97
- <div
98
- className={portalPopoverCls}
99
- style={position}
100
- onMouseEnter={onMouseEnter}
101
- onMouseLeave={onMouseLeave}
102
- >
103
- {children}
104
- </div>,
105
- document.body,
106
- );
107
- }
108
-
109
28
  function NavDesktopItemsRaw({
110
29
  items,
111
30
  maxVisible,
@@ -0,0 +1,42 @@
1
+ import { cn } from '@djangocfg/ui-core/lib';
2
+
3
+ // Corners come from ui-core's radius tokens, never raw Tailwind steps —
4
+ // presets (`dense`, `windows`) retune those variables, and a hardcoded
5
+ // `rounded-lg` would opt this navbar out of the active theme.
6
+ //
7
+ // Radius is chosen per element KIND, not unified across the bar: nav rows and
8
+ // menu items take `rounded-control` (--radius-control, the token documented for
9
+ // "nav items, inputs, chips"), their popover takes --radius-popover, while
10
+ // BUTTONS stay pills like every other button in the product. Making the rows
11
+ // pills too is what produced capsules floating inside a soft-cornered popover.
12
+ export const navItemCls = cn(
13
+ 'inline-flex min-h-9 items-center justify-center gap-1 rounded-control border border-transparent px-4 py-1.5 text-sm !font-medium',
14
+ 'ring-0 focus-visible:ring-0',
15
+ 'text-foreground/90 transition-colors',
16
+ 'surface-hover hover:text-foreground',
17
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
18
+ );
19
+
20
+ export const navItemActiveCls =
21
+ 'border-transparent bg-accent text-foreground shadow-sm dark:bg-muted dark:shadow-none';
22
+
23
+ export const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
24
+
25
+ export function subMenuLinkCls(active: boolean) {
26
+ return cn(
27
+ 'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-control border-0 px-3 py-2 text-sm font-medium transition-colors',
28
+ 'surface-hover',
29
+ active
30
+ ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:bg-muted/90 dark:shadow-none'
31
+ : 'border-0 text-foreground/90',
32
+ );
33
+ }
34
+
35
+ // Real glass: low-opacity surface so the blur actually shows the page behind
36
+ // it. The package-level nav-dropdown-glass utility keeps the frosted effect
37
+ // present even when the consuming app does not scan layout sources.
38
+ export const popoverCls =
39
+ 'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
40
+
41
+ export const portalPopoverCls =
42
+ 'fixed z-[1200] min-w-[14.5rem] rounded-[var(--radius-popover)] border border-border/70 nav-dropdown-glass backdrop-blur-[5px] backdrop-saturate-150 p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_8px_28px_rgba(0,0,0,0.18)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]';
@@ -16,7 +16,6 @@
16
16
  'use client';
17
17
 
18
18
  import React, {
19
- type ReactNode,
20
19
  memo,
21
20
  useEffect,
22
21
  useLayoutEffect,
@@ -31,190 +30,24 @@ import { cn } from '@djangocfg/ui-core/lib';
31
30
  import { usePathnameWithoutLocale } from '../../../hooks';
32
31
  import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
33
32
  import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
34
- import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
33
+ import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
35
34
  import { usePublicLayoutOptional } from '../context';
36
35
  import {
37
36
  useDropdownMenu,
38
37
  useNavbarScroll,
39
38
  useNavbarViewportVars,
40
39
  } from '../hooks';
41
- import type {
42
- PublicDesktopDropdownRenderer,
43
- PublicNavbarHeight,
44
- PublicNavbarInsetX,
45
- PublicNavbarPosition,
46
- PublicNavbarVariant,
47
- PublicNavLayout,
48
- } from '../navbarTypes';
49
- import type { NavigationItem, UserMenuConfig } from '../../types';
40
+ import type { NavigationItem } from '../../types';
50
41
 
51
42
  import { NavActions } from '../primitives/NavActions';
52
- import type { NavAction } from '../primitives/NavActionItem';
53
43
  import { NavBrand } from '../primitives/NavBrand';
54
44
  import { NavControls } from '../primitives/NavControls';
55
45
  import { NavDesktopItems } from '../primitives/NavDesktopItems';
56
46
 
57
47
  import { NavbarRowDesktop, NavbarRowMobile } from './navbarRows';
58
48
 
59
- /**
60
- * Vertical padding, per breakpoint. Two independent scales rather than one with
61
- * `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
62
- * so padding there is pure added height, while the desktop row needs the extra
63
- * air to keep the link list off the panel edge.
64
- */
65
- const heightClsDesktop: Record<PublicNavbarHeight, string> = {
66
- sm: 'py-2',
67
- md: 'py-3.5',
68
- lg: 'py-5',
69
- };
70
-
71
- const heightClsMobile: Record<PublicNavbarHeight, string> = {
72
- sm: 'py-1.5',
73
- md: 'py-2',
74
- lg: 'py-3',
75
- };
76
-
77
- /**
78
- * Token → Tailwind class for the inner-row horizontal padding.
79
- *
80
- * Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
81
- * optical imbalance, not a measured one. On the left the gutter meets the
82
- * brand's glyphs directly; on the right it meets a button — an icon square on
83
- * phones, the Login pill on desktop — whose box carries its own internal
84
- * padding around the glyph. Equal `px` therefore *looks* lopsided, with a
85
- * conspicuous moat to the right of the last control.
86
- *
87
- * It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
88
- * lg:px-6`), which is why the right side stays small in absolute terms: at
89
- * `lg` the symmetric version put ~48px between the Login button and the
90
- * viewport edge.
91
- */
92
- /** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
93
- const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
94
- none: '',
95
- xs: 'lg:pl-4 lg:pr-1.5',
96
- sm: 'lg:pl-6 lg:pr-2',
97
- md: 'lg:pl-8 lg:pr-3',
98
- lg: 'lg:pl-12 lg:pr-5',
99
- };
100
-
101
- /** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
102
- const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
103
- none: '',
104
- xs: 'pl-3 pr-1',
105
- sm: 'pl-4 pr-1.5',
106
- md: 'pl-5 pr-2',
107
- lg: 'pl-6 pr-3',
108
- };
109
-
110
- /**
111
- * Resolves the inset token into one class string covering both breakpoints.
112
- *
113
- * A caller may also pass raw Tailwind instead of a token; that escape hatch is
114
- * applied verbatim to both rows, since we cannot know how to split it.
115
- */
116
- function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
117
- const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
118
- if (!(token in insetXClsMobile)) return value as string;
119
- return cn(insetXClsMobile[token], insetXClsDesktop[token]);
120
- }
121
-
122
- interface ShapeInputs {
123
- /** true at scrollY >= threshold */
124
- scrolled: boolean;
125
- /** Matches `transparent` flag at the source. */
126
- transparent: boolean;
127
- /**
128
- * true while the mobile drawer is open. A `transparent` bar MUST go opaque
129
- * here regardless of scroll position: an open drawer means the brand + close
130
- * button sit over page content, and a see-through bar leaves them floating on
131
- * whatever is scrolled behind — the "navbar lost its background" bug.
132
- */
133
- menuOpen: boolean;
134
- }
135
-
136
- export interface NavbarShellProps {
137
- // ── Surface identity (reported to context) ────────────────────────────────
138
- variant: PublicNavbarVariant;
139
- position: PublicNavbarPosition;
140
-
141
- // ── Content ───────────────────────────────────────────────────────────────
142
- brand?: ReactNode;
143
- brandHref?: string;
144
- navigation?: NavigationItem[];
145
- userMenu?: UserMenuConfig;
146
- renderDesktopDropdown?: PublicDesktopDropdownRenderer;
147
- /**
148
- * Hard cap for how many items to try to fit on desktop. Useful when a
149
- * consumer wants to force a compact primary row even if more would fit.
150
- * Defaults to the full list length (let the responsive measurer decide).
151
- */
152
- desktopMaxPrimaryItems?: number;
153
-
154
- // ── Layout ────────────────────────────────────────────────────────────────
155
- navLayout?: PublicNavLayout;
156
- navbarHeight?: PublicNavbarHeight;
157
- /** Horizontal inset between outer edge and the inner row.
158
- * @default 'sm' */
159
- insetX?: PublicNavbarInsetX;
160
- /** Full override for the row container (escape hatch).
161
- * When set, wins over `insetX`. */
162
- innerPadding?: string;
163
-
164
- // ── Scroll behavior ───────────────────────────────────────────────────────
165
- hideNavOnScroll?: boolean;
166
- transparent?: boolean;
167
- transparentThreshold?: number;
168
-
169
- // ── Chrome (per-variant) ──────────────────────────────────────────────────
170
- /** Classes on the outer wrapper (fixed/sticky, top offset, outer x-padding). */
171
- outerClassName?: string;
172
- /** Classes for the `<nav>` element defining the shape (rounding, border, shadow). */
173
- shapeClassName?: string;
174
- /** Surface classes (bg, backdrop-blur). Driven by `shapeForState`. */
175
- shapeForState?: (inputs: ShapeInputs) => string;
176
-
177
- /**
178
- * Replaces the default actions column (UserMenu + mobile-toggle).
179
- * Variant owns the mobile toggle too. Receives helpers so it can build the
180
- * right-hand side however it wants.
181
- */
182
- renderActions?: (ctx: NavbarActionsContext) => ReactNode;
183
-
184
- // ── Default-actions slots (only used when `renderActions` is NOT provided) ─
185
- /** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
186
- actions?: NavAction[];
187
- /** Arbitrary ReactNode between actions and UserMenu. */
188
- actionsLeadingSlot?: ReactNode;
189
- /** Arbitrary ReactNode after the mobile toggle. */
190
- actionsTrailingSlot?: ReactNode;
191
-
192
- // ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
193
- /** Toggle individual controls. Locale switcher also requires `i18n`. */
194
- controls?: {
195
- /** Light / system / dark pill. @default false */
196
- showThemeSwitcher?: boolean;
197
- /** Locale dropdown. Requires `i18n`. @default false */
198
- showLocaleSwitcher?: boolean;
199
- };
200
-
201
- // ── Props override (used by variants that proxy ctx differently) ──────────
202
- mobileMenuOpen?: boolean;
203
- onMobileMenuToggle?: () => void;
204
- }
205
-
206
- export interface NavbarActionsContext {
207
- userMenu?: UserMenuConfig;
208
- mobileMenuOpen: boolean;
209
- toggleMobileMenu: () => void;
210
- toggleMobileLabel: string;
211
- navLayout: PublicNavLayout;
212
- /**
213
- * Pre-built theme/locale controls node. `null` when both toggles are off.
214
- * Variants with a custom `renderActions` can choose where to place it.
215
- */
216
- controls: ReactNode | null;
217
- }
49
+ import { heightClsDesktop, heightClsMobile, resolveInsetX } from './navbarShellStyles';
50
+ import type { NavbarShellProps } from './navbarShell.types';
218
51
 
219
52
  function NavbarShellRaw(props: NavbarShellProps) {
220
53
  const context = usePublicLayoutOptional();
@@ -1,4 +1,4 @@
1
1
  export { NavbarShell } from './NavbarShell';
2
- export type { NavbarShellProps, NavbarActionsContext } from './NavbarShell';
2
+ export type { NavbarShellProps, NavbarActionsContext } from './navbarShell.types';
3
3
  export { MobileDrawerShell } from './MobileDrawerShell';
4
4
  export type { MobileDrawerShellProps } from './MobileDrawerShell';