@djangocfg/layouts 2.1.592 → 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 (37) 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/components/PrivateSidebarAccount.tsx +10 -51
  14. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  15. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  16. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  17. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  18. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  19. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  20. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  21. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  22. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  23. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  24. package/src/layouts/SettingsLayout/SettingsDialog.tsx +13 -3
  25. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  26. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +1 -1
  27. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +1 -1
  28. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +14 -29
  29. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  30. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  31. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  32. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  33. package/src/layouts/SettingsLayout/index.ts +3 -0
  34. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +1 -1
  35. package/src/layouts/SettingsLayout/store.ts +12 -0
  36. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  37. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext } from 'react';
4
+
5
+ export interface AuthLayoutContextValue {
6
+ /** True when the consumer passed custom children — step headers should be hidden */
7
+ hideHeader: boolean;
8
+ /**
9
+ * True when the brand is already rendered on the media half, so the form
10
+ * header must not repeat it.
11
+ *
12
+ * A statement about the DESKTOP layout only: `fullsplit` drops the media half
13
+ * below 1024px, where the logo above the form is the only brand left. CSS
14
+ * owns that half of the decision on the same breakpoint — see
15
+ * `.auth-header[data-brand-elsewhere]` in `auth.css`.
16
+ */
17
+ hasBrandPanel: boolean;
18
+ }
19
+
20
+ export const AuthLayoutContext = createContext<AuthLayoutContextValue>({
21
+ hideHeader: false,
22
+ hasBrandPanel: false,
23
+ });
24
+
25
+ export const useAuthLayoutContext = (): AuthLayoutContextValue => useContext(AuthLayoutContext);
@@ -88,7 +88,6 @@
88
88
  through the panel as frosted glass. color-mix keeps it theme-aware. */
89
89
  background: color-mix(in oklab, var(--card) 72%, transparent);
90
90
  backdrop-filter: blur(20px) saturate(140%);
91
- -webkit-backdrop-filter: blur(20px) saturate(140%);
92
91
  color: var(--card-foreground);
93
92
  outline: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
94
93
  box-shadow: 0 24px 64px hsl(0 0% 0% / 0.28);
@@ -11,7 +11,7 @@
11
11
 
12
12
  'use client';
13
13
 
14
- import { ChevronDown, Languages, LogOut, Settings as SettingsIcon } from 'lucide-react';
14
+ import { ChevronDown, Languages } from 'lucide-react';
15
15
  import { Link } from '@djangocfg/ui-core/components';
16
16
  import React, { memo } from 'react';
17
17
 
@@ -29,13 +29,12 @@ import type { MenuItem } from '@djangocfg/ui-core/components';
29
29
  import { cn, isDev } from '@djangocfg/ui-core/lib';
30
30
  import { useSidebar } from '@djangocfg/ui-core/components';
31
31
 
32
- import { useLogout } from '../../../hooks';
33
- import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
32
+ import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
34
33
  import { LucideIcon as LucideIconRender } from '../../../components';
35
34
  import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
36
35
  import { usePeekPin } from '../hooks';
37
36
  import { usePrivateLayoutContext } from '../context';
38
- import { useSettingsDialogStore } from '../../SettingsLayout/store';
37
+ import { useAccountMenuItems } from '../../SettingsLayout/hooks';
39
38
 
40
39
  import type { HeaderConfig } from '../types';
41
40
 
@@ -53,7 +52,6 @@ interface AccountView {
53
52
 
54
53
  function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
55
54
  const { user } = useAuth();
56
- const handleLogout = useLogout();
57
55
  const t = useAppT();
58
56
  const layoutI18n = useLayoutI18nOptional();
59
57
  const { setOpen: setSidebarOpen } = useSidebar();
@@ -61,8 +59,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
61
59
  const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
62
60
  usePeekPin(isAccountMenuOpen);
63
61
 
64
- const signOutLabel = t('layouts.profile.signOut');
65
-
66
62
  const accountLinks = React.useMemo(() => {
67
63
  if (!header?.groups?.length) return [];
68
64
  return header.groups.flatMap((g) => g.items.filter((i) => i.href));
@@ -99,27 +95,9 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
99
95
 
100
96
  const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
101
97
 
102
- // ── Declarative account menu (Claude-style) — fed to MenuBuilder ──
103
- const menuItems = React.useMemo<MenuItem[]>(() => {
98
+ // Language submenu and the app's account links sit between the shared Settings shortcuts and Sign out.
99
+ const extraItems = React.useMemo<MenuItem[]>(() => {
104
100
  const items: MenuItem[] = [];
105
-
106
- // Email header.
107
- if (headerLabelText) {
108
- items.push({ kind: 'label', id: 'email', label: headerLabelText });
109
- }
110
-
111
- // Settings → opens the global SettingsDialog (⌘, like Claude).
112
- items.push({
113
- kind: 'item',
114
- id: 'settings',
115
- label: t('layouts.profile.settings') || 'Settings',
116
- icon: SettingsIcon,
117
- shortcut: '⇧⌘,',
118
- onSelect: () => useSettingsDialogStore.getState().open(),
119
- });
120
-
121
- // Language → submenu with a radio group of locales (flag + native name,
122
- // checkmark on the active one). Only when an i18n layout context is present.
123
101
  if (layoutI18n && layoutI18n.locales.length > 1) {
124
102
  items.push({
125
103
  kind: 'submenu',
@@ -145,36 +123,17 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
145
123
  ],
146
124
  });
147
125
  }
148
-
149
- // App-provided account links (from header.groups).
150
126
  if (accountLinks.length > 0) {
151
127
  items.push({ kind: 'separator', id: 'sep-links' });
152
128
  for (const link of accountLinks) {
153
- items.push({
154
- kind: 'item',
155
- id: link.href!,
156
- label: link.label,
157
- icon: link.icon,
158
- href: link.href,
159
- });
129
+ items.push({ kind: 'item', id: link.href!, label: link.label, icon: link.icon, href: link.href });
160
130
  }
161
131
  }
162
-
163
- // Log out (destructive). Let the menu close normally (no preventDefault) —
164
- // the logout confirm dialog should appear over a CLOSED menu, not under a
165
- // still-open one.
166
- items.push({ kind: 'separator', id: 'sep-logout' });
167
- items.push({
168
- kind: 'item',
169
- id: 'logout',
170
- label: signOutLabel,
171
- icon: LogOut,
172
- variant: 'destructive',
173
- onSelect: () => handleLogout(),
174
- });
175
-
176
132
  return items;
177
- }, [headerLabelText, t, layoutI18n, accountLinks, signOutLabel, handleLogout]);
133
+ }, [t, layoutI18n, accountLinks]);
134
+
135
+ // Shared with every host: email, Settings, section shortcuts, Sign out (logout confirm opens over a CLOSED menu).
136
+ const menuItems = useAccountMenuItems({ extra: extraItems, label: headerLabelText });
178
137
 
179
138
  // Hide entirely in production when there's no user (auth still loading or
180
139
  // /me failed and the parent guard hasn't redirected yet). In dev keep a
@@ -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)]';