@djangocfg/layouts 2.1.356 → 2.1.357

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 (75) hide show
  1. package/package.json +17 -17
  2. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  3. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  4. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  5. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  6. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  7. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  8. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  9. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  10. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  13. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  14. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  15. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  16. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  17. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  18. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  22. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  23. package/src/layouts/AuthLayout/context.tsx +35 -13
  24. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  25. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  26. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  28. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -247
  29. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +115 -426
  30. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +40 -19
  31. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +165 -0
  32. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  33. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  34. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  35. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  36. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  37. package/src/layouts/PrivateLayout/context.tsx +211 -0
  38. package/src/layouts/PrivateLayout/density.ts +48 -0
  39. package/src/layouts/PrivateLayout/hooks/index.ts +13 -0
  40. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  41. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +103 -0
  42. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  43. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  44. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  45. package/src/layouts/PrivateLayout/index.ts +2 -2
  46. package/src/layouts/PrivateLayout/types.ts +187 -0
  47. package/src/layouts/ProfileLayout/ProfileLayout.tsx +44 -183
  48. package/src/layouts/ProfileLayout/README.md +58 -0
  49. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  50. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  51. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  52. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  53. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +29 -0
  54. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  55. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  56. package/src/layouts/ProfileLayout/components/index.ts +4 -2
  57. package/src/layouts/ProfileLayout/context.tsx +4 -6
  58. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  59. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +43 -0
  60. package/src/layouts/ProfileLayout/index.ts +6 -3
  61. package/src/layouts/ProfileLayout/types.ts +37 -0
  62. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  63. package/src/layouts/PublicLayout/components/index.ts +4 -0
  64. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  65. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  66. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  67. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  68. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  69. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  70. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  71. package/src/layouts/_components/index.ts +2 -7
  72. package/src/layouts/index.ts +9 -4
  73. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  74. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  75. /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Sidebar Slots Component
3
+ *
4
+ * Renders optional content slots: menuStart, menuEnd, featured CTA, footer.
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, { memo, useMemo } from 'react';
10
+
11
+ import { SidebarFeatured } from './SidebarFeatured';
12
+ import { usePrivateLayoutContext } from '../context';
13
+ import { useShellVisualState } from '../hooks';
14
+
15
+ function SidebarSlotsRaw() {
16
+ const { sidebar } = usePrivateLayoutContext();
17
+ const { content } = useShellVisualState();
18
+
19
+ const menuStartSlot = useMemo(
20
+ () =>
21
+ sidebar?.menuStart ? (
22
+ <div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuStart}</div>
23
+ ) : null,
24
+ [sidebar?.menuStart],
25
+ );
26
+
27
+ const menuEndSlot = useMemo(
28
+ () =>
29
+ sidebar?.menuEnd ? (
30
+ <div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuEnd}</div>
31
+ ) : null,
32
+ [sidebar?.menuEnd],
33
+ );
34
+
35
+ const featuredSlot = useMemo(
36
+ () =>
37
+ sidebar?.featured ? (
38
+ <div className="w-full min-w-0 shrink-0 px-2">
39
+ <SidebarFeatured config={sidebar.featured} />
40
+ </div>
41
+ ) : null,
42
+ [sidebar?.featured],
43
+ );
44
+
45
+ const footerExtra = useMemo(
46
+ () =>
47
+ sidebar?.footer ? (
48
+ <div className="mb-2">{sidebar.footer}</div>
49
+ ) : null,
50
+ [sidebar?.footer],
51
+ );
52
+
53
+ if (content.hideSlots) return null;
54
+
55
+ return (
56
+ <>
57
+ {menuStartSlot}
58
+ {featuredSlot}
59
+ {menuEndSlot}
60
+ {footerExtra}
61
+ </>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Memoised slots wrapper. Skips re-renders when the sidebar config object
67
+ * reference stays the same. The slot ReactNodes themselves (menuStart,
68
+ * menuEnd, footer) are *not* deeply compared — they remain reactive because
69
+ * the consumer is expected to pass a new ReactNode when slot content changes.
70
+ */
71
+ export const SidebarSlots = memo(SidebarSlotsRaw);
@@ -4,3 +4,7 @@
4
4
 
5
5
  export { PrivateSidebar } from './PrivateSidebar';
6
6
  export { PrivateContent } from './PrivateContent';
7
+ export { SidebarBrand } from './SidebarBrand';
8
+ export { SidebarNavGroup } from './SidebarNavGroup';
9
+ export { SidebarNavItem } from './SidebarNavItem';
10
+ export { SidebarSlots } from './SidebarSlots';
@@ -0,0 +1,211 @@
1
+ /**
2
+ * PrivateLayout Context
3
+ *
4
+ * Provides UI state for the private layout shell:
5
+ * - sidebar state (collapsed/expanded, mobile)
6
+ * - nav density based on item count
7
+ * - active indicator style
8
+ * - group label style
9
+ * - current pathname
10
+ */
11
+
12
+ 'use client';
13
+
14
+ import React, { createContext, useContext, useMemo } from 'react';
15
+
16
+ import type {
17
+ HeaderConfig,
18
+ SidebarActiveIndicator,
19
+ SidebarConfig,
20
+ SidebarGroupLabelStyle,
21
+ SidebarItem,
22
+ } from './types';
23
+
24
+ import { DENSITY, RAIL_NAV } from './density';
25
+ import type { NavDensity } from './density';
26
+
27
+ // ============================================================================
28
+ // Context Type
29
+ // ============================================================================
30
+
31
+ export interface PrivateLayoutContextValue {
32
+ /** Sidebar configuration */
33
+ sidebar: SidebarConfig | undefined;
34
+ /** Header configuration */
35
+ header: HeaderConfig | undefined;
36
+ /** Current pathname for active highlighting */
37
+ pathname: string;
38
+ /** Whether the sidebar is in mobile mode */
39
+ isMobile: boolean;
40
+ /** Whether the sidebar is expanded (vs collapsed icon rail) */
41
+ isExpanded: boolean;
42
+ /** Whether the sidebar is collapsed to icon rail */
43
+ isCollapsed: boolean;
44
+ /** Whether the sidebar is temporarily hover-expanded overlay */
45
+ isHoverExpanded: boolean;
46
+ /** Nav density based on total item count */
47
+ density: NavDensity;
48
+ /** Density tokens for the current state */
49
+ menuNav: (typeof DENSITY)[NavDensity];
50
+ /** Active indicator style */
51
+ activeIndicator: SidebarActiveIndicator;
52
+ /** Group label style */
53
+ groupLabelStyle: SidebarGroupLabelStyle;
54
+ /** All nav items flattened */
55
+ allItems: SidebarItem[];
56
+ /** Check if a href is active */
57
+ isActive: (href: string) => boolean;
58
+ /** Whether menuStart slot should be shown */
59
+ showMenuStart: boolean;
60
+ /** Whether menuEnd slot should be shown */
61
+ showMenuEnd: boolean;
62
+ /** Whether featured CTA should be shown */
63
+ showFeatured: boolean;
64
+ /** Home href */
65
+ homeHref: string;
66
+ /** Brand title derived from header */
67
+ brandTitle: string;
68
+ /** Brand monogram derived from header/title */
69
+ brandMonogram: string;
70
+ }
71
+
72
+ // ============================================================================
73
+ // Context
74
+ // ============================================================================
75
+
76
+ const PrivateLayoutContext = createContext<PrivateLayoutContextValue | null>(null);
77
+
78
+ export function usePrivateLayoutContext(): PrivateLayoutContextValue {
79
+ const ctx = useContext(PrivateLayoutContext);
80
+ if (!ctx) {
81
+ throw new Error('usePrivateLayoutContext must be used within a PrivateLayoutProvider');
82
+ }
83
+ return ctx;
84
+ }
85
+
86
+ // ============================================================================
87
+ // Provider Props
88
+ // ============================================================================
89
+
90
+ interface PrivateLayoutProviderProps {
91
+ children: React.ReactNode;
92
+ sidebar: SidebarConfig | undefined;
93
+ header: HeaderConfig | undefined;
94
+ pathname: string;
95
+ isMobile: boolean;
96
+ state: 'expanded' | 'collapsed';
97
+ isHoverExpanded?: boolean;
98
+ }
99
+
100
+ // ============================================================================
101
+ // Provider
102
+ // ============================================================================
103
+
104
+ export function PrivateLayoutProvider({
105
+ children,
106
+ sidebar,
107
+ header,
108
+ pathname,
109
+ isMobile,
110
+ state,
111
+ isHoverExpanded = false,
112
+ }: PrivateLayoutProviderProps) {
113
+ const homeHref = sidebar?.homeHref || '/';
114
+ const brandTitle = header?.title?.trim() || 'Dashboard';
115
+ const brandMonogram = (
116
+ header?.brandLetter?.trim().charAt(0) ||
117
+ brandTitle.charAt(0) ||
118
+ 'D'
119
+ ).toUpperCase();
120
+
121
+ const allItems = useMemo(
122
+ () => sidebar?.groups.flatMap((g) => g.items) ?? [],
123
+ [sidebar?.groups],
124
+ );
125
+
126
+ const density: NavDensity = sidebar?.density ?? 'default';
127
+ const isExpanded = state === 'expanded';
128
+ const isCollapsed = state === 'collapsed';
129
+
130
+ /** Expanded or hover-expanded: follow item-count tier; collapsed rail: fixed `default` sizing so icons stay uniform. */
131
+ const menuNav = isCollapsed && !isHoverExpanded ? RAIL_NAV : DENSITY[density];
132
+
133
+ const isActive = React.useCallback(
134
+ (href: string) => {
135
+ const matches = pathname === href || pathname.startsWith(`${href}/`);
136
+ if (!matches) return false;
137
+ return !allItems.some(
138
+ (other) =>
139
+ other.href !== href &&
140
+ other.href.startsWith(`${href}/`) &&
141
+ (pathname === other.href || pathname.startsWith(`${other.href}/`)),
142
+ );
143
+ },
144
+ [pathname, allItems],
145
+ );
146
+
147
+ const collapsedRail = !isMobile && isCollapsed && !isHoverExpanded;
148
+
149
+ const hasMenuStart = sidebar?.menuStart != null && sidebar.menuStart !== false;
150
+ const hasMenuEnd = sidebar?.menuEnd != null && sidebar.menuEnd !== false;
151
+
152
+ const showMenuStart =
153
+ hasMenuStart && (!collapsedRail || sidebar?.menuStartShowOnCollapsed === true);
154
+ const showMenuEnd =
155
+ hasMenuEnd && (!collapsedRail || sidebar?.menuEndShowOnCollapsed === true);
156
+ const showFeatured = Boolean(sidebar?.featured) && !collapsedRail;
157
+
158
+ const activeIndicator = sidebar?.activeIndicator ?? 'background';
159
+ const groupLabelStyle = sidebar?.groupLabelStyle ?? 'uppercase';
160
+
161
+ const value = useMemo(
162
+ () => ({
163
+ sidebar,
164
+ header,
165
+ pathname,
166
+ isMobile,
167
+ isExpanded,
168
+ isCollapsed,
169
+ isHoverExpanded,
170
+ density,
171
+ menuNav,
172
+ activeIndicator,
173
+ groupLabelStyle,
174
+ allItems,
175
+ isActive,
176
+ showMenuStart,
177
+ showMenuEnd,
178
+ showFeatured,
179
+ homeHref,
180
+ brandTitle,
181
+ brandMonogram,
182
+ }),
183
+ [
184
+ sidebar,
185
+ header,
186
+ pathname,
187
+ isMobile,
188
+ isExpanded,
189
+ isCollapsed,
190
+ isHoverExpanded,
191
+ density,
192
+ menuNav,
193
+ activeIndicator,
194
+ groupLabelStyle,
195
+ allItems,
196
+ isActive,
197
+ showMenuStart,
198
+ showMenuEnd,
199
+ showFeatured,
200
+ homeHref,
201
+ brandTitle,
202
+ brandMonogram,
203
+ ],
204
+ );
205
+
206
+ return (
207
+ <PrivateLayoutContext.Provider value={value}>
208
+ {children}
209
+ </PrivateLayoutContext.Provider>
210
+ );
211
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Nav Density Tokens
3
+ *
4
+ * Density presets for sidebar navigation items.
5
+ * The active preset is chosen via the `density` prop on `SidebarConfig`;
6
+ * when omitted it defaults to `'default'`.
7
+ */
8
+
9
+ export type NavDensity = 'comfortable' | 'default' | 'compact';
10
+
11
+ export const DENSITY = {
12
+ comfortable: {
13
+ menu: 'gap-1.5',
14
+ group: 'gap-2',
15
+ groupPad: 'px-2 py-1',
16
+ label:
17
+ 'h-7 uppercase text-[10px] font-light leading-none tracking-[0.14em] text-sidebar-foreground/40',
18
+ buttonSize: 'lg' as const,
19
+ iconClass: 'h-5 w-5',
20
+ extraButton: 'rounded-lg !px-3',
21
+ headerRowInset: 'pl-2',
22
+ },
23
+ default: {
24
+ menu: 'gap-1',
25
+ group: 'gap-1.5',
26
+ groupPad: 'px-2 py-1',
27
+ label:
28
+ 'uppercase text-[9px] font-light leading-none tracking-[0.12em] text-sidebar-foreground/50',
29
+ buttonSize: 'default' as const,
30
+ iconClass: 'h-4 w-4',
31
+ extraButton: 'rounded-lg',
32
+ headerRowInset: 'pl-1.5',
33
+ },
34
+ compact: {
35
+ menu: 'gap-0.5',
36
+ group: 'gap-0.5',
37
+ groupPad: 'px-2 py-0.5',
38
+ label:
39
+ 'h-6 uppercase text-[8px] font-light leading-none tracking-[0.1em] text-sidebar-foreground/40',
40
+ buttonSize: 'sm' as const,
41
+ iconClass: 'h-3.5 w-3.5',
42
+ extraButton: 'rounded-md !px-2',
43
+ headerRowInset: 'pl-1.5',
44
+ },
45
+ } as const;
46
+
47
+ /** Icon rail: always the same geometry — ignore comfortable/compact. */
48
+ export const RAIL_NAV = DENSITY.default;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * PrivateLayout Hooks
3
+ */
4
+
5
+ export { useAuthGuard } from './useAuthGuard';
6
+ export { useLayoutVisual } from './useLayoutVisual';
7
+ export {
8
+ useHoverExpand,
9
+ blockSidebarCollapse,
10
+ allowSidebarCollapse,
11
+ } from './useHoverExpand';
12
+ export { useShellVisualState } from './useShellVisualState';
13
+ export { useSidebarKeyboard } from './useSidebarKeyboard';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Auth Guard Hook
3
+ *
4
+ * Handles authentication check and redirect to login page.
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import { useEffect, useState } from 'react';
10
+ import { useRouter } from 'next/navigation';
11
+
12
+ import { useAuth } from '@djangocfg/api/auth';
13
+
14
+ interface UseAuthGuardOptions {
15
+ requireAuth?: boolean;
16
+ authPath?: string;
17
+ }
18
+
19
+ interface UseAuthGuardResult {
20
+ /** Whether auth is still loading or redirecting */
21
+ isLoading: boolean;
22
+ /** Whether the user is authenticated (or auth is not required) */
23
+ isAuthenticated: boolean;
24
+ /** Loading text for the preloader */
25
+ loadingText: string;
26
+ }
27
+
28
+ export function useAuthGuard({
29
+ requireAuth = true,
30
+ authPath = '/auth',
31
+ }: UseAuthGuardOptions): UseAuthGuardResult {
32
+ const { isAuthenticated, isLoading: authLoading, saveRedirectUrl } = useAuth();
33
+ const router = useRouter();
34
+ const [isRedirecting, setIsRedirecting] = useState(false);
35
+
36
+ useEffect(() => {
37
+ if (!requireAuth) return;
38
+ if (!authLoading && !isAuthenticated && !isRedirecting) {
39
+ const currentUrl = window.location.pathname + window.location.search;
40
+ saveRedirectUrl(currentUrl);
41
+ setIsRedirecting(true);
42
+ router.push(authPath);
43
+ }
44
+ }, [requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, authPath]);
45
+
46
+ const isLoading = requireAuth && (authLoading || isRedirecting || !isAuthenticated);
47
+ const loadingText = isRedirecting ? 'Redirecting to login...' : 'Authenticating...';
48
+
49
+ return {
50
+ isLoading,
51
+ isAuthenticated: !requireAuth || isAuthenticated,
52
+ loadingText,
53
+ };
54
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Hover Expand Hook
3
+ *
4
+ * Manages hover-expand state for collapsed sidebar with debounce.
5
+ * - enterDelay: ms before expanding on hover (default 250)
6
+ * - leaveDelay: ms before collapsing on mouse leave (default 150)
7
+ * - Listens for `sidebar:blockcollapse` / `sidebar:allowcollapse` custom events
8
+ * so descendant components (e.g. account dropdown) can block collapse without
9
+ * prop drilling or context churn.
10
+ * - Clears timers on unmount / re-hover to prevent false triggers.
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+
17
+ interface UseHoverExpandOptions {
18
+ /** Delay before expanding on mouse enter (ms). Default 250. */
19
+ enterDelay?: number;
20
+ /** Delay before collapsing on mouse leave (ms). Default 150. */
21
+ leaveDelay?: number;
22
+ /** Whether hover-expand is enabled (e.g. only on desktop collapsed rail). */
23
+ enabled?: boolean;
24
+ }
25
+
26
+ interface UseHoverExpandResult {
27
+ /** Whether the sidebar is currently hover-expanded */
28
+ isHoverExpanded: boolean;
29
+ /** Attach to the sidebar root element */
30
+ onMouseEnter: () => void;
31
+ /** Attach to the sidebar root element */
32
+ onMouseLeave: () => void;
33
+ }
34
+
35
+ export function useHoverExpand({
36
+ enterDelay = 250,
37
+ leaveDelay = 150,
38
+ enabled = true,
39
+ }: UseHoverExpandOptions = {}): UseHoverExpandResult {
40
+ const [isHoverExpanded, setIsHoverExpanded] = useState(false);
41
+ const blockedRef = useRef(false);
42
+ const enterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43
+ const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
44
+
45
+ const clearTimers = useCallback(() => {
46
+ if (enterTimerRef.current) {
47
+ clearTimeout(enterTimerRef.current);
48
+ enterTimerRef.current = null;
49
+ }
50
+ if (leaveTimerRef.current) {
51
+ clearTimeout(leaveTimerRef.current);
52
+ leaveTimerRef.current = null;
53
+ }
54
+ }, []);
55
+
56
+ useEffect(() => {
57
+ const onBlock = () => {
58
+ blockedRef.current = true;
59
+ };
60
+ const onAllow = () => {
61
+ blockedRef.current = false;
62
+ };
63
+ window.addEventListener('sidebar:blockcollapse', onBlock);
64
+ window.addEventListener('sidebar:allowcollapse', onAllow);
65
+ return () => {
66
+ window.removeEventListener('sidebar:blockcollapse', onBlock);
67
+ window.removeEventListener('sidebar:allowcollapse', onAllow);
68
+ };
69
+ }, []);
70
+
71
+ const onMouseEnter = useCallback(() => {
72
+ if (!enabled) return;
73
+ clearTimers();
74
+ enterTimerRef.current = setTimeout(() => {
75
+ setIsHoverExpanded(true);
76
+ }, enterDelay);
77
+ }, [enabled, enterDelay, clearTimers]);
78
+
79
+ const onMouseLeave = useCallback(() => {
80
+ if (!enabled) return;
81
+ if (blockedRef.current) return;
82
+ clearTimers();
83
+ leaveTimerRef.current = setTimeout(() => {
84
+ setIsHoverExpanded(false);
85
+ }, leaveDelay);
86
+ }, [enabled, leaveDelay, clearTimers]);
87
+
88
+ useEffect(() => {
89
+ return () => clearTimers();
90
+ }, [clearTimers]);
91
+
92
+ return { isHoverExpanded, onMouseEnter, onMouseLeave };
93
+ }
94
+
95
+ /** Dispatch from any descendant to block sidebar collapse while e.g. a dropdown is open. */
96
+ export function blockSidebarCollapse(): void {
97
+ window.dispatchEvent(new CustomEvent('sidebar:blockcollapse'));
98
+ }
99
+
100
+ /** Dispatch when the blocking reason is gone (e.g. dropdown closed). */
101
+ export function allowSidebarCollapse(): void {
102
+ window.dispatchEvent(new CustomEvent('sidebar:allowcollapse'));
103
+ }
@@ -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
+ }