@djangocfg/layouts 2.1.357 → 2.1.359

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 (37) hide show
  1. package/package.json +21 -19
  2. package/src/configurator/private/schema.ts +20 -0
  3. package/src/layouts/PrivateLayout/PrivateLayout.tsx +17 -1
  4. package/src/layouts/PrivateLayout/README.md +47 -1
  5. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +5 -72
  6. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +47 -96
  7. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +36 -17
  8. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +223 -0
  9. package/src/layouts/PrivateLayout/components/index.ts +1 -0
  10. package/src/layouts/PrivateLayout/context.tsx +2 -9
  11. package/src/layouts/PrivateLayout/hooks/index.ts +1 -5
  12. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +10 -3
  13. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +11 -88
  14. package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +32 -0
  15. package/src/layouts/PrivateLayout/index.ts +3 -0
  16. package/src/layouts/PrivateLayout/types.ts +41 -0
  17. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
  18. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
  19. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
  20. package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +4 -2
  21. package/src/layouts/ProfileLayout/{ProfileLayout.tsx → ProfileForm/index.tsx} +10 -7
  22. package/src/layouts/ProfileLayout/README.md +65 -5
  23. package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
  24. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
  25. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +1 -1
  26. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +17 -11
  27. package/src/layouts/ProfileLayout/components/index.ts +1 -0
  28. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +17 -12
  29. package/src/layouts/ProfileLayout/index.ts +5 -4
  30. package/src/layouts/ProfileLayout/types.ts +11 -1
  31. package/src/layouts/_components/index.ts +1 -0
  32. package/src/layouts/types/providers.types.ts +2 -2
  33. package/src/theme/ThemeStyleBridge.tsx +1 -3
  34. package/src/theme/index.ts +2 -4
  35. package/src/theme/buildThemeStyleSheet.ts +0 -71
  36. package/src/theme/themeStyle.types.ts +0 -89
  37. package/src/theme/themeStylePresets.ts +0 -202
@@ -15,6 +15,7 @@ import { cn } from '@djangocfg/ui-core/lib';
15
15
  import { LucideIcon } from '../../../components';
16
16
  import { usePrivateLayoutContext } from '../context';
17
17
  import { useShellVisualState } from '../hooks';
18
+ import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
18
19
 
19
20
  function SidebarBrandRaw() {
20
21
  const { header, homeHref, brandTitle, brandMonogram, isMobile } =
@@ -41,8 +42,8 @@ function SidebarBrandRaw() {
41
42
  const headerRowClass = useMemo(
42
43
  () =>
43
44
  cn(
44
- 'flex items-center gap-2',
45
- content.showLabels ? 'px-2' : 'px-1.5',
45
+ 'flex items-center gap-2 mb-2',
46
+ // content.showLabels ? 'px-2' : 'px-1.5',
46
47
  ),
47
48
  [content.showLabels],
48
49
  );
@@ -86,14 +87,19 @@ function SidebarBrandRaw() {
86
87
 
87
88
  const collapsedHeader = useMemo(
88
89
  () => (
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>
90
+ <div className="group/collapsed-brand flex justify-center py-1">
91
+ <div className="relative h-7 w-7">
92
+ <Link
93
+ href={homeHref}
94
+ className="absolute inset-0 flex items-center justify-center rounded-md bg-sidebar-primary outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-brand:opacity-0"
95
+ aria-label={brandTitle}
96
+ >
97
+ {brandMark}
98
+ </Link>
99
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-brand:opacity-100">
100
+ <SidebarTrigger aria-label="Expand sidebar" />
101
+ </div>
102
+ </div>
97
103
  </div>
98
104
  ),
99
105
  [homeHref, brandTitle, brandMark],
@@ -136,19 +142,32 @@ function SidebarBrandRaw() {
136
142
  [customBrand, homeHref, brandMark, brandTitle],
137
143
  );
138
144
 
139
- const sidebarHeaderContent = isMobile
140
- ? mobileHeader
141
- : content.showLabels
142
- ? expandedHeader
143
- : collapsedHeader;
145
+ // Switcher mode: trigger embedded inside switcher row on desktop expanded
146
+ const switcherContent = header?.switcher
147
+ ? (
148
+ <div className="mb-2">
149
+ <SidebarBrandSwitcher
150
+ config={header.switcher}
151
+ showCollapseTrigger={content.showLabels && !isMobile}
152
+ />
153
+ </div>
154
+ )
155
+ : null;
156
+
157
+ const sidebarHeaderContent = switcherContent
158
+ ?? (isMobile
159
+ ? mobileHeader
160
+ : content.showLabels
161
+ ? expandedHeader
162
+ : collapsedHeader);
144
163
 
145
164
  const sidebarHeaderClass = useMemo(
146
165
  () =>
147
166
  cn(
148
167
  'pb-2',
149
168
  isMobile
150
- ? 'px-4 pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
151
- : 'px-2 pt-3.5',
169
+ ? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
170
+ : 'pt-3.5',
152
171
  ),
153
172
  [isMobile],
154
173
  );
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Sidebar Brand Switcher
3
+ *
4
+ * Dropdown for switching workspaces / accounts / projects.
5
+ * Renders in the sidebar header area, replacing the static brand block.
6
+ *
7
+ * Collapsed rail: shows only the active item's avatar/monogram (no dropdown trigger).
8
+ * Hover-expanded / mobile: shows full trigger + dropdown.
9
+ */
10
+
11
+ 'use client';
12
+
13
+ import React, { memo, useMemo } from 'react';
14
+ import { Check, ChevronsUpDown, Plus } from 'lucide-react';
15
+
16
+ import {
17
+ Avatar,
18
+ AvatarFallback,
19
+ AvatarImage,
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ SidebarTrigger,
26
+ } from '@djangocfg/ui-core/components';
27
+ import { Link } from '@djangocfg/ui-core/components';
28
+ import { cn } from '@djangocfg/ui-core/lib';
29
+
30
+ import { useShellVisualState } from '../hooks';
31
+ import type { SidebarBrandSwitcherConfig, SidebarBrandSwitcherItem } from '../types';
32
+
33
+ interface SidebarBrandSwitcherProps {
34
+ config: SidebarBrandSwitcherConfig;
35
+ /** Show the sidebar collapse toggle inside the switcher row (desktop expanded only). */
36
+ showCollapseTrigger?: boolean;
37
+ }
38
+
39
+ function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSwitcherProps) {
40
+ const { content } = useShellVisualState();
41
+ const [open, setOpen] = React.useState(false);
42
+
43
+ const activeItem = useMemo(
44
+ () => config.items.find((i) => i.active) ?? config.items[0] ?? null,
45
+ [config.items],
46
+ );
47
+
48
+ const onOpenChange = React.useCallback((next: boolean) => {
49
+ setOpen(next);
50
+ }, []);
51
+
52
+ if (!activeItem) return null;
53
+
54
+ const activeMonogram = (
55
+ activeItem.monogram?.charAt(0) ||
56
+ activeItem.label.charAt(0) ||
57
+ '?'
58
+ ).toUpperCase();
59
+
60
+ const activeAvatar = (
61
+ <Avatar className="h-7 w-7 shrink-0 rounded-md">
62
+ <AvatarImage src={activeItem.avatar} alt={activeItem.label} />
63
+ <AvatarFallback className="rounded-md bg-sidebar-primary text-[11px] font-bold text-sidebar-primary-foreground">
64
+ {activeMonogram}
65
+ </AvatarFallback>
66
+ </Avatar>
67
+ );
68
+
69
+ // Collapsed rail — avatar with trigger on hover
70
+ if (!content.showLabels) {
71
+ return (
72
+ <div className="group/collapsed-switcher flex justify-center py-1">
73
+ <div className="relative h-7 w-7">
74
+ {activeItem.href ? (
75
+ <Link
76
+ href={activeItem.href}
77
+ className="absolute inset-0 flex items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-switcher:opacity-0"
78
+ aria-label={activeItem.label}
79
+ >
80
+ {activeAvatar}
81
+ </Link>
82
+ ) : (
83
+ <div className="absolute inset-0 flex items-center justify-center transition-opacity group-hover/collapsed-switcher:opacity-0">
84
+ {activeAvatar}
85
+ </div>
86
+ )}
87
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-switcher:opacity-100">
88
+ <SidebarTrigger aria-label="Expand sidebar" />
89
+ </div>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <div className="flex items-center gap-1">
97
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
98
+ <DropdownMenuTrigger asChild>
99
+ <button
100
+ type="button"
101
+ className={cn(
102
+ 'group/switcher flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2',
103
+ 'text-left transition-colors',
104
+ 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
105
+ 'outline-none ring-sidebar-ring focus-visible:ring-2',
106
+ )}
107
+ data-no-expand
108
+ >
109
+ {activeAvatar}
110
+ <span className="flex min-w-0 flex-1 flex-col">
111
+ <span className="truncate text-sm font-semibold leading-tight text-sidebar-foreground">
112
+ {activeItem.label}
113
+ </span>
114
+ {activeItem.description ? (
115
+ <span className="truncate text-xs leading-snug text-sidebar-foreground/55">
116
+ {activeItem.description}
117
+ </span>
118
+ ) : null}
119
+ </span>
120
+ <ChevronsUpDown
121
+ className="h-4 w-4 shrink-0 text-sidebar-foreground/40 transition-colors group-hover/switcher:text-sidebar-foreground/70"
122
+ aria-hidden
123
+ />
124
+ </button>
125
+ </DropdownMenuTrigger>
126
+
127
+ <DropdownMenuContent
128
+ side="bottom"
129
+ align="start"
130
+ sideOffset={4}
131
+ className="min-w-52 p-1.5"
132
+ >
133
+ {config.items.map((item) => (
134
+ <SwitcherItem key={item.label} item={item} onClose={() => onOpenChange(false)} />
135
+ ))}
136
+
137
+ {config.addLabel ? (
138
+ <>
139
+ <DropdownMenuSeparator />
140
+ <DropdownMenuItem
141
+ onSelect={() => {
142
+ onOpenChange(false);
143
+ config.onAdd?.();
144
+ }}
145
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground"
146
+ >
147
+ <Plus className="h-4 w-4 shrink-0" aria-hidden />
148
+ <span className="truncate">{config.addLabel}</span>
149
+ </DropdownMenuItem>
150
+ </>
151
+ ) : null}
152
+ </DropdownMenuContent>
153
+ </DropdownMenu>
154
+
155
+ {showCollapseTrigger ? (
156
+ <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" data-no-expand />
157
+ ) : null}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ interface SwitcherItemProps {
163
+ item: SidebarBrandSwitcherItem;
164
+ onClose: () => void;
165
+ }
166
+
167
+ function SwitcherItem({ item, onClose }: SwitcherItemProps) {
168
+ const monogram = (
169
+ item.monogram?.charAt(0) ||
170
+ item.label.charAt(0) ||
171
+ '?'
172
+ ).toUpperCase();
173
+
174
+ const handleSelect = React.useCallback(() => {
175
+ onClose();
176
+ item.onSelect?.();
177
+ }, [item, onClose]);
178
+
179
+ const inner = (
180
+ <>
181
+ <Avatar className="h-6 w-6 shrink-0 rounded-md">
182
+ <AvatarImage src={item.avatar} alt={item.label} />
183
+ <AvatarFallback className="rounded-md bg-sidebar-primary text-[10px] font-bold text-sidebar-primary-foreground">
184
+ {monogram}
185
+ </AvatarFallback>
186
+ </Avatar>
187
+ <span className="flex min-w-0 flex-1 flex-col">
188
+ <span className="truncate text-sm font-medium">{item.label}</span>
189
+ {item.description ? (
190
+ <span className="truncate text-xs text-muted-foreground">{item.description}</span>
191
+ ) : null}
192
+ </span>
193
+ {item.active ? (
194
+ <Check className="h-4 w-4 shrink-0 text-primary" aria-hidden />
195
+ ) : null}
196
+ </>
197
+ );
198
+
199
+ if (item.href && !item.onSelect) {
200
+ return (
201
+ <DropdownMenuItem asChild>
202
+ <Link
203
+ href={item.href}
204
+ onClick={onClose}
205
+ className="flex items-center gap-2 rounded-md px-2 py-1.5"
206
+ >
207
+ {inner}
208
+ </Link>
209
+ </DropdownMenuItem>
210
+ );
211
+ }
212
+
213
+ return (
214
+ <DropdownMenuItem
215
+ onSelect={handleSelect}
216
+ className="flex items-center gap-2 rounded-md px-2 py-1.5"
217
+ >
218
+ {inner}
219
+ </DropdownMenuItem>
220
+ );
221
+ }
222
+
223
+ export const SidebarBrandSwitcher = memo(SidebarBrandSwitcherRaw);
@@ -5,6 +5,7 @@
5
5
  export { PrivateSidebar } from './PrivateSidebar';
6
6
  export { PrivateContent } from './PrivateContent';
7
7
  export { SidebarBrand } from './SidebarBrand';
8
+ export { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
8
9
  export { SidebarNavGroup } from './SidebarNavGroup';
9
10
  export { SidebarNavItem } from './SidebarNavItem';
10
11
  export { SidebarSlots } from './SidebarSlots';
@@ -41,8 +41,6 @@ export interface PrivateLayoutContextValue {
41
41
  isExpanded: boolean;
42
42
  /** Whether the sidebar is collapsed to icon rail */
43
43
  isCollapsed: boolean;
44
- /** Whether the sidebar is temporarily hover-expanded overlay */
45
- isHoverExpanded: boolean;
46
44
  /** Nav density based on total item count */
47
45
  density: NavDensity;
48
46
  /** Density tokens for the current state */
@@ -94,7 +92,6 @@ interface PrivateLayoutProviderProps {
94
92
  pathname: string;
95
93
  isMobile: boolean;
96
94
  state: 'expanded' | 'collapsed';
97
- isHoverExpanded?: boolean;
98
95
  }
99
96
 
100
97
  // ============================================================================
@@ -108,7 +105,6 @@ export function PrivateLayoutProvider({
108
105
  pathname,
109
106
  isMobile,
110
107
  state,
111
- isHoverExpanded = false,
112
108
  }: PrivateLayoutProviderProps) {
113
109
  const homeHref = sidebar?.homeHref || '/';
114
110
  const brandTitle = header?.title?.trim() || 'Dashboard';
@@ -127,8 +123,7 @@ export function PrivateLayoutProvider({
127
123
  const isExpanded = state === 'expanded';
128
124
  const isCollapsed = state === 'collapsed';
129
125
 
130
- /** Expanded or hover-expanded: follow item-count tier; collapsed rail: fixed `default` sizing so icons stay uniform. */
131
- const menuNav = isCollapsed && !isHoverExpanded ? RAIL_NAV : DENSITY[density];
126
+ const menuNav = isCollapsed ? RAIL_NAV : DENSITY[density];
132
127
 
133
128
  const isActive = React.useCallback(
134
129
  (href: string) => {
@@ -144,7 +139,7 @@ export function PrivateLayoutProvider({
144
139
  [pathname, allItems],
145
140
  );
146
141
 
147
- const collapsedRail = !isMobile && isCollapsed && !isHoverExpanded;
142
+ const collapsedRail = !isMobile && isCollapsed;
148
143
 
149
144
  const hasMenuStart = sidebar?.menuStart != null && sidebar.menuStart !== false;
150
145
  const hasMenuEnd = sidebar?.menuEnd != null && sidebar.menuEnd !== false;
@@ -166,7 +161,6 @@ export function PrivateLayoutProvider({
166
161
  isMobile,
167
162
  isExpanded,
168
163
  isCollapsed,
169
- isHoverExpanded,
170
164
  density,
171
165
  menuNav,
172
166
  activeIndicator,
@@ -187,7 +181,6 @@ export function PrivateLayoutProvider({
187
181
  isMobile,
188
182
  isExpanded,
189
183
  isCollapsed,
190
- isHoverExpanded,
191
184
  density,
192
185
  menuNav,
193
186
  activeIndicator,
@@ -4,10 +4,6 @@
4
4
 
5
5
  export { useAuthGuard } from './useAuthGuard';
6
6
  export { useLayoutVisual } from './useLayoutVisual';
7
- export {
8
- useHoverExpand,
9
- blockSidebarCollapse,
10
- allowSidebarCollapse,
11
- } from './useHoverExpand';
12
7
  export { useShellVisualState } from './useShellVisualState';
13
8
  export { useSidebarKeyboard } from './useSidebarKeyboard';
9
+ export { useSidebarDefaultOpen } from './useSidebarDefaultOpen';
@@ -30,11 +30,13 @@ interface UseHoverExpandResult {
30
30
  onMouseEnter: () => void;
31
31
  /** Attach to the sidebar root element */
32
32
  onMouseLeave: () => void;
33
+ /** Programmatically set hover-expanded state (for click-to-expand) */
34
+ setHoverExpanded: (value: boolean) => void;
33
35
  }
34
36
 
35
37
  export function useHoverExpand({
36
- enterDelay = 250,
37
- leaveDelay = 150,
38
+ enterDelay = 2000,
39
+ leaveDelay = 450,
38
40
  enabled = true,
39
41
  }: UseHoverExpandOptions = {}): UseHoverExpandResult {
40
42
  const [isHoverExpanded, setIsHoverExpanded] = useState(false);
@@ -89,7 +91,12 @@ export function useHoverExpand({
89
91
  return () => clearTimers();
90
92
  }, [clearTimers]);
91
93
 
92
- return { isHoverExpanded, onMouseEnter, onMouseLeave };
94
+ const setHoverExpanded = useCallback((value: boolean) => {
95
+ clearTimers();
96
+ setIsHoverExpanded(value);
97
+ }, [clearTimers]);
98
+
99
+ return { isHoverExpanded, onMouseEnter, onMouseLeave, setHoverExpanded };
93
100
  }
94
101
 
95
102
  /** Dispatch from any descendant to block sidebar collapse while e.g. a dropdown is open. */
@@ -2,7 +2,7 @@
2
2
  * Shell Visual State Hook
3
3
  *
4
4
  * Centralizes ALL visual decisions for the sidebar + content pair:
5
- * - sidebar state: expanded | hover-overlay | collapsed-rail
5
+ * - sidebar state: expanded | collapsed-rail
6
6
  * - layout variant: boxed (inset) | full-bleed
7
7
  * - what to show/hide in each state
8
8
  * - CSS modifiers for sidebar root, inner shell, and content inset
@@ -28,9 +28,7 @@ export interface ShellVisualFlags {
28
28
  isExpanded: boolean;
29
29
  /** Collapsed to icon rail */
30
30
  isCollapsed: boolean;
31
- /** Temporary hover-expand overlay on desktop rail */
32
- isHoverOverlay: boolean;
33
- /** True only in desktop collapsed rail without hover */
31
+ /** True only in desktop collapsed rail */
34
32
  isRail: boolean;
35
33
  }
36
34
 
@@ -58,12 +56,8 @@ export interface ShellContentFlags {
58
56
  // ============================================================================
59
57
 
60
58
  export interface ShellChromeFlags {
61
- /** Sidebar casts a shadow (overlay mode) */
62
- showShadow: boolean;
63
59
  /** Right border/separator on sidebar */
64
60
  showBorder: boolean;
65
- /** Internal right padding inside sidebar (overlay breathing room) */
66
- needsInternalPadding: boolean;
67
61
  /** Content inset should have margin to make room for sidebar */
68
62
  contentHasSidebarGap: boolean;
69
63
  /** Sidebar width is fixed to icon-rail */
@@ -101,107 +95,36 @@ export interface ShellVisualState {
101
95
  export function useShellVisualState(
102
96
  layoutVariant?: LayoutVisualConfig['variant'],
103
97
  ): ShellVisualState {
104
- const { isMobile, isExpanded, isCollapsed, isHoverExpanded } =
105
- usePrivateLayoutContext();
98
+ const { isMobile, isExpanded, isCollapsed } = usePrivateLayoutContext();
106
99
 
107
- const isRail = !isMobile && isCollapsed && !isHoverExpanded;
108
- const isHoverOverlay = !isMobile && isCollapsed && isHoverExpanded;
100
+ const isRail = !isMobile && isCollapsed;
109
101
  const variant = layoutVariant ?? 'boxed';
110
102
 
111
103
  return useMemo(() => {
112
- // ------------------------------------------------------------------------
113
- // Content visibility
114
- // ------------------------------------------------------------------------
115
- const showLabels = isExpanded || isHoverOverlay;
104
+ const showLabels = isExpanded || isMobile;
116
105
  const showGroupLabels = showLabels;
117
106
  const showBadgeText = showLabels;
118
107
  const showTooltips = isRail;
119
108
  const isAccountCompact = isRail;
120
109
  const hideSlots = isRail;
121
110
 
122
- // ------------------------------------------------------------------------
123
- // Chrome
124
- // ------------------------------------------------------------------------
125
- // Only hover-overlay is an overlay — it needs shadow + border + padding.
126
- // Persistent expanded is part of the layout flow — no shadow.
127
- const showShadow = isHoverOverlay;
128
- const showBorder = isHoverOverlay;
129
- const needsInternalPadding = isHoverOverlay;
130
-
131
- // Content gap: only persistent expanded pushes the inset.
132
- // Hover-overlay is temporary — content must NOT shift.
133
- // Collapsed rail also leaves a gap (the rail itself is narrow but present).
134
111
  const contentHasSidebarGap = isExpanded || isRail;
135
112
  const isRailWidth = isRail;
113
+ const showBorder = false;
136
114
 
137
- // ------------------------------------------------------------------------
138
- // Modifiers
139
- // ------------------------------------------------------------------------
140
115
  const sidebarRoot: string[] = [];
141
116
  const sidebarInner: string[] = [];
142
117
  const sidebarContent: string[] = [];
143
118
 
144
- if (isHoverOverlay) {
145
- sidebarRoot.push('z-50', '!w-[var(--sidebar-width)]');
146
- // Allow scroll inside hover-expanded overlay — shadcn hardcodes
147
- // overflow-hidden on div[data-sidebar=sidebar]; override it here.
148
- sidebarInner.push('!overflow-auto');
149
- // Extra right padding on content so text doesn't hug the right edge
150
- sidebarContent.push('!overflow-auto', 'pr-4');
151
-
152
- if (showShadow) {
153
- sidebarInner.push(
154
- 'shadow-[4px_0_24px_-4px_rgba(0,0,0,0.08)]',
155
- 'dark:shadow-[4px_0_24px_-4px_rgba(0,0,0,0.25)]',
156
- );
157
- }
158
- if (showBorder) {
159
- sidebarInner.push('border-r', 'border-sidebar-border/30');
160
- }
161
- // Hide the shadcn gradient border line on hover overlay
162
- sidebarInner.push('[&>div[aria-hidden]]:hidden');
163
- }
164
-
165
- // Boxed variant: inner sidebar gets rounded corners when persistent expanded
166
119
  if (variant === 'boxed' && isExpanded) {
167
120
  sidebarInner.push('rounded-sm');
168
121
  }
169
122
 
170
123
  return {
171
- flags: {
172
- isMobile,
173
- isExpanded,
174
- isCollapsed,
175
- isHoverOverlay,
176
- isRail,
177
- },
178
- content: {
179
- showLabels,
180
- showGroupLabels,
181
- showBadgeText,
182
- showTooltips,
183
- isAccountCompact,
184
- hideSlots,
185
- },
186
- chrome: {
187
- showShadow,
188
- showBorder,
189
- needsInternalPadding,
190
- contentHasSidebarGap,
191
- isRailWidth,
192
- },
193
- modifiers: {
194
- sidebarRoot,
195
- sidebarInner,
196
- sidebarContent,
197
- },
124
+ flags: { isMobile, isExpanded, isCollapsed, isRail },
125
+ content: { showLabels, showGroupLabels, showBadgeText, showTooltips, isAccountCompact, hideSlots },
126
+ chrome: { showBorder, contentHasSidebarGap, isRailWidth },
127
+ modifiers: { sidebarRoot, sidebarInner, sidebarContent },
198
128
  };
199
- }, [
200
- isMobile,
201
- isExpanded,
202
- isCollapsed,
203
- isHoverOverlay,
204
- isRail,
205
- variant,
206
- ]);
129
+ }, [isMobile, isExpanded, isCollapsed, isRail, variant]);
207
130
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Resolves the default sidebar open/closed state.
3
+ *
4
+ * Priority:
5
+ * 1. Cookie written by shadcn-sidebar (user's last explicit choice)
6
+ * 2. Viewport width — collapse by default on tablet and below (< 1024px lg)
7
+ * 3. Expanded (large desktop, no cookie)
8
+ *
9
+ * Must run on the client — returns `true` during SSR.
10
+ */
11
+
12
+ const SIDEBAR_COOKIE_NAME = 'sidebar_state';
13
+ const COLLAPSE_BELOW_PX = 1024; // Tailwind lg
14
+
15
+ export function useSidebarDefaultOpen(): boolean {
16
+ if (typeof document === 'undefined') return true;
17
+
18
+ const match = document.cookie
19
+ .split('; ')
20
+ .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`));
21
+
22
+ if (match) {
23
+ return match.split('=')[1] === 'true';
24
+ }
25
+
26
+ // No cookie — first visit. Collapse on smaller screens.
27
+ if (typeof window !== 'undefined') {
28
+ return window.innerWidth >= COLLAPSE_BELOW_PX;
29
+ }
30
+
31
+ return true;
32
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
6
  export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
7
+ export { SidebarBrandSwitcher } from './components';
7
8
  export type {
8
9
  PrivateLayoutProps,
9
10
  SidebarItem,
@@ -13,4 +14,6 @@ export type {
13
14
  SidebarActiveIndicator,
14
15
  SidebarGroupLabelStyle,
15
16
  SidebarFeaturedConfig,
17
+ SidebarBrandSwitcherConfig,
18
+ SidebarBrandSwitcherItem,
16
19
  } from './types';
@@ -111,11 +111,46 @@ export interface SidebarConfig {
111
111
  density?: 'comfortable' | 'default' | 'compact';
112
112
  }
113
113
 
114
+ // ============================================================================
115
+ // Brand Switcher Types
116
+ // ============================================================================
117
+
118
+ export interface SidebarBrandSwitcherItem {
119
+ /** Display name */
120
+ label: string;
121
+ /** Avatar image URL or initials fallback */
122
+ avatar?: string;
123
+ /** Single letter shown when `avatar` is absent */
124
+ monogram?: string;
125
+ /** Navigation target on select */
126
+ href?: string;
127
+ /** Callback on select (alternative to href) */
128
+ onSelect?: () => void;
129
+ /** Mark as currently active */
130
+ active?: boolean;
131
+ /** Small secondary line under label (e.g. plan name, role) */
132
+ description?: string;
133
+ }
134
+
135
+ export interface SidebarBrandSwitcherConfig {
136
+ items: SidebarBrandSwitcherItem[];
137
+ /** Label for "add new" action at the bottom of the dropdown. Omit to hide. */
138
+ addLabel?: string;
139
+ /** Called when "add new" is clicked */
140
+ onAdd?: () => void;
141
+ }
142
+
114
143
  // ============================================================================
115
144
  // Header Config
116
145
  // ============================================================================
117
146
 
118
147
  export interface HeaderConfig {
148
+ /**
149
+ * Brand switcher config. When provided, replaces the static brand header
150
+ * with a dropdown for switching workspaces/accounts/projects.
151
+ * Takes priority over `brand`, `title`, `brandIcon`, `brandLetter`.
152
+ */
153
+ switcher?: SidebarBrandSwitcherConfig;
119
154
  /** Custom header brand node (same idea as PublicNavbar `brand`). */
120
155
  brand?: ReactNode;
121
156
  /** Shown next to the logo when the sidebar is expanded */
@@ -132,6 +167,12 @@ export interface HeaderConfig {
132
167
  brandLetter?: string;
133
168
  /** User menu groups (account panel in the sidebar footer) */
134
169
  groups?: UserMenuConfig['groups'];
170
+ /**
171
+ * Behaviour of the footer account button.
172
+ * - `'menu'` (default) — opens a DropdownMenu with account links, locale/theme controls, and sign-out.
173
+ * - `'dialog'` — opens the global ProfileDialog (managed via Zustand store).
174
+ */
175
+ accountAction?: 'menu' | 'dialog';
135
176
  /** Auth page path (for sign in button) */
136
177
  authPath?: string;
137
178
  /** Subtitle under the display name in the sidebar footer (e.g. "Max plan"). */