@djangocfg/layouts 2.1.356 → 2.1.358
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 +21 -19
- package/src/configurator/private/schema.ts +12 -0
- package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
- package/src/layouts/AppLayout/AppLayout.tsx +35 -15
- package/src/layouts/AppLayout/BaseApp.tsx +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
- package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
- package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
- package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
- package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
- package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
- package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
- package/src/layouts/AuthLayout/context.tsx +35 -13
- package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
- package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
- package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
- package/src/layouts/AuthLayout/shells/context.tsx +16 -5
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +45 -248
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +113 -430
- package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +82 -105
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +168 -0
- package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
- package/src/layouts/PrivateLayout/components/index.ts +4 -0
- package/src/layouts/PrivateLayout/context.tsx +211 -0
- package/src/layouts/PrivateLayout/density.ts +48 -0
- package/src/layouts/PrivateLayout/hooks/index.ts +14 -0
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +110 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
- package/src/layouts/PrivateLayout/index.ts +2 -2
- package/src/layouts/PrivateLayout/types.ts +193 -0
- package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
- package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
- package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
- package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +8 -8
- package/src/layouts/ProfileLayout/ProfileForm/index.tsx +148 -0
- package/src/layouts/ProfileLayout/README.md +118 -0
- package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
- package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
- package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
- package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
- package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +35 -0
- package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
- package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
- package/src/layouts/ProfileLayout/components/index.ts +5 -2
- package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +48 -0
- package/src/layouts/ProfileLayout/index.ts +7 -3
- package/src/layouts/ProfileLayout/types.ts +47 -0
- package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
- package/src/layouts/PublicLayout/components/index.ts +4 -0
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
- package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
- package/src/layouts/_components/index.ts +2 -6
- package/src/layouts/index.ts +9 -4
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +0 -284
- package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
- package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
- /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sidebar account: rich footer trigger (avatar + name + plan + optional secondary
|
|
3
|
-
* action) opens a popover (DropdownMenu) upward with email, account links,
|
|
4
|
-
*
|
|
3
|
+
* action) opens a popover (DropdownMenu) upward with email, account links, and
|
|
4
|
+
* sign-out. Language and theme controls live inside ProfileLayout / PreferencesSection
|
|
5
|
+
* so they are not duplicated here.
|
|
6
|
+
*
|
|
7
|
+
* Reads `isAccountMenuOpen` / `setIsAccountMenuOpen` directly from
|
|
8
|
+
* PrivateLayoutContext so the parent sidebar can block collapse while the menu
|
|
9
|
+
* is open.
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
'use client';
|
|
8
13
|
|
|
9
|
-
import {
|
|
14
|
+
import { ChevronsUpDown, LogOut } from 'lucide-react';
|
|
10
15
|
import { Link } from '@djangocfg/ui-core/components';
|
|
11
|
-
import React from 'react';
|
|
16
|
+
import React, { memo, useMemo } from 'react';
|
|
12
17
|
|
|
13
18
|
import { useAuth } from '@djangocfg/api/auth';
|
|
14
19
|
import { useAppT } from '@djangocfg/i18n';
|
|
@@ -26,15 +31,15 @@ import {
|
|
|
26
31
|
} from '@djangocfg/ui-core/components';
|
|
27
32
|
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
28
33
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
29
|
-
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
30
34
|
|
|
31
|
-
import { useLogout } from '
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
35
|
+
import { useLogout } from '../../../hooks';
|
|
36
|
+
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
37
|
+
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
38
|
+
import { useShellVisualState } from '../hooks';
|
|
39
|
+
import { blockSidebarCollapse, allowSidebarCollapse } from '../hooks/useHoverExpand';
|
|
40
|
+
import { useProfileDialogStore } from '../../ProfileLayout/ProfileDialog/store';
|
|
36
41
|
|
|
37
|
-
import type { HeaderConfig } from '../
|
|
42
|
+
import type { HeaderConfig } from '../types';
|
|
38
43
|
|
|
39
44
|
interface PrivateSidebarAccountProps {
|
|
40
45
|
header?: HeaderConfig;
|
|
@@ -48,15 +53,14 @@ interface AccountView {
|
|
|
48
53
|
plan: string | null;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
52
57
|
const { user } = useAuth();
|
|
53
58
|
const handleLogout = useLogout();
|
|
54
59
|
const t = useAppT();
|
|
55
60
|
const layoutI18n = useLayoutI18nOptional();
|
|
56
|
-
const {
|
|
57
|
-
const {
|
|
58
|
-
const [
|
|
59
|
-
const narrow = state === 'collapsed';
|
|
61
|
+
const { setOpen: setSidebarOpen } = useSidebar();
|
|
62
|
+
const { content } = useShellVisualState();
|
|
63
|
+
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
60
64
|
|
|
61
65
|
const signOutLabel = t('layouts.profile.signOut');
|
|
62
66
|
|
|
@@ -91,8 +95,8 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
91
95
|
}, [user, header?.userPlan]);
|
|
92
96
|
|
|
93
97
|
const onTriggerInteract = React.useCallback(() => {
|
|
94
|
-
if (
|
|
95
|
-
}, [
|
|
98
|
+
if (content.isAccountCompact) setSidebarOpen(true);
|
|
99
|
+
}, [content.isAccountCompact, setSidebarOpen]);
|
|
96
100
|
|
|
97
101
|
const onSecondaryExpand = React.useCallback(() => {
|
|
98
102
|
setSidebarOpen(true);
|
|
@@ -103,21 +107,6 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
103
107
|
handleLogout();
|
|
104
108
|
}, [handleLogout]);
|
|
105
109
|
|
|
106
|
-
const onLanguageSelect = React.useCallback((e: Event) => {
|
|
107
|
-
// Keep the dropdown closed (default behaviour) but defer dialog mount to
|
|
108
|
-
// the next tick so Radix has time to unmount the dropdown overlay first
|
|
109
|
-
// (avoids the "two open overlays steal focus" bug).
|
|
110
|
-
e.preventDefault();
|
|
111
|
-
setTimeout(() => setLangDialogOpen(true), 0);
|
|
112
|
-
}, []);
|
|
113
|
-
|
|
114
|
-
const onThemeSelect = React.useCallback((e: Event) => {
|
|
115
|
-
e.preventDefault();
|
|
116
|
-
// Cycle: light → dark → system → light
|
|
117
|
-
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
|
|
118
|
-
setTheme(next);
|
|
119
|
-
}, [theme, setTheme]);
|
|
120
|
-
|
|
121
110
|
// Hide entirely in production when there's no user (auth still loading or
|
|
122
111
|
// /me failed and the parent guard hasn't redirected yet). In dev keep a
|
|
123
112
|
// placeholder so the footer + Log out are reachable for debugging.
|
|
@@ -128,20 +117,20 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
128
117
|
const secondary = header?.footerSecondaryAction;
|
|
129
118
|
|
|
130
119
|
const triggerClassName = cn(
|
|
131
|
-
'group h-auto w-full gap-3 rounded-
|
|
120
|
+
'group h-auto w-full gap-3 rounded-lg px-3 py-3 text-left',
|
|
132
121
|
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
133
|
-
|
|
122
|
+
content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
|
|
134
123
|
);
|
|
135
124
|
|
|
136
|
-
const secondaryButton = secondary && !
|
|
125
|
+
const secondaryButton = secondary && !content.isAccountCompact ? (
|
|
137
126
|
<SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
|
|
138
127
|
) : null;
|
|
139
128
|
|
|
140
129
|
const dropdownContentClass = cn(
|
|
141
130
|
'p-1.5',
|
|
142
|
-
|
|
131
|
+
content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
|
|
143
132
|
);
|
|
144
|
-
const dropdownSide: 'top' | 'right' =
|
|
133
|
+
const dropdownSide: 'top' | 'right' = content.isAccountCompact ? 'right' : 'top';
|
|
145
134
|
const avatarClass = cn(
|
|
146
135
|
'h-9 w-9 shrink-0 border border-transparent transition-colors',
|
|
147
136
|
'group-hover:border-sidebar-border/70',
|
|
@@ -175,41 +164,7 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
175
164
|
</>
|
|
176
165
|
) : null;
|
|
177
166
|
|
|
178
|
-
const
|
|
179
|
-
? getLocaleMeta(layoutI18n.locale).native
|
|
180
|
-
: null;
|
|
181
|
-
const languageItem = layoutI18n ? (
|
|
182
|
-
<DropdownMenuItem
|
|
183
|
-
onSelect={onLanguageSelect}
|
|
184
|
-
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
185
|
-
>
|
|
186
|
-
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
187
|
-
<span className="flex-1 truncate">Language</span>
|
|
188
|
-
<span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
|
189
|
-
{currentLocaleLabel}
|
|
190
|
-
<ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
|
191
|
-
</span>
|
|
192
|
-
</DropdownMenuItem>
|
|
193
|
-
) : null;
|
|
194
|
-
|
|
195
|
-
const themeIcon = theme === 'dark'
|
|
196
|
-
? <Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
197
|
-
: theme === 'light'
|
|
198
|
-
? <Sun className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
199
|
-
: <Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
|
200
|
-
const themeValueLabel = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System';
|
|
201
|
-
const themeItem = (
|
|
202
|
-
<DropdownMenuItem
|
|
203
|
-
onSelect={onThemeSelect}
|
|
204
|
-
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
205
|
-
>
|
|
206
|
-
{themeIcon}
|
|
207
|
-
<span className="flex-1 truncate">Theme</span>
|
|
208
|
-
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{themeValueLabel}</span>
|
|
209
|
-
</DropdownMenuItem>
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const expandedMeta = narrow ? null : (
|
|
167
|
+
const expandedMeta = content.isAccountCompact ? null : (
|
|
213
168
|
<>
|
|
214
169
|
<span className="flex min-w-0 flex-1 flex-col text-left">
|
|
215
170
|
<span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
|
|
@@ -223,28 +178,59 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
223
178
|
</span>
|
|
224
179
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
225
180
|
{secondaryButton}
|
|
226
|
-
|
|
181
|
+
{header?.accountAction !== 'dialog' ? (
|
|
182
|
+
<ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
|
|
183
|
+
) : null}
|
|
227
184
|
</span>
|
|
228
185
|
</>
|
|
229
186
|
);
|
|
230
187
|
|
|
188
|
+
const openProfileDialog = React.useCallback(() => {
|
|
189
|
+
useProfileDialogStore.getState().open();
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const triggerButton = (
|
|
193
|
+
<Button
|
|
194
|
+
type="button"
|
|
195
|
+
variant="ghost"
|
|
196
|
+
aria-label={content.isAccountCompact ? account.displayName : undefined}
|
|
197
|
+
className={triggerClassName}
|
|
198
|
+
onClick={header?.accountAction === 'dialog' ? openProfileDialog : onTriggerInteract}
|
|
199
|
+
>
|
|
200
|
+
<Avatar className={avatarClass}>
|
|
201
|
+
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
202
|
+
<AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
|
|
203
|
+
</Avatar>
|
|
204
|
+
{expandedMeta}
|
|
205
|
+
</Button>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const wrapperClass = cn(
|
|
209
|
+
'w-full min-w-0',
|
|
210
|
+
content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Dialog mode: simple button that opens the global ProfileDialog
|
|
214
|
+
if (header?.accountAction === 'dialog') {
|
|
215
|
+
return (
|
|
216
|
+
<div className={wrapperClass}>
|
|
217
|
+
{triggerButton}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
231
222
|
return (
|
|
232
|
-
<div className=
|
|
233
|
-
<DropdownMenu
|
|
223
|
+
<div className={wrapperClass}>
|
|
224
|
+
<DropdownMenu
|
|
225
|
+
open={isAccountMenuOpen}
|
|
226
|
+
onOpenChange={(open) => {
|
|
227
|
+
setIsAccountMenuOpen(open);
|
|
228
|
+
if (open) blockSidebarCollapse();
|
|
229
|
+
else allowSidebarCollapse();
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
234
232
|
<DropdownMenuTrigger asChild>
|
|
235
|
-
|
|
236
|
-
type="button"
|
|
237
|
-
variant="ghost"
|
|
238
|
-
aria-label={narrow ? account.displayName : undefined}
|
|
239
|
-
className={triggerClassName}
|
|
240
|
-
onClick={onTriggerInteract}
|
|
241
|
-
>
|
|
242
|
-
<Avatar className={avatarClass}>
|
|
243
|
-
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
244
|
-
<AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
|
|
245
|
-
</Avatar>
|
|
246
|
-
{expandedMeta}
|
|
247
|
-
</Button>
|
|
233
|
+
{triggerButton}
|
|
248
234
|
</DropdownMenuTrigger>
|
|
249
235
|
|
|
250
236
|
<DropdownMenuContent
|
|
@@ -256,10 +242,6 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
256
242
|
{headerLabel}
|
|
257
243
|
{accountLinksBlock}
|
|
258
244
|
|
|
259
|
-
<DropdownMenuSeparator />
|
|
260
|
-
{languageItem}
|
|
261
|
-
{themeItem}
|
|
262
|
-
|
|
263
245
|
<DropdownMenuSeparator />
|
|
264
246
|
<DropdownMenuItem
|
|
265
247
|
onSelect={onLogoutSelect}
|
|
@@ -270,22 +252,17 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
270
252
|
</DropdownMenuItem>
|
|
271
253
|
</DropdownMenuContent>
|
|
272
254
|
</DropdownMenu>
|
|
273
|
-
|
|
274
|
-
{layoutI18n ? (
|
|
275
|
-
<LocaleSwitcherDialog
|
|
276
|
-
open={langDialogOpen}
|
|
277
|
-
onOpenChange={setLangDialogOpen}
|
|
278
|
-
locale={layoutI18n.locale}
|
|
279
|
-
locales={layoutI18n.locales}
|
|
280
|
-
onChange={layoutI18n.onLocaleChange}
|
|
281
|
-
brand={layoutI18n.brand}
|
|
282
|
-
i18nLabels={layoutI18n.dialogLabels}
|
|
283
|
-
/>
|
|
284
|
-
) : null}
|
|
285
255
|
</div>
|
|
286
256
|
);
|
|
287
257
|
}
|
|
288
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Memoised account footer. Re-renders only when the `header` prop reference
|
|
261
|
+
* changes. Internal reactive data (user from useAuth, locale) are
|
|
262
|
+
* consumed via hooks and still update independently.
|
|
263
|
+
*/
|
|
264
|
+
export const PrivateSidebarAccount = memo(PrivateSidebarAccountRaw);
|
|
265
|
+
|
|
289
266
|
interface SecondaryActionProps {
|
|
290
267
|
action: NonNullable<HeaderConfig['footerSecondaryAction']>;
|
|
291
268
|
onParentExpand: () => void;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar Brand Component
|
|
3
|
+
*
|
|
4
|
+
* Renders the sidebar header with brand mark + title.
|
|
5
|
+
* Three modes: expanded (desktop), collapsed rail (desktop), mobile drawer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { memo, useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
import { Link, SidebarHeader, SidebarTrigger } from '@djangocfg/ui-core/components';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { LucideIcon } from '../../../components';
|
|
16
|
+
import { usePrivateLayoutContext } from '../context';
|
|
17
|
+
import { useShellVisualState } from '../hooks';
|
|
18
|
+
|
|
19
|
+
function SidebarBrandRaw() {
|
|
20
|
+
const { header, homeHref, brandTitle, brandMonogram, isMobile, isHoverExpanded } =
|
|
21
|
+
usePrivateLayoutContext();
|
|
22
|
+
const { content } = useShellVisualState();
|
|
23
|
+
|
|
24
|
+
const brandMark = useMemo(
|
|
25
|
+
() =>
|
|
26
|
+
header?.brandIcon ? (
|
|
27
|
+
<LucideIcon
|
|
28
|
+
icon={header.brandIcon}
|
|
29
|
+
className="h-4 w-4 text-sidebar-primary-foreground"
|
|
30
|
+
/>
|
|
31
|
+
) : (
|
|
32
|
+
<span className="text-[11px] font-bold leading-none tracking-tight text-sidebar-primary-foreground">
|
|
33
|
+
{brandMonogram}
|
|
34
|
+
</span>
|
|
35
|
+
),
|
|
36
|
+
[header?.brandIcon, brandMonogram],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const customBrand = header?.brand;
|
|
40
|
+
|
|
41
|
+
const headerRowClass = useMemo(
|
|
42
|
+
() =>
|
|
43
|
+
cn(
|
|
44
|
+
'flex items-center gap-2 mb-2',
|
|
45
|
+
// content.showLabels ? 'px-2' : 'px-1.5',
|
|
46
|
+
),
|
|
47
|
+
[content.showLabels],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const expandedHeader = useMemo(
|
|
51
|
+
() => (
|
|
52
|
+
<div className={headerRowClass}>
|
|
53
|
+
<div className="min-w-0 flex-1">
|
|
54
|
+
{customBrand != null && customBrand !== false ? (
|
|
55
|
+
typeof customBrand === 'string' ? (
|
|
56
|
+
<Link
|
|
57
|
+
href={homeHref}
|
|
58
|
+
className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
59
|
+
>
|
|
60
|
+
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
61
|
+
{customBrand}
|
|
62
|
+
</span>
|
|
63
|
+
</Link>
|
|
64
|
+
) : (
|
|
65
|
+
customBrand
|
|
66
|
+
)
|
|
67
|
+
) : (
|
|
68
|
+
<Link
|
|
69
|
+
href={homeHref}
|
|
70
|
+
className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
71
|
+
>
|
|
72
|
+
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
|
|
73
|
+
{brandMark}
|
|
74
|
+
</div>
|
|
75
|
+
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
76
|
+
{brandTitle}
|
|
77
|
+
</span>
|
|
78
|
+
</Link>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
{!isMobile && <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />}
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
[headerRowClass, customBrand, homeHref, brandMark, brandTitle, isMobile],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const collapsedHeader = useMemo(
|
|
88
|
+
() => (
|
|
89
|
+
<div className="flex justify-center py-1">
|
|
90
|
+
<Link
|
|
91
|
+
href={homeHref}
|
|
92
|
+
className="flex h-7 w-7 items-center justify-center rounded-md bg-sidebar-primary outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
93
|
+
aria-label={brandTitle}
|
|
94
|
+
>
|
|
95
|
+
{brandMark}
|
|
96
|
+
</Link>
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
[homeHref, brandTitle, brandMark],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
|
|
103
|
+
const mobileHeader = useMemo(
|
|
104
|
+
() => (
|
|
105
|
+
<div className="flex items-center gap-3">
|
|
106
|
+
<div className="min-w-0 flex-1">
|
|
107
|
+
{customBrand != null && customBrand !== false ? (
|
|
108
|
+
typeof customBrand === 'string' ? (
|
|
109
|
+
<Link
|
|
110
|
+
href={homeHref}
|
|
111
|
+
className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
112
|
+
>
|
|
113
|
+
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
114
|
+
{customBrand}
|
|
115
|
+
</span>
|
|
116
|
+
</Link>
|
|
117
|
+
) : (
|
|
118
|
+
customBrand
|
|
119
|
+
)
|
|
120
|
+
) : (
|
|
121
|
+
<Link
|
|
122
|
+
href={homeHref}
|
|
123
|
+
className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
124
|
+
>
|
|
125
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
|
|
126
|
+
{brandMark}
|
|
127
|
+
</div>
|
|
128
|
+
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
129
|
+
{brandTitle}
|
|
130
|
+
</span>
|
|
131
|
+
</Link>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
[customBrand, homeHref, brandMark, brandTitle],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const sidebarHeaderContent = isMobile
|
|
140
|
+
? mobileHeader
|
|
141
|
+
: content.showLabels
|
|
142
|
+
? expandedHeader
|
|
143
|
+
: collapsedHeader;
|
|
144
|
+
|
|
145
|
+
const sidebarHeaderClass = useMemo(
|
|
146
|
+
() =>
|
|
147
|
+
cn(
|
|
148
|
+
'pb-2',
|
|
149
|
+
isMobile
|
|
150
|
+
? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
|
|
151
|
+
: 'pt-3.5',
|
|
152
|
+
// Hover-expanded overlay: SidebarHeader from ui-core forces paddingLeft/Right to 0
|
|
153
|
+
// when state is collapsed. Override it so content has breathing room.
|
|
154
|
+
!isMobile && isHoverExpanded && '!px-2',
|
|
155
|
+
),
|
|
156
|
+
[isMobile, isHoverExpanded],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Memoised brand header. Re-renders only when context values change
|
|
164
|
+
* (header config, brand title, mobile state, showLabels). The three
|
|
165
|
+
* visual modes (expanded / collapsed rail / mobile) are pre-built with
|
|
166
|
+
* useMemo so the JSX tree is stable across renders.
|
|
167
|
+
*/
|
|
168
|
+
export const SidebarBrand = memo(SidebarBrandRaw);
|
|
@@ -11,9 +11,9 @@ import React from 'react';
|
|
|
11
11
|
|
|
12
12
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
13
|
|
|
14
|
-
import { LucideIcon } from '
|
|
14
|
+
import { LucideIcon } from '../../../components';
|
|
15
15
|
|
|
16
|
-
import type { SidebarFeaturedConfig } from '../
|
|
16
|
+
import type { SidebarFeaturedConfig } from '../types';
|
|
17
17
|
|
|
18
18
|
const ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
|
|
19
19
|
green: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15',
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar Nav Group Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a group of nav items as either a flat list or collapsible accordion.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Collapsible,
|
|
13
|
+
CollapsibleContent,
|
|
14
|
+
CollapsibleTrigger,
|
|
15
|
+
SidebarGroup,
|
|
16
|
+
SidebarGroupContent,
|
|
17
|
+
SidebarGroupLabel,
|
|
18
|
+
SidebarMenu,
|
|
19
|
+
} from '@djangocfg/ui-core/components';
|
|
20
|
+
import { ChevronDown } from 'lucide-react';
|
|
21
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
22
|
+
|
|
23
|
+
import { LucideIcon } from '../../../components';
|
|
24
|
+
import { usePrivateLayoutContext } from '../context';
|
|
25
|
+
import { useShellVisualState } from '../hooks';
|
|
26
|
+
import type { SidebarGroupConfig } from '../types';
|
|
27
|
+
import { SidebarNavItem } from './SidebarNavItem';
|
|
28
|
+
|
|
29
|
+
interface SidebarNavGroupProps {
|
|
30
|
+
group: SidebarGroupConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function SidebarNavGroupRaw({ group }: SidebarNavGroupProps) {
|
|
34
|
+
const { isActive, menuNav, groupLabelStyle } = usePrivateLayoutContext();
|
|
35
|
+
const { content } = useShellVisualState();
|
|
36
|
+
|
|
37
|
+
const hasLabel = Boolean(group.label && group.label.trim().length > 0);
|
|
38
|
+
const isCollapsible = Boolean(group.collapsible) && hasLabel && content.showLabels;
|
|
39
|
+
const hideItemIcons = group.hideItemIcons ?? isCollapsible;
|
|
40
|
+
|
|
41
|
+
const hasActiveChild = useMemo(
|
|
42
|
+
() => group.items.some((item) => isActive(item.href)),
|
|
43
|
+
[group.items, isActive],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const [open, setOpen] = useState<boolean>(
|
|
47
|
+
isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (isCollapsible && hasActiveChild) setOpen(true);
|
|
52
|
+
}, [isCollapsible, hasActiveChild]);
|
|
53
|
+
|
|
54
|
+
const items = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
group.items.map((item) => (
|
|
57
|
+
<SidebarNavItem key={item.href} item={item} hideItemIcons={hideItemIcons} />
|
|
58
|
+
)),
|
|
59
|
+
[group.items, hideItemIcons],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const groupLabelUppercaseClass = useMemo(
|
|
63
|
+
() => cn('px-2', menuNav.label),
|
|
64
|
+
[menuNav.label],
|
|
65
|
+
);
|
|
66
|
+
const groupLabelPlainClass = useMemo(
|
|
67
|
+
() =>
|
|
68
|
+
cn(
|
|
69
|
+
'px-2 text-sm font-semibold text-sidebar-foreground',
|
|
70
|
+
'h-7 leading-none',
|
|
71
|
+
),
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const labelClass = useMemo(
|
|
76
|
+
() =>
|
|
77
|
+
cn(
|
|
78
|
+
isCollapsible || groupLabelStyle === 'plain'
|
|
79
|
+
? groupLabelPlainClass
|
|
80
|
+
: groupLabelUppercaseClass,
|
|
81
|
+
content.showGroupLabels && '!mt-0 !opacity-100 !pointer-events-auto !flex !h-auto',
|
|
82
|
+
),
|
|
83
|
+
[isCollapsible, groupLabelStyle, groupLabelPlainClass, groupLabelUppercaseClass, content.showGroupLabels],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const sidebarGroupClass = useMemo(
|
|
87
|
+
() => cn('gap-0', menuNav.groupPad),
|
|
88
|
+
[menuNav.groupPad],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const handleOpenChange = useCallback((value: boolean) => {
|
|
92
|
+
setOpen(value);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
if (isCollapsible) {
|
|
96
|
+
const triggerIcon = group.icon ? (
|
|
97
|
+
<LucideIcon
|
|
98
|
+
icon={group.icon}
|
|
99
|
+
className={cn(menuNav.iconClass, 'shrink-0 text-sidebar-foreground/70')}
|
|
100
|
+
/>
|
|
101
|
+
) : null;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<SidebarGroup className={sidebarGroupClass}>
|
|
105
|
+
<Collapsible open={open} onOpenChange={handleOpenChange} className="w-full">
|
|
106
|
+
<CollapsibleTrigger asChild>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
className={cn(
|
|
110
|
+
'group/trig flex w-full items-center gap-2 rounded-md px-2 py-1.5',
|
|
111
|
+
'text-sm font-semibold text-sidebar-foreground',
|
|
112
|
+
'transition-colors hover:bg-sidebar-accent/40',
|
|
113
|
+
'data-[no-expand]',
|
|
114
|
+
)}
|
|
115
|
+
aria-expanded={open}
|
|
116
|
+
data-no-expand
|
|
117
|
+
>
|
|
118
|
+
{triggerIcon}
|
|
119
|
+
<span className="flex-1 truncate text-left">{group.label}</span>
|
|
120
|
+
<ChevronDown
|
|
121
|
+
className={cn(
|
|
122
|
+
'h-4 w-4 shrink-0 text-sidebar-foreground/55 transition-transform duration-200',
|
|
123
|
+
open && 'rotate-180',
|
|
124
|
+
)}
|
|
125
|
+
aria-hidden
|
|
126
|
+
/>
|
|
127
|
+
</button>
|
|
128
|
+
</CollapsibleTrigger>
|
|
129
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
|
130
|
+
<SidebarGroupContent>
|
|
131
|
+
<SidebarMenu className={cn(menuNav.menu, 'mt-1')}>
|
|
132
|
+
{items}
|
|
133
|
+
</SidebarMenu>
|
|
134
|
+
</SidebarGroupContent>
|
|
135
|
+
</CollapsibleContent>
|
|
136
|
+
</Collapsible>
|
|
137
|
+
</SidebarGroup>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<SidebarGroup className={sidebarGroupClass}>
|
|
143
|
+
{hasLabel ? (
|
|
144
|
+
<SidebarGroupLabel className={labelClass}>{group.label}</SidebarGroupLabel>
|
|
145
|
+
) : null}
|
|
146
|
+
<SidebarGroupContent className="mt-1">
|
|
147
|
+
<SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
|
|
148
|
+
</SidebarGroupContent>
|
|
149
|
+
</SidebarGroup>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Shallow equality for SidebarGroupConfig — compares all scalar fields and
|
|
155
|
+
* deep-shallow-compares the `items` array. This lets badge counts and
|
|
156
|
+
* dynamic group visibility update, while skipping re-renders caused by
|
|
157
|
+
* parent reference churn.
|
|
158
|
+
*/
|
|
159
|
+
function groupShallowEqual(a: SidebarGroupConfig, b: SidebarGroupConfig): boolean {
|
|
160
|
+
return (
|
|
161
|
+
a.label === b.label &&
|
|
162
|
+
a.collapsible === b.collapsible &&
|
|
163
|
+
a.defaultOpen === b.defaultOpen &&
|
|
164
|
+
a.icon === b.icon &&
|
|
165
|
+
a.hideItemIcons === b.hideItemIcons &&
|
|
166
|
+
a.dynamic === b.dynamic &&
|
|
167
|
+
a.items.length === b.items.length &&
|
|
168
|
+
a.items.every((ai, i) => {
|
|
169
|
+
const bi = b.items[i];
|
|
170
|
+
return (
|
|
171
|
+
ai.href === bi.href &&
|
|
172
|
+
ai.label === bi.label &&
|
|
173
|
+
ai.icon === bi.icon &&
|
|
174
|
+
ai.badge === bi.badge &&
|
|
175
|
+
ai.badgeVariant === bi.badgeVariant &&
|
|
176
|
+
ai.tooltip === bi.tooltip
|
|
177
|
+
);
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Memoised group wrapper. Re-renders only when the `group` prop changes
|
|
184
|
+
* (label, items, badges, etc.). Same data from a new parent object is
|
|
185
|
+
* ignored thanks to `groupShallowEqual`.
|
|
186
|
+
*/
|
|
187
|
+
export const SidebarNavGroup = memo(SidebarNavGroupRaw, (prev, next) => {
|
|
188
|
+
return groupShallowEqual(prev.group, next.group);
|
|
189
|
+
});
|