@djangocfg/layouts 2.1.320 → 2.1.322

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
@@ -23,115 +23,68 @@ Peers: `@djangocfg/ui-core`, `@djangocfg/ui-nextjs`, React 19, Next.js 16+, Tail
23
23
 
24
24
  ---
25
25
 
26
- ## BaseApp
26
+ ## Quick start
27
27
 
28
- Root provider (theme, auth, analytics, SWR, toasts). Use when you **don't** need route-based layout switching.
29
-
30
- ```tsx
31
- <BaseApp
32
- project="my-app"
33
- theme={{ defaultTheme: 'system', storageKey: 'my-theme' }}
34
- auth={{ apiUrl: process.env.NEXT_PUBLIC_API_URL }}
35
- >
36
- {children}
37
- </BaseApp>
38
- ```
39
-
40
- | Prop | Role |
41
- |---|---|
42
- | `project` | Name for monitor / debug. Enables `window.monitor`. |
43
- | `theme` | `defaultTheme`, `storageKey`, and optional **`style`** (preset + CSS-var overrides). |
44
- | `auth`, `analytics`, `centrifugo`, `errorTracking`, `errorBoundary`, `swr`, `pwaInstall`, `monitor`, `debug` | Optional integrations. |
45
-
46
- ### `theme.style`
47
-
48
- Mounts `ThemeStyleBridge` → injects `<style id="djangocfg-baseapp-theme-style">` with `--*` variables. Merge order: imported globals → preset → `vars` (strongest).
49
-
50
- | Piece | Meaning |
51
- |---|---|
52
- | `preset` | One of `THEME_STYLE_PRESET_ORDER`. Bundles live in `THEME_STYLE_PRESETS`. |
53
- | `vars.light` / `vars.dark` | Partial `ThemeCssVarMap` — HSL triplets (`192 90% 35%`); `radius` accepts any CSS length. |
54
-
55
- Presets: `default` · `django-cfg` · `ios` · `soft` · `dense` · `high-contrast`.
56
-
57
- Playground-only buckets (shadows, typography, spacing) are **not** injected — export full CSS from the Theme Configurator when you need them.
58
-
59
- Exports: `ThemeStyleConfig`, `ThemeCssVarKey`, `ThemeCssVarMap`, `ThemeStylePresetId`, `THEME_STYLE_PRESETS`, `THEME_STYLE_PRESET_ORDER`, `buildThemeStyleSheet`, `ThemeStyleBridge`.
60
-
61
- ---
62
-
63
- ## AppLayout
64
-
65
- Wraps `BaseApp` and picks **admin → private → public** layout by path (`matchesPath` / `enabledPath`). `noLayoutPaths` skips the shell entirely (fullscreen / embeds). `publicChrome` merges navbar/footer/main spacing defaults.
28
+ `AppLayout` wraps `BaseApp` (theme, auth, analytics, SWR, toasts) and routes the page to the right shell based on path:
66
29
 
67
30
  ```tsx
68
31
  <AppLayout
69
32
  layouts={{
70
- public: { component: PublicLayout, enabledPath: ['/', '/legal', '/contact'] },
33
+ public: { component: PublicLayout, enabledPath: ['/', '/legal'] },
71
34
  private: { component: PrivateLayout, enabledPath: ['/dashboard'] },
72
35
  admin: { component: AdminLayout, enabledPath: '/admin' },
73
- noLayoutPaths: ['/embed', '/ui'],
36
+ noLayoutPaths: ['/embed'],
74
37
  }}
75
38
  baseApp={{ project: 'my-app', theme: { defaultTheme: 'system' }, auth: { apiUrl: '…' } }}
76
- i18n={{ locale, locales, onLocaleChange }}
39
+ i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}
77
40
  >
78
41
  {children}
79
42
  </AppLayout>
80
43
  ```
81
44
 
82
- > **`i18n` is required** for correct path matching when using `next-intl` or any locale-prefixed routing. Without it the fallback regex can mis-strip 2-letter path segments (e.g. `/ui/*` treated as locale `ui`).
45
+ Use `BaseApp` directly when you don't need route-based layout switching see [AppLayout README](./src/layouts/AppLayout/README.md) for the matching rules, `noLayoutPaths`, `publicChrome`, and `i18n.routing` plumbing.
46
+
47
+ > **Pass `i18n`** when using `next-intl` or any locale-prefixed routing. Without it, the path matcher can mis-strip 2-letter segments (e.g. `/ui/*` treated as locale `ui`).
83
48
 
84
49
  ---
85
50
 
86
51
  ## Layouts
87
52
 
88
- | Component | Use |
89
- |---|---|
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 a `controls` block to show theme / locale switchers next to `UserMenu`; locale data flows from `LayoutI18nProvider` (mounted by `AppLayout` / `BaseApp` when you pass `i18n`). The locale switcher renders SVG country flags and matches the `UserMenu` avatar size at `size="icon"`. **[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. 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
- | **`AuthLayout`** | Sign-in flows. |
93
- | **`AdminLayout`** | Admin console. |
94
- | **`ProfileLayout`** | Profile page — avatar, editable fields, 2FA, tabs, slots (see below). |
53
+ | Component | Use | Docs |
54
+ |---|---|---|
55
+ | **`PublicLayout`** | Marketing / docs. Slots for navbar (`Floating`/`Flush`/`Minimal`) + footer + locale + auth controls. | [README](./src/layouts/PublicLayout/README.md) |
56
+ | **`PrivateLayout`** | Authenticated app shell — sidebar (collapsible icon rail, accordion groups, rail/featured/CTA slots) + popover account footer. | [README](./src/layouts/PrivateLayout/README.md) |
57
+ | **`AuthLayout`** | Sign-in / sign-up flows. | [README](./src/layouts/AuthLayout/README.md) |
58
+ | **`AdminLayout`** | Admin console. | — |
59
+ | **`ProfileLayout`** | Profile page — see below. | |
95
60
 
96
- ### `PrivateLayout` visual variants
61
+ ### `ProfileLayout`
97
62
 
98
63
  ```tsx
99
- <PrivateLayout
100
- sidebar={sidebar}
101
- header={header}
102
- visual={{ variant: 'boxed', inset: 12, radius: '2xl', border: true }}
103
- >
104
- {children}
105
- </PrivateLayout>
64
+ <ProfileLayout enable2FA enableDeleteAccount tabs={tabs} slots={slots} />
106
65
  ```
107
66
 
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' }}`.
67
+ | Prop | Role |
68
+ |---|---|
69
+ | `enable2FA` | Show Security tab with 2FA management. |
70
+ | `enableDeleteAccount` | Show Delete account in `⋯` menu. |
71
+ | `tabs` | Extra `ProfileTab[]` appended after built-ins. |
72
+ | `slots.headerBadge` / `headerMenuItems` / `headerAfter` / `footer` | Slot content around the avatar row, menu, and tab body. |
110
73
 
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. |
74
+ ---
119
75
 
120
- ### `ProfileLayout`
76
+ ## Theme
121
77
 
122
- ```tsx
123
- <ProfileLayout enable2FA enableDeleteAccount tabs={tabs} slots={slots} />
124
- ```
78
+ `baseApp.theme.style` injects `<style id="djangocfg-baseapp-theme-style">` with `--*` CSS variables. Merge order: imported globals → preset → `vars` (strongest).
125
79
 
126
- | Prop | Type | Role |
127
- |---|---|---|
128
- | `enable2FA` | `boolean` | Show Security tab with 2FA management. |
129
- | `enableDeleteAccount` | `boolean` | Show Delete account in `⋯` menu. |
130
- | `tabs` | `ProfileTab[]` | Extra tabs appended after built-in ones. |
131
- | `slots.headerBadge` | `ReactNode` | Next to user name (plan, role…). |
132
- | `slots.headerMenuItems` | `ReactNode` | Extra `DropdownMenuItem`s in `⋯` menu. |
133
- | `slots.headerAfter` | `ReactNode` | Below avatar row, above tabs. |
134
- | `slots.footer` | `ReactNode` | Below all tab content. |
80
+ | Piece | Meaning |
81
+ |---|---|
82
+ | `preset` | `default` · `django-cfg` · `ios` · `soft` · `dense` · `high-contrast`. |
83
+ | `vars.light` / `vars.dark` | Partial `ThemeCssVarMap` HSL triplets (`192 90% 35%`); `radius` accepts any CSS length. |
84
+
85
+ Playground-only buckets (shadows, typography, spacing) are **not** injected export full CSS from the Theme Configurator when you need them.
86
+
87
+ Exports: `ThemeStyleConfig`, `ThemeCssVarKey`, `ThemeCssVarMap`, `ThemeStylePresetId`, `THEME_STYLE_PRESETS`, `THEME_STYLE_PRESET_ORDER`, `buildThemeStyleSheet`, `ThemeStyleBridge`.
135
88
 
136
89
  ---
137
90
 
@@ -142,29 +95,16 @@ import { useLocaleSwitcher } from '@djangocfg/nextjs/i18n/client';
142
95
  import { routing } from '@djangocfg/nextjs/i18n/routing';
143
96
 
144
97
  const { locale, locales, changeLocale } = useLocaleSwitcher();
145
- <AppLayout i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>
146
- {children}
147
- </AppLayout>
98
+ <AppLayout i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>...</AppLayout>
148
99
  ```
149
100
 
150
- `i18n.routing` is the object returned by `defineRouting()` (`next-intl/routing`). When passed, `BaseApp` builds a locale-aware `Link` adapter from it and mounts a `LinkProvider`, so every `<Link>` rendered inside layouts (navbar, footer, drawer, …) keeps the active locale prefix on click. Drop it for default-locale-only apps — raw `next/link` works fine.
101
+ When `routing` is set, `BaseApp` mounts a locale-aware `<Link>` adapter so every layout-rendered link keeps the active locale prefix. Drop it for default-locale-only apps.
151
102
 
152
103
  ---
153
104
 
154
105
  ## Monitor & debug
155
106
 
156
- `project` on `BaseApp` enables `window.monitor`. Debug panel: `Cmd+D` or `?debug=1`.
157
-
158
- ---
159
-
160
- ## Utilities
161
-
162
- | API | Role |
163
- |---|---|
164
- | `useErrorEmitter`, `emitRuntimeError` | Error tracking hooks. |
165
- | `ErrorLayout` (`/components/errors`) | 404 / error pages. |
166
- | `RedirectPage` | Auth redirect helper. |
167
- | `PrivacyPage`, `TermsPage`, … (`/pages/legal`) | Legal templates. |
107
+ `baseApp.project` enables `window.monitor`. Debug panel: `Cmd+D` or `?debug=1`.
168
108
 
169
109
  ---
170
110
 
@@ -174,13 +114,12 @@ const { locale, locales, changeLocale } = useLocaleSwitcher();
174
114
  |---|---|
175
115
  | `@djangocfg/layouts` | Barrel. |
176
116
  | `@djangocfg/layouts/layouts` | Layout components. |
177
- | `@djangocfg/layouts/components` | Misc. |
178
- | `@djangocfg/layouts/pages/legal` | Legal pages. |
117
+ | `@djangocfg/layouts/components` | Misc + `ErrorLayout`, `RedirectPage`. |
118
+ | `@djangocfg/layouts/pages/legal` | Legal page templates. |
119
+ | `@djangocfg/layouts/configurator` | JSON Schemas for `<JsonSchemaForm>`-driven configurator UIs (PrivateLayout for now). Pair with `@djangocfg/ui-tools`. |
179
120
  | `@djangocfg/layouts/styles` | Base CSS. |
180
121
 
181
- ## Extensions
182
-
183
- `@djangocfg/ext-newsletter`, `ext-knowbase`, `ext-leads`, `ext-payments`, `ext-support`, …
122
+ Extensions: `@djangocfg/ext-newsletter`, `ext-knowbase`, `ext-leads`, `ext-payments`, `ext-support`, …
184
123
 
185
124
  ## License
186
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.320",
3
+ "version": "2.1.322",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -60,6 +60,16 @@
60
60
  "import": "./src/pages/index.ts",
61
61
  "require": "./src/pages/index.ts"
62
62
  },
63
+ "./configurator": {
64
+ "types": "./src/configurator/index.ts",
65
+ "import": "./src/configurator/index.ts",
66
+ "require": "./src/configurator/index.ts"
67
+ },
68
+ "./configurator/private": {
69
+ "types": "./src/configurator/private/index.ts",
70
+ "import": "./src/configurator/private/index.ts",
71
+ "require": "./src/configurator/private/index.ts"
72
+ },
63
73
  "./styles": "./src/styles/index.css",
64
74
  "./styles/dashboard": "./src/styles/dashboard.css"
65
75
  },
@@ -74,14 +84,13 @@
74
84
  "check": "tsc --noEmit"
75
85
  },
76
86
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.320",
78
- "@djangocfg/centrifugo": "^2.1.320",
79
- "@djangocfg/debuger": "^2.1.320",
80
- "@djangocfg/i18n": "^2.1.320",
81
- "@djangocfg/monitor": "^2.1.320",
82
- "@djangocfg/ui-core": "^2.1.320",
83
- "@djangocfg/ui-nextjs": "^2.1.320",
84
- "@djangocfg/ui-tools": "^2.1.320",
87
+ "@djangocfg/api": "^2.1.322",
88
+ "@djangocfg/centrifugo": "^2.1.322",
89
+ "@djangocfg/debuger": "^2.1.322",
90
+ "@djangocfg/i18n": "^2.1.322",
91
+ "@djangocfg/monitor": "^2.1.322",
92
+ "@djangocfg/ui-core": "^2.1.322",
93
+ "@djangocfg/ui-nextjs": "^2.1.322",
85
94
  "@hookform/resolvers": "^5.2.2",
86
95
  "consola": "^3.4.2",
87
96
  "lucide-react": "^0.545.0",
@@ -111,15 +120,15 @@
111
120
  "uuid": "^11.1.0"
112
121
  },
113
122
  "devDependencies": {
114
- "@djangocfg/api": "^2.1.320",
115
- "@djangocfg/centrifugo": "^2.1.320",
116
- "@djangocfg/debuger": "^2.1.320",
117
- "@djangocfg/i18n": "^2.1.320",
118
- "@djangocfg/monitor": "^2.1.320",
119
- "@djangocfg/typescript-config": "^2.1.320",
120
- "@djangocfg/ui-core": "^2.1.320",
121
- "@djangocfg/ui-nextjs": "^2.1.320",
122
- "@djangocfg/ui-tools": "^2.1.320",
123
+ "@djangocfg/api": "^2.1.322",
124
+ "@djangocfg/centrifugo": "^2.1.322",
125
+ "@djangocfg/debuger": "^2.1.322",
126
+ "@djangocfg/i18n": "^2.1.322",
127
+ "@djangocfg/monitor": "^2.1.322",
128
+ "@djangocfg/typescript-config": "^2.1.322",
129
+ "@djangocfg/ui-core": "^2.1.322",
130
+ "@djangocfg/ui-nextjs": "^2.1.322",
131
+ "@djangocfg/ui-tools": "^2.1.322",
123
132
  "@types/node": "^24.7.2",
124
133
  "@types/react": "^19.1.0",
125
134
  "@types/react-dom": "^19.1.0",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * JSON Schema descriptors for `<JsonSchemaForm>`-driven layout configurators.
3
+ *
4
+ * Drop a schema + uiSchema into `@djangocfg/ui-tools`'s `<JsonSchemaForm>` and
5
+ * you get a ready-made sidebar that edits the matching `<Layout>` props live.
6
+ *
7
+ * Public surface today:
8
+ * - `private` — `PrivateLayout` configurator (shell + sidebar + header)
9
+ *
10
+ * Public + theme configurators are not exported yet — they currently live in
11
+ * the demo app while the schema shape is iterated on.
12
+ */
13
+
14
+ export * from './private';
@@ -0,0 +1,6 @@
1
+ export {
2
+ privateLayoutConfiguratorSchema,
3
+ privateLayoutConfiguratorUiSchema,
4
+ defaultPrivateLayoutConfiguratorData,
5
+ } from './schema';
6
+ export type { PrivateLayoutConfiguratorData } from './schema';
@@ -0,0 +1,190 @@
1
+ /**
2
+ * JSON Schema + uiSchema for a `PrivateLayout` configurator UI.
3
+ *
4
+ * The shape mirrors the real `PrivateLayout` runtime API:
5
+ * - `shell.*` → `visual` prop (`LayoutVisualConfig`)
6
+ * - `sidebar.*` → flags consumed by `SidebarConfig` (caller still maps these
7
+ * into a real `SidebarConfig` with concrete `groups`)
8
+ * - `header.*` → fields consumed by `HeaderConfig`
9
+ *
10
+ * Pair with `<JsonSchemaForm density="compact" schema={...} uiSchema={...}>`
11
+ * from `@djangocfg/ui-tools` to get a ready-made sidebar configurator. The
12
+ * `defaultPrivateLayoutConfiguratorData` is a safe initial value that already
13
+ * matches the schema (no nullable fields, no missing keys).
14
+ *
15
+ * Playground-only knobs (sample item count, banner toggles, etc.) live in the
16
+ * consuming app, not here — this stays a clean view of the real layout API.
17
+ */
18
+
19
+ import type { CustomJsonSchema7, CustomJsonUiSchema7 } from '@djangocfg/ui-core/lib';
20
+
21
+ /** Strongly-typed mirror of the schema — feed this back into PrivateLayout. */
22
+ export interface PrivateLayoutConfiguratorData {
23
+ shell: {
24
+ variant: 'full-bleed' | 'boxed';
25
+ inset: number;
26
+ radius: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
27
+ background: 'sidebar' | 'muted' | 'card' | 'background';
28
+ border: boolean;
29
+ maxWidth: 'none' | '7xl' | 'screen-xl' | 'screen-2xl';
30
+ };
31
+ sidebar: {
32
+ activeIndicator: 'background' | 'rail' | 'both';
33
+ groupLabelStyle: 'uppercase' | 'plain';
34
+ collapsibleGroups: boolean;
35
+ showFeatured: boolean;
36
+ };
37
+ header: {
38
+ userPlan: string;
39
+ showSecondaryAction: boolean;
40
+ };
41
+ }
42
+
43
+ export const privateLayoutConfiguratorSchema: CustomJsonSchema7 = {
44
+ type: 'object',
45
+ properties: {
46
+ shell: {
47
+ type: 'object',
48
+ title: 'Shell',
49
+ properties: {
50
+ variant: {
51
+ type: 'string',
52
+ title: 'Variant',
53
+ enum: ['full-bleed', 'boxed'],
54
+ description: '`boxed` paints a sidebar-coloured canvas around a rounded content card.',
55
+ },
56
+ inset: {
57
+ type: 'integer',
58
+ title: 'Inset (md+)',
59
+ minimum: 0,
60
+ maximum: 32,
61
+ description: 'Gap between the card and viewport edges.',
62
+ },
63
+ radius: {
64
+ type: 'string',
65
+ title: 'Radius',
66
+ enum: ['sm', 'md', 'lg', 'xl', '2xl', '3xl'],
67
+ },
68
+ background: {
69
+ type: 'string',
70
+ title: 'Canvas background',
71
+ enum: ['sidebar', 'muted', 'card', 'background'],
72
+ description: 'Colour painted behind the boxed card.',
73
+ },
74
+ border: {
75
+ type: 'boolean',
76
+ title: 'Border on card',
77
+ description: '1px hairline border around the boxed card.',
78
+ },
79
+ maxWidth: {
80
+ type: 'string',
81
+ title: 'Inner max-width',
82
+ enum: ['none', '7xl', 'screen-xl', 'screen-2xl'],
83
+ },
84
+ },
85
+ },
86
+ sidebar: {
87
+ type: 'object',
88
+ title: 'Sidebar',
89
+ properties: {
90
+ activeIndicator: {
91
+ type: 'string',
92
+ title: 'Active indicator',
93
+ enum: ['background', 'rail', 'both'],
94
+ description: 'Visual treatment for the active nav item.',
95
+ },
96
+ groupLabelStyle: {
97
+ type: 'string',
98
+ title: 'Group label style',
99
+ enum: ['uppercase', 'plain'],
100
+ description: 'Collapsible groups always render `plain` regardless of this setting.',
101
+ },
102
+ collapsibleGroups: {
103
+ type: 'boolean',
104
+ title: 'Collapsible groups',
105
+ description: 'Mailersend-style accordion: label becomes a clickable trigger.',
106
+ },
107
+ showFeatured: {
108
+ type: 'boolean',
109
+ title: 'Featured CTA tile',
110
+ description: 'Accent-tinted tile rendered below groups.',
111
+ },
112
+ },
113
+ },
114
+ header: {
115
+ type: 'object',
116
+ title: 'Header / account footer',
117
+ properties: {
118
+ userPlan: {
119
+ type: 'string',
120
+ title: 'User plan',
121
+ enum: ['', 'Free plan', 'Pro plan', 'Max plan', 'Enterprise'],
122
+ description: 'Subtitle under the display name in the footer trigger. Empty hides it.',
123
+ },
124
+ showSecondaryAction: {
125
+ type: 'boolean',
126
+ title: 'Footer secondary action',
127
+ description: 'Adds a download-style icon button inside the footer trigger with a pulsing accent dot.',
128
+ },
129
+ },
130
+ },
131
+ },
132
+ };
133
+
134
+ export const privateLayoutConfiguratorUiSchema: CustomJsonUiSchema7 = {
135
+ shell: {
136
+ 'ui:collapsible': true,
137
+ inset: {
138
+ 'ui:widget': 'slider',
139
+ 'ui:options': { unit: 'px', step: 2, showInput: false },
140
+ 'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
141
+ },
142
+ radius: {
143
+ 'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
144
+ },
145
+ background: {
146
+ 'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
147
+ },
148
+ border: {
149
+ 'ui:widget': 'switch',
150
+ 'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
151
+ },
152
+ },
153
+ sidebar: {
154
+ 'ui:collapsible': true,
155
+ 'ui:groups': [
156
+ {
157
+ title: 'Visual',
158
+ fields: ['activeIndicator', 'groupLabelStyle', 'collapsibleGroups', 'showFeatured'],
159
+ defaultOpen: true,
160
+ },
161
+ ],
162
+ collapsibleGroups: { 'ui:widget': 'switch' },
163
+ showFeatured: { 'ui:widget': 'switch' },
164
+ },
165
+ header: {
166
+ 'ui:collapsible': true,
167
+ showSecondaryAction: { 'ui:widget': 'switch' },
168
+ },
169
+ };
170
+
171
+ export const defaultPrivateLayoutConfiguratorData: PrivateLayoutConfiguratorData = {
172
+ shell: {
173
+ variant: 'boxed',
174
+ inset: 12,
175
+ radius: '2xl',
176
+ background: 'sidebar',
177
+ border: true,
178
+ maxWidth: 'none',
179
+ },
180
+ sidebar: {
181
+ activeIndicator: 'background',
182
+ groupLabelStyle: 'uppercase',
183
+ collapsibleGroups: false,
184
+ showFeatured: false,
185
+ },
186
+ header: {
187
+ userPlan: 'Pro plan',
188
+ showSecondaryAction: false,
189
+ },
190
+ };
@@ -13,7 +13,7 @@ import { useRouter } from 'next/navigation';
13
13
 
14
14
  import { useAuth } from '@djangocfg/api/auth';
15
15
  import { Preloader } from '@djangocfg/ui-core/components';
16
- import { SidebarInset, SidebarProvider } from '@djangocfg/ui-nextjs/components';
16
+ import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
17
17
 
18
18
  import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
19
19
  import type { LayoutVisualConfig } from '../types';
@@ -27,6 +27,8 @@ export interface SidebarItem {
27
27
  href: string;
28
28
  icon?: string | LucideIconType;
29
29
  badge?: string | number;
30
+ /** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
31
+ badgeVariant?: 'count' | 'pill';
30
32
  /** Collapsed rail: shown in tooltip; defaults to `label`. */
31
33
  tooltip?: string;
32
34
  }
@@ -38,6 +40,32 @@ export interface SidebarGroupConfig {
38
40
  items: SidebarItem[];
39
41
  /** If true, group is only shown when it has items (for dynamic groups like extensions) */
40
42
  dynamic?: boolean;
43
+ /** Render group as an accordion (Mailersend/Vercel-style). Label becomes a clickable trigger. */
44
+ collapsible?: boolean;
45
+ /** Initial open state for collapsible groups. Auto-expanded if any child is active. Default `false`. */
46
+ defaultOpen?: boolean;
47
+ /** Icon for the group trigger (only when `collapsible`). */
48
+ icon?: string | LucideIconType;
49
+ /**
50
+ * Hide per-item icons inside the group. Defaults to `true` when `collapsible`,
51
+ * `false` otherwise (Mailersend convention: icons live on the trigger, not on children).
52
+ */
53
+ hideItemIcons?: boolean;
54
+ }
55
+
56
+ /** Active-state visual treatment for sidebar nav items. */
57
+ export type SidebarActiveIndicator = 'background' | 'rail' | 'both';
58
+
59
+ /** Rendering of group labels. `'uppercase'` is the legacy ultra-light caps; `'plain'` is sm bold. */
60
+ export type SidebarGroupLabelStyle = 'uppercase' | 'plain';
61
+
62
+ /** Featured CTA tile rendered below groups. */
63
+ export interface SidebarFeaturedConfig {
64
+ icon?: string | LucideIconType;
65
+ label: string;
66
+ href: string;
67
+ badge?: string;
68
+ accent?: 'green' | 'blue' | 'amber' | 'primary';
41
69
  }
42
70
 
43
71
  export interface SidebarConfig {
@@ -65,6 +93,12 @@ export interface SidebarConfig {
65
93
  menuEndShowOnCollapsed?: boolean;
66
94
  /** Custom footer component rendered at the bottom of the sidebar */
67
95
  footer?: ReactNode;
96
+ /** Active-state visual on nav items. Default `'background'` (legacy). */
97
+ activeIndicator?: SidebarActiveIndicator;
98
+ /** Style of group labels. Default `'uppercase'` (legacy). Collapsible groups always use `plain`. */
99
+ groupLabelStyle?: SidebarGroupLabelStyle;
100
+ /** Featured CTA tile rendered below all groups, above `menuEnd`. */
101
+ featured?: SidebarFeaturedConfig;
68
102
  }
69
103
 
70
104
  export interface HeaderConfig {
@@ -0,0 +1,129 @@
1
+ # PrivateLayout
2
+
3
+ Authenticated app shell — collapsible sidebar (icon-rail vs expanded) + scrollable content area.
4
+
5
+ ```tsx
6
+ <PrivateLayout
7
+ sidebar={sidebar}
8
+ header={header}
9
+ pathname={usePathname()}
10
+ visual={{ variant: 'boxed', inset: 12, radius: '2xl', border: true }}
11
+ >
12
+ {children}
13
+ </PrivateLayout>
14
+ ```
15
+
16
+ The auth guard redirects to `header.authPath` when there's no session. Pass `requireAuth={false}` to render without it (static showcases / playground).
17
+
18
+ ---
19
+
20
+ ## Visual variants
21
+
22
+ `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.
23
+
24
+ `full-bleed` — content stretches edge-to-edge next to the sidebar (legacy look). Opt in with `visual={{ variant: 'full-bleed' }}`.
25
+
26
+ | Field | Type | Default | Notes |
27
+ |---|---|---|---|
28
+ | `variant` | `'full-bleed' \| 'boxed'` | `'boxed'` | Switch between the two shells. |
29
+ | `inset` | `number \| { x?: number; y?: number }` | `12` | Gap (px) between the card and the viewport edges (md+). |
30
+ | `radius` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | `'2xl'` | Card corner radius. |
31
+ | `background` | `'sidebar' \| 'muted' \| 'card' \| 'background'` | `'sidebar'` | Canvas colour painted *behind* the boxed card. |
32
+ | `border` | `boolean` | `true` | 1px border on the card. |
33
+ | `maxWidth` | `'none' \| '7xl' \| 'screen-xl' \| 'screen-2xl'` | `'none'` | Optional inner content width cap. |
34
+
35
+ ---
36
+
37
+ ## Sidebar config
38
+
39
+ `SidebarConfig` (passed as `sidebar` prop):
40
+
41
+ | Field | Type | Notes |
42
+ |---|---|---|
43
+ | `groups` | `SidebarGroupConfig[]` | Nav items grouped under labels. Set `group.label = ''` to render flat (no header). |
44
+ | `homeHref` | `string` | Brand link target. |
45
+ | `menuStart` / `menuEnd` | `ReactNode` | Free slots above / below `groups` in the scrollable column. |
46
+ | `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` | Keep slots visible on the icon rail. |
47
+ | `footer` | `ReactNode` | Custom footer node above the account block. |
48
+ | `activeIndicator` | `'background' \| 'rail' \| 'both'` | Active item visual. Default `'background'` (legacy). `'rail'` paints a 2px primary stripe on the right edge (Vercel-style). |
49
+ | `groupLabelStyle` | `'uppercase' \| 'plain'` | Label typography. Default `'uppercase'` (legacy ultra-light caps). Collapsible groups always render `plain`. |
50
+ | `featured` | `SidebarFeaturedConfig` | Accent-tinted CTA tile rendered below groups (Mailersend-style). |
51
+
52
+ `SidebarGroupConfig`:
53
+
54
+ | Field | Type | Notes |
55
+ |---|---|---|
56
+ | `label` | `string` | Group title. Empty string → flat group (no header). |
57
+ | `items` | `SidebarItem[]` | Nav links. |
58
+ | `dynamic` | `boolean` | Hide group when `items` is empty (for extension-driven groups). |
59
+ | `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active. Disabled on the icon rail. |
60
+ | `defaultOpen` | `boolean` | Initial open state for collapsible groups. |
61
+ | `icon` | `string \| LucideIcon` | Icon for the trigger (collapsible only). |
62
+ | `hideItemIcons` | `boolean` | Hide per-item icons inside the group. Defaults to `true` when `collapsible`. |
63
+
64
+ `SidebarItem`:
65
+
66
+ | Field | Type | Notes |
67
+ |---|---|---|
68
+ | `label`, `href`, `icon`, `tooltip` | — | Standard nav item fields. |
69
+ | `badge` | `string \| number` | Trailing badge content. |
70
+ | `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
71
+
72
+ `SidebarFeaturedConfig`:
73
+
74
+ | Field | Type | Notes |
75
+ |---|---|---|
76
+ | `icon`, `label`, `href` | — | Tile content. |
77
+ | `badge` | `string` | Inline pill (e.g. "lite"). |
78
+ | `accent` | `'green' \| 'blue' \| 'amber' \| 'primary'` | Tile tint. Default `'green'`. |
79
+
80
+ ---
81
+
82
+ ## Header / account footer
83
+
84
+ `HeaderConfig` (passed as `header` prop):
85
+
86
+ | Field | Type | Notes |
87
+ |---|---|---|
88
+ | `brand`, `title`, `brandIcon`, `brandLetter` | — | Sidebar header brand. |
89
+ | `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
90
+ | `authPath` | `string` | Sign-in redirect target. |
91
+ | `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
92
+ | `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
93
+
94
+ 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.
95
+
96
+ ---
97
+
98
+ ## Sample
99
+
100
+ ```tsx
101
+ const sidebar: SidebarConfig = {
102
+ homeHref: '/',
103
+ activeIndicator: 'rail',
104
+ groupLabelStyle: 'plain',
105
+ groups: [
106
+ {
107
+ label: 'Email',
108
+ collapsible: true,
109
+ icon: 'Mail',
110
+ defaultOpen: true,
111
+ items: [
112
+ { label: 'Domains', href: '/email/domains' },
113
+ { label: 'Activity', href: '/email/activity' },
114
+ ],
115
+ },
116
+ { label: 'SMS', collapsible: true, icon: 'Smartphone', items: [...] },
117
+ ],
118
+ featured: { icon: 'Sparkles', label: 'Marketing', href: '/marketing', badge: 'lite', accent: 'green' },
119
+ };
120
+
121
+ const header: HeaderConfig = {
122
+ title: 'Dashboard',
123
+ brandIcon: 'Layers',
124
+ authPath: '/auth',
125
+ userPlan: 'Pro plan',
126
+ footerSecondaryAction: { icon: 'Download', href: '/apps', ariaLabel: 'Get apps', pulse: true },
127
+ groups: [{ title: 'Account', items: [{ label: 'Settings', href: '/settings' }] }],
128
+ };
129
+ ```
@@ -7,7 +7,7 @@
7
7
 
8
8
  import React, { ReactNode } from 'react';
9
9
 
10
- import { SidebarTrigger, useSidebar } from '@djangocfg/ui-nextjs/components';
10
+ import { SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
 
13
13
  import type { LayoutVisualConfig } from '../../types';
@@ -5,7 +5,13 @@
5
5
 
6
6
  'use client';
7
7
 
8
- import { Link } from '@djangocfg/ui-core/components';
8
+ import {
9
+ Collapsible,
10
+ CollapsibleContent,
11
+ CollapsibleTrigger,
12
+ Link,
13
+ } from '@djangocfg/ui-core/components';
14
+ import { ChevronDown } from 'lucide-react';
9
15
  import { usePathname as useNextPathname } from 'next/navigation';
10
16
  import React from 'react';
11
17
 
@@ -23,13 +29,21 @@ import {
23
29
  SidebarMenuItem,
24
30
  SidebarTrigger,
25
31
  useSidebar,
26
- } from '@djangocfg/ui-nextjs/components';
32
+ } from '@djangocfg/ui-core/components';
27
33
  import { cn } from '@djangocfg/ui-core/lib';
28
34
 
29
35
  import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
30
36
  import { LucideIcon } from '../../../components';
31
37
 
32
- import type { HeaderConfig, SidebarItem, SidebarConfig } from '../PrivateLayout';
38
+ import type {
39
+ HeaderConfig,
40
+ SidebarActiveIndicator,
41
+ SidebarConfig,
42
+ SidebarGroupConfig,
43
+ SidebarGroupLabelStyle,
44
+ SidebarItem,
45
+ } from '../PrivateLayout';
46
+ import { SidebarFeatured } from '../../_components/SidebarFeatured';
33
47
 
34
48
  /** Few items → roomier rows; many items → tighter. Same breakpoints for demo, CarAPIS, etc. */
35
49
  const DENSITY_COMFORTABLE_MAX = 6;
@@ -46,17 +60,36 @@ function navDensityFromCount(n: number): NavDensity {
46
60
  /**
47
61
  * Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
48
62
  */
49
- const navItemClass = cn(
50
- 'group/nav border-0 font-medium shadow-none transition-colors',
63
+ const navItemBaseClass = cn(
64
+ 'group/nav relative border-0 font-medium shadow-none transition-colors',
51
65
  'text-sidebar-foreground/80',
52
- 'data-[active=true]:font-semibold data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
66
+ 'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
53
67
  'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
54
- 'data-[active=true]:hover:bg-sidebar-accent',
55
68
  '[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
56
69
  '[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
57
70
  'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
58
71
  );
59
72
 
73
+ const ACTIVE_INDICATOR_CLASS: Record<SidebarActiveIndicator, string> = {
74
+ background: cn(
75
+ 'data-[active=true]:bg-sidebar-accent',
76
+ 'data-[active=true]:hover:bg-sidebar-accent',
77
+ ),
78
+ rail: cn(
79
+ 'data-[active=true]:after:absolute data-[active=true]:after:right-0',
80
+ 'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
81
+ 'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
82
+ 'data-[active=true]:after:bg-primary',
83
+ ),
84
+ both: cn(
85
+ 'data-[active=true]:bg-sidebar-accent data-[active=true]:hover:bg-sidebar-accent',
86
+ 'data-[active=true]:after:absolute data-[active=true]:after:right-0',
87
+ 'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
88
+ 'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
89
+ 'data-[active=true]:after:bg-primary',
90
+ ),
91
+ };
92
+
60
93
  const DENSITY = {
61
94
  comfortable: {
62
95
  menu: 'gap-1.5',
@@ -174,49 +207,39 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
174
207
 
175
208
  const sidebarContentClass = cn('gap-2', menuNav.group);
176
209
 
177
- const renderedGroups = React.useMemo(() => {
178
- const navButtonClass = cn(navItemClass, menuNav.extraButton);
179
- const groupLabelClass = cn('px-2', menuNav.label);
180
- const sidebarGroupClass = cn('gap-0', menuNav.groupPad);
181
-
182
- return sidebar.groups.map((group) => {
183
- if (group.dynamic && group.items.length === 0) return null;
184
- const hasLabel = Boolean(group.label && group.label.trim().length > 0);
185
- const items = group.items.map((item: SidebarItem) => {
186
- const iconProp = typeof item.icon === 'string' ? item.icon : item.icon;
187
- const tooltipText = item.tooltip ?? item.label;
188
- return (
189
- <SidebarMenuItem key={item.href}>
190
- <SidebarMenuButton
191
- asChild
192
- isActive={isActive(item.href)}
193
- size={menuNav.buttonSize}
194
- tooltip={tooltipText}
195
- className={navButtonClass}
196
- >
197
- <Link href={item.href}>
198
- {item.icon ? <LucideIcon icon={iconProp} className={menuNav.iconClass} /> : null}
199
- <span>{item.label}</span>
200
- {item.badge ? <SidebarMenuBadge>{item.badge}</SidebarMenuBadge> : null}
201
- </Link>
202
- </SidebarMenuButton>
203
- </SidebarMenuItem>
204
- );
205
- });
206
-
207
- const groupKey = group.label || `__flat_${group.items.map((i) => i.href).join('|')}`;
208
- return (
209
- <SidebarGroup key={groupKey} className={sidebarGroupClass}>
210
- {hasLabel ? (
211
- <SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
212
- ) : null}
213
- <SidebarGroupContent>
214
- <SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
215
- </SidebarGroupContent>
216
- </SidebarGroup>
217
- );
218
- });
219
- }, [sidebar.groups, isActive, menuNav]);
210
+ const activeIndicator: SidebarActiveIndicator = sidebar.activeIndicator ?? 'background';
211
+ const groupLabelStyle: SidebarGroupLabelStyle = sidebar.groupLabelStyle ?? 'uppercase';
212
+ const navButtonClass = cn(navItemBaseClass, ACTIVE_INDICATOR_CLASS[activeIndicator], menuNav.extraButton);
213
+ const groupLabelUppercaseClass = cn('px-2', menuNav.label);
214
+ const groupLabelPlainClass = cn(
215
+ 'px-2 text-sm font-semibold text-sidebar-foreground',
216
+ 'h-7 leading-none',
217
+ );
218
+ const sidebarGroupClass = cn('gap-0', menuNav.groupPad);
219
+
220
+ const renderedGroups = sidebar.groups.map((group) => {
221
+ if (group.dynamic && group.items.length === 0) return null;
222
+ return (
223
+ <SidebarGroupRenderer
224
+ key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
225
+ group={group}
226
+ isActive={isActive}
227
+ navButtonClass={navButtonClass}
228
+ sidebarGroupClass={sidebarGroupClass}
229
+ groupLabelUppercaseClass={groupLabelUppercaseClass}
230
+ groupLabelPlainClass={groupLabelPlainClass}
231
+ groupLabelStyle={groupLabelStyle}
232
+ menuNav={menuNav}
233
+ collapsedRail={collapsedRail}
234
+ />
235
+ );
236
+ });
237
+
238
+ const featuredSlot = sidebar.featured && !collapsedRail ? (
239
+ <div className="w-full min-w-0 shrink-0 px-2">
240
+ <SidebarFeatured config={sidebar.featured} />
241
+ </div>
242
+ ) : null;
220
243
 
221
244
  const expandedHeader = (
222
245
  <div className={headerRowClass}>
@@ -327,6 +350,7 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
327
350
  <SidebarContent className={sidebarContentClass}>
328
351
  {menuStartSlot}
329
352
  {renderedGroups}
353
+ {featuredSlot}
330
354
  {menuEndSlot}
331
355
  </SidebarContent>
332
356
 
@@ -337,3 +361,136 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
337
361
  </Sidebar>
338
362
  );
339
363
  }
364
+
365
+ interface SidebarGroupRendererProps {
366
+ group: SidebarGroupConfig;
367
+ isActive: (href: string) => boolean;
368
+ navButtonClass: string;
369
+ sidebarGroupClass: string;
370
+ groupLabelUppercaseClass: string;
371
+ groupLabelPlainClass: string;
372
+ groupLabelStyle: SidebarGroupLabelStyle;
373
+ menuNav: typeof DENSITY[keyof typeof DENSITY];
374
+ collapsedRail: boolean;
375
+ }
376
+
377
+ function SidebarGroupRenderer({
378
+ group,
379
+ isActive,
380
+ navButtonClass,
381
+ sidebarGroupClass,
382
+ groupLabelUppercaseClass,
383
+ groupLabelPlainClass,
384
+ groupLabelStyle,
385
+ menuNav,
386
+ collapsedRail,
387
+ }: SidebarGroupRendererProps) {
388
+ const hasLabel = Boolean(group.label && group.label.trim().length > 0);
389
+ const isCollapsible = Boolean(group.collapsible) && hasLabel && !collapsedRail;
390
+ const hideItemIcons = group.hideItemIcons ?? isCollapsible;
391
+
392
+ const hasActiveChild = React.useMemo(
393
+ () => group.items.some((item) => isActive(item.href)),
394
+ [group.items, isActive],
395
+ );
396
+
397
+ const [open, setOpen] = React.useState<boolean>(
398
+ isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true,
399
+ );
400
+
401
+ React.useEffect(() => {
402
+ if (isCollapsible && hasActiveChild) setOpen(true);
403
+ }, [isCollapsible, hasActiveChild]);
404
+
405
+ const items = group.items.map((item: SidebarItem) => {
406
+ const tooltipText = item.tooltip ?? item.label;
407
+ const itemIcon = !hideItemIcons && item.icon ? (
408
+ <LucideIcon icon={item.icon} className={menuNav.iconClass} />
409
+ ) : null;
410
+ const itemClassName = hideItemIcons
411
+ ? cn(navButtonClass, 'pl-8')
412
+ : navButtonClass;
413
+ const badgeNode = item.badge ? (
414
+ <SidebarMenuBadge
415
+ className={item.badgeVariant === 'pill'
416
+ ? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
417
+ : undefined}
418
+ >
419
+ {item.badge}
420
+ </SidebarMenuBadge>
421
+ ) : null;
422
+
423
+ return (
424
+ <SidebarMenuItem key={item.href}>
425
+ <SidebarMenuButton
426
+ asChild
427
+ isActive={isActive(item.href)}
428
+ size={menuNav.buttonSize}
429
+ tooltip={tooltipText}
430
+ className={itemClassName}
431
+ >
432
+ <Link href={item.href}>
433
+ {itemIcon}
434
+ <span>{item.label}</span>
435
+ {badgeNode}
436
+ </Link>
437
+ </SidebarMenuButton>
438
+ </SidebarMenuItem>
439
+ );
440
+ });
441
+
442
+ const labelClass = isCollapsible || groupLabelStyle === 'plain'
443
+ ? groupLabelPlainClass
444
+ : groupLabelUppercaseClass;
445
+
446
+ if (isCollapsible) {
447
+ const triggerIcon = group.icon ? (
448
+ <LucideIcon icon={group.icon} className={cn(menuNav.iconClass, 'shrink-0 text-sidebar-foreground/70')} />
449
+ ) : null;
450
+ return (
451
+ <SidebarGroup className={sidebarGroupClass}>
452
+ <Collapsible open={open} onOpenChange={setOpen} className="w-full">
453
+ <CollapsibleTrigger asChild>
454
+ <button
455
+ type="button"
456
+ className={cn(
457
+ 'group/trig flex w-full items-center gap-2 rounded-md px-2 py-1.5',
458
+ 'text-sm font-semibold text-sidebar-foreground',
459
+ 'transition-colors hover:bg-sidebar-accent/40',
460
+ 'data-[no-expand]', // marker so rail-expand click handler ignores it (pattern in PrivateSidebar)
461
+ )}
462
+ aria-expanded={open}
463
+ data-no-expand
464
+ >
465
+ {triggerIcon}
466
+ <span className="flex-1 truncate text-left">{group.label}</span>
467
+ <ChevronDown
468
+ className={cn(
469
+ 'h-4 w-4 shrink-0 text-sidebar-foreground/55 transition-transform duration-200',
470
+ open && 'rotate-180',
471
+ )}
472
+ aria-hidden
473
+ />
474
+ </button>
475
+ </CollapsibleTrigger>
476
+ <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
477
+ <SidebarGroupContent>
478
+ <SidebarMenu className={cn(menuNav.menu, 'mt-1')}>{items}</SidebarMenu>
479
+ </SidebarGroupContent>
480
+ </CollapsibleContent>
481
+ </Collapsible>
482
+ </SidebarGroup>
483
+ );
484
+ }
485
+
486
+ return (
487
+ <SidebarGroup className={sidebarGroupClass}>
488
+ {hasLabel ? (
489
+ <SidebarGroupLabel className={labelClass}>{group.label}</SidebarGroupLabel>
490
+ ) : null}
491
+ <SidebarGroupContent>
492
+ <SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
493
+ </SidebarGroupContent>
494
+ </SidebarGroup>
495
+ );
496
+ }
@@ -3,5 +3,14 @@
3
3
  */
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
- export type { PrivateLayoutProps, SidebarItem, SidebarGroupConfig, SidebarConfig, HeaderConfig } from './PrivateLayout';
6
+ export type {
7
+ PrivateLayoutProps,
8
+ SidebarItem,
9
+ SidebarGroupConfig,
10
+ SidebarConfig,
11
+ HeaderConfig,
12
+ SidebarActiveIndicator,
13
+ SidebarGroupLabelStyle,
14
+ SidebarFeaturedConfig,
15
+ } from './PrivateLayout';
7
16
 
@@ -25,7 +25,7 @@ import {
25
25
  DropdownMenuTrigger,
26
26
  } from '@djangocfg/ui-core/components';
27
27
  import { cn, isDev } from '@djangocfg/ui-core/lib';
28
- import { useSidebar } from '@djangocfg/ui-nextjs/components';
28
+ import { useSidebar } from '@djangocfg/ui-core/components';
29
29
  import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
30
30
 
31
31
  import { useLogout } from '../../hooks';
@@ -292,10 +292,15 @@ interface SecondaryActionProps {
292
292
  }
293
293
 
294
294
  function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
295
- const handleClick = (e: React.MouseEvent) => {
296
- // Don't open the parent dropdown when interacting with the secondary action.
295
+ // Radix's DropdownMenuTrigger opens on pointerdown, before React onClick fires.
296
+ // Stop the event there too onClick alone is too late.
297
+ const stop = (e: React.SyntheticEvent) => {
297
298
  e.stopPropagation();
298
299
  e.preventDefault();
300
+ };
301
+
302
+ const handleClick = (e: React.MouseEvent) => {
303
+ stop(e);
299
304
  onParentExpand();
300
305
  action.onClick?.();
301
306
  };
@@ -324,6 +329,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
324
329
  href={action.href}
325
330
  aria-label={action.ariaLabel}
326
331
  onClick={handleClick}
332
+ onPointerDown={stop}
333
+ onPointerUp={stop}
327
334
  data-no-expand
328
335
  >
329
336
  {inner}
@@ -336,6 +343,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
336
343
  type="button"
337
344
  aria-label={action.ariaLabel}
338
345
  onClick={handleClick}
346
+ onPointerDown={stop}
347
+ onPointerUp={stop}
339
348
  data-no-expand
340
349
  >
341
350
  {inner}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Featured CTA tile for the sidebar — accent-tinted plate with icon, label,
3
+ * optional inline badge, and a trailing arrow. Mailersend-style.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import { ArrowRight } from 'lucide-react';
9
+ import { Link } from '@djangocfg/ui-core/components';
10
+ import React from 'react';
11
+
12
+ import { cn } from '@djangocfg/ui-core/lib';
13
+
14
+ import { LucideIcon } from '../../components';
15
+
16
+ import type { SidebarFeaturedConfig } from '../PrivateLayout/PrivateLayout';
17
+
18
+ const ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
19
+ green: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15',
20
+ blue: 'bg-sky-500/10 text-sky-700 hover:bg-sky-500/15 dark:text-sky-300 dark:bg-sky-400/10 dark:hover:bg-sky-400/15',
21
+ amber: 'bg-amber-500/10 text-amber-800 hover:bg-amber-500/15 dark:text-amber-300 dark:bg-amber-400/10 dark:hover:bg-amber-400/15',
22
+ primary: 'bg-primary/10 text-primary hover:bg-primary/15',
23
+ };
24
+
25
+ const BADGE_ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
26
+ green: 'bg-emerald-500 text-white dark:bg-emerald-400 dark:text-emerald-950',
27
+ blue: 'bg-sky-500 text-white dark:bg-sky-400 dark:text-sky-950',
28
+ amber: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-amber-950',
29
+ primary: 'bg-primary text-primary-foreground',
30
+ };
31
+
32
+ interface SidebarFeaturedProps {
33
+ config: SidebarFeaturedConfig;
34
+ }
35
+
36
+ export function SidebarFeatured({ config }: SidebarFeaturedProps) {
37
+ const accent = config.accent ?? 'green';
38
+ const tileClass = cn(
39
+ 'group/featured flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2',
40
+ 'transition-colors',
41
+ ACCENT_CLASS[accent],
42
+ );
43
+
44
+ const badgeNode = config.badge ? (
45
+ <span
46
+ className={cn(
47
+ 'inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
48
+ BADGE_ACCENT_CLASS[accent],
49
+ )}
50
+ >
51
+ {config.badge}
52
+ </span>
53
+ ) : null;
54
+
55
+ const iconNode = config.icon ? (
56
+ <LucideIcon icon={config.icon} className="h-4 w-4 shrink-0" />
57
+ ) : null;
58
+
59
+ return (
60
+ <Link href={config.href} className={tileClass}>
61
+ {iconNode}
62
+ {badgeNode}
63
+ <span className="flex-1 truncate text-sm font-medium">{config.label}</span>
64
+ <ArrowRight
65
+ className="h-4 w-4 shrink-0 transition-transform duration-200 group-hover/featured:translate-x-0.5"
66
+ aria-hidden
67
+ />
68
+ </Link>
69
+ );
70
+ }