@djangocfg/layouts 2.1.355 → 2.1.357
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 +17 -17
- 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 +32 -247
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +115 -426
- package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +40 -19
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +165 -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 +13 -0
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +103 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
- package/src/layouts/PrivateLayout/index.ts +2 -2
- package/src/layouts/PrivateLayout/types.ts +187 -0
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +44 -183
- package/src/layouts/ProfileLayout/README.md +58 -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/ProfileHeader.tsx +110 -0
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +29 -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 +4 -2
- package/src/layouts/ProfileLayout/context.tsx +4 -6
- package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +43 -0
- package/src/layouts/ProfileLayout/index.ts +6 -3
- package/src/layouts/ProfileLayout/types.ts +37 -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 -7
- package/src/layouts/index.ts +9 -4
- 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
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* Sidebar account: rich footer trigger (avatar + name + plan + optional secondary
|
|
3
3
|
* action) opens a popover (DropdownMenu) upward with email, account links, locale +
|
|
4
4
|
* theme controls, and sign-out. Replaces the legacy inline collapsible.
|
|
5
|
+
*
|
|
6
|
+
* Reads `isAccountMenuOpen` / `setIsAccountMenuOpen` directly from
|
|
7
|
+
* PrivateLayoutContext so the parent sidebar can block collapse while the menu
|
|
8
|
+
* is open.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
'use client';
|
|
8
12
|
|
|
9
13
|
import { ChevronRight, ChevronsUpDown, Globe, LogOut, Monitor, Moon, Sun } from 'lucide-react';
|
|
10
14
|
import { Link } from '@djangocfg/ui-core/components';
|
|
11
|
-
import React from 'react';
|
|
15
|
+
import React, { memo, useMemo } from 'react';
|
|
12
16
|
|
|
13
17
|
import { useAuth } from '@djangocfg/api/auth';
|
|
14
18
|
import { useAppT } from '@djangocfg/i18n';
|
|
@@ -28,13 +32,15 @@ import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
|
28
32
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
29
33
|
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
30
34
|
|
|
31
|
-
import { useLogout } from '
|
|
32
|
-
import { LocaleSwitcherDialog } from '
|
|
33
|
-
import { getLocaleMeta } from '
|
|
34
|
-
import { useLayoutI18nOptional } from '
|
|
35
|
-
import { LucideIcon as LucideIconRender } from '
|
|
35
|
+
import { useLogout } from '../../../hooks';
|
|
36
|
+
import { LocaleSwitcherDialog } from '../../_components/locale-switcher';
|
|
37
|
+
import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
|
|
38
|
+
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
39
|
+
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
40
|
+
import { useShellVisualState } from '../hooks';
|
|
41
|
+
import { blockSidebarCollapse, allowSidebarCollapse } from '../hooks/useHoverExpand';
|
|
36
42
|
|
|
37
|
-
import type { HeaderConfig } from '../
|
|
43
|
+
import type { HeaderConfig } from '../types';
|
|
38
44
|
|
|
39
45
|
interface PrivateSidebarAccountProps {
|
|
40
46
|
header?: HeaderConfig;
|
|
@@ -48,15 +54,16 @@ interface AccountView {
|
|
|
48
54
|
plan: string | null;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
52
58
|
const { user } = useAuth();
|
|
53
59
|
const handleLogout = useLogout();
|
|
54
60
|
const t = useAppT();
|
|
55
61
|
const layoutI18n = useLayoutI18nOptional();
|
|
56
|
-
const {
|
|
62
|
+
const { setOpen: setSidebarOpen } = useSidebar();
|
|
63
|
+
const { content } = useShellVisualState();
|
|
64
|
+
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
57
65
|
const { theme, setTheme } = useThemeContext();
|
|
58
66
|
const [langDialogOpen, setLangDialogOpen] = React.useState(false);
|
|
59
|
-
const narrow = state === 'collapsed';
|
|
60
67
|
|
|
61
68
|
const signOutLabel = t('layouts.profile.signOut');
|
|
62
69
|
|
|
@@ -91,8 +98,8 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
91
98
|
}, [user, header?.userPlan]);
|
|
92
99
|
|
|
93
100
|
const onTriggerInteract = React.useCallback(() => {
|
|
94
|
-
if (
|
|
95
|
-
}, [
|
|
101
|
+
if (content.isAccountCompact) setSidebarOpen(true);
|
|
102
|
+
}, [content.isAccountCompact, setSidebarOpen]);
|
|
96
103
|
|
|
97
104
|
const onSecondaryExpand = React.useCallback(() => {
|
|
98
105
|
setSidebarOpen(true);
|
|
@@ -130,18 +137,18 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
130
137
|
const triggerClassName = cn(
|
|
131
138
|
'group h-auto w-full gap-3 rounded-none px-3 py-3 text-left',
|
|
132
139
|
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
133
|
-
|
|
140
|
+
content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
|
|
134
141
|
);
|
|
135
142
|
|
|
136
|
-
const secondaryButton = secondary && !
|
|
143
|
+
const secondaryButton = secondary && !content.isAccountCompact ? (
|
|
137
144
|
<SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
|
|
138
145
|
) : null;
|
|
139
146
|
|
|
140
147
|
const dropdownContentClass = cn(
|
|
141
148
|
'p-1.5',
|
|
142
|
-
|
|
149
|
+
content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
|
|
143
150
|
);
|
|
144
|
-
const dropdownSide: 'top' | 'right' =
|
|
151
|
+
const dropdownSide: 'top' | 'right' = content.isAccountCompact ? 'right' : 'top';
|
|
145
152
|
const avatarClass = cn(
|
|
146
153
|
'h-9 w-9 shrink-0 border border-transparent transition-colors',
|
|
147
154
|
'group-hover:border-sidebar-border/70',
|
|
@@ -209,7 +216,7 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
209
216
|
</DropdownMenuItem>
|
|
210
217
|
);
|
|
211
218
|
|
|
212
|
-
const expandedMeta =
|
|
219
|
+
const expandedMeta = content.isAccountCompact ? null : (
|
|
213
220
|
<>
|
|
214
221
|
<span className="flex min-w-0 flex-1 flex-col text-left">
|
|
215
222
|
<span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
|
|
@@ -230,12 +237,19 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
230
237
|
|
|
231
238
|
return (
|
|
232
239
|
<div className="w-full min-w-0 border-t border-sidebar-border/40">
|
|
233
|
-
<DropdownMenu
|
|
240
|
+
<DropdownMenu
|
|
241
|
+
open={isAccountMenuOpen}
|
|
242
|
+
onOpenChange={(open) => {
|
|
243
|
+
setIsAccountMenuOpen(open);
|
|
244
|
+
if (open) blockSidebarCollapse();
|
|
245
|
+
else allowSidebarCollapse();
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
234
248
|
<DropdownMenuTrigger asChild>
|
|
235
249
|
<Button
|
|
236
250
|
type="button"
|
|
237
251
|
variant="ghost"
|
|
238
|
-
aria-label={
|
|
252
|
+
aria-label={content.isAccountCompact ? account.displayName : undefined}
|
|
239
253
|
className={triggerClassName}
|
|
240
254
|
onClick={onTriggerInteract}
|
|
241
255
|
>
|
|
@@ -286,6 +300,13 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
286
300
|
);
|
|
287
301
|
}
|
|
288
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Memoised account footer. Re-renders only when the `header` prop reference
|
|
305
|
+
* changes. Internal reactive data (user from useAuth, theme, locale) are
|
|
306
|
+
* consumed via hooks and still update independently.
|
|
307
|
+
*/
|
|
308
|
+
export const PrivateSidebarAccount = memo(PrivateSidebarAccountRaw);
|
|
309
|
+
|
|
289
310
|
interface SecondaryActionProps {
|
|
290
311
|
action: NonNullable<HeaderConfig['footerSecondaryAction']>;
|
|
291
312
|
onParentExpand: () => void;
|
|
@@ -0,0 +1,165 @@
|
|
|
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 } =
|
|
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',
|
|
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
|
+
? 'px-4 pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
|
|
151
|
+
: 'px-2 pt-3.5',
|
|
152
|
+
),
|
|
153
|
+
[isMobile],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Memoised brand header. Re-renders only when context values change
|
|
161
|
+
* (header config, brand title, mobile state, showLabels). The three
|
|
162
|
+
* visual modes (expanded / collapsed rail / mobile) are pre-built with
|
|
163
|
+
* useMemo so the JSX tree is stable across renders.
|
|
164
|
+
*/
|
|
165
|
+
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
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar Nav Item Component
|
|
3
|
+
*
|
|
4
|
+
* Individual navigation item with icon, label, badge, and active state.
|
|
5
|
+
* Memoized with shallow item comparison so badge number changes still re-render.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { memo, useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Link,
|
|
14
|
+
SidebarMenuBadge,
|
|
15
|
+
SidebarMenuButton,
|
|
16
|
+
SidebarMenuItem,
|
|
17
|
+
} from '@djangocfg/ui-core/components';
|
|
18
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
19
|
+
|
|
20
|
+
import { LucideIcon } from '../../../components';
|
|
21
|
+
import { usePrivateLayoutContext } from '../context';
|
|
22
|
+
import { useShellVisualState } from '../hooks';
|
|
23
|
+
import type { SidebarItem } from '../types';
|
|
24
|
+
|
|
25
|
+
interface SidebarNavItemProps {
|
|
26
|
+
item: SidebarItem;
|
|
27
|
+
hideItemIcons?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
|
|
31
|
+
const { isActive, menuNav } = usePrivateLayoutContext();
|
|
32
|
+
const { content } = useShellVisualState();
|
|
33
|
+
|
|
34
|
+
const tooltipText = item.tooltip ?? item.label;
|
|
35
|
+
|
|
36
|
+
const itemIcon = useMemo(
|
|
37
|
+
() =>
|
|
38
|
+
!hideItemIcons && item.icon ? (
|
|
39
|
+
<LucideIcon icon={item.icon} className={menuNav.iconClass} />
|
|
40
|
+
) : null,
|
|
41
|
+
[hideItemIcons, item.icon, menuNav.iconClass],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const hasBadge = Boolean(item.badge);
|
|
45
|
+
|
|
46
|
+
const collapsedBadgeDot = useMemo(
|
|
47
|
+
() =>
|
|
48
|
+
!content.showBadgeText && hasBadge ? (
|
|
49
|
+
<span className="absolute right-1 top-1 flex h-2 w-2">
|
|
50
|
+
<span className="inline-flex h-full w-full rounded-full bg-primary" />
|
|
51
|
+
</span>
|
|
52
|
+
) : null,
|
|
53
|
+
[content.showBadgeText, hasBadge],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const expandedBadgeNode = useMemo(
|
|
57
|
+
() =>
|
|
58
|
+
content.showBadgeText && hasBadge ? (
|
|
59
|
+
<SidebarMenuBadge
|
|
60
|
+
className={cn(
|
|
61
|
+
item.badgeVariant === 'pill'
|
|
62
|
+
? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
|
|
63
|
+
: undefined,
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{item.badge}
|
|
67
|
+
</SidebarMenuBadge>
|
|
68
|
+
) : null,
|
|
69
|
+
[content.showBadgeText, hasBadge, item.badgeVariant, item.badge],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const isItemActive = isActive(item.href);
|
|
73
|
+
|
|
74
|
+
const buttonClass = useMemo(
|
|
75
|
+
() =>
|
|
76
|
+
cn(
|
|
77
|
+
'group/nav relative border-0 font-medium shadow-none transition-colors',
|
|
78
|
+
'text-sidebar-foreground/80',
|
|
79
|
+
'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
|
|
80
|
+
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
81
|
+
'[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
|
|
82
|
+
'[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
|
|
83
|
+
'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
|
|
84
|
+
hideItemIcons && 'pl-8',
|
|
85
|
+
content.showLabels && '[&>span]:!inline !p-2 !justify-start',
|
|
86
|
+
),
|
|
87
|
+
[hideItemIcons, content.showLabels],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<SidebarMenuItem>
|
|
92
|
+
<SidebarMenuButton
|
|
93
|
+
asChild
|
|
94
|
+
isActive={isItemActive}
|
|
95
|
+
size={menuNav.buttonSize}
|
|
96
|
+
tooltip={content.showTooltips ? tooltipText : undefined}
|
|
97
|
+
className={buttonClass}
|
|
98
|
+
>
|
|
99
|
+
<Link href={item.href}>
|
|
100
|
+
{itemIcon}
|
|
101
|
+
<span>{item.label}</span>
|
|
102
|
+
{expandedBadgeNode}
|
|
103
|
+
</Link>
|
|
104
|
+
</SidebarMenuButton>
|
|
105
|
+
{collapsedBadgeDot}
|
|
106
|
+
</SidebarMenuItem>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Shallow equality for SidebarItem — compares primitive fields so that
|
|
112
|
+
* badge number changes (e.g. 3 → 5) still trigger a re-render, but
|
|
113
|
+
* object-reference churn from parent re-renders is ignored.
|
|
114
|
+
*/
|
|
115
|
+
function itemShallowEqual(a: SidebarItem, b: SidebarItem): boolean {
|
|
116
|
+
return (
|
|
117
|
+
a.href === b.href &&
|
|
118
|
+
a.label === b.label &&
|
|
119
|
+
a.icon === b.icon &&
|
|
120
|
+
a.badge === b.badge &&
|
|
121
|
+
a.badgeVariant === b.badgeVariant &&
|
|
122
|
+
a.tooltip === b.tooltip
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Memoised leaf component. Re-renders only when:
|
|
128
|
+
* - `hideItemIcons` changes, or
|
|
129
|
+
* - any field inside `item` changes (badge count, label, href, etc.).
|
|
130
|
+
* Parent re-renders with the same item data are skipped.
|
|
131
|
+
*/
|
|
132
|
+
export const SidebarNavItem = memo(SidebarNavItemRaw, (prev, next) => {
|
|
133
|
+
return (
|
|
134
|
+
prev.hideItemIcons === next.hideItemIcons &&
|
|
135
|
+
itemShallowEqual(prev.item, next.item)
|
|
136
|
+
);
|
|
137
|
+
});
|