@djangocfg/layouts 2.1.356 → 2.1.358

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 (83) hide show
  1. package/package.json +21 -19
  2. package/src/configurator/private/schema.ts +12 -0
  3. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  6. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  7. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  8. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  9. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  10. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  13. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  14. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  15. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  16. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  17. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  18. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  22. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  23. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  24. package/src/layouts/AuthLayout/context.tsx +35 -13
  25. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  26. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  28. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  29. package/src/layouts/PrivateLayout/PrivateLayout.tsx +45 -248
  30. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +113 -430
  31. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +82 -105
  32. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +168 -0
  33. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  34. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  35. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  36. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  37. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  38. package/src/layouts/PrivateLayout/context.tsx +211 -0
  39. package/src/layouts/PrivateLayout/density.ts +48 -0
  40. package/src/layouts/PrivateLayout/hooks/index.ts +14 -0
  41. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  42. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +110 -0
  43. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  44. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  45. package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
  46. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  47. package/src/layouts/PrivateLayout/index.ts +2 -2
  48. package/src/layouts/PrivateLayout/types.ts +193 -0
  49. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
  50. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
  51. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
  52. package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +8 -8
  53. package/src/layouts/ProfileLayout/ProfileForm/index.tsx +148 -0
  54. package/src/layouts/ProfileLayout/README.md +118 -0
  55. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  56. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  57. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  58. package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
  59. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
  60. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  61. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +35 -0
  62. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  63. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  64. package/src/layouts/ProfileLayout/components/index.ts +5 -2
  65. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  66. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +48 -0
  67. package/src/layouts/ProfileLayout/index.ts +7 -3
  68. package/src/layouts/ProfileLayout/types.ts +47 -0
  69. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  70. package/src/layouts/PublicLayout/components/index.ts +4 -0
  71. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  72. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  73. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  74. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  75. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  76. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  77. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  78. package/src/layouts/_components/index.ts +2 -6
  79. package/src/layouts/index.ts +9 -4
  80. package/src/layouts/ProfileLayout/ProfileLayout.tsx +0 -284
  81. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  82. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  83. /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Layout Visual Hook
3
+ *
4
+ * Computes CSS variables and class names for the boxed/full-bleed layout variants.
5
+ */
6
+
7
+ import { useMemo } from 'react';
8
+
9
+ import type { LayoutVisualConfig } from '../../types';
10
+
11
+ /** CSS variables consumed by the boxed `SidebarInset` (margin + radius). */
12
+ export function resolveProviderStyle(
13
+ visual: LayoutVisualConfig | undefined,
14
+ ): React.CSSProperties | undefined {
15
+ if ((visual?.variant ?? 'boxed') !== 'boxed') return undefined;
16
+ const inset = normaliseInset(visual?.inset);
17
+ return {
18
+ ['--app-shell-inset-x' as string]: `${inset.x}px`,
19
+ ['--app-shell-inset-y' as string]: `${inset.y}px`,
20
+ ['--app-shell-radius' as string]: BOXED_RADIUS_REM[visual?.radius ?? '2xl'],
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Statically-known Tailwind classes for the boxed inset. Margin and radius are
26
+ * driven by the CSS variables set in `resolveProviderStyle`, so JIT can fully
27
+ * extract these classes at build time.
28
+ */
29
+ const BOXED_INSET_CLASS = [
30
+ 'flex flex-col',
31
+ 'md:peer-data-[variant=inset]:my-[var(--app-shell-inset-y)]',
32
+ 'md:peer-data-[variant=inset]:mr-[var(--app-shell-inset-x)]',
33
+ 'md:peer-data-[variant=inset]:rounded-[var(--app-shell-radius)]',
34
+ 'md:peer-data-[variant=inset]:overflow-hidden',
35
+ ].join(' ');
36
+
37
+ const BOXED_INSET_BORDER_CLASS =
38
+ 'md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border/60';
39
+
40
+ const BOXED_RADIUS_REM: Record<NonNullable<LayoutVisualConfig['radius']>, string> = {
41
+ sm: '0.375rem',
42
+ md: '0.5rem',
43
+ lg: '0.75rem',
44
+ xl: '1rem',
45
+ '2xl': '1.25rem',
46
+ '3xl': '1.75rem',
47
+ };
48
+
49
+ export function resolveInsetClassName(visual: LayoutVisualConfig | undefined): string {
50
+ if ((visual?.variant ?? 'boxed') !== 'boxed') return 'flex flex-col';
51
+ const border = visual?.border ?? true;
52
+ return border ? `${BOXED_INSET_CLASS} ${BOXED_INSET_BORDER_CLASS}` : BOXED_INSET_CLASS;
53
+ }
54
+
55
+ /**
56
+ * Background painted *behind* the boxed container on md+. On mobile the
57
+ * canvas tint is dropped because the sidebar is a Drawer — leaking the
58
+ * canvas colour to the whole viewport just makes the page look dim.
59
+ *
60
+ * `bg-sidebar` (the default) overrides shadcn-sidebar's built-in
61
+ * `has-[&_[data-variant=inset]]:bg-sidebar` only at the breakpoint where
62
+ * the inset shape actually exists.
63
+ */
64
+ const BOXED_BG_CLASS: Record<NonNullable<LayoutVisualConfig['background']>, string> = {
65
+ sidebar: 'md:!bg-sidebar',
66
+ muted: 'md:!bg-muted',
67
+ card: 'md:!bg-card',
68
+ background: 'md:!bg-background',
69
+ };
70
+
71
+ export function resolveProviderClassName(
72
+ visual: LayoutVisualConfig | undefined,
73
+ ): string | undefined {
74
+ // h-svh + overflow-hidden: lock the shell to exactly one viewport height so
75
+ // the inner scroll-area (PrivateContent) is the only scroll surface. Without
76
+ // this, SidebarProvider grows via min-h-svh and the whole page scrolls.
77
+ const base = 'h-svh overflow-hidden';
78
+ if ((visual?.variant ?? 'boxed') !== 'boxed') return base;
79
+ // `max-md:!bg-background` neutralises shadcn-sidebar's built-in
80
+ // `has-[[data-variant=inset]]:bg-sidebar` below md so the mobile Drawer shell
81
+ // doesn't paint the whole viewport with the canvas tint.
82
+ return `${base} max-md:!bg-background ${BOXED_BG_CLASS[visual?.background ?? 'sidebar']}`;
83
+ }
84
+
85
+ function normaliseInset(inset: LayoutVisualConfig['inset']): { x: number; y: number } {
86
+ if (typeof inset === 'number') return { x: inset, y: inset };
87
+ return { x: inset?.x ?? 12, y: inset?.y ?? 12 };
88
+ }
89
+
90
+ export interface LayoutVisualResult {
91
+ /** CSS variables for the provider */
92
+ providerStyle: React.CSSProperties | undefined;
93
+ /** Class name for the provider */
94
+ providerClassName: string | undefined;
95
+ /** Class name for the inset */
96
+ insetClassName: string;
97
+ /** Sidebar variant for shadcn */
98
+ sidebarVariant: 'sidebar' | 'inset';
99
+ }
100
+
101
+ export function useLayoutVisual(visual: LayoutVisualConfig | undefined): LayoutVisualResult {
102
+ return useMemo(() => {
103
+ const variant = visual?.variant ?? 'boxed';
104
+ const sidebarVariant = variant === 'boxed' ? 'inset' : 'sidebar';
105
+
106
+ return {
107
+ providerStyle: resolveProviderStyle(visual),
108
+ providerClassName: resolveProviderClassName(visual),
109
+ insetClassName: resolveInsetClassName(visual),
110
+ sidebarVariant,
111
+ };
112
+ }, [visual]);
113
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Shell Visual State Hook
3
+ *
4
+ * Centralizes ALL visual decisions for the sidebar + content pair:
5
+ * - sidebar state: expanded | hover-overlay | collapsed-rail
6
+ * - layout variant: boxed (inset) | full-bleed
7
+ * - what to show/hide in each state
8
+ * - CSS modifiers for sidebar root, inner shell, and content inset
9
+ *
10
+ * Every component reads from this hook — no scattered ternaries.
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import { useMemo } from 'react';
16
+
17
+ import { usePrivateLayoutContext } from '../context';
18
+ import type { LayoutVisualConfig } from '../../types';
19
+
20
+ // ============================================================================
21
+ // State flags
22
+ // ============================================================================
23
+
24
+ export interface ShellVisualFlags {
25
+ /** Mobile drawer mode */
26
+ isMobile: boolean;
27
+ /** Persistent expanded (user toggled or default open) */
28
+ isExpanded: boolean;
29
+ /** Collapsed to icon rail */
30
+ isCollapsed: boolean;
31
+ /** Temporary hover-expand overlay on desktop rail */
32
+ isHoverOverlay: boolean;
33
+ /** True only in desktop collapsed rail without hover */
34
+ isRail: boolean;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Content visibility flags
39
+ // ============================================================================
40
+
41
+ export interface ShellContentFlags {
42
+ /** Nav item text labels visible */
43
+ showLabels: boolean;
44
+ /** Group section headers visible */
45
+ showGroupLabels: boolean;
46
+ /** Badges render as full text (false = dot only) */
47
+ showBadgeText: boolean;
48
+ /** Tooltips on nav items (only when labels hidden) */
49
+ showTooltips: boolean;
50
+ /** Account footer in compact avatar-only mode */
51
+ isAccountCompact: boolean;
52
+ /** menuStart / menuEnd / featured slots hidden */
53
+ hideSlots: boolean;
54
+ }
55
+
56
+ // ============================================================================
57
+ // Chrome flags
58
+ // ============================================================================
59
+
60
+ export interface ShellChromeFlags {
61
+ /** Sidebar casts a shadow (overlay mode) */
62
+ showShadow: boolean;
63
+ /** Right border/separator on sidebar */
64
+ showBorder: boolean;
65
+ /** Internal right padding inside sidebar (overlay breathing room) */
66
+ needsInternalPadding: boolean;
67
+ /** Content inset should have margin to make room for sidebar */
68
+ contentHasSidebarGap: boolean;
69
+ /** Sidebar width is fixed to icon-rail */
70
+ isRailWidth: boolean;
71
+ }
72
+
73
+ // ============================================================================
74
+ // CSS modifiers
75
+ // ============================================================================
76
+
77
+ export interface ShellModifiers {
78
+ /** Classes for the shadcn <Sidebar> root (fixed container) */
79
+ sidebarRoot: string[];
80
+ /** Classes for the inner div[data-sidebar=sidebar] */
81
+ sidebarInner: string[];
82
+ /** Classes for the shadcn <SidebarContent> */
83
+ sidebarContent: string[];
84
+ }
85
+
86
+ // ============================================================================
87
+ // Combined result
88
+ // ============================================================================
89
+
90
+ export interface ShellVisualState {
91
+ flags: ShellVisualFlags;
92
+ content: ShellContentFlags;
93
+ chrome: ShellChromeFlags;
94
+ modifiers: ShellModifiers;
95
+ }
96
+
97
+ // ============================================================================
98
+ // Hook
99
+ // ============================================================================
100
+
101
+ export function useShellVisualState(
102
+ layoutVariant?: LayoutVisualConfig['variant'],
103
+ ): ShellVisualState {
104
+ const { isMobile, isExpanded, isCollapsed, isHoverExpanded } =
105
+ usePrivateLayoutContext();
106
+
107
+ const isRail = !isMobile && isCollapsed && !isHoverExpanded;
108
+ const isHoverOverlay = !isMobile && isCollapsed && isHoverExpanded;
109
+ const variant = layoutVariant ?? 'boxed';
110
+
111
+ return useMemo(() => {
112
+ // ------------------------------------------------------------------------
113
+ // Content visibility
114
+ // ------------------------------------------------------------------------
115
+ const showLabels = isExpanded || isHoverOverlay;
116
+ const showGroupLabels = showLabels;
117
+ const showBadgeText = showLabels;
118
+ const showTooltips = isRail;
119
+ const isAccountCompact = isRail;
120
+ const hideSlots = isRail;
121
+
122
+ // ------------------------------------------------------------------------
123
+ // Chrome
124
+ // ------------------------------------------------------------------------
125
+ // Only hover-overlay is an overlay — it needs shadow + border + padding.
126
+ // Persistent expanded is part of the layout flow — no shadow.
127
+ const showShadow = isHoverOverlay;
128
+ const showBorder = isHoverOverlay;
129
+ const needsInternalPadding = isHoverOverlay;
130
+
131
+ // Content gap: only persistent expanded pushes the inset.
132
+ // Hover-overlay is temporary — content must NOT shift.
133
+ // Collapsed rail also leaves a gap (the rail itself is narrow but present).
134
+ const contentHasSidebarGap = isExpanded || isRail;
135
+ const isRailWidth = isRail;
136
+
137
+ // ------------------------------------------------------------------------
138
+ // Modifiers
139
+ // ------------------------------------------------------------------------
140
+ const sidebarRoot: string[] = [];
141
+ const sidebarInner: string[] = [];
142
+ const sidebarContent: string[] = [];
143
+
144
+ if (isHoverOverlay) {
145
+ sidebarRoot.push('z-50', '!w-[var(--sidebar-width)]', 'min-w-[var(--sidebar-width)]');
146
+ // Allow scroll inside hover-expanded overlay — shadcn hardcodes
147
+ // overflow-hidden on div[data-sidebar=sidebar]; override it here.
148
+ sidebarInner.push('!overflow-auto');
149
+ // Extra right padding on content so text doesn't hug the right edge
150
+ sidebarContent.push('!overflow-auto', 'pr-4');
151
+
152
+ if (showShadow) {
153
+ sidebarInner.push(
154
+ 'shadow-[4px_0_24px_-4px_rgba(0,0,0,0.08)]',
155
+ 'dark:shadow-[4px_0_24px_-4px_rgba(0,0,0,0.25)]',
156
+ );
157
+ }
158
+ if (showBorder) {
159
+ sidebarInner.push('border-r', 'border-sidebar-border/30');
160
+ }
161
+ // Hide the shadcn gradient border line on hover overlay
162
+ sidebarInner.push('[&>div[aria-hidden]]:hidden');
163
+ }
164
+
165
+ // Boxed variant: inner sidebar gets rounded corners when persistent expanded
166
+ if (variant === 'boxed' && isExpanded) {
167
+ sidebarInner.push('rounded-sm');
168
+ }
169
+
170
+ return {
171
+ flags: {
172
+ isMobile,
173
+ isExpanded,
174
+ isCollapsed,
175
+ isHoverOverlay,
176
+ isRail,
177
+ },
178
+ content: {
179
+ showLabels,
180
+ showGroupLabels,
181
+ showBadgeText,
182
+ showTooltips,
183
+ isAccountCompact,
184
+ hideSlots,
185
+ },
186
+ chrome: {
187
+ showShadow,
188
+ showBorder,
189
+ needsInternalPadding,
190
+ contentHasSidebarGap,
191
+ isRailWidth,
192
+ },
193
+ modifiers: {
194
+ sidebarRoot,
195
+ sidebarInner,
196
+ sidebarContent,
197
+ },
198
+ };
199
+ }, [
200
+ isMobile,
201
+ isExpanded,
202
+ isCollapsed,
203
+ isHoverOverlay,
204
+ isRail,
205
+ variant,
206
+ ]);
207
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Reads the sidebar open/closed state from the cookie written by shadcn-sidebar.
3
+ * Falls back to `true` (expanded) when no cookie exists.
4
+ *
5
+ * Must run on the client — returns `true` during SSR.
6
+ */
7
+
8
+ const SIDEBAR_COOKIE_NAME = 'sidebar_state';
9
+
10
+ export function useSidebarDefaultOpen(): boolean {
11
+ if (typeof document === 'undefined') return true;
12
+
13
+ const match = document.cookie
14
+ .split('; ')
15
+ .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`));
16
+
17
+ if (!match) return true;
18
+
19
+ const value = match.split('=')[1];
20
+ return value === 'true';
21
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Sidebar Keyboard Navigation Hook
3
+ *
4
+ * Global shortcuts:
5
+ * - Ctrl/Cmd + B → toggle sidebar expanded/collapsed (desktop)
6
+ * - Escape → close mobile drawer / hover overlay
7
+ *
8
+ * Focus-trap navigation inside the sidebar:
9
+ * - ArrowDown / ArrowUp → move focus between focusable items
10
+ * - Home → first focusable item
11
+ * - End → last focusable item
12
+ * - Enter / Space → activate (native behaviour, no override)
13
+ */
14
+
15
+ 'use client';
16
+
17
+ import { useCallback, useEffect, useRef } from 'react';
18
+
19
+ import { useSidebar } from '@djangocfg/ui-core/components';
20
+
21
+ interface UseSidebarKeyboardOptions {
22
+ /** Whether keyboard shortcuts are active */
23
+ enabled?: boolean;
24
+ }
25
+
26
+ const FOCUSABLE_SELECTOR = [
27
+ 'a[href]',
28
+ 'button:not([disabled])',
29
+ '[tabindex]:not([tabindex="-1"])',
30
+ ].join(', ');
31
+
32
+ export function useSidebarKeyboard({ enabled = true }: UseSidebarKeyboardOptions = {}) {
33
+ const { toggleSidebar, setOpenMobile, isMobile, openMobile } = useSidebar();
34
+ const sidebarRef = useRef<HTMLElement | null>(null);
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Global shortcuts (Ctrl/Cmd+B, Escape)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ useEffect(() => {
41
+ if (!enabled) return;
42
+
43
+ const onKeyDown = (e: KeyboardEvent) => {
44
+ // Ctrl/Cmd + B → toggle sidebar
45
+ if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
46
+ e.preventDefault();
47
+ if (isMobile) {
48
+ setOpenMobile(!openMobile);
49
+ } else {
50
+ toggleSidebar();
51
+ }
52
+ return;
53
+ }
54
+
55
+ // Escape → close mobile drawer or hover overlay
56
+ if (e.key === 'Escape') {
57
+ if (isMobile && openMobile) {
58
+ setOpenMobile(false);
59
+ }
60
+ }
61
+ };
62
+
63
+ window.addEventListener('keydown', onKeyDown);
64
+ return () => window.removeEventListener('keydown', onKeyDown);
65
+ }, [enabled, isMobile, openMobile, setOpenMobile, toggleSidebar]);
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Focus navigation inside sidebar (arrow keys, Home, End)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const handleSidebarKeyDown = useCallback(
72
+ (e: React.KeyboardEvent<HTMLElement>) => {
73
+ if (!sidebarRef.current) return;
74
+
75
+ const focusables = Array.from(
76
+ sidebarRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
77
+ ).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
78
+
79
+ const currentIndex = focusables.indexOf(document.activeElement as HTMLElement);
80
+ if (currentIndex === -1) return;
81
+
82
+ let nextIndex = currentIndex;
83
+
84
+ switch (e.key) {
85
+ case 'ArrowDown':
86
+ e.preventDefault();
87
+ nextIndex = (currentIndex + 1) % focusables.length;
88
+ break;
89
+ case 'ArrowUp':
90
+ e.preventDefault();
91
+ nextIndex = (currentIndex - 1 + focusables.length) % focusables.length;
92
+ break;
93
+ case 'Home':
94
+ e.preventDefault();
95
+ nextIndex = 0;
96
+ break;
97
+ case 'End':
98
+ e.preventDefault();
99
+ nextIndex = focusables.length - 1;
100
+ break;
101
+ default:
102
+ return;
103
+ }
104
+
105
+ focusables[nextIndex]?.focus();
106
+ },
107
+ [],
108
+ );
109
+
110
+ const setSidebarRef = useCallback((node: HTMLElement | null) => {
111
+ sidebarRef.current = node;
112
+ }, []);
113
+
114
+ return { setSidebarRef, handleSidebarKeyDown };
115
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
+ export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
6
7
  export type {
7
8
  PrivateLayoutProps,
8
9
  SidebarItem,
@@ -12,5 +13,4 @@ export type {
12
13
  SidebarActiveIndicator,
13
14
  SidebarGroupLabelStyle,
14
15
  SidebarFeaturedConfig,
15
- } from './PrivateLayout';
16
-
16
+ } from './types';
@@ -0,0 +1,193 @@
1
+ /**
2
+ * PrivateLayout Types
3
+ *
4
+ * All type definitions for the PrivateLayout component family.
5
+ */
6
+
7
+ import type { ReactNode } from 'react';
8
+ import type { LucideIcon } from 'lucide-react';
9
+
10
+ import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
11
+ import type { LayoutVisualConfig } from '../types';
12
+ import type { UserMenuConfig } from '../types';
13
+
14
+ // ============================================================================
15
+ // Sidebar Item Types
16
+ // ============================================================================
17
+
18
+ export interface SidebarItem {
19
+ label: string;
20
+ href: string;
21
+ icon?: string | LucideIcon;
22
+ badge?: string | number;
23
+ /** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
24
+ badgeVariant?: 'count' | 'pill';
25
+ /** Collapsed rail: shown in tooltip; defaults to `label`. */
26
+ tooltip?: string;
27
+ }
28
+
29
+ // ============================================================================
30
+ // Sidebar Group Types
31
+ // ============================================================================
32
+
33
+ export interface SidebarGroupConfig {
34
+ /** Group label displayed above items */
35
+ label: string;
36
+ /** Items in this group */
37
+ items: SidebarItem[];
38
+ /** If true, group is only shown when it has items (for dynamic groups like extensions) */
39
+ dynamic?: boolean;
40
+ /** Render group as an accordion (Mailersend/Vercel-style). Label becomes a clickable trigger. */
41
+ collapsible?: boolean;
42
+ /** Initial open state for collapsible groups. Auto-expanded if any child is active. Default `false`. */
43
+ defaultOpen?: boolean;
44
+ /** Icon for the group trigger (only when `collapsible`). */
45
+ icon?: string | LucideIcon;
46
+ /**
47
+ * Hide per-item icons inside the group. Defaults to `true` when `collapsible`,
48
+ * `false` otherwise (Mailersend convention: icons live on the trigger, not on children).
49
+ */
50
+ hideItemIcons?: boolean;
51
+ }
52
+
53
+ /** Active-state visual treatment for sidebar nav items. */
54
+ export type SidebarActiveIndicator = 'background' | 'rail' | 'both';
55
+
56
+ /** Rendering of group labels. `'uppercase'` is the legacy ultra-light caps; `'plain'` is sm bold. */
57
+ export type SidebarGroupLabelStyle = 'uppercase' | 'plain';
58
+
59
+ // ============================================================================
60
+ // Featured CTA Types
61
+ // ============================================================================
62
+
63
+ /** Featured CTA tile rendered below groups. */
64
+ export interface SidebarFeaturedConfig {
65
+ icon?: string | LucideIcon;
66
+ label: string;
67
+ href: string;
68
+ badge?: string;
69
+ accent?: 'green' | 'blue' | 'amber' | 'primary';
70
+ }
71
+
72
+ // ============================================================================
73
+ // Sidebar Config
74
+ // ============================================================================
75
+
76
+ export interface SidebarConfig {
77
+ /** Grouped items with labels */
78
+ groups: SidebarGroupConfig[];
79
+ /** Home link href */
80
+ homeHref?: string;
81
+ /**
82
+ * Custom block inside the scrollable nav column, **above** all `groups`
83
+ * (below the brand header, same horizontal padding as nav).
84
+ */
85
+ menuStart?: ReactNode;
86
+ /**
87
+ * Custom block inside the scrollable nav column, **below** all `groups`
88
+ * (above `footer` + account block).
89
+ */
90
+ menuEnd?: ReactNode;
91
+ /**
92
+ * Keep `menuStart` visible when the desktop sidebar is collapsed to the
93
+ * icon rail. Default `false` — most slot content is full-width and looks
94
+ * broken at ~56px. Set `true` only when the slot renders well in compact mode.
95
+ */
96
+ menuStartShowOnCollapsed?: boolean;
97
+ /** Same as `menuStartShowOnCollapsed`, but for `menuEnd`. Default `false`. */
98
+ menuEndShowOnCollapsed?: boolean;
99
+ /** Custom footer component rendered at the bottom of the sidebar */
100
+ footer?: ReactNode;
101
+ /** Active-state visual on nav items. Default `'background'` (legacy). */
102
+ activeIndicator?: SidebarActiveIndicator;
103
+ /** Style of group labels. Default `'uppercase'` (legacy). Collapsible groups always use `plain`. */
104
+ groupLabelStyle?: SidebarGroupLabelStyle;
105
+ /** Featured CTA tile rendered below all groups, above `menuEnd`. */
106
+ featured?: SidebarFeaturedConfig;
107
+ /**
108
+ * Override the auto-calculated nav density. When omitted density is derived
109
+ * from total item count (comfortable ≤5, default ≤9, compact >9).
110
+ */
111
+ density?: 'comfortable' | 'default' | 'compact';
112
+ }
113
+
114
+ // ============================================================================
115
+ // Header Config
116
+ // ============================================================================
117
+
118
+ export interface HeaderConfig {
119
+ /** Custom header brand node (same idea as PublicNavbar `brand`). */
120
+ brand?: ReactNode;
121
+ /** Shown next to the logo when the sidebar is expanded */
122
+ title?: string;
123
+ /**
124
+ * Brand mark in the sidebar header (Lucide icon name or component).
125
+ * If omitted, a single-letter monogram from `brandLetter` / `title` is shown.
126
+ */
127
+ brandIcon?: string | LucideIcon;
128
+ /**
129
+ * Monogram when `brandIcon` is not set (one visible character).
130
+ * Defaults to the first letter of `title`, uppercased.
131
+ */
132
+ brandLetter?: string;
133
+ /** User menu groups (account panel in the sidebar footer) */
134
+ groups?: UserMenuConfig['groups'];
135
+ /**
136
+ * Behaviour of the footer account button.
137
+ * - `'menu'` (default) — opens a DropdownMenu with account links, locale/theme controls, and sign-out.
138
+ * - `'dialog'` — opens the global ProfileDialog (managed via Zustand store).
139
+ */
140
+ accountAction?: 'menu' | 'dialog';
141
+ /** Auth page path (for sign in button) */
142
+ authPath?: string;
143
+ /** Subtitle under the display name in the sidebar footer (e.g. "Max plan"). */
144
+ userPlan?: string;
145
+ /** Optional secondary action button rendered inside the footer trigger (e.g. Get apps download button). */
146
+ footerSecondaryAction?: {
147
+ icon: string | LucideIcon;
148
+ href?: string;
149
+ onClick?: () => void;
150
+ ariaLabel: string;
151
+ /** Show pulsing accent dot on the action (Claude-style "new"). */
152
+ pulse?: boolean;
153
+ };
154
+ }
155
+
156
+ // ============================================================================
157
+ // PrivateLayout Props
158
+ // ============================================================================
159
+
160
+ export interface PrivateLayoutProps {
161
+ children: ReactNode;
162
+ /** Sidebar configuration */
163
+ sidebar?: SidebarConfig;
164
+ /** Title + account links (no top navbar — title is used in the sidebar chrome) */
165
+ header?: HeaderConfig;
166
+ /**
167
+ * Path for active nav highlighting. With `@djangocfg/nextjs` i18n routing, pass `usePathname()` from
168
+ * `@djangocfg/nextjs/i18n/navigation` (no `/[locale]` segment). If omitted, uses `next/navigation` (includes locale).
169
+ */
170
+ pathname?: string;
171
+ /** Content padding */
172
+ contentPadding?: 'none' | 'default';
173
+ /**
174
+ * Content scroll behaviour.
175
+ * - `'auto'` (default) — the shell scroll-area scrolls vertically.
176
+ * - `'hidden'` — shell does NOT scroll; use for full-height pages (e.g. Kanban)
177
+ * where children manage their own scroll surfaces.
178
+ */
179
+ contentScroll?: 'auto' | 'hidden';
180
+ /**
181
+ * Visual style of the shell. Defaults to `'boxed'` (inset rounded card on a
182
+ * sidebar-coloured canvas). Pass `{ variant: 'full-bleed' }` for the legacy
183
+ * edge-to-edge layout.
184
+ */
185
+ visual?: LayoutVisualConfig;
186
+ /**
187
+ * Skip the built-in auth guard. Useful for static showcases / playground
188
+ * embeds where there's no real session. Default `true` (guard on).
189
+ */
190
+ requireAuth?: boolean;
191
+ /** Reserved for `AppLayout` passthrough (`publicChrome`); unused in this layout. */
192
+ publicChrome?: AppLayoutPublicChrome;
193
+ }