@djangocfg/layouts 2.1.591 → 2.1.593

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 (100) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  14. package/src/layouts/PrivateLayout/README.md +20 -12
  15. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  16. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  17. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
  18. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  19. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  20. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  21. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  22. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  23. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  24. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  25. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  26. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  27. package/src/layouts/PrivateLayout/context.tsx +10 -115
  28. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  29. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  30. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  31. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  32. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  33. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  34. package/src/layouts/PrivateLayout/types.ts +13 -20
  35. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  36. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  37. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  38. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  39. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  40. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  41. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  42. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  43. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  44. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  45. package/src/layouts/SettingsLayout/README.md +23 -10
  46. package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
  47. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  48. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  49. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  50. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  51. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  52. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  53. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  54. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  55. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  56. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  57. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
  58. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  59. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  60. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  61. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  62. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  63. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  64. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  65. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  66. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  67. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  68. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  69. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  70. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  71. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  72. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  73. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  74. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  75. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  76. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  77. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  78. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  79. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  80. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  81. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  82. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  83. package/src/layouts/SettingsLayout/index.ts +3 -1
  84. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  85. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  86. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  87. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
  88. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  89. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  90. package/src/layouts/SettingsLayout/store.ts +12 -0
  91. package/src/layouts/SettingsLayout/types.ts +12 -4
  92. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  93. package/src/layouts/types/layout.types.ts +5 -6
  94. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
  95. package/src/styles/dashboard.css +10 -0
  96. package/src/layouts/PrivateLayout/density.ts +0 -48
  97. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  98. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  99. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  100. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext } from 'react';
4
+
5
+ export interface AuthLayoutContextValue {
6
+ /** True when the consumer passed custom children — step headers should be hidden */
7
+ hideHeader: boolean;
8
+ /**
9
+ * True when the brand is already rendered on the media half, so the form
10
+ * header must not repeat it.
11
+ *
12
+ * A statement about the DESKTOP layout only: `fullsplit` drops the media half
13
+ * below 1024px, where the logo above the form is the only brand left. CSS
14
+ * owns that half of the decision on the same breakpoint — see
15
+ * `.auth-header[data-brand-elsewhere]` in `auth.css`.
16
+ */
17
+ hasBrandPanel: boolean;
18
+ }
19
+
20
+ export const AuthLayoutContext = createContext<AuthLayoutContextValue>({
21
+ hideHeader: false,
22
+ hasBrandPanel: false,
23
+ });
24
+
25
+ export const useAuthLayoutContext = (): AuthLayoutContextValue => useContext(AuthLayoutContext);
@@ -88,7 +88,6 @@
88
88
  through the panel as frosted glass. color-mix keeps it theme-aware. */
89
89
  background: color-mix(in oklab, var(--card) 72%, transparent);
90
90
  backdrop-filter: blur(20px) saturate(140%);
91
- -webkit-backdrop-filter: blur(20px) saturate(140%);
92
91
  color: var(--card-foreground);
93
92
  outline: 1px solid color-mix(in oklab, var(--border) 80%, transparent);
94
93
  box-shadow: 0 24px 64px hsl(0 0% 0% / 0.28);
@@ -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
- }