@djangocfg/layouts 2.1.356 → 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.
Files changed (75) hide show
  1. package/package.json +17 -17
  2. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  3. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  4. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  5. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  6. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  7. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  8. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  9. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  10. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  13. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  14. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  15. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  16. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  17. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  18. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  22. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  23. package/src/layouts/AuthLayout/context.tsx +35 -13
  24. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  25. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  26. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  28. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -247
  29. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +115 -426
  30. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +40 -19
  31. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +165 -0
  32. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  33. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  34. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  35. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  36. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  37. package/src/layouts/PrivateLayout/context.tsx +211 -0
  38. package/src/layouts/PrivateLayout/density.ts +48 -0
  39. package/src/layouts/PrivateLayout/hooks/index.ts +13 -0
  40. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  41. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +103 -0
  42. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  43. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  44. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  45. package/src/layouts/PrivateLayout/index.ts +2 -2
  46. package/src/layouts/PrivateLayout/types.ts +187 -0
  47. package/src/layouts/ProfileLayout/ProfileLayout.tsx +44 -183
  48. package/src/layouts/ProfileLayout/README.md +58 -0
  49. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  50. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  51. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  52. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  53. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +29 -0
  54. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  55. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  56. package/src/layouts/ProfileLayout/components/index.ts +4 -2
  57. package/src/layouts/ProfileLayout/context.tsx +4 -6
  58. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  59. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +43 -0
  60. package/src/layouts/ProfileLayout/index.ts +6 -3
  61. package/src/layouts/ProfileLayout/types.ts +37 -0
  62. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  63. package/src/layouts/PublicLayout/components/index.ts +4 -0
  64. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  65. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  66. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  67. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  68. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  69. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  70. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  71. package/src/layouts/_components/index.ts +2 -7
  72. package/src/layouts/index.ts +9 -4
  73. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  74. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  75. /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 '../../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 { 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 '../PrivateLayout/PrivateLayout';
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
- export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
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 { state, setOpen: setSidebarOpen } = useSidebar();
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 (narrow) setSidebarOpen(true);
95
- }, [narrow, setSidebarOpen]);
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
- narrow ? 'justify-center px-0 py-2' : 'min-h-[52px]',
140
+ content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
134
141
  );
135
142
 
136
- const secondaryButton = secondary && !narrow ? (
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
- narrow ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
149
+ content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
143
150
  );
144
- const dropdownSide: 'top' | 'right' = narrow ? 'right' : 'top';
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 = narrow ? null : (
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={narrow ? account.displayName : undefined}
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 '../../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
+ });
@@ -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
+ });