@djangocfg/layouts 2.1.300 → 2.1.302

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 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.300",
3
+ "version": "2.1.302",
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.300",
78
- "@djangocfg/centrifugo": "^2.1.300",
79
- "@djangocfg/debuger": "^2.1.300",
80
- "@djangocfg/i18n": "^2.1.300",
81
- "@djangocfg/monitor": "^2.1.300",
82
- "@djangocfg/ui-core": "^2.1.300",
83
- "@djangocfg/ui-nextjs": "^2.1.300",
84
- "@djangocfg/ui-tools": "^2.1.300",
77
+ "@djangocfg/api": "^2.1.302",
78
+ "@djangocfg/centrifugo": "^2.1.302",
79
+ "@djangocfg/debuger": "^2.1.302",
80
+ "@djangocfg/i18n": "^2.1.302",
81
+ "@djangocfg/monitor": "^2.1.302",
82
+ "@djangocfg/ui-core": "^2.1.302",
83
+ "@djangocfg/ui-nextjs": "^2.1.302",
84
+ "@djangocfg/ui-tools": "^2.1.302",
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.300",
114
- "@djangocfg/centrifugo": "^2.1.300",
115
- "@djangocfg/debuger": "^2.1.300",
116
- "@djangocfg/i18n": "^2.1.300",
117
- "@djangocfg/monitor": "^2.1.300",
118
- "@djangocfg/typescript-config": "^2.1.300",
119
- "@djangocfg/ui-core": "^2.1.300",
120
- "@djangocfg/ui-nextjs": "^2.1.300",
121
- "@djangocfg/ui-tools": "^2.1.300",
113
+ "@djangocfg/api": "^2.1.302",
114
+ "@djangocfg/centrifugo": "^2.1.302",
115
+ "@djangocfg/debuger": "^2.1.302",
116
+ "@djangocfg/i18n": "^2.1.302",
117
+ "@djangocfg/monitor": "^2.1.302",
118
+ "@djangocfg/typescript-config": "^2.1.302",
119
+ "@djangocfg/ui-core": "^2.1.302",
120
+ "@djangocfg/ui-nextjs": "^2.1.302",
121
+ "@djangocfg/ui-tools": "^2.1.302",
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 defaultOpen={true}>
161
+ <SidebarProvider
162
+ defaultOpen={true}
163
+ style={resolveProviderStyle(visual)}
164
+ className={resolveProviderClassName(visual)}
165
+ >
137
166
  {sidebar && (
138
- <PrivateSidebar sidebar={sidebar} header={header} i18n={i18n} pathname={pathname} />
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="flex flex-col">
142
- <PrivateContent padding={contentPadding} hasSidebar={Boolean(sidebar)}>
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}>{children}</div>
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 showMenuStart = sidebar.menuStart != null && sidebar.menuStart !== false;
155
- const showMenuEnd = sidebar.menuEnd != null && sidebar.menuEnd !== false;
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 collapsible="icon">
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 { BaseLayoutProps, DebugConfig, I18nLayoutConfig } from './layout.types';
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
+