@djangocfg/layouts 2.1.590 → 2.1.592
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 +12 -12
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
- package/src/layouts/PrivateLayout/README.md +20 -12
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +32 -62
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
- package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
- package/src/layouts/PrivateLayout/components/index.ts +0 -4
- package/src/layouts/PrivateLayout/context.tsx +10 -115
- package/src/layouts/PrivateLayout/geometry.ts +17 -0
- package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
- package/src/layouts/PrivateLayout/types.ts +13 -20
- package/src/layouts/SettingsLayout/README.md +23 -10
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +14 -8
- package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
- package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
- package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
- package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
- package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +52 -105
- package/src/layouts/SettingsLayout/components/index.ts +0 -1
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
- package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
- package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
- package/src/layouts/SettingsLayout/index.ts +0 -1
- package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
- package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
- package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +12 -4
- package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
- package/src/layouts/SettingsLayout/sections/index.ts +2 -2
- package/src/layouts/SettingsLayout/types.ts +12 -4
- package/src/layouts/types/layout.types.ts +5 -6
- package/src/styles/dashboard.css +10 -0
- package/src/layouts/PrivateLayout/density.ts +0 -48
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
- package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
- package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.592",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"check": "tsc --noEmit"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/analytics": "^2.1.
|
|
94
|
-
"@djangocfg/api": "^2.1.
|
|
95
|
-
"@djangocfg/devtools": "^2.1.
|
|
96
|
-
"@djangocfg/i18n": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
+
"@djangocfg/analytics": "^2.1.592",
|
|
94
|
+
"@djangocfg/api": "^2.1.592",
|
|
95
|
+
"@djangocfg/devtools": "^2.1.592",
|
|
96
|
+
"@djangocfg/i18n": "^2.1.592",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.592",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -124,12 +124,12 @@
|
|
|
124
124
|
"uuid": "^11.1.1"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
|
-
"@djangocfg/analytics": "^2.1.
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/devtools": "^2.1.
|
|
130
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
132
|
-
"@djangocfg/ui-core": "^2.1.
|
|
127
|
+
"@djangocfg/analytics": "^2.1.592",
|
|
128
|
+
"@djangocfg/api": "^2.1.592",
|
|
129
|
+
"@djangocfg/devtools": "^2.1.592",
|
|
130
|
+
"@djangocfg/i18n": "^2.1.592",
|
|
131
|
+
"@djangocfg/typescript-config": "^2.1.592",
|
|
132
|
+
"@djangocfg/ui-core": "^2.1.592",
|
|
133
133
|
"@storybook/react-vite": "^10.5.0",
|
|
134
134
|
"@types/node": "^25.9.5",
|
|
135
135
|
"@types/react": "19.2.15",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Private Layout
|
|
3
3
|
*
|
|
4
|
-
* Authenticated shell:
|
|
5
|
-
*
|
|
6
|
-
* Ctrl/Cmd + B
|
|
4
|
+
* Authenticated shell: a docked, resizable sidebar + scrollable content.
|
|
5
|
+
* Collapsed, the sidebar leaves a trigger whose hover zone peeks it open over the page;
|
|
6
|
+
* on narrow viewports the same trigger opens a drawer. Ctrl/Cmd + B toggles it.
|
|
7
7
|
*
|
|
8
8
|
* Guarded: no session redirects to `header.authPath`. With `requireStaff`, a
|
|
9
9
|
* signed-in non-staff user gets `ForbiddenState` instead — never a redirect,
|
|
@@ -18,9 +18,14 @@ import { Preloader } from '@djangocfg/ui-core/components';
|
|
|
18
18
|
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
|
|
19
19
|
|
|
20
20
|
import { ForbiddenState, PrivateContent, PrivateSidebar } from './components';
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
import {
|
|
22
|
+
SidebarPeekProvider,
|
|
23
|
+
SidebarWidthContext,
|
|
24
|
+
useAuthGuard,
|
|
25
|
+
useLayoutVisual,
|
|
26
|
+
useSidebarDefaultOpen,
|
|
27
|
+
useSidebarWidth,
|
|
28
|
+
} from './hooks';
|
|
24
29
|
|
|
25
30
|
import type { PrivateLayoutProps } from './types';
|
|
26
31
|
|
|
@@ -67,6 +72,7 @@ export function PrivateLayout({
|
|
|
67
72
|
useLayoutVisual(visual);
|
|
68
73
|
|
|
69
74
|
const defaultOpen = useSidebarDefaultOpen();
|
|
75
|
+
const sidebarWidth = useSidebarWidth(Boolean(sidebar) && sidebar?.resizable !== false);
|
|
70
76
|
|
|
71
77
|
if (isLoading) {
|
|
72
78
|
return (
|
|
@@ -90,28 +96,33 @@ export function PrivateLayout({
|
|
|
90
96
|
return (
|
|
91
97
|
<SidebarProvider
|
|
92
98
|
defaultOpen={defaultOpen}
|
|
93
|
-
style={providerStyle}
|
|
99
|
+
style={{ ...providerStyle, ...sidebarWidth.style }}
|
|
94
100
|
className={providerClassName}
|
|
101
|
+
data-sidebar-resizing={sidebarWidth.dragging || undefined}
|
|
95
102
|
>
|
|
96
|
-
{
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
103
|
+
<SidebarWidthContext.Provider value={sidebarWidth}>
|
|
104
|
+
<SidebarPeekProvider pathname={pathname ?? ''}>
|
|
105
|
+
{sidebar && (
|
|
106
|
+
<PrivateSidebar
|
|
107
|
+
sidebar={sidebar}
|
|
108
|
+
header={header}
|
|
109
|
+
pathname={pathname}
|
|
110
|
+
variant={sidebarVariant}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<SidebarInset className={insetClassName}>
|
|
115
|
+
<PrivateContent
|
|
116
|
+
padding={contentPadding}
|
|
117
|
+
scroll={contentScroll}
|
|
118
|
+
hasSidebar={Boolean(sidebar)}
|
|
119
|
+
visual={visual}
|
|
120
|
+
>
|
|
121
|
+
{children}
|
|
122
|
+
</PrivateContent>
|
|
123
|
+
</SidebarInset>
|
|
124
|
+
</SidebarPeekProvider>
|
|
125
|
+
</SidebarWidthContext.Provider>
|
|
115
126
|
|
|
116
127
|
{/* Settings is the primary account dialog — always mounted so the
|
|
117
128
|
account-menu "Settings" item works out of the box. `settings` is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PrivateLayout
|
|
2
2
|
|
|
3
|
-
Authenticated app shell —
|
|
3
|
+
Authenticated app shell — a docked, resizable sidebar + scrollable content area. Collapsed, a trigger remains and hovering it peeks the sidebar open over the page; on mobile it is a drawer.
|
|
4
4
|
|
|
5
5
|
```tsx
|
|
6
6
|
<PrivateLayout
|
|
@@ -81,13 +81,13 @@ thin-wrapper reference, and the **BaseApp README** for the provider root.
|
|
|
81
81
|
|
|
82
82
|
## Visual variants
|
|
83
83
|
|
|
84
|
-
`boxed`
|
|
84
|
+
`boxed` — opt in with `visual={{ variant: 'boxed' }}`. `<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.
|
|
85
85
|
|
|
86
|
-
`full-bleed` —
|
|
86
|
+
`full-bleed` (default) — flat, Claude-style: sidebar, one 1px border, page.
|
|
87
87
|
|
|
88
88
|
| Field | Type | Default | Notes |
|
|
89
89
|
|---|---|---|---|
|
|
90
|
-
| `variant` | `'full-bleed' \| 'boxed'` | `'
|
|
90
|
+
| `variant` | `'full-bleed' \| 'boxed'` | `'full-bleed'` | Switch between the two shells. |
|
|
91
91
|
| `inset` | `number \| { x?: number; y?: number }` | `12` | Gap (px) between the card and the viewport edges (md+). |
|
|
92
92
|
| `radius` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | `'2xl'` | Card corner radius. |
|
|
93
93
|
| `background` | `'sidebar' \| 'muted' \| 'card' \| 'background'` | `'sidebar'` | Canvas colour painted *behind* the boxed card. |
|
|
@@ -139,10 +139,11 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
|
|
|
139
139
|
| `groups` | `SidebarGroupConfig[]` | Nav items grouped under labels. Set `group.label = ''` to render flat (no header). |
|
|
140
140
|
| `homeHref` | `string` | Brand link target. |
|
|
141
141
|
| `menuStart` / `menuEnd` | `ReactNode` | Free slots above / below `groups` in the scrollable column. |
|
|
142
|
-
| `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` |
|
|
142
|
+
| `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` | Deprecated, no effect (no icon rail). |
|
|
143
|
+
| `resizable` | `boolean` | Drag the sidebar edge (14–24rem, remembered under `cfg.layouts.sidebar-width`; double-click resets). Default `true`. |
|
|
143
144
|
| `footer` | `ReactNode` | Custom footer node above the account block. |
|
|
144
|
-
| `activeIndicator` |
|
|
145
|
-
| `groupLabelStyle` |
|
|
145
|
+
| `activeIndicator` | — | Deprecated, no effect. |
|
|
146
|
+
| `groupLabelStyle` | — | Deprecated, no effect: labels are sentence case. |
|
|
146
147
|
| `featured` | `SidebarFeaturedConfig` | Accent-tinted CTA tile rendered below groups (Mailersend-style). |
|
|
147
148
|
|
|
148
149
|
`SidebarGroupConfig`:
|
|
@@ -152,7 +153,7 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
|
|
|
152
153
|
| `label` | `string` | Group title. Empty string → flat group (no header). |
|
|
153
154
|
| `items` | `SidebarItem[]` | Nav links. |
|
|
154
155
|
| `dynamic` | `boolean` | Hide group when `items` is empty (for extension-driven groups). |
|
|
155
|
-
| `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active.
|
|
156
|
+
| `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active. |
|
|
156
157
|
| `defaultOpen` | `boolean` | Initial open state for collapsible groups. |
|
|
157
158
|
| `icon` | `string \| LucideIcon` | Icon for the trigger (collapsible only). |
|
|
158
159
|
| `hideItemIcons` | `boolean` | Hide per-item icons inside the group. Defaults to `true` when `collapsible`. |
|
|
@@ -186,7 +187,7 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
|
|
|
186
187
|
| `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
|
|
187
188
|
| `authPath` | `string` | Sign-in redirect target. |
|
|
188
189
|
| `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
|
|
189
|
-
| `accountAction` |
|
|
190
|
+
| `accountAction` | — | Deprecated, no effect: the account footer always opens the menu. |
|
|
190
191
|
| `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
|
|
191
192
|
|
|
192
193
|
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.
|
|
@@ -233,7 +234,7 @@ const switcher: SidebarBrandSwitcherConfig = {
|
|
|
233
234
|
| `onSelect` | `() => void` | Callback on select (alternative to `href`). |
|
|
234
235
|
| `active` | `boolean` | Mark as currently selected (shows checkmark in dropdown). |
|
|
235
236
|
|
|
236
|
-
|
|
237
|
+
While the sidebar is peeked open the switcher menu keeps the panel open until it closes.
|
|
237
238
|
|
|
238
239
|
---
|
|
239
240
|
|
|
@@ -242,8 +243,6 @@ On the collapsed icon-rail the switcher renders only the active item's avatar
|
|
|
242
243
|
```tsx
|
|
243
244
|
const sidebar: SidebarConfig = {
|
|
244
245
|
homeHref: '/',
|
|
245
|
-
activeIndicator: 'rail',
|
|
246
|
-
groupLabelStyle: 'plain',
|
|
247
246
|
groups: [
|
|
248
247
|
{
|
|
249
248
|
label: 'Email',
|
|
@@ -269,3 +268,12 @@ const header: HeaderConfig = {
|
|
|
269
268
|
groups: [{ title: 'Account', items: [{ label: 'Settings', href: '/settings' }] }],
|
|
270
269
|
};
|
|
271
270
|
```
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
## Sidebar states
|
|
274
|
+
|
|
275
|
+
| State | Behaviour |
|
|
276
|
+
|---|---|
|
|
277
|
+
| Docked | Width from `--sidebar-width`; drag the right edge, arrow keys, double-click to reset. |
|
|
278
|
+
| Collapsed (desktop) | Hidden. A trigger stays top-left; hovering it (or the free area around it) floats the sidebar over the page. The panel stays while the pointer is on it or a menu opened from it is open; Esc, navigation and moving away close it. Clicking the trigger docks it. |
|
|
279
|
+
| Mobile (< md) | Drawer up to 22rem: 44px rows, 20px icons, close button, safe-area insets. Trigger is a 44px ghost button, top-left. |
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private layout main column
|
|
3
|
-
*
|
|
2
|
+
* Private layout main column: the scroll surface, plus the sidebar trigger
|
|
3
|
+
* whenever the sidebar is not docked (collapsed on desktop, always on mobile).
|
|
4
|
+
* On desktop the trigger sits in a hover zone that peeks the sidebar open.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
'use client';
|
|
@@ -11,6 +12,7 @@ import { SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
|
|
|
11
12
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
13
|
|
|
13
14
|
import type { LayoutVisualConfig } from '../../types';
|
|
15
|
+
import { useSidebarPeek } from '../hooks';
|
|
14
16
|
|
|
15
17
|
interface PrivateContentProps {
|
|
16
18
|
children: ReactNode;
|
|
@@ -42,61 +44,46 @@ export function PrivateContent({
|
|
|
42
44
|
hasSidebar = true,
|
|
43
45
|
visual,
|
|
44
46
|
}: PrivateContentProps) {
|
|
45
|
-
const { isMobile,
|
|
47
|
+
const { isMobile, state } = useSidebar();
|
|
48
|
+
const peek = useSidebarPeek();
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
* Space for fixed FAB + safe area so content does not sit under the button.
|
|
49
|
-
* Tighter when the mobile drawer is closed (FAB only). When the drawer is open, a bit more
|
|
50
|
-
* room avoids the first line sitting under the overlay edge.
|
|
51
|
-
*/
|
|
52
|
-
const mobileFabClearance =
|
|
53
|
-
hasSidebar &&
|
|
54
|
-
padding === 'default' &&
|
|
55
|
-
(isMobile && !openMobile
|
|
56
|
-
? 'max-md:pt-[max(4.25rem,calc(3.5rem+env(safe-area-inset-top,0px)))]'
|
|
57
|
-
: 'max-md:pt-[max(4.5rem,calc(3.75rem+env(safe-area-inset-top,0px)))]');
|
|
50
|
+
const showTrigger = hasSidebar && (isMobile || state === 'collapsed');
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
:
|
|
52
|
+
// The trigger floats over the top-left corner; reserve its row.
|
|
53
|
+
const topPadding = showTrigger
|
|
54
|
+
? 'pt-[max(3.5rem,calc(3rem+env(safe-area-inset-top,0px)))]'
|
|
55
|
+
: hasSidebar
|
|
56
|
+
? 'max-md:pt-[max(3.5rem,calc(3rem+env(safe-area-inset-top,0px)))] md:pt-6 lg:pt-8'
|
|
57
|
+
: 'pt-4 sm:pt-6 lg:pt-8';
|
|
65
58
|
|
|
66
59
|
const scrollAreaClass = cn(
|
|
67
|
-
// The boxed content is the MAIN surface (Claude bg-100), not a raised card.
|
|
68
|
-
// `bg-background` keeps it the warm page tone; the slightly darker
|
|
69
|
-
// sidebar-canvas behind the inset gives the floating look. (`bg-card`, now
|
|
70
|
-
// lifted to 18.4%, made this big panel read too light.)
|
|
71
60
|
'min-h-0 flex-1 bg-background',
|
|
72
61
|
scroll === 'auto' ? 'overflow-y-auto' : 'flex flex-col overflow-hidden',
|
|
73
62
|
padding === 'default' && scroll === 'auto' && [
|
|
74
63
|
'px-4 sm:px-6 lg:px-8',
|
|
75
64
|
'pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] lg:pb-[calc(2rem+env(safe-area-inset-bottom,0px))]',
|
|
65
|
+
topPadding,
|
|
76
66
|
],
|
|
77
|
-
scroll === 'auto' && contentTopPaddingClass,
|
|
78
|
-
mobileFabClearance,
|
|
79
67
|
);
|
|
80
68
|
|
|
81
|
-
const mobileMenuFab = hasSidebar ? (
|
|
82
|
-
<SidebarTrigger
|
|
83
|
-
variant="secondary"
|
|
84
|
-
className={cn(
|
|
85
|
-
'fixed z-40 md:hidden',
|
|
86
|
-
'left-3 top-[max(0.75rem,env(safe-area-inset-top,0px))]',
|
|
87
|
-
'h-12 w-12 rounded-xl',
|
|
88
|
-
'border border-border shadow-md',
|
|
89
|
-
'[&_svg]:!h-6 [&_svg]:!w-6',
|
|
90
|
-
'touch-manipulation',
|
|
91
|
-
)}
|
|
92
|
-
/>
|
|
93
|
-
) : null;
|
|
94
|
-
|
|
95
69
|
const innerWidthClass = MAX_WIDTH_CLASS[visual?.maxWidth ?? 'none'];
|
|
96
70
|
|
|
97
71
|
return (
|
|
98
|
-
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
99
|
-
{
|
|
72
|
+
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
|
73
|
+
{showTrigger ? (
|
|
74
|
+
<div
|
|
75
|
+
data-sidebar="trigger-zone"
|
|
76
|
+
{...(isMobile ? undefined : peek?.zoneProps)}
|
|
77
|
+
className="absolute left-0 top-[env(safe-area-inset-top,0px)] z-20 flex size-14 items-center justify-center"
|
|
78
|
+
>
|
|
79
|
+
<SidebarTrigger
|
|
80
|
+
className={cn(
|
|
81
|
+
'text-muted-foreground hover:text-foreground',
|
|
82
|
+
isMobile ? 'size-11 [&_svg]:!size-5' : 'size-8',
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
) : null}
|
|
100
87
|
<div className={scrollAreaClass}>
|
|
101
88
|
{innerWidthClass ? <div className={innerWidthClass}>{children}</div> : children}
|
|
102
89
|
</div>
|
|
@@ -1,22 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar: docked on desktop, a peek panel while collapsed, a drawer on mobile.
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
'use client';
|
|
2
6
|
|
|
3
7
|
import React from 'react';
|
|
4
8
|
|
|
5
|
-
import {
|
|
6
|
-
Sidebar,
|
|
7
|
-
SidebarContent,
|
|
8
|
-
SidebarFooter,
|
|
9
|
-
useSidebar,
|
|
10
|
-
} from '@djangocfg/ui-core/components';
|
|
11
|
-
import { cn } from '@djangocfg/ui-core/lib';
|
|
9
|
+
import { Sidebar, useSidebar } from '@djangocfg/ui-core/components';
|
|
12
10
|
|
|
13
|
-
import { PrivateSidebarAccount } from './PrivateSidebarAccount';
|
|
14
11
|
import { PrivateLayoutProvider } from '../context';
|
|
15
|
-
import {
|
|
12
|
+
import { useSidebarKeyboard } from '../hooks';
|
|
16
13
|
import type { HeaderConfig, SidebarConfig } from '../types';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
14
|
+
import { SidebarBody } from './SidebarBody';
|
|
15
|
+
import { SidebarPeek } from './SidebarPeek';
|
|
16
|
+
import { SidebarResizeHandle } from './SidebarResizeHandle';
|
|
20
17
|
|
|
21
18
|
interface PrivateSidebarProps {
|
|
22
19
|
sidebar: SidebarConfig;
|
|
@@ -25,94 +22,28 @@ interface PrivateSidebarProps {
|
|
|
25
22
|
variant?: 'sidebar' | 'inset';
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
export function PrivateSidebar({
|
|
29
|
-
sidebar,
|
|
30
|
-
header,
|
|
31
|
-
pathname: pathnameProp,
|
|
32
|
-
variant = 'sidebar',
|
|
33
|
-
}: PrivateSidebarProps) {
|
|
25
|
+
export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'sidebar' }: PrivateSidebarProps) {
|
|
34
26
|
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
35
|
-
const
|
|
27
|
+
const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
|
|
36
28
|
|
|
37
29
|
React.useEffect(() => {
|
|
38
30
|
if (isMobile) setOpenMobile(false);
|
|
39
31
|
}, [pathname, isMobile, setOpenMobile]);
|
|
40
32
|
|
|
41
33
|
return (
|
|
42
|
-
<PrivateLayoutProvider
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
isMobile={isMobile}
|
|
47
|
-
state={state}
|
|
48
|
-
>
|
|
49
|
-
<PrivateSidebarInner
|
|
50
|
-
sidebar={sidebar}
|
|
51
|
-
header={header}
|
|
34
|
+
<PrivateLayoutProvider sidebar={sidebar} header={header} pathname={pathname} isMobile={isMobile}>
|
|
35
|
+
<Sidebar
|
|
36
|
+
ref={setSidebarRef}
|
|
37
|
+
collapsible="offcanvas"
|
|
52
38
|
variant={variant}
|
|
53
|
-
|
|
39
|
+
onKeyDown={handleSidebarKeyDown}
|
|
40
|
+
// Collapsed: parked off-canvas. Inert keeps it out of tab order and the a11y tree.
|
|
41
|
+
inert={state === 'collapsed' && !isMobile}
|
|
42
|
+
>
|
|
43
|
+
<SidebarBody />
|
|
44
|
+
<SidebarResizeHandle />
|
|
45
|
+
</Sidebar>
|
|
46
|
+
<SidebarPeek />
|
|
54
47
|
</PrivateLayoutProvider>
|
|
55
48
|
);
|
|
56
49
|
}
|
|
57
|
-
|
|
58
|
-
interface PrivateSidebarInnerProps {
|
|
59
|
-
sidebar: SidebarConfig;
|
|
60
|
-
header?: HeaderConfig;
|
|
61
|
-
variant: 'sidebar' | 'inset';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function PrivateSidebarInner({ sidebar, header, variant }: PrivateSidebarInnerProps) {
|
|
65
|
-
const layoutVariant = variant === 'inset' ? 'boxed' : 'full-bleed';
|
|
66
|
-
const { modifiers } = useShellVisualState(layoutVariant);
|
|
67
|
-
const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
|
|
68
|
-
|
|
69
|
-
const sidebarRootClass = React.useMemo(
|
|
70
|
-
() =>
|
|
71
|
-
cn(
|
|
72
|
-
'[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
|
|
73
|
-
modifiers.sidebarRoot,
|
|
74
|
-
modifiers.sidebarInner.map((m) => `[&>[data-sidebar=sidebar]]:${m}`),
|
|
75
|
-
),
|
|
76
|
-
[modifiers],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const sidebarContentClass = React.useMemo(
|
|
80
|
-
() => cn('gap-2', modifiers.sidebarContent),
|
|
81
|
-
[modifiers.sidebarContent],
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
const renderedGroups = React.useMemo(
|
|
85
|
-
() =>
|
|
86
|
-
sidebar.groups.map((group) => {
|
|
87
|
-
if (group.dynamic && group.items.length === 0) return null;
|
|
88
|
-
return (
|
|
89
|
-
<SidebarNavGroup
|
|
90
|
-
key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
|
|
91
|
-
group={group}
|
|
92
|
-
/>
|
|
93
|
-
);
|
|
94
|
-
}),
|
|
95
|
-
[sidebar.groups],
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<Sidebar
|
|
100
|
-
ref={setSidebarRef}
|
|
101
|
-
collapsible="icon"
|
|
102
|
-
variant={variant}
|
|
103
|
-
className={sidebarRootClass}
|
|
104
|
-
onKeyDown={handleSidebarKeyDown}
|
|
105
|
-
>
|
|
106
|
-
<SidebarBrand />
|
|
107
|
-
|
|
108
|
-
<SidebarContent className={sidebarContentClass}>
|
|
109
|
-
<SidebarSlots />
|
|
110
|
-
{renderedGroups}
|
|
111
|
-
</SidebarContent>
|
|
112
|
-
|
|
113
|
-
<SidebarFooter className="p-0">
|
|
114
|
-
<PrivateSidebarAccount header={header} />
|
|
115
|
-
</SidebarFooter>
|
|
116
|
-
</Sidebar>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
'use client';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { ChevronDown, Languages, LogOut, Settings as SettingsIcon } from 'lucide-react';
|
|
15
15
|
import { Link } from '@djangocfg/ui-core/components';
|
|
16
16
|
import React, { memo } from 'react';
|
|
17
17
|
|
|
@@ -33,7 +33,8 @@ import { useLogout } from '../../../hooks';
|
|
|
33
33
|
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
34
34
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
35
35
|
import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
|
|
36
|
-
import {
|
|
36
|
+
import { usePeekPin } from '../hooks';
|
|
37
|
+
import { usePrivateLayoutContext } from '../context';
|
|
37
38
|
import { useSettingsDialogStore } from '../../SettingsLayout/store';
|
|
38
39
|
|
|
39
40
|
import type { HeaderConfig } from '../types';
|
|
@@ -56,8 +57,9 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
56
57
|
const t = useAppT();
|
|
57
58
|
const layoutI18n = useLayoutI18nOptional();
|
|
58
59
|
const { setOpen: setSidebarOpen } = useSidebar();
|
|
59
|
-
const {
|
|
60
|
+
const { isMobile } = usePrivateLayoutContext();
|
|
60
61
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
62
|
+
usePeekPin(isAccountMenuOpen);
|
|
61
63
|
|
|
62
64
|
const signOutLabel = t('layouts.profile.signOut');
|
|
63
65
|
|
|
@@ -91,22 +93,12 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
91
93
|
};
|
|
92
94
|
}, [user, header?.userPlan]);
|
|
93
95
|
|
|
94
|
-
const onTriggerInteract = React.useCallback(() => {
|
|
95
|
-
if (content.isAccountCompact) setSidebarOpen(true);
|
|
96
|
-
}, [content.isAccountCompact, setSidebarOpen]);
|
|
97
|
-
|
|
98
96
|
const onSecondaryExpand = React.useCallback(() => {
|
|
99
97
|
setSidebarOpen(true);
|
|
100
98
|
}, [setSidebarOpen]);
|
|
101
99
|
|
|
102
100
|
const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
|
|
103
101
|
|
|
104
|
-
// `accountAction: 'dialog'` opens the global SettingsDialog (mounted in
|
|
105
|
-
// PrivateLayout). Requires `settings` to be passed to PrivateLayout.
|
|
106
|
-
const openSettingsDialog = React.useCallback(() => {
|
|
107
|
-
useSettingsDialogStore.getState().open();
|
|
108
|
-
}, []);
|
|
109
|
-
|
|
110
102
|
// ── Declarative account menu (Claude-style) — fed to MenuBuilder ──
|
|
111
103
|
const menuItems = React.useMemo<MenuItem[]>(() => {
|
|
112
104
|
const items: MenuItem[] = [];
|
|
@@ -201,80 +193,57 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
201
193
|
const secondary = header?.footerSecondaryAction;
|
|
202
194
|
|
|
203
195
|
const triggerClassName = cn(
|
|
204
|
-
'group h-
|
|
205
|
-
|
|
196
|
+
'group h-8 min-w-0 max-w-full justify-start gap-2 rounded-lg px-2 text-left font-normal select-none',
|
|
197
|
+
// Full-width row unless a secondary action shares the footer.
|
|
198
|
+
!secondary && 'w-full',
|
|
199
|
+
'hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
|
|
206
200
|
// No focus ring on the menu trigger — the open menu is the affordance.
|
|
207
201
|
'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
|
208
|
-
|
|
202
|
+
isMobile && 'h-11',
|
|
209
203
|
);
|
|
210
204
|
|
|
211
|
-
const secondaryButton = secondary
|
|
205
|
+
const secondaryButton = secondary ? (
|
|
212
206
|
<SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
|
|
213
207
|
) : null;
|
|
214
208
|
|
|
215
|
-
const dropdownContentClass =
|
|
216
|
-
'p-1.5',
|
|
217
|
-
content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
|
|
218
|
-
);
|
|
209
|
+
const dropdownContentClass = 'w-60 p-1.5';
|
|
219
210
|
// Always open UPWARD over the avatar (Claude pattern). The footer sits at the
|
|
220
211
|
// bottom of the rail in both expanded and collapsed states, so `top` reads
|
|
221
212
|
// correctly either way (vs. `right`, which floated off to the side).
|
|
222
213
|
const dropdownSide = 'top' as const;
|
|
223
|
-
const avatarClass = cn(
|
|
224
|
-
'h-9 w-9 shrink-0 border border-transparent transition-colors',
|
|
225
|
-
'group-hover:border-sidebar-border/70',
|
|
226
|
-
);
|
|
214
|
+
const avatarClass = cn('shrink-0', isMobile ? 'size-7' : 'size-6');
|
|
227
215
|
|
|
228
|
-
|
|
216
|
+
// Like Claude: "Name · Plan ⌄". The email lives in the menu header.
|
|
217
|
+
const expandedMeta = (
|
|
229
218
|
<>
|
|
230
|
-
<span className="flex min-w-0
|
|
231
|
-
<span className="truncate text-
|
|
232
|
-
{account.displayName}
|
|
233
|
-
</span>
|
|
219
|
+
<span className="flex min-w-0 items-baseline gap-1 text-left">
|
|
220
|
+
<span className="truncate text-base text-sidebar-foreground">{account.displayName}</span>
|
|
234
221
|
{account.plan ? (
|
|
235
|
-
<span className="
|
|
236
|
-
{account.plan}
|
|
237
|
-
</span>
|
|
238
|
-
) : null}
|
|
239
|
-
</span>
|
|
240
|
-
<span className="flex shrink-0 items-center gap-1.5">
|
|
241
|
-
{secondaryButton}
|
|
242
|
-
{header?.accountAction !== 'dialog' ? (
|
|
243
|
-
<ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
|
|
222
|
+
<span className="shrink-0 text-xs text-sidebar-foreground/50">· {account.plan}</span>
|
|
244
223
|
) : null}
|
|
245
224
|
</span>
|
|
225
|
+
<ChevronDown
|
|
226
|
+
className={cn(
|
|
227
|
+
'size-4 shrink-0 text-sidebar-foreground/40 transition-transform duration-150 group-data-[state=open]:rotate-180',
|
|
228
|
+
!secondary && 'ml-auto',
|
|
229
|
+
)}
|
|
230
|
+
aria-hidden
|
|
231
|
+
/>
|
|
246
232
|
</>
|
|
247
233
|
);
|
|
248
234
|
|
|
249
235
|
const triggerButton = (
|
|
250
|
-
<Button
|
|
251
|
-
type="button"
|
|
252
|
-
variant="ghost"
|
|
253
|
-
aria-label={content.isAccountCompact ? account.displayName : undefined}
|
|
254
|
-
className={triggerClassName}
|
|
255
|
-
onClick={header?.accountAction === 'dialog' ? openSettingsDialog : onTriggerInteract}
|
|
256
|
-
>
|
|
236
|
+
<Button type="button" variant="ghost" className={triggerClassName}>
|
|
257
237
|
<Avatar className={avatarClass}>
|
|
258
238
|
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
259
|
-
<AvatarFallback className="text-
|
|
239
|
+
<AvatarFallback className="text-xs font-medium">{userInitial}</AvatarFallback>
|
|
260
240
|
</Avatar>
|
|
261
241
|
{expandedMeta}
|
|
262
242
|
</Button>
|
|
263
243
|
);
|
|
264
244
|
|
|
265
|
-
const wrapperClass =
|
|
266
|
-
'w-full min-w-0'
|
|
267
|
-
content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
// Dialog mode: simple button that opens the global SettingsDialog
|
|
271
|
-
if (header?.accountAction === 'dialog') {
|
|
272
|
-
return (
|
|
273
|
-
<div className={wrapperClass}>
|
|
274
|
-
{triggerButton}
|
|
275
|
-
</div>
|
|
276
|
-
);
|
|
277
|
-
}
|
|
245
|
+
const wrapperClass =
|
|
246
|
+
'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]';
|
|
278
247
|
|
|
279
248
|
return (
|
|
280
249
|
<div className={wrapperClass}>
|
|
@@ -289,6 +258,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
289
258
|
>
|
|
290
259
|
{triggerButton}
|
|
291
260
|
</MenuBuilder>
|
|
261
|
+
{secondaryButton}
|
|
292
262
|
</div>
|
|
293
263
|
);
|
|
294
264
|
}
|
|
@@ -322,8 +292,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
|
|
|
322
292
|
const inner = (
|
|
323
293
|
<span
|
|
324
294
|
className={cn(
|
|
325
|
-
'relative inline-flex
|
|
326
|
-
'
|
|
295
|
+
'relative inline-flex size-7 shrink-0 items-center justify-center rounded-md',
|
|
296
|
+
'bg-transparent text-sidebar-foreground/60',
|
|
327
297
|
'transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
|
328
298
|
)}
|
|
329
299
|
>
|