@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.
- package/package.json +21 -19
- package/src/configurator/private/schema.ts +20 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +17 -1
- package/src/layouts/PrivateLayout/README.md +47 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +5 -72
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +47 -96
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +36 -17
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +223 -0
- package/src/layouts/PrivateLayout/components/index.ts +1 -0
- package/src/layouts/PrivateLayout/context.tsx +2 -9
- package/src/layouts/PrivateLayout/hooks/index.ts +1 -5
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +10 -3
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +11 -88
- package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +32 -0
- package/src/layouts/PrivateLayout/index.ts +3 -0
- package/src/layouts/PrivateLayout/types.ts +41 -0
- package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
- package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
- package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
- package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +4 -2
- package/src/layouts/ProfileLayout/{ProfileLayout.tsx → ProfileForm/index.tsx} +10 -7
- package/src/layouts/ProfileLayout/README.md +65 -5
- package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
- package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +1 -1
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +17 -11
- package/src/layouts/ProfileLayout/components/index.ts +1 -0
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +17 -12
- package/src/layouts/ProfileLayout/index.ts +5 -4
- package/src/layouts/ProfileLayout/types.ts +11 -1
- package/src/layouts/_components/index.ts +1 -0
- package/src/layouts/types/providers.types.ts +2 -2
- package/src/theme/ThemeStyleBridge.tsx +1 -3
- package/src/theme/index.ts +2 -4
- package/src/theme/buildThemeStyleSheet.ts +0 -71
- package/src/theme/themeStyle.types.ts +0 -89
- 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
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
? '
|
|
151
|
-
: '
|
|
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
|
-
|
|
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
|
|
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 =
|
|
37
|
-
leaveDelay =
|
|
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
|
-
|
|
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 |
|
|
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
|
-
/**
|
|
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
|
|
105
|
-
usePrivateLayoutContext();
|
|
98
|
+
const { isMobile, isExpanded, isCollapsed } = usePrivateLayoutContext();
|
|
106
99
|
|
|
107
|
-
const isRail = !isMobile && isCollapsed
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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"). */
|