@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,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,356 +37,143 @@ 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
52
 
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
-
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, setHoverExpanded } = 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
+ setHoverExpanded={setHoverExpanded}
73
+ onMouseEnter={onMouseEnter}
74
+ onMouseLeave={onMouseLeave}
234
75
  />
235
- );
236
- });
76
+ </PrivateLayoutProvider>
77
+ );
78
+ }
237
79
 
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;
80
+ // ---------------------------------------------------------------------------
81
+ // Inner component runs inside PrivateLayoutProvider so useShellVisualState
82
+ // can safely consume the context.
83
+ // ---------------------------------------------------------------------------
243
84
 
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
- );
85
+ interface PrivateSidebarInnerProps {
86
+ sidebar: SidebarConfig;
87
+ header?: HeaderConfig;
88
+ variant: 'sidebar' | 'inset';
89
+ collapsedRail: boolean;
90
+ setHoverExpanded: (value: boolean) => void;
91
+ onMouseEnter: () => void;
92
+ onMouseLeave: () => void;
93
+ }
271
94
 
272
- const collapsedHeader = (
273
- <div className="flex justify-center py-1">
274
- <SidebarTrigger aria-label="Expand sidebar" />
275
- </div>
95
+ function PrivateSidebarInner({
96
+ sidebar,
97
+ header,
98
+ variant,
99
+ collapsedRail,
100
+ setHoverExpanded,
101
+ onMouseEnter,
102
+ onMouseLeave,
103
+ }: PrivateSidebarInnerProps) {
104
+ const layoutVariant = variant === 'inset' ? 'boxed' : 'full-bleed';
105
+ const { modifiers } = useShellVisualState(layoutVariant);
106
+ const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
107
+
108
+ const railExpandHintClass = collapsedRail ? 'cursor-pointer' : undefined;
109
+
110
+ /** Click on the collapsed rail acts like a hover — temporary expand, not persistent. */
111
+ const expandOnRailClick = React.useCallback(
112
+ (event: React.MouseEvent<HTMLDivElement>) => {
113
+ const interactive = (event.target as Element | null)?.closest(
114
+ 'a, button, [role="menuitem"], [data-no-expand]',
115
+ );
116
+ if (interactive) return;
117
+ setHoverExpanded(true);
118
+ },
119
+ [setHoverExpanded],
276
120
  );
277
121
 
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>
122
+ const sidebarRootClass = React.useMemo(
123
+ () =>
124
+ cn(
125
+ railExpandHintClass,
126
+ '[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
127
+ modifiers.sidebarRoot,
128
+ modifiers.sidebarInner.map((m) => `[&>[data-sidebar=sidebar]]:${m}`),
129
+ ),
130
+ [railExpandHintClass, modifiers],
304
131
  );
305
132
 
306
- const sidebarHeaderContent = isMobile ? mobileHeader : expanded ? expandedHeader : collapsedHeader;
307
- const footerExtra = sidebar.footer ? <div className="mb-2">{sidebar.footer}</div> : null;
308
-
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',
133
+ const sidebarContentClass = React.useMemo(
134
+ () =>
135
+ cn(
136
+ 'gap-2',
137
+ modifiers.sidebarContent,
138
+ ),
139
+ [modifiers.sidebarContent],
314
140
  );
315
141
 
316
- /**
317
- * Click on the collapsed icon-rail expands the sidebar — but only on empty
318
- * areas. Native interactive elements (nav links, the trigger, account menu,
319
- * tooltips) keep their original behaviour: we bail out as soon as the click
320
- * target sits inside a `button`, `a`, or anything explicitly marked
321
- * non-expandable via `data-no-expand`.
322
- */
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]',
142
+ const renderedGroups = React.useMemo(
143
+ () =>
144
+ sidebar.groups.map((group) => {
145
+ if (group.dynamic && group.items.length === 0) return null;
146
+ return (
147
+ <SidebarNavGroup
148
+ key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
149
+ group={group}
150
+ />
327
151
  );
328
- if (interactive) return;
329
- setOpen(true);
330
- }
331
- : undefined;
332
-
333
- const railExpandHintClass =
334
- !isMobile && state === 'collapsed' ? 'cursor-pointer' : undefined;
335
-
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',
152
+ }),
153
+ [sidebar.groups],
339
154
  );
340
155
 
341
156
  return (
342
157
  <Sidebar
158
+ ref={setSidebarRef}
343
159
  collapsible="icon"
344
160
  variant={variant}
345
161
  className={sidebarRootClass}
346
- onClick={expandOnRailClick}
162
+ onClick={collapsedRail ? expandOnRailClick : undefined}
163
+ onMouseEnter={onMouseEnter}
164
+ onMouseLeave={onMouseLeave}
165
+ onKeyDown={handleSidebarKeyDown}
347
166
  >
348
- <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
167
+ <SidebarBrand />
349
168
 
350
169
  <SidebarContent className={sidebarContentClass}>
351
- {menuStartSlot}
170
+ <SidebarSlots />
352
171
  {renderedGroups}
353
- {featuredSlot}
354
- {menuEndSlot}
355
172
  </SidebarContent>
356
173
 
357
174
  <SidebarFooter className="p-0">
358
- {footerExtra}
359
175
  <PrivateSidebarAccount header={header} />
360
176
  </SidebarFooter>
361
177
  </Sidebar>
362
178
  );
363
179
  }
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
- }