@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.
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
@@ -1,133 +1,29 @@
1
1
  /**
2
- * Private sidebar: header (brand only when expanded; icon mode shows expand trigger only),
3
- * nav groups, account footer. Item-count density when **expanded**; collapsed rail always uses `default` metrics so icons match.
2
+ * Private Sidebar
3
+ *
4
+ * Composed from smaller components: SidebarBrand, SidebarNavGroup, SidebarSlots.
5
+ * Uses PrivateLayoutContext for all UI state.
4
6
  */
5
7
 
6
8
  'use client';
7
9
 
8
- import {
9
- Collapsible,
10
- CollapsibleContent,
11
- CollapsibleTrigger,
12
- Link,
13
- } from '@djangocfg/ui-core/components';
14
- import { ChevronDown } from 'lucide-react';
15
- import { usePathname as useNextPathname } from 'next/navigation';
16
10
  import React from 'react';
17
11
 
18
12
  import {
19
13
  Sidebar,
20
14
  SidebarContent,
21
15
  SidebarFooter,
22
- SidebarGroup,
23
- SidebarGroupContent,
24
- SidebarGroupLabel,
25
- SidebarHeader,
26
- SidebarMenu,
27
- SidebarMenuBadge,
28
- SidebarMenuButton,
29
- SidebarMenuItem,
30
- SidebarTrigger,
31
16
  useSidebar,
32
17
  } from '@djangocfg/ui-core/components';
33
18
  import { cn } from '@djangocfg/ui-core/lib';
34
19
 
35
- import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
36
- import { LucideIcon } from '../../../components';
37
-
38
- import type {
39
- HeaderConfig,
40
- SidebarActiveIndicator,
41
- SidebarConfig,
42
- SidebarGroupConfig,
43
- SidebarGroupLabelStyle,
44
- SidebarItem,
45
- } from '../PrivateLayout';
46
- import { SidebarFeatured } from '../../_components/SidebarFeatured';
47
-
48
- /** Few items → roomier rows; many items → tighter. Same breakpoints for demo, CarAPIS, etc. */
49
- const DENSITY_COMFORTABLE_MAX = 5;
50
- const DENSITY_DEFAULT_MAX = 9;
51
-
52
- type NavDensity = 'comfortable' | 'default' | 'compact';
53
-
54
- function navDensityFromCount(n: number): NavDensity {
55
- if (n <= DENSITY_COMFORTABLE_MAX) return 'comfortable';
56
- if (n <= DENSITY_DEFAULT_MAX) return 'default';
57
- return 'compact';
58
- }
59
-
60
- /**
61
- * Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
62
- */
63
- const navItemBaseClass = cn(
64
- 'group/nav relative border-0 font-medium shadow-none transition-colors',
65
- 'text-sidebar-foreground/80',
66
- 'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
67
- 'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
68
- '[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
69
- '[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
70
- 'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
71
- );
72
-
73
- const ACTIVE_INDICATOR_CLASS: Record<SidebarActiveIndicator, string> = {
74
- background: cn(
75
- 'data-[active=true]:bg-sidebar-accent',
76
- 'data-[active=true]:hover:bg-sidebar-accent',
77
- ),
78
- rail: cn(
79
- 'data-[active=true]:after:absolute data-[active=true]:after:right-0',
80
- 'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
81
- 'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
82
- 'data-[active=true]:after:bg-primary',
83
- ),
84
- both: cn(
85
- 'data-[active=true]:bg-sidebar-accent data-[active=true]:hover:bg-sidebar-accent',
86
- 'data-[active=true]:after:absolute data-[active=true]:after:right-0',
87
- 'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
88
- 'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
89
- 'data-[active=true]:after:bg-primary',
90
- ),
91
- };
92
-
93
- const DENSITY = {
94
- comfortable: {
95
- menu: 'gap-1.5',
96
- group: 'gap-2',
97
- groupPad: 'px-2 py-1',
98
- label:
99
- 'h-7 uppercase text-[10px] font-light leading-none tracking-[0.14em] text-sidebar-foreground/40',
100
- buttonSize: 'lg' as const,
101
- iconClass: 'h-5 w-5',
102
- extraButton: 'rounded-lg !px-3',
103
- headerRowInset: 'pl-2',
104
- },
105
- default: {
106
- menu: 'gap-1',
107
- group: 'gap-1.5',
108
- groupPad: 'px-2 py-1',
109
- label:
110
- 'uppercase text-[9px] font-light leading-none tracking-[0.12em] text-sidebar-foreground/50',
111
- buttonSize: 'default' as const,
112
- iconClass: 'h-4 w-4',
113
- extraButton: 'rounded-lg',
114
- headerRowInset: 'pl-1.5',
115
- },
116
- compact: {
117
- menu: 'gap-0.5',
118
- group: 'gap-0.5',
119
- groupPad: 'px-2 py-0.5',
120
- label:
121
- 'h-6 uppercase text-[8px] font-light leading-none tracking-[0.1em] text-sidebar-foreground/40',
122
- buttonSize: 'sm' as const,
123
- iconClass: 'h-3.5 w-3.5',
124
- extraButton: 'rounded-md !px-2',
125
- headerRowInset: 'pl-1.5',
126
- },
127
- } as const;
128
-
129
- /** Icon rail: always the same geometry — ignore comfortable/compact (larger/smaller rows only when expanded). */
130
- const RAIL_NAV = DENSITY.default;
20
+ import { PrivateSidebarAccount } from './PrivateSidebarAccount';
21
+ import { PrivateLayoutProvider } from '../context';
22
+ import { useHoverExpand, useShellVisualState, useSidebarKeyboard } from '../hooks';
23
+ import type { HeaderConfig, SidebarConfig } from '../types';
24
+ import { SidebarBrand } from './SidebarBrand';
25
+ import { SidebarNavGroup } from './SidebarNavGroup';
26
+ import { SidebarSlots } from './SidebarSlots';
131
27
 
132
28
  interface PrivateSidebarProps {
133
29
  sidebar: SidebarConfig;
@@ -141,177 +37,73 @@ interface PrivateSidebarProps {
141
37
  variant?: 'sidebar' | 'inset';
142
38
  }
143
39
 
144
- export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, variant = 'sidebar' }: PrivateSidebarProps) {
145
- const pathnameFromNext = useNextPathname();
146
- const pathname = pathnameProp ?? pathnameFromNext;
147
- const { state, isMobile, setOpen, setOpenMobile } = useSidebar();
148
- const homeHref = sidebar.homeHref || '/';
40
+ export function PrivateSidebar({
41
+ sidebar,
42
+ header,
43
+ pathname: pathnameProp,
44
+ variant = 'sidebar',
45
+ }: PrivateSidebarProps) {
46
+ const { state, isMobile, setOpenMobile, setOpen } = useSidebar();
47
+ const pathname = pathnameProp ?? '';
149
48
 
150
49
  React.useEffect(() => {
151
50
  if (isMobile) setOpenMobile(false);
152
51
  }, [pathname, isMobile, setOpenMobile]);
153
- const brandTitle = header?.title?.trim() || 'Dashboard';
154
- const brandMonogram = (header?.brandLetter?.trim().charAt(0) || brandTitle.charAt(0) || 'D').toUpperCase();
155
- const customBrand = header?.brand;
156
-
157
- const allItems = React.useMemo(
158
- () => sidebar.groups.flatMap((g) => g.items),
159
- [sidebar.groups],
160
- );
161
-
162
- const density = React.useMemo(() => navDensityFromCount(allItems.length), [allItems.length]);
163
- const tierNav = DENSITY[density];
164
- /** Expanded: follow item-count tier; collapsed rail: fixed `default` sizing so icons stay uniform. */
165
- const menuNav = state === 'collapsed' ? RAIL_NAV : tierNav;
166
-
167
- const isActive = React.useCallback(
168
- (href: string) => {
169
- const matches = pathname === href || pathname.startsWith(`${href}/`);
170
- if (!matches) return false;
171
- return !allItems.some(
172
- (other) =>
173
- other.href !== href &&
174
- other.href.startsWith(`${href}/`) &&
175
- (pathname === other.href || pathname.startsWith(`${other.href}/`)),
176
- );
177
- },
178
- [pathname, allItems],
179
- );
180
52
 
181
- const expanded = state === 'expanded';
182
-
183
- const headerRowClass = cn('flex items-center gap-2', tierNav.headerRowInset);
184
- const brandMark = header?.brandIcon ? (
185
- <LucideIcon icon={header.brandIcon} className="h-4 w-4 text-sidebar-primary-foreground" />
186
- ) : (
187
- <span className="text-[11px] font-bold leading-none tracking-tight text-sidebar-primary-foreground">
188
- {brandMonogram}
189
- </span>
190
- );
191
-
192
- const hasMenuStart = sidebar.menuStart != null && sidebar.menuStart !== false;
193
- const hasMenuEnd = sidebar.menuEnd != null && sidebar.menuEnd !== false;
194
- // Hide slots on the desktop icon rail unless the consumer opted in. Mobile
195
- // drawer always shows them — there's no rail in the drawer to begin with.
196
53
  const collapsedRail = !isMobile && state === 'collapsed';
197
- const showMenuStart =
198
- hasMenuStart && (!collapsedRail || sidebar.menuStartShowOnCollapsed === true);
199
- const showMenuEnd =
200
- hasMenuEnd && (!collapsedRail || sidebar.menuEndShowOnCollapsed === true);
201
- const menuStartSlot = showMenuStart ? (
202
- <div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuStart}</div>
203
- ) : null;
204
- const menuEndSlot = showMenuEnd ? (
205
- <div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuEnd}</div>
206
- ) : null;
207
-
208
- const sidebarContentClass = cn('gap-2', menuNav.group);
209
-
210
- const activeIndicator: SidebarActiveIndicator = sidebar.activeIndicator ?? 'background';
211
- const groupLabelStyle: SidebarGroupLabelStyle = sidebar.groupLabelStyle ?? 'uppercase';
212
- const navButtonClass = cn(navItemBaseClass, ACTIVE_INDICATOR_CLASS[activeIndicator], menuNav.extraButton);
213
- const groupLabelUppercaseClass = cn('px-2', menuNav.label);
214
- const groupLabelPlainClass = cn(
215
- 'px-2 text-sm font-semibold text-sidebar-foreground',
216
- 'h-7 leading-none',
217
- );
218
- const sidebarGroupClass = cn('gap-0', menuNav.groupPad);
54
+ const { isHoverExpanded, onMouseEnter, onMouseLeave } = useHoverExpand({
55
+ enabled: collapsedRail,
56
+ });
219
57
 
220
- const renderedGroups = sidebar.groups.map((group) => {
221
- if (group.dynamic && group.items.length === 0) return null;
222
- return (
223
- <SidebarGroupRenderer
224
- key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
225
- group={group}
226
- isActive={isActive}
227
- navButtonClass={navButtonClass}
228
- sidebarGroupClass={sidebarGroupClass}
229
- groupLabelUppercaseClass={groupLabelUppercaseClass}
230
- groupLabelPlainClass={groupLabelPlainClass}
231
- groupLabelStyle={groupLabelStyle}
232
- menuNav={menuNav}
58
+ return (
59
+ <PrivateLayoutProvider
60
+ sidebar={sidebar}
61
+ header={header}
62
+ pathname={pathname}
63
+ isMobile={isMobile}
64
+ state={state}
65
+ isHoverExpanded={isHoverExpanded}
66
+ >
67
+ <PrivateSidebarInner
68
+ sidebar={sidebar}
69
+ header={header}
70
+ variant={variant}
233
71
  collapsedRail={collapsedRail}
72
+ setOpen={setOpen}
73
+ onMouseEnter={onMouseEnter}
74
+ onMouseLeave={onMouseLeave}
234
75
  />
235
- );
236
- });
237
-
238
- const featuredSlot = sidebar.featured && !collapsedRail ? (
239
- <div className="w-full min-w-0 shrink-0 px-2">
240
- <SidebarFeatured config={sidebar.featured} />
241
- </div>
242
- ) : null;
243
-
244
- const expandedHeader = (
245
- <div className={headerRowClass}>
246
- <div className="min-w-0 flex-1">
247
- {customBrand != null && customBrand !== false
248
- ? typeof customBrand === 'string'
249
- ? (
250
- <Link
251
- href={homeHref}
252
- className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
253
- >
254
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{customBrand}</span>
255
- </Link>
256
- )
257
- : customBrand
258
- : (
259
- <Link
260
- href={homeHref}
261
- className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
262
- >
263
- <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
264
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
265
- </Link>
266
- )}
267
- </div>
268
- {!isMobile && <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />}
269
- </div>
270
- );
271
-
272
- const collapsedHeader = (
273
- <div className="flex justify-center py-1">
274
- <SidebarTrigger aria-label="Expand sidebar" />
275
- </div>
76
+ </PrivateLayoutProvider>
276
77
  );
78
+ }
277
79
 
278
- /** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
279
- const mobileHeader = (
280
- <div className="flex items-center gap-3">
281
- <div className="min-w-0 flex-1">
282
- {customBrand != null && customBrand !== false
283
- ? typeof customBrand === 'string'
284
- ? (
285
- <Link
286
- href={homeHref}
287
- className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
288
- >
289
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{customBrand}</span>
290
- </Link>
291
- )
292
- : customBrand
293
- : (
294
- <Link
295
- href={homeHref}
296
- className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
297
- >
298
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
299
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
300
- </Link>
301
- )}
302
- </div>
303
- </div>
304
- );
80
+ // ---------------------------------------------------------------------------
81
+ // Inner component — runs inside PrivateLayoutProvider so useShellVisualState
82
+ // can safely consume the context.
83
+ // ---------------------------------------------------------------------------
305
84
 
306
- const sidebarHeaderContent = isMobile ? mobileHeader : expanded ? expandedHeader : collapsedHeader;
307
- const footerExtra = sidebar.footer ? <div className="mb-2">{sidebar.footer}</div> : null;
85
+ interface PrivateSidebarInnerProps {
86
+ sidebar: SidebarConfig;
87
+ header?: HeaderConfig;
88
+ variant: 'sidebar' | 'inset';
89
+ collapsedRail: boolean;
90
+ setOpen: (open: boolean) => void;
91
+ onMouseEnter: () => void;
92
+ onMouseLeave: () => void;
93
+ }
308
94
 
309
- const sidebarHeaderClass = cn(
310
- 'pb-2',
311
- isMobile
312
- ? 'px-4 pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
313
- : 'px-2 pt-3.5',
314
- );
95
+ function PrivateSidebarInner({
96
+ sidebar,
97
+ header,
98
+ variant,
99
+ collapsedRail,
100
+ setOpen,
101
+ onMouseEnter,
102
+ onMouseLeave,
103
+ }: PrivateSidebarInnerProps) {
104
+ const layoutVariant = variant === 'inset' ? 'boxed' : 'full-bleed';
105
+ const { modifiers } = useShellVisualState(layoutVariant);
106
+ const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
315
107
 
316
108
  /**
317
109
  * Click on the collapsed icon-rail expands the sidebar — but only on empty
@@ -320,177 +112,74 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
320
112
  * target sits inside a `button`, `a`, or anything explicitly marked
321
113
  * non-expandable via `data-no-expand`.
322
114
  */
323
- const expandOnRailClick = !isMobile && state === 'collapsed'
324
- ? (event: React.MouseEvent<HTMLDivElement>) => {
325
- const interactive = (event.target as Element | null)?.closest(
326
- 'a, button, [role="menuitem"], [data-no-expand]',
327
- );
328
- if (interactive) return;
329
- setOpen(true);
330
- }
331
- : undefined;
115
+ const expandOnRailClick = React.useCallback(
116
+ (event: React.MouseEvent<HTMLDivElement>) => {
117
+ const interactive = (event.target as Element | null)?.closest(
118
+ 'a, button, [role="menuitem"], [data-no-expand]',
119
+ );
120
+ if (interactive) return;
121
+ setOpen(true);
122
+ },
123
+ [setOpen],
124
+ );
332
125
 
333
- const railExpandHintClass =
334
- !isMobile && state === 'collapsed' ? 'cursor-pointer' : undefined;
126
+ const railExpandHintClass = collapsedRail ? 'cursor-pointer' : undefined;
127
+
128
+ const sidebarRootClass = React.useMemo(
129
+ () =>
130
+ cn(
131
+ railExpandHintClass,
132
+ '[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
133
+ modifiers.sidebarRoot,
134
+ modifiers.sidebarInner.map((m) => `[&>[data-sidebar=sidebar]]:${m}`),
135
+ ),
136
+ [railExpandHintClass, modifiers],
137
+ );
335
138
 
336
- const sidebarRootClass = cn(
337
- railExpandHintClass,
338
- '[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
139
+ const sidebarContentClass = React.useMemo(
140
+ () =>
141
+ cn(
142
+ 'gap-2',
143
+ modifiers.sidebarContent,
144
+ ),
145
+ [modifiers.sidebarContent],
146
+ );
147
+
148
+ const renderedGroups = React.useMemo(
149
+ () =>
150
+ sidebar.groups.map((group) => {
151
+ if (group.dynamic && group.items.length === 0) return null;
152
+ return (
153
+ <SidebarNavGroup
154
+ key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
155
+ group={group}
156
+ />
157
+ );
158
+ }),
159
+ [sidebar.groups],
339
160
  );
340
161
 
341
162
  return (
342
163
  <Sidebar
164
+ ref={setSidebarRef}
343
165
  collapsible="icon"
344
166
  variant={variant}
345
167
  className={sidebarRootClass}
346
- onClick={expandOnRailClick}
168
+ onClick={collapsedRail ? expandOnRailClick : undefined}
169
+ onMouseEnter={onMouseEnter}
170
+ onMouseLeave={onMouseLeave}
171
+ onKeyDown={handleSidebarKeyDown}
347
172
  >
348
- <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
173
+ <SidebarBrand />
349
174
 
350
175
  <SidebarContent className={sidebarContentClass}>
351
- {menuStartSlot}
176
+ <SidebarSlots />
352
177
  {renderedGroups}
353
- {featuredSlot}
354
- {menuEndSlot}
355
178
  </SidebarContent>
356
179
 
357
180
  <SidebarFooter className="p-0">
358
- {footerExtra}
359
181
  <PrivateSidebarAccount header={header} />
360
182
  </SidebarFooter>
361
183
  </Sidebar>
362
184
  );
363
185
  }
364
-
365
- interface SidebarGroupRendererProps {
366
- group: SidebarGroupConfig;
367
- isActive: (href: string) => boolean;
368
- navButtonClass: string;
369
- sidebarGroupClass: string;
370
- groupLabelUppercaseClass: string;
371
- groupLabelPlainClass: string;
372
- groupLabelStyle: SidebarGroupLabelStyle;
373
- menuNav: typeof DENSITY[keyof typeof DENSITY];
374
- collapsedRail: boolean;
375
- }
376
-
377
- function SidebarGroupRenderer({
378
- group,
379
- isActive,
380
- navButtonClass,
381
- sidebarGroupClass,
382
- groupLabelUppercaseClass,
383
- groupLabelPlainClass,
384
- groupLabelStyle,
385
- menuNav,
386
- collapsedRail,
387
- }: SidebarGroupRendererProps) {
388
- const hasLabel = Boolean(group.label && group.label.trim().length > 0);
389
- const isCollapsible = Boolean(group.collapsible) && hasLabel && !collapsedRail;
390
- const hideItemIcons = group.hideItemIcons ?? isCollapsible;
391
-
392
- const hasActiveChild = React.useMemo(
393
- () => group.items.some((item) => isActive(item.href)),
394
- [group.items, isActive],
395
- );
396
-
397
- const [open, setOpen] = React.useState<boolean>(
398
- isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true,
399
- );
400
-
401
- React.useEffect(() => {
402
- if (isCollapsible && hasActiveChild) setOpen(true);
403
- }, [isCollapsible, hasActiveChild]);
404
-
405
- const items = group.items.map((item: SidebarItem) => {
406
- const tooltipText = item.tooltip ?? item.label;
407
- const itemIcon = !hideItemIcons && item.icon ? (
408
- <LucideIcon icon={item.icon} className={menuNav.iconClass} />
409
- ) : null;
410
- const itemClassName = hideItemIcons
411
- ? cn(navButtonClass, 'pl-8')
412
- : navButtonClass;
413
- const badgeNode = item.badge ? (
414
- <SidebarMenuBadge
415
- className={item.badgeVariant === 'pill'
416
- ? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
417
- : undefined}
418
- >
419
- {item.badge}
420
- </SidebarMenuBadge>
421
- ) : null;
422
-
423
- return (
424
- <SidebarMenuItem key={item.href}>
425
- <SidebarMenuButton
426
- asChild
427
- isActive={isActive(item.href)}
428
- size={menuNav.buttonSize}
429
- tooltip={tooltipText}
430
- className={itemClassName}
431
- >
432
- <Link href={item.href}>
433
- {itemIcon}
434
- <span>{item.label}</span>
435
- {badgeNode}
436
- </Link>
437
- </SidebarMenuButton>
438
- </SidebarMenuItem>
439
- );
440
- });
441
-
442
- const labelClass = isCollapsible || groupLabelStyle === 'plain'
443
- ? groupLabelPlainClass
444
- : groupLabelUppercaseClass;
445
-
446
- if (isCollapsible) {
447
- const triggerIcon = group.icon ? (
448
- <LucideIcon icon={group.icon} className={cn(menuNav.iconClass, 'shrink-0 text-sidebar-foreground/70')} />
449
- ) : null;
450
- return (
451
- <SidebarGroup className={sidebarGroupClass}>
452
- <Collapsible open={open} onOpenChange={setOpen} className="w-full">
453
- <CollapsibleTrigger asChild>
454
- <button
455
- type="button"
456
- className={cn(
457
- 'group/trig flex w-full items-center gap-2 rounded-md px-2 py-1.5',
458
- 'text-sm font-semibold text-sidebar-foreground',
459
- 'transition-colors hover:bg-sidebar-accent/40',
460
- 'data-[no-expand]', // marker so rail-expand click handler ignores it (pattern in PrivateSidebar)
461
- )}
462
- aria-expanded={open}
463
- data-no-expand
464
- >
465
- {triggerIcon}
466
- <span className="flex-1 truncate text-left">{group.label}</span>
467
- <ChevronDown
468
- className={cn(
469
- 'h-4 w-4 shrink-0 text-sidebar-foreground/55 transition-transform duration-200',
470
- open && 'rotate-180',
471
- )}
472
- aria-hidden
473
- />
474
- </button>
475
- </CollapsibleTrigger>
476
- <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
477
- <SidebarGroupContent>
478
- <SidebarMenu className={cn(menuNav.menu, 'mt-1')}>{items}</SidebarMenu>
479
- </SidebarGroupContent>
480
- </CollapsibleContent>
481
- </Collapsible>
482
- </SidebarGroup>
483
- );
484
- }
485
-
486
- return (
487
- <SidebarGroup className={sidebarGroupClass}>
488
- {hasLabel ? (
489
- <SidebarGroupLabel className={labelClass}>{group.label}</SidebarGroupLabel>
490
- ) : null}
491
- <SidebarGroupContent>
492
- <SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
493
- </SidebarGroupContent>
494
- </SidebarGroup>
495
- );
496
- }