@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.
Files changed (83) hide show
  1. package/package.json +21 -19
  2. package/src/configurator/private/schema.ts +12 -0
  3. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  6. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  7. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  8. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  9. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  10. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  13. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  14. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  15. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  16. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  17. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  18. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  22. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  23. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  24. package/src/layouts/AuthLayout/context.tsx +35 -13
  25. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  26. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  28. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  29. package/src/layouts/PrivateLayout/PrivateLayout.tsx +45 -248
  30. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +113 -430
  31. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +82 -105
  32. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +168 -0
  33. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  34. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  35. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  36. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  37. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  38. package/src/layouts/PrivateLayout/context.tsx +211 -0
  39. package/src/layouts/PrivateLayout/density.ts +48 -0
  40. package/src/layouts/PrivateLayout/hooks/index.ts +14 -0
  41. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  42. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +110 -0
  43. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  44. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  45. package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
  46. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  47. package/src/layouts/PrivateLayout/index.ts +2 -2
  48. package/src/layouts/PrivateLayout/types.ts +193 -0
  49. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
  50. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
  51. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
  52. package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +8 -8
  53. package/src/layouts/ProfileLayout/ProfileForm/index.tsx +148 -0
  54. package/src/layouts/ProfileLayout/README.md +118 -0
  55. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  56. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  57. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  58. package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
  59. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
  60. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  61. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +35 -0
  62. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  63. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  64. package/src/layouts/ProfileLayout/components/index.ts +5 -2
  65. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  66. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +48 -0
  67. package/src/layouts/ProfileLayout/index.ts +7 -3
  68. package/src/layouts/ProfileLayout/types.ts +47 -0
  69. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  70. package/src/layouts/PublicLayout/components/index.ts +4 -0
  71. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  72. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  73. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  74. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  75. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  76. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  77. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  78. package/src/layouts/_components/index.ts +2 -6
  79. package/src/layouts/index.ts +9 -4
  80. package/src/layouts/ProfileLayout/ProfileLayout.tsx +0 -284
  81. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  82. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  83. /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, locale +
4
- * theme controls, and sign-out. Replaces the legacy inline collapsible.
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 { ChevronRight, ChevronsUpDown, Globe, LogOut, Monitor, Moon, Sun } from 'lucide-react';
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 '../../hooks';
32
- import { LocaleSwitcherDialog } from './locale-switcher';
33
- import { getLocaleMeta } from './locale-switcher/localeMeta';
34
- import { useLayoutI18nOptional } from '../AppLayout/LayoutI18nProvider';
35
- import { LucideIcon as LucideIconRender } from '../../components';
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 '../PrivateLayout/PrivateLayout';
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
- export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
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 { state, setOpen: setSidebarOpen } = useSidebar();
57
- const { theme, setTheme } = useThemeContext();
58
- const [langDialogOpen, setLangDialogOpen] = React.useState(false);
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 (narrow) setSidebarOpen(true);
95
- }, [narrow, setSidebarOpen]);
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-none px-3 py-3 text-left',
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
- narrow ? 'justify-center px-0 py-2' : 'min-h-[52px]',
122
+ content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
134
123
  );
135
124
 
136
- const secondaryButton = secondary && !narrow ? (
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
- narrow ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
131
+ content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
143
132
  );
144
- const dropdownSide: 'top' | 'right' = narrow ? 'right' : 'top';
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 currentLocaleLabel = layoutI18n
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
- <ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
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="w-full min-w-0 border-t border-sidebar-border/40">
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
- <Button
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 '../../components';
14
+ import { LucideIcon } from '../../../components';
15
15
 
16
- import type { SidebarFeaturedConfig } from '../PrivateLayout/PrivateLayout';
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
+ });