@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.
Files changed (71) hide show
  1. package/package.json +12 -12
  2. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  3. package/src/layouts/PrivateLayout/README.md +20 -12
  4. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  5. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  6. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +32 -62
  7. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  8. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  9. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  10. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  11. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  12. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  13. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  14. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  15. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  16. package/src/layouts/PrivateLayout/context.tsx +10 -115
  17. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  18. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  19. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  20. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  21. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  22. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  23. package/src/layouts/PrivateLayout/types.ts +13 -20
  24. package/src/layouts/SettingsLayout/README.md +23 -10
  25. package/src/layouts/SettingsLayout/SettingsDialog.tsx +14 -8
  26. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  27. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  28. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  29. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  30. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  31. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  32. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  33. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  34. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  35. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +52 -105
  36. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  37. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  38. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  39. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  40. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  41. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  42. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  43. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  44. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  45. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  46. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  47. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  48. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  49. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  50. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  51. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  52. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  53. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  54. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  55. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  56. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  57. package/src/layouts/SettingsLayout/index.ts +0 -1
  58. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  59. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  60. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  61. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +12 -4
  62. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  63. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  64. package/src/layouts/SettingsLayout/types.ts +12 -4
  65. package/src/layouts/types/layout.types.ts +5 -6
  66. package/src/styles/dashboard.css +10 -0
  67. package/src/layouts/PrivateLayout/density.ts +0 -48
  68. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  69. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  70. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  71. 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.590",
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.590",
94
- "@djangocfg/api": "^2.1.590",
95
- "@djangocfg/devtools": "^2.1.590",
96
- "@djangocfg/i18n": "^2.1.590",
97
- "@djangocfg/ui-core": "^2.1.590",
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.590",
128
- "@djangocfg/api": "^2.1.590",
129
- "@djangocfg/devtools": "^2.1.590",
130
- "@djangocfg/i18n": "^2.1.590",
131
- "@djangocfg/typescript-config": "^2.1.590",
132
- "@djangocfg/ui-core": "^2.1.590",
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: collapsible sidebar (icon rail vs expanded) + scrollable content.
5
- * Toggle lives in the sidebar header on desktop; on narrow viewports a `SidebarTrigger` sits in `PrivateContent` (opens the mobile Sheet).
6
- * Ctrl/Cmd + B still toggles the sidebar width.
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 { useAuthGuard } from './hooks';
22
- import { useLayoutVisual } from './hooks';
23
- import { useSidebarDefaultOpen } from './hooks';
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
- {sidebar && (
97
- <PrivateSidebar
98
- sidebar={sidebar}
99
- header={header}
100
- pathname={pathname}
101
- variant={sidebarVariant}
102
- />
103
- )}
104
-
105
- <SidebarInset className={insetClassName}>
106
- <PrivateContent
107
- padding={contentPadding}
108
- scroll={contentScroll}
109
- hasSidebar={Boolean(sidebar)}
110
- visual={visual}
111
- >
112
- {children}
113
- </PrivateContent>
114
- </SidebarInset>
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 — collapsible sidebar (icon-rail vs expanded) + scrollable content area.
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` (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.
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` — content stretches edge-to-edge next to the sidebar (legacy look). Opt in with `visual={{ variant: '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'` | `'boxed'` | Switch between the two shells. |
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` | Keep slots visible on the icon rail. |
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` | `'background' \| 'rail' \| 'both'` | Active item visual. Default `'background'` (legacy). `'rail'` paints a 2px primary stripe on the right edge (Vercel-style). |
145
- | `groupLabelStyle` | `'uppercase' \| 'plain'` | Label typography. Default `'uppercase'` (legacy ultra-light caps). Collapsible groups always render `plain`. |
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. Disabled on the icon rail. |
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` | `'menu' \| 'dialog'` | Footer button behaviour. `'menu'` opens dropdown; `'dialog'` opens global ProfileDialog. Default `'menu'`. |
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
- On the collapsed icon-rail the switcher renders only the active item's avatar — no dropdown trigger.
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 — on narrow viewports a fixed menu FAB (`SidebarTrigger`) + scrollable area.
3
- * On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from the ui-core sidebar.
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, openMobile } = useSidebar();
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
- const contentTopPaddingClass =
60
- padding === 'default'
61
- ? hasSidebar
62
- ? 'md:pt-6 lg:pt-8'
63
- : 'pt-4 sm:pt-6 lg:pt-8'
64
- : undefined;
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
- {mobileMenuFab}
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 { useShellVisualState, useSidebarKeyboard } from '../hooks';
12
+ import { useSidebarKeyboard } from '../hooks';
16
13
  import type { HeaderConfig, SidebarConfig } from '../types';
17
- import { SidebarBrand } from './SidebarBrand';
18
- import { SidebarNavGroup } from './SidebarNavGroup';
19
- import { SidebarSlots } from './SidebarSlots';
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 pathname = pathnameProp ?? '';
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
- sidebar={sidebar}
44
- header={header}
45
- pathname={pathname}
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 { ChevronsUpDown, Languages, LogOut, Settings as SettingsIcon } from 'lucide-react';
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 { useShellVisualState } from '../hooks';
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 { content } = useShellVisualState();
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-auto w-full gap-3 rounded-lg px-3 py-3 text-left select-none',
205
- 'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
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
- content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
202
+ isMobile && 'h-11',
209
203
  );
210
204
 
211
- const secondaryButton = secondary && !content.isAccountCompact ? (
205
+ const secondaryButton = secondary ? (
212
206
  <SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
213
207
  ) : null;
214
208
 
215
- const dropdownContentClass = cn(
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
- const expandedMeta = content.isAccountCompact ? null : (
216
+ // Like Claude: "Name · Plan ⌄". The email lives in the menu header.
217
+ const expandedMeta = (
229
218
  <>
230
- <span className="flex min-w-0 flex-1 flex-col text-left">
231
- <span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
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="truncate text-xs leading-snug text-sidebar-foreground/60">
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-sm font-semibold">{userInitial}</AvatarFallback>
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 = cn(
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 h-8 w-8 shrink-0 items-center justify-center rounded-md',
326
- 'border border-sidebar-border/40 bg-transparent text-sidebar-foreground/70',
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
  >