@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.
- package/package.json +12 -12
- package/src/components/errors/ErrorLayout.tsx +1 -164
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
- package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
- package/src/components/errors/errorGlyphs.tsx +167 -0
- package/src/layouts/AppLayout/BaseApp.tsx +1 -1
- package/src/layouts/AppLayout/index.ts +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
- package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/layoutContext.ts +25 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +10 -51
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
- package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
- package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
- package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
- package/src/layouts/PublicLayout/shared/index.ts +1 -1
- package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
- package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +13 -3
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +1 -1
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +1 -1
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +14 -29
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
- package/src/layouts/SettingsLayout/index.ts +3 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +1 -1
- package/src/layouts/SettingsLayout/store.ts +12 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
- 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
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
103
|
-
const
|
|
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
|
-
}, [
|
|
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
|
|
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 '
|
|
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 '
|
|
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)]';
|