@djangocfg/layouts 2.1.358 → 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 +17 -17
- package/src/configurator/private/schema.ts +8 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +4 -0
- package/src/layouts/PrivateLayout/README.md +47 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +5 -66
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +1 -6
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +34 -18
- 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 +0 -5
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +11 -88
- package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +16 -5
- package/src/layouts/PrivateLayout/index.ts +3 -0
- package/src/layouts/PrivateLayout/types.ts +35 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.359",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -84,13 +84,13 @@
|
|
|
84
84
|
"check": "tsc --noEmit"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"@djangocfg/api": "^2.1.
|
|
88
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
89
|
-
"@djangocfg/debuger": "^2.1.
|
|
90
|
-
"@djangocfg/i18n": "^2.1.
|
|
91
|
-
"@djangocfg/monitor": "^2.1.
|
|
92
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
87
|
+
"@djangocfg/api": "^2.1.359",
|
|
88
|
+
"@djangocfg/centrifugo": "^2.1.359",
|
|
89
|
+
"@djangocfg/debuger": "^2.1.359",
|
|
90
|
+
"@djangocfg/i18n": "^2.1.359",
|
|
91
|
+
"@djangocfg/monitor": "^2.1.359",
|
|
92
|
+
"@djangocfg/ui-core": "^2.1.359",
|
|
93
|
+
"@djangocfg/ui-nextjs": "^2.1.359",
|
|
94
94
|
"@hookform/resolvers": "^5.2.2",
|
|
95
95
|
"consola": "^3.4.2",
|
|
96
96
|
"lucide-react": "^0.545.0",
|
|
@@ -121,15 +121,15 @@
|
|
|
121
121
|
"uuid": "^11.1.0"
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
|
-
"@djangocfg/api": "^2.1.
|
|
125
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
126
|
-
"@djangocfg/debuger": "^2.1.
|
|
127
|
-
"@djangocfg/i18n": "^2.1.
|
|
128
|
-
"@djangocfg/monitor": "^2.1.
|
|
129
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
130
|
-
"@djangocfg/ui-core": "^2.1.
|
|
131
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
132
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
124
|
+
"@djangocfg/api": "^2.1.359",
|
|
125
|
+
"@djangocfg/centrifugo": "^2.1.359",
|
|
126
|
+
"@djangocfg/debuger": "^2.1.359",
|
|
127
|
+
"@djangocfg/i18n": "^2.1.359",
|
|
128
|
+
"@djangocfg/monitor": "^2.1.359",
|
|
129
|
+
"@djangocfg/typescript-config": "^2.1.359",
|
|
130
|
+
"@djangocfg/ui-core": "^2.1.359",
|
|
131
|
+
"@djangocfg/ui-nextjs": "^2.1.359",
|
|
132
|
+
"@djangocfg/ui-tools": "^2.1.359",
|
|
133
133
|
"@types/node": "^24.7.2",
|
|
134
134
|
"@types/react": "^19.1.0",
|
|
135
135
|
"@types/react-dom": "^19.1.0",
|
|
@@ -38,6 +38,7 @@ export interface PrivateLayoutConfiguratorData {
|
|
|
38
38
|
userPlan: string;
|
|
39
39
|
showSecondaryAction: boolean;
|
|
40
40
|
accountAction: 'menu' | 'dialog';
|
|
41
|
+
showSwitcher: boolean;
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -133,6 +134,11 @@ export const privateLayoutConfiguratorSchema: CustomJsonSchema7 = {
|
|
|
133
134
|
enum: ['menu', 'dialog'],
|
|
134
135
|
description: "'menu' opens a dropdown; 'dialog' opens the global ProfileDialog.",
|
|
135
136
|
},
|
|
137
|
+
showSwitcher: {
|
|
138
|
+
type: 'boolean',
|
|
139
|
+
title: 'Brand switcher',
|
|
140
|
+
description: 'Replace the static brand with a workspace/account dropdown.',
|
|
141
|
+
},
|
|
136
142
|
},
|
|
137
143
|
},
|
|
138
144
|
},
|
|
@@ -172,6 +178,7 @@ export const privateLayoutConfiguratorUiSchema: CustomJsonUiSchema7 = {
|
|
|
172
178
|
header: {
|
|
173
179
|
'ui:collapsible': true,
|
|
174
180
|
showSecondaryAction: { 'ui:widget': 'switch' },
|
|
181
|
+
showSwitcher: { 'ui:widget': 'switch' },
|
|
175
182
|
accountAction: {
|
|
176
183
|
'ui:widget': 'radio',
|
|
177
184
|
'ui:options': { inline: true },
|
|
@@ -198,5 +205,6 @@ export const defaultPrivateLayoutConfiguratorData: PrivateLayoutConfiguratorData
|
|
|
198
205
|
userPlan: 'Pro plan',
|
|
199
206
|
showSecondaryAction: false,
|
|
200
207
|
accountAction: 'menu',
|
|
208
|
+
showSwitcher: false,
|
|
201
209
|
},
|
|
202
210
|
};
|
|
@@ -35,8 +35,12 @@ export type {
|
|
|
35
35
|
SidebarActiveIndicator,
|
|
36
36
|
SidebarGroupLabelStyle,
|
|
37
37
|
SidebarFeaturedConfig,
|
|
38
|
+
SidebarBrandSwitcherConfig,
|
|
39
|
+
SidebarBrandSwitcherItem,
|
|
38
40
|
} from './types';
|
|
39
41
|
|
|
42
|
+
export { SidebarBrandSwitcher } from './components';
|
|
43
|
+
|
|
40
44
|
export { PrivateLayoutProps };
|
|
41
45
|
|
|
42
46
|
// Lazy-load ProfileDialog so the profile bundle is only fetched when opened.
|
|
@@ -119,16 +119,62 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
|
|
|
119
119
|
|
|
120
120
|
| Field | Type | Notes |
|
|
121
121
|
|---|---|---|
|
|
122
|
-
| `
|
|
122
|
+
| `switcher` | `SidebarBrandSwitcherConfig` | Brand switcher dropdown (replaces `brand`/`title`/`brandIcon` when set). |
|
|
123
|
+
| `brand`, `title`, `brandIcon`, `brandLetter` | — | Static sidebar header brand (used when `switcher` is not set). |
|
|
123
124
|
| `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
|
|
124
125
|
| `authPath` | `string` | Sign-in redirect target. |
|
|
125
126
|
| `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
|
|
127
|
+
| `accountAction` | `'menu' \| 'dialog'` | Footer button behaviour. `'menu'` opens dropdown; `'dialog'` opens global ProfileDialog. Default `'menu'`. |
|
|
126
128
|
| `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
|
|
127
129
|
|
|
128
130
|
The footer button opens a popover (`DropdownMenu`, `side="top"`) with: email, account links, **Language** (opens fullscreen `LocaleSwitcherDialog` if `LayoutI18nProvider` is mounted), **Theme** (cycles `light → dark → system`), and **Log out**. In dev mode the footer renders a `Guest (dev)` placeholder when there's no authenticated user, so debug controls stay reachable; in production the block hides itself.
|
|
129
131
|
|
|
130
132
|
---
|
|
131
133
|
|
|
134
|
+
## Brand switcher
|
|
135
|
+
|
|
136
|
+
Replace the static brand header with a workspace/account/project switcher:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { SidebarBrandSwitcherConfig } from '@djangocfg/layouts';
|
|
140
|
+
|
|
141
|
+
const switcher: SidebarBrandSwitcherConfig = {
|
|
142
|
+
items: [
|
|
143
|
+
{ label: 'My Workspace', description: 'Personal', href: '/', active: true },
|
|
144
|
+
{ label: 'Team Workspace', description: 'Pro plan', href: '/team' },
|
|
145
|
+
{ label: 'Client Project', onSelect: () => switchProject('client') },
|
|
146
|
+
],
|
|
147
|
+
addLabel: 'Add workspace',
|
|
148
|
+
onAdd: () => openCreateDialog(),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
<PrivateLayout header={{ switcher }} ...>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`SidebarBrandSwitcherConfig`:
|
|
155
|
+
|
|
156
|
+
| Field | Type | Notes |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `items` | `SidebarBrandSwitcherItem[]` | List of workspaces / accounts / projects. |
|
|
159
|
+
| `addLabel` | `string` | Label for the "add new" action at the bottom. Omit to hide. |
|
|
160
|
+
| `onAdd` | `() => void` | Called when "add new" is clicked. |
|
|
161
|
+
|
|
162
|
+
`SidebarBrandSwitcherItem`:
|
|
163
|
+
|
|
164
|
+
| Field | Type | Notes |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `label` | `string` | Display name. |
|
|
167
|
+
| `avatar` | `string` | Image URL. Falls back to `monogram` / first letter of `label`. |
|
|
168
|
+
| `monogram` | `string` | Single letter override for the avatar fallback. |
|
|
169
|
+
| `description` | `string` | Secondary line under the label (plan, role, etc.). |
|
|
170
|
+
| `href` | `string` | Navigate on select. |
|
|
171
|
+
| `onSelect` | `() => void` | Callback on select (alternative to `href`). |
|
|
172
|
+
| `active` | `boolean` | Mark as currently selected (shows checkmark in dropdown). |
|
|
173
|
+
|
|
174
|
+
On the collapsed icon-rail the switcher renders only the active item's avatar — no dropdown trigger.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
132
178
|
## Sample
|
|
133
179
|
|
|
134
180
|
```tsx
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Private Sidebar
|
|
3
|
-
*
|
|
4
|
-
* Composed from smaller components: SidebarBrand, SidebarNavGroup, SidebarSlots.
|
|
5
|
-
* Uses PrivateLayoutContext for all UI state.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
'use client';
|
|
9
2
|
|
|
10
3
|
import React from 'react';
|
|
@@ -19,7 +12,7 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
19
12
|
|
|
20
13
|
import { PrivateSidebarAccount } from './PrivateSidebarAccount';
|
|
21
14
|
import { PrivateLayoutProvider } from '../context';
|
|
22
|
-
import {
|
|
15
|
+
import { useShellVisualState, useSidebarKeyboard } from '../hooks';
|
|
23
16
|
import type { HeaderConfig, SidebarConfig } from '../types';
|
|
24
17
|
import { SidebarBrand } from './SidebarBrand';
|
|
25
18
|
import { SidebarNavGroup } from './SidebarNavGroup';
|
|
@@ -29,11 +22,6 @@ interface PrivateSidebarProps {
|
|
|
29
22
|
sidebar: SidebarConfig;
|
|
30
23
|
header?: HeaderConfig;
|
|
31
24
|
pathname?: string;
|
|
32
|
-
/**
|
|
33
|
-
* shadcn-sidebar `variant`. Used to trigger the inset/boxed visual:
|
|
34
|
-
* `'inset'` makes the sidebar wrapper paint `bg-sidebar` and lets `SidebarInset`
|
|
35
|
-
* float as a rounded card. Default `'sidebar'` (full-bleed).
|
|
36
|
-
*/
|
|
37
25
|
variant?: 'sidebar' | 'inset';
|
|
38
26
|
}
|
|
39
27
|
|
|
@@ -43,18 +31,13 @@ export function PrivateSidebar({
|
|
|
43
31
|
pathname: pathnameProp,
|
|
44
32
|
variant = 'sidebar',
|
|
45
33
|
}: PrivateSidebarProps) {
|
|
46
|
-
const { state, isMobile, setOpenMobile
|
|
34
|
+
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
47
35
|
const pathname = pathnameProp ?? '';
|
|
48
36
|
|
|
49
37
|
React.useEffect(() => {
|
|
50
38
|
if (isMobile) setOpenMobile(false);
|
|
51
39
|
}, [pathname, isMobile, setOpenMobile]);
|
|
52
40
|
|
|
53
|
-
const collapsedRail = !isMobile && state === 'collapsed';
|
|
54
|
-
const { isHoverExpanded, onMouseEnter, onMouseLeave, setHoverExpanded } = useHoverExpand({
|
|
55
|
-
enabled: collapsedRail,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
41
|
return (
|
|
59
42
|
<PrivateLayoutProvider
|
|
60
43
|
sidebar={sidebar}
|
|
@@ -62,80 +45,39 @@ export function PrivateSidebar({
|
|
|
62
45
|
pathname={pathname}
|
|
63
46
|
isMobile={isMobile}
|
|
64
47
|
state={state}
|
|
65
|
-
isHoverExpanded={isHoverExpanded}
|
|
66
48
|
>
|
|
67
49
|
<PrivateSidebarInner
|
|
68
50
|
sidebar={sidebar}
|
|
69
51
|
header={header}
|
|
70
52
|
variant={variant}
|
|
71
|
-
collapsedRail={collapsedRail}
|
|
72
|
-
setHoverExpanded={setHoverExpanded}
|
|
73
|
-
onMouseEnter={onMouseEnter}
|
|
74
|
-
onMouseLeave={onMouseLeave}
|
|
75
53
|
/>
|
|
76
54
|
</PrivateLayoutProvider>
|
|
77
55
|
);
|
|
78
56
|
}
|
|
79
57
|
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// Inner component — runs inside PrivateLayoutProvider so useShellVisualState
|
|
82
|
-
// can safely consume the context.
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
58
|
interface PrivateSidebarInnerProps {
|
|
86
59
|
sidebar: SidebarConfig;
|
|
87
60
|
header?: HeaderConfig;
|
|
88
61
|
variant: 'sidebar' | 'inset';
|
|
89
|
-
collapsedRail: boolean;
|
|
90
|
-
setHoverExpanded: (value: boolean) => void;
|
|
91
|
-
onMouseEnter: () => void;
|
|
92
|
-
onMouseLeave: () => void;
|
|
93
62
|
}
|
|
94
63
|
|
|
95
|
-
function PrivateSidebarInner({
|
|
96
|
-
sidebar,
|
|
97
|
-
header,
|
|
98
|
-
variant,
|
|
99
|
-
collapsedRail,
|
|
100
|
-
setHoverExpanded,
|
|
101
|
-
onMouseEnter,
|
|
102
|
-
onMouseLeave,
|
|
103
|
-
}: PrivateSidebarInnerProps) {
|
|
64
|
+
function PrivateSidebarInner({ sidebar, header, variant }: PrivateSidebarInnerProps) {
|
|
104
65
|
const layoutVariant = variant === 'inset' ? 'boxed' : 'full-bleed';
|
|
105
66
|
const { modifiers } = useShellVisualState(layoutVariant);
|
|
106
67
|
const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
|
|
107
68
|
|
|
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],
|
|
120
|
-
);
|
|
121
|
-
|
|
122
69
|
const sidebarRootClass = React.useMemo(
|
|
123
70
|
() =>
|
|
124
71
|
cn(
|
|
125
|
-
railExpandHintClass,
|
|
126
72
|
'[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
|
|
127
73
|
modifiers.sidebarRoot,
|
|
128
74
|
modifiers.sidebarInner.map((m) => `[&>[data-sidebar=sidebar]]:${m}`),
|
|
129
75
|
),
|
|
130
|
-
[
|
|
76
|
+
[modifiers],
|
|
131
77
|
);
|
|
132
78
|
|
|
133
79
|
const sidebarContentClass = React.useMemo(
|
|
134
|
-
() =>
|
|
135
|
-
cn(
|
|
136
|
-
'gap-2',
|
|
137
|
-
modifiers.sidebarContent,
|
|
138
|
-
),
|
|
80
|
+
() => cn('gap-2', modifiers.sidebarContent),
|
|
139
81
|
[modifiers.sidebarContent],
|
|
140
82
|
);
|
|
141
83
|
|
|
@@ -159,9 +101,6 @@ function PrivateSidebarInner({
|
|
|
159
101
|
collapsible="icon"
|
|
160
102
|
variant={variant}
|
|
161
103
|
className={sidebarRootClass}
|
|
162
|
-
onClick={collapsedRail ? expandOnRailClick : undefined}
|
|
163
|
-
onMouseEnter={onMouseEnter}
|
|
164
|
-
onMouseLeave={onMouseLeave}
|
|
165
104
|
onKeyDown={handleSidebarKeyDown}
|
|
166
105
|
>
|
|
167
106
|
<SidebarBrand />
|
|
@@ -36,7 +36,6 @@ import { useLogout } from '../../../hooks';
|
|
|
36
36
|
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
37
37
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
38
38
|
import { useShellVisualState } from '../hooks';
|
|
39
|
-
import { blockSidebarCollapse, allowSidebarCollapse } from '../hooks/useHoverExpand';
|
|
40
39
|
import { useProfileDialogStore } from '../../ProfileLayout/ProfileDialog/store';
|
|
41
40
|
|
|
42
41
|
import type { HeaderConfig } from '../types';
|
|
@@ -223,11 +222,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
223
222
|
<div className={wrapperClass}>
|
|
224
223
|
<DropdownMenu
|
|
225
224
|
open={isAccountMenuOpen}
|
|
226
|
-
onOpenChange={
|
|
227
|
-
setIsAccountMenuOpen(open);
|
|
228
|
-
if (open) blockSidebarCollapse();
|
|
229
|
-
else allowSidebarCollapse();
|
|
230
|
-
}}
|
|
225
|
+
onOpenChange={setIsAccountMenuOpen}
|
|
231
226
|
>
|
|
232
227
|
<DropdownMenuTrigger asChild>
|
|
233
228
|
{triggerButton}
|
|
@@ -15,9 +15,10 @@ 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
|
-
const { header, homeHref, brandTitle, brandMonogram, isMobile
|
|
21
|
+
const { header, homeHref, brandTitle, brandMonogram, isMobile } =
|
|
21
22
|
usePrivateLayoutContext();
|
|
22
23
|
const { content } = useShellVisualState();
|
|
23
24
|
|
|
@@ -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,11 +142,24 @@ 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
|
() =>
|
|
@@ -149,11 +168,8 @@ function SidebarBrandRaw() {
|
|
|
149
168
|
isMobile
|
|
150
169
|
? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
|
|
151
170
|
: 'pt-3.5',
|
|
152
|
-
// Hover-expanded overlay: SidebarHeader from ui-core forces paddingLeft/Right to 0
|
|
153
|
-
// when state is collapsed. Override it so content has breathing room.
|
|
154
|
-
!isMobile && isHoverExpanded && '!px-2',
|
|
155
171
|
),
|
|
156
|
-
[isMobile
|
|
172
|
+
[isMobile],
|
|
157
173
|
);
|
|
158
174
|
|
|
159
175
|
return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
|
|
@@ -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';
|