@djangocfg/layouts 2.1.299 → 2.1.301
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/README.md +25 -1
- package/package.json +18 -18
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +117 -7
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +17 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +43 -5
- package/src/layouts/types/index.ts +9 -1
- package/src/layouts/types/layout.types.ts +46 -0
package/README.md
CHANGED
|
@@ -88,11 +88,35 @@ Wraps `BaseApp` and picks **admin → private → public** layout by path (`matc
|
|
|
88
88
|
| Component | Use |
|
|
89
89
|
|---|---|
|
|
90
90
|
| **`PublicLayout`** | Marketing / docs. Slots for navbar + footer. All anchors render through `<Link>` from `@djangocfg/ui-core/components` — wrap with `LinkProvider` higher in the tree to inject a locale-aware Link (e.g. `next-intl`). All three navbars accept `controls` + `i18n` to show theme / locale switchers next to UserMenu (same shape as `DefaultFooter.controls`). **[See PublicLayout README](./src/layouts/PublicLayout/README.md)** for full props, navbar variants (`FloatingNavbar` / `FlushNavbar` / `MinimalNavbar`), `DefaultFooter`, `NavAction`, `NavControls`, and hooks. |
|
|
91
|
-
| **`PrivateLayout`** | App shell — sidebar + header. |
|
|
91
|
+
| **`PrivateLayout`** | App shell — sidebar + header. Defaults to the `boxed` visual (inset rounded card on a sidebar-coloured canvas); pass `visual={{ variant: 'full-bleed' }}` for the legacy edge-to-edge layout. |
|
|
92
92
|
| **`AuthLayout`** | Sign-in flows. |
|
|
93
93
|
| **`AdminLayout`** | Admin console. |
|
|
94
94
|
| **`ProfileLayout`** | Profile page — avatar, editable fields, 2FA, tabs, slots (see below). |
|
|
95
95
|
|
|
96
|
+
### `PrivateLayout` visual variants
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
<PrivateLayout
|
|
100
|
+
sidebar={sidebar}
|
|
101
|
+
header={header}
|
|
102
|
+
visual={{ variant: 'boxed', inset: 12, radius: '2xl', border: true }}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</PrivateLayout>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`boxed` (default) — `<SidebarInset>` becomes a rounded card; the wrapper paints `bg-sidebar` so the brand colour bleeds to the viewport edges. Mobile (<md) degrades to full-bleed automatically.
|
|
109
|
+
`full-bleed` — content stretches edge-to-edge next to the sidebar (legacy look). Opt in with `visual={{ variant: 'full-bleed' }}`.
|
|
110
|
+
|
|
111
|
+
| Field | Type | Default | Notes |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| `variant` | `'full-bleed' \| 'boxed'` | `'boxed'` | Switch between the two shells. |
|
|
114
|
+
| `inset` | `number \| { x?: number; y?: number }` | `12` | Gap (px) between the card and the viewport edges (md+). |
|
|
115
|
+
| `radius` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | `'2xl'` | Card corner radius. |
|
|
116
|
+
| `background` | `'sidebar' \| 'muted' \| 'card' \| 'background'` | `'sidebar'` | Canvas colour painted *behind* the boxed card. |
|
|
117
|
+
| `border` | `boolean` | `true` | 1px border on the card. |
|
|
118
|
+
| `maxWidth` | `'none' \| '7xl' \| 'screen-xl' \| 'screen-2xl'` | `'none'` | Optional inner content width cap. |
|
|
119
|
+
|
|
96
120
|
### `ProfileLayout`
|
|
97
121
|
|
|
98
122
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.301",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
"check": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@djangocfg/api": "^2.1.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/debuger": "^2.1.
|
|
80
|
-
"@djangocfg/i18n": "^2.1.
|
|
81
|
-
"@djangocfg/monitor": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
77
|
+
"@djangocfg/api": "^2.1.301",
|
|
78
|
+
"@djangocfg/centrifugo": "^2.1.301",
|
|
79
|
+
"@djangocfg/debuger": "^2.1.301",
|
|
80
|
+
"@djangocfg/i18n": "^2.1.301",
|
|
81
|
+
"@djangocfg/monitor": "^2.1.301",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.301",
|
|
83
|
+
"@djangocfg/ui-nextjs": "^2.1.301",
|
|
84
|
+
"@djangocfg/ui-tools": "^2.1.301",
|
|
85
85
|
"@hookform/resolvers": "^5.2.2",
|
|
86
86
|
"consola": "^3.4.2",
|
|
87
87
|
"lucide-react": "^0.545.0",
|
|
@@ -110,15 +110,15 @@
|
|
|
110
110
|
"uuid": "^11.1.0"
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
|
-
"@djangocfg/api": "^2.1.
|
|
114
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
115
|
-
"@djangocfg/debuger": "^2.1.
|
|
116
|
-
"@djangocfg/i18n": "^2.1.
|
|
117
|
-
"@djangocfg/monitor": "^2.1.
|
|
118
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
119
|
-
"@djangocfg/ui-core": "^2.1.
|
|
120
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
121
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
113
|
+
"@djangocfg/api": "^2.1.301",
|
|
114
|
+
"@djangocfg/centrifugo": "^2.1.301",
|
|
115
|
+
"@djangocfg/debuger": "^2.1.301",
|
|
116
|
+
"@djangocfg/i18n": "^2.1.301",
|
|
117
|
+
"@djangocfg/monitor": "^2.1.301",
|
|
118
|
+
"@djangocfg/typescript-config": "^2.1.301",
|
|
119
|
+
"@djangocfg/ui-core": "^2.1.301",
|
|
120
|
+
"@djangocfg/ui-nextjs": "^2.1.301",
|
|
121
|
+
"@djangocfg/ui-tools": "^2.1.301",
|
|
122
122
|
"@types/node": "^24.7.2",
|
|
123
123
|
"@types/react": "^19.1.0",
|
|
124
124
|
"@types/react-dom": "^19.1.0",
|
|
@@ -16,7 +16,7 @@ import { Preloader } from '@djangocfg/ui-core/components';
|
|
|
16
16
|
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-nextjs/components';
|
|
17
17
|
|
|
18
18
|
import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
|
|
19
|
-
import type { I18nLayoutConfig } from '../types';
|
|
19
|
+
import type { I18nLayoutConfig, LayoutVisualConfig } from '../types';
|
|
20
20
|
import { UserMenuConfig } from '../types';
|
|
21
21
|
import { PrivateContent, PrivateSidebar } from './components';
|
|
22
22
|
|
|
@@ -55,6 +55,14 @@ export interface SidebarConfig {
|
|
|
55
55
|
* (above `footer` + account block).
|
|
56
56
|
*/
|
|
57
57
|
menuEnd?: ReactNode;
|
|
58
|
+
/**
|
|
59
|
+
* Keep `menuStart` visible when the desktop sidebar is collapsed to the
|
|
60
|
+
* icon rail. Default `false` — most slot content is full-width and looks
|
|
61
|
+
* broken at ~56px. Set `true` only when the slot renders well in compact mode.
|
|
62
|
+
*/
|
|
63
|
+
menuStartShowOnCollapsed?: boolean;
|
|
64
|
+
/** Same as `menuStartShowOnCollapsed`, but for `menuEnd`. Default `false`. */
|
|
65
|
+
menuEndShowOnCollapsed?: boolean;
|
|
58
66
|
/** Custom footer component rendered at the bottom of the sidebar */
|
|
59
67
|
footer?: ReactNode;
|
|
60
68
|
}
|
|
@@ -95,6 +103,17 @@ export interface PrivateLayoutProps {
|
|
|
95
103
|
contentPadding?: 'none' | 'default';
|
|
96
104
|
/** i18n configuration for locale switching */
|
|
97
105
|
i18n?: I18nLayoutConfig;
|
|
106
|
+
/**
|
|
107
|
+
* Visual style of the shell. Defaults to `'boxed'` (inset rounded card on a
|
|
108
|
+
* sidebar-coloured canvas). Pass `{ variant: 'full-bleed' }` for the legacy
|
|
109
|
+
* edge-to-edge layout.
|
|
110
|
+
*/
|
|
111
|
+
visual?: LayoutVisualConfig;
|
|
112
|
+
/**
|
|
113
|
+
* Skip the built-in auth guard. Useful for static showcases / playground
|
|
114
|
+
* embeds where there's no real session. Default `true` (guard on).
|
|
115
|
+
*/
|
|
116
|
+
requireAuth?: boolean;
|
|
98
117
|
/** Reserved for `AppLayout` passthrough (`publicChrome`); unused in this layout. */
|
|
99
118
|
publicChrome?: AppLayoutPublicChrome;
|
|
100
119
|
}
|
|
@@ -106,21 +125,24 @@ export function PrivateLayout({
|
|
|
106
125
|
pathname,
|
|
107
126
|
contentPadding = 'default',
|
|
108
127
|
i18n,
|
|
128
|
+
visual,
|
|
129
|
+
requireAuth = true,
|
|
109
130
|
}: PrivateLayoutProps) {
|
|
110
131
|
const { isAuthenticated, isLoading, saveRedirectUrl } = useAuth();
|
|
111
132
|
const router = useRouter();
|
|
112
133
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
|
113
134
|
|
|
114
135
|
useEffect(() => {
|
|
136
|
+
if (!requireAuth) return;
|
|
115
137
|
if (!isLoading && !isAuthenticated && !isRedirecting) {
|
|
116
138
|
const currentUrl = window.location.pathname + window.location.search;
|
|
117
139
|
saveRedirectUrl(currentUrl);
|
|
118
140
|
setIsRedirecting(true);
|
|
119
141
|
router.push(header?.authPath || '/auth');
|
|
120
142
|
}
|
|
121
|
-
}, [isAuthenticated, isLoading, isRedirecting, router, saveRedirectUrl, header?.authPath]);
|
|
143
|
+
}, [requireAuth, isAuthenticated, isLoading, isRedirecting, router, saveRedirectUrl, header?.authPath]);
|
|
122
144
|
|
|
123
|
-
if (isLoading || isRedirecting || !isAuthenticated) {
|
|
145
|
+
if (requireAuth && (isLoading || isRedirecting || !isAuthenticated)) {
|
|
124
146
|
return (
|
|
125
147
|
<Preloader
|
|
126
148
|
variant="fullscreen"
|
|
@@ -132,17 +154,105 @@ export function PrivateLayout({
|
|
|
132
154
|
);
|
|
133
155
|
}
|
|
134
156
|
|
|
157
|
+
const variant: LayoutVisualConfig['variant'] = visual?.variant ?? 'boxed';
|
|
158
|
+
const sidebarVariant = variant === 'boxed' ? 'inset' : 'sidebar';
|
|
159
|
+
|
|
135
160
|
return (
|
|
136
|
-
<SidebarProvider
|
|
161
|
+
<SidebarProvider
|
|
162
|
+
defaultOpen={true}
|
|
163
|
+
style={resolveProviderStyle(visual)}
|
|
164
|
+
className={resolveProviderClassName(visual)}
|
|
165
|
+
>
|
|
137
166
|
{sidebar && (
|
|
138
|
-
<PrivateSidebar
|
|
167
|
+
<PrivateSidebar
|
|
168
|
+
sidebar={sidebar}
|
|
169
|
+
header={header}
|
|
170
|
+
i18n={i18n}
|
|
171
|
+
pathname={pathname}
|
|
172
|
+
variant={sidebarVariant}
|
|
173
|
+
/>
|
|
139
174
|
)}
|
|
140
175
|
|
|
141
|
-
<SidebarInset className=
|
|
142
|
-
<PrivateContent
|
|
176
|
+
<SidebarInset className={resolveInsetClassName(visual)}>
|
|
177
|
+
<PrivateContent
|
|
178
|
+
padding={contentPadding}
|
|
179
|
+
hasSidebar={Boolean(sidebar)}
|
|
180
|
+
visual={visual}
|
|
181
|
+
>
|
|
143
182
|
{children}
|
|
144
183
|
</PrivateContent>
|
|
145
184
|
</SidebarInset>
|
|
146
185
|
</SidebarProvider>
|
|
147
186
|
);
|
|
148
187
|
}
|
|
188
|
+
|
|
189
|
+
/** CSS variables consumed by the boxed `SidebarInset` (margin + radius). */
|
|
190
|
+
function resolveProviderStyle(visual: LayoutVisualConfig | undefined): React.CSSProperties | undefined {
|
|
191
|
+
if ((visual?.variant ?? 'boxed') !== 'boxed') return undefined;
|
|
192
|
+
const inset = normaliseInset(visual?.inset);
|
|
193
|
+
return {
|
|
194
|
+
['--app-shell-inset-x' as string]: `${inset.x}px`,
|
|
195
|
+
['--app-shell-inset-y' as string]: `${inset.y}px`,
|
|
196
|
+
['--app-shell-radius' as string]: BOXED_RADIUS_REM[visual?.radius ?? '2xl'],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Statically-known Tailwind classes for the boxed inset. Margin and radius are
|
|
202
|
+
* driven by the CSS variables set in `resolveProviderStyle`, so JIT can fully
|
|
203
|
+
* extract these classes at build time.
|
|
204
|
+
*/
|
|
205
|
+
const BOXED_INSET_CLASS = [
|
|
206
|
+
'flex flex-col',
|
|
207
|
+
'md:peer-data-[variant=inset]:my-[var(--app-shell-inset-y)]',
|
|
208
|
+
'md:peer-data-[variant=inset]:mr-[var(--app-shell-inset-x)]',
|
|
209
|
+
'md:peer-data-[variant=inset]:rounded-[var(--app-shell-radius)]',
|
|
210
|
+
'md:peer-data-[variant=inset]:overflow-hidden',
|
|
211
|
+
].join(' ');
|
|
212
|
+
|
|
213
|
+
const BOXED_INSET_BORDER_CLASS =
|
|
214
|
+
'md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border/60';
|
|
215
|
+
|
|
216
|
+
const BOXED_RADIUS_REM: Record<NonNullable<LayoutVisualConfig['radius']>, string> = {
|
|
217
|
+
sm: '0.375rem',
|
|
218
|
+
md: '0.5rem',
|
|
219
|
+
lg: '0.75rem',
|
|
220
|
+
xl: '1rem',
|
|
221
|
+
'2xl': '1.25rem',
|
|
222
|
+
'3xl': '1.75rem',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function resolveInsetClassName(visual: LayoutVisualConfig | undefined): string {
|
|
226
|
+
if ((visual?.variant ?? 'boxed') !== 'boxed') return 'flex flex-col';
|
|
227
|
+
const border = visual?.border ?? true;
|
|
228
|
+
return border ? `${BOXED_INSET_CLASS} ${BOXED_INSET_BORDER_CLASS}` : BOXED_INSET_CLASS;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Background painted *behind* the boxed container on md+. On mobile the
|
|
233
|
+
* canvas tint is dropped because the sidebar is a Drawer — leaking the
|
|
234
|
+
* canvas colour to the whole viewport just makes the page look dim.
|
|
235
|
+
*
|
|
236
|
+
* `bg-sidebar` (the default) overrides shadcn-sidebar's built-in
|
|
237
|
+
* `has-[&_[data-variant=inset]]:bg-sidebar` only at the breakpoint where
|
|
238
|
+
* the inset shape actually exists.
|
|
239
|
+
*/
|
|
240
|
+
const BOXED_BG_CLASS: Record<NonNullable<LayoutVisualConfig['background']>, string> = {
|
|
241
|
+
sidebar: 'md:!bg-sidebar',
|
|
242
|
+
muted: 'md:!bg-muted',
|
|
243
|
+
card: 'md:!bg-card',
|
|
244
|
+
background: 'md:!bg-background',
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function resolveProviderClassName(visual: LayoutVisualConfig | undefined): string | undefined {
|
|
248
|
+
if ((visual?.variant ?? 'boxed') !== 'boxed') return undefined;
|
|
249
|
+
// `max-md:!bg-background` neutralises shadcn-sidebar's built-in
|
|
250
|
+
// `has-[[data-variant=inset]]:bg-sidebar` below md so the mobile Drawer shell
|
|
251
|
+
// doesn't paint the whole viewport with the canvas tint.
|
|
252
|
+
return `max-md:!bg-background ${BOXED_BG_CLASS[visual?.background ?? 'sidebar']}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normaliseInset(inset: LayoutVisualConfig['inset']): { x: number; y: number } {
|
|
256
|
+
if (typeof inset === 'number') return { x: inset, y: inset };
|
|
257
|
+
return { x: inset?.x ?? 12, y: inset?.y ?? 12 };
|
|
258
|
+
}
|
|
@@ -10,17 +10,29 @@ import React, { ReactNode } from 'react';
|
|
|
10
10
|
import { SidebarTrigger, useSidebar } from '@djangocfg/ui-nextjs/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
|
|
13
|
+
import type { LayoutVisualConfig } from '../../types';
|
|
14
|
+
|
|
13
15
|
interface PrivateContentProps {
|
|
14
16
|
children: ReactNode;
|
|
15
17
|
padding?: 'none' | 'default';
|
|
16
18
|
/** When false, no mobile hamburger (e.g. layout without a sidebar). Default true. */
|
|
17
19
|
hasSidebar?: boolean;
|
|
20
|
+
/** Visual config from PrivateLayout — controls maxWidth in boxed mode. */
|
|
21
|
+
visual?: LayoutVisualConfig;
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
const MAX_WIDTH_CLASS: Record<NonNullable<LayoutVisualConfig['maxWidth']>, string> = {
|
|
25
|
+
none: '',
|
|
26
|
+
'7xl': 'mx-auto w-full max-w-7xl',
|
|
27
|
+
'screen-xl': 'mx-auto w-full max-w-screen-xl',
|
|
28
|
+
'screen-2xl': 'mx-auto w-full max-w-screen-2xl',
|
|
29
|
+
};
|
|
30
|
+
|
|
20
31
|
export function PrivateContent({
|
|
21
32
|
children,
|
|
22
33
|
padding = 'default',
|
|
23
34
|
hasSidebar = true,
|
|
35
|
+
visual,
|
|
24
36
|
}: PrivateContentProps) {
|
|
25
37
|
const { isMobile, openMobile } = useSidebar();
|
|
26
38
|
|
|
@@ -66,10 +78,14 @@ export function PrivateContent({
|
|
|
66
78
|
/>
|
|
67
79
|
) : null;
|
|
68
80
|
|
|
81
|
+
const innerWidthClass = MAX_WIDTH_CLASS[visual?.maxWidth ?? 'none'];
|
|
82
|
+
|
|
69
83
|
return (
|
|
70
84
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
71
85
|
{mobileMenuFab}
|
|
72
|
-
<div className={scrollAreaClass}>
|
|
86
|
+
<div className={scrollAreaClass}>
|
|
87
|
+
{innerWidthClass ? <div className={innerWidthClass}>{children}</div> : children}
|
|
88
|
+
</div>
|
|
73
89
|
</div>
|
|
74
90
|
);
|
|
75
91
|
}
|
|
@@ -101,12 +101,18 @@ interface PrivateSidebarProps {
|
|
|
101
101
|
header?: HeaderConfig;
|
|
102
102
|
i18n?: I18nLayoutConfig;
|
|
103
103
|
pathname?: string;
|
|
104
|
+
/**
|
|
105
|
+
* shadcn-sidebar `variant`. Used to trigger the inset/boxed visual:
|
|
106
|
+
* `'inset'` makes the sidebar wrapper paint `bg-sidebar` and lets `SidebarInset`
|
|
107
|
+
* float as a rounded card. Default `'sidebar'` (full-bleed).
|
|
108
|
+
*/
|
|
109
|
+
variant?: 'sidebar' | 'inset';
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }: PrivateSidebarProps) {
|
|
112
|
+
export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp, variant = 'sidebar' }: PrivateSidebarProps) {
|
|
107
113
|
const pathnameFromNext = useNextPathname();
|
|
108
114
|
const pathname = pathnameProp ?? pathnameFromNext;
|
|
109
|
-
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
115
|
+
const { state, isMobile, setOpen, setOpenMobile } = useSidebar();
|
|
110
116
|
const homeHref = sidebar.homeHref || '/';
|
|
111
117
|
|
|
112
118
|
React.useEffect(() => {
|
|
@@ -151,8 +157,15 @@ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }
|
|
|
151
157
|
</span>
|
|
152
158
|
);
|
|
153
159
|
|
|
154
|
-
const
|
|
155
|
-
const
|
|
160
|
+
const hasMenuStart = sidebar.menuStart != null && sidebar.menuStart !== false;
|
|
161
|
+
const hasMenuEnd = sidebar.menuEnd != null && sidebar.menuEnd !== false;
|
|
162
|
+
// Hide slots on the desktop icon rail unless the consumer opted in. Mobile
|
|
163
|
+
// drawer always shows them — there's no rail in the drawer to begin with.
|
|
164
|
+
const collapsedRail = !isMobile && state === 'collapsed';
|
|
165
|
+
const showMenuStart =
|
|
166
|
+
hasMenuStart && (!collapsedRail || sidebar.menuStartShowOnCollapsed === true);
|
|
167
|
+
const showMenuEnd =
|
|
168
|
+
hasMenuEnd && (!collapsedRail || sidebar.menuEndShowOnCollapsed === true);
|
|
156
169
|
const menuStartSlot = showMenuStart ? (
|
|
157
170
|
<div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuStart}</div>
|
|
158
171
|
) : null;
|
|
@@ -274,8 +287,33 @@ export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }
|
|
|
274
287
|
: 'px-2 pt-3.5',
|
|
275
288
|
);
|
|
276
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Click on the collapsed icon-rail expands the sidebar — but only on empty
|
|
292
|
+
* areas. Native interactive elements (nav links, the trigger, account menu,
|
|
293
|
+
* tooltips) keep their original behaviour: we bail out as soon as the click
|
|
294
|
+
* target sits inside a `button`, `a`, or anything explicitly marked
|
|
295
|
+
* non-expandable via `data-no-expand`.
|
|
296
|
+
*/
|
|
297
|
+
const expandOnRailClick = !isMobile && state === 'collapsed'
|
|
298
|
+
? (event: React.MouseEvent<HTMLDivElement>) => {
|
|
299
|
+
const interactive = (event.target as Element | null)?.closest(
|
|
300
|
+
'a, button, [role="menuitem"], [data-no-expand]',
|
|
301
|
+
);
|
|
302
|
+
if (interactive) return;
|
|
303
|
+
setOpen(true);
|
|
304
|
+
}
|
|
305
|
+
: undefined;
|
|
306
|
+
|
|
307
|
+
const railExpandHintClass =
|
|
308
|
+
!isMobile && state === 'collapsed' ? 'cursor-pointer' : undefined;
|
|
309
|
+
|
|
277
310
|
return (
|
|
278
|
-
<Sidebar
|
|
311
|
+
<Sidebar
|
|
312
|
+
collapsible="icon"
|
|
313
|
+
variant={variant}
|
|
314
|
+
className={railExpandHintClass}
|
|
315
|
+
onClick={expandOnRailClick}
|
|
316
|
+
>
|
|
279
317
|
<SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
|
|
280
318
|
|
|
281
319
|
<SidebarContent className={sidebarContentClass}>
|
|
@@ -52,4 +52,12 @@ export type {
|
|
|
52
52
|
// Layout Types
|
|
53
53
|
// ============================================================================
|
|
54
54
|
|
|
55
|
-
export type {
|
|
55
|
+
export type {
|
|
56
|
+
BaseLayoutProps,
|
|
57
|
+
DebugConfig,
|
|
58
|
+
I18nLayoutConfig,
|
|
59
|
+
LayoutVisualVariant,
|
|
60
|
+
LayoutVisualConfig,
|
|
61
|
+
LayoutBoxedRadius,
|
|
62
|
+
LayoutBoxedBackground,
|
|
63
|
+
} from './layout.types';
|
|
@@ -83,3 +83,49 @@ export interface I18nLayoutConfig {
|
|
|
83
83
|
onLocaleChange: (locale: string) => void;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Layout Visual Variant
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Visual style of the private/app shell.
|
|
92
|
+
* - `boxed` (default) — content lives in a fixed, rounded card inset from the
|
|
93
|
+
* viewport edges while the sidebar-coloured background bleeds to the screen
|
|
94
|
+
* border. Internal scroll inside the card; mobile (<md) falls back to
|
|
95
|
+
* `full-bleed` automatically.
|
|
96
|
+
* - `full-bleed` — content stretches edge-to-edge next to the sidebar (legacy look).
|
|
97
|
+
*/
|
|
98
|
+
export type LayoutVisualVariant = 'full-bleed' | 'boxed';
|
|
99
|
+
|
|
100
|
+
/** Border radius preset for the boxed container. */
|
|
101
|
+
export type LayoutBoxedRadius = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
|
102
|
+
|
|
103
|
+
/** Background token used for the area *behind* the boxed container. */
|
|
104
|
+
export type LayoutBoxedBackground = 'sidebar' | 'muted' | 'card' | 'background';
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Visual config for the private layout shell.
|
|
108
|
+
* All `boxed`-only options are ignored when `variant === 'full-bleed'`.
|
|
109
|
+
*/
|
|
110
|
+
export interface LayoutVisualConfig {
|
|
111
|
+
/** Visual variant. Default: `'boxed'`. */
|
|
112
|
+
variant?: LayoutVisualVariant;
|
|
113
|
+
/**
|
|
114
|
+
* Inset (px) between the boxed container and the viewport edges on desktop.
|
|
115
|
+
* Either a single value (applied to all sides next to the sidebar) or per-axis.
|
|
116
|
+
* Default: `12`.
|
|
117
|
+
*/
|
|
118
|
+
inset?: number | { x?: number; y?: number };
|
|
119
|
+
/** Border radius preset of the boxed container. Default: `'2xl'`. */
|
|
120
|
+
radius?: LayoutBoxedRadius;
|
|
121
|
+
/** Background colour token shown *behind* the boxed container. Default: `'sidebar'`. */
|
|
122
|
+
background?: LayoutBoxedBackground;
|
|
123
|
+
/** Whether to render a 1px border on the boxed container. Default: `true`. */
|
|
124
|
+
border?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Optional max width for the inner scrollable area (Tailwind size token).
|
|
127
|
+
* Default: `'none'` (fills available width minus inset).
|
|
128
|
+
*/
|
|
129
|
+
maxWidth?: 'none' | 'screen-xl' | 'screen-2xl' | '7xl';
|
|
130
|
+
}
|
|
131
|
+
|