@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
@@ -1,76 +1,31 @@
1
1
  /**
2
2
  * PrivateLayout Context
3
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
4
+ * What the sidebar parts share: config, the active-route check, brand
5
+ * identity and the row geometry for the current form factor.
10
6
  */
11
7
 
12
8
  'use client';
13
9
 
14
10
  import React, { createContext, useContext, useMemo } from 'react';
15
11
 
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
- // ============================================================================
12
+ import { NAV_GEOMETRY } from './geometry';
13
+ import type { NavGeometry } from './geometry';
14
+ import type { HeaderConfig, SidebarConfig, SidebarItem } from './types';
30
15
 
31
16
  export interface PrivateLayoutContextValue {
32
- /** Sidebar configuration */
33
17
  sidebar: SidebarConfig | undefined;
34
- /** Header configuration */
35
18
  header: HeaderConfig | undefined;
36
- /** Current pathname for active highlighting */
37
19
  pathname: string;
38
- /** Whether the sidebar is in mobile mode */
39
20
  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
- /** Nav density based on total item count */
45
- density: NavDensity;
46
- /** Density tokens for the current state */
47
- menuNav: (typeof DENSITY)[NavDensity];
48
- /** Active indicator style */
49
- activeIndicator: SidebarActiveIndicator;
50
- /** Group label style */
51
- groupLabelStyle: SidebarGroupLabelStyle;
52
- /** All nav items flattened */
21
+ geometry: NavGeometry;
53
22
  allItems: SidebarItem[];
54
- /** Check if a href is active */
55
23
  isActive: (href: string) => boolean;
56
- /** Whether menuStart slot should be shown */
57
- showMenuStart: boolean;
58
- /** Whether menuEnd slot should be shown */
59
- showMenuEnd: boolean;
60
- /** Whether featured CTA should be shown */
61
- showFeatured: boolean;
62
- /** Home href */
63
24
  homeHref: string;
64
- /** Brand title derived from header */
65
25
  brandTitle: string;
66
- /** Brand monogram derived from header/title */
67
26
  brandMonogram: string;
68
27
  }
69
28
 
70
- // ============================================================================
71
- // Context
72
- // ============================================================================
73
-
74
29
  const PrivateLayoutContext = createContext<PrivateLayoutContextValue | null>(null);
75
30
 
76
31
  export function usePrivateLayoutContext(): PrivateLayoutContextValue {
@@ -81,30 +36,20 @@ export function usePrivateLayoutContext(): PrivateLayoutContextValue {
81
36
  return ctx;
82
37
  }
83
38
 
84
- // ============================================================================
85
- // Provider Props
86
- // ============================================================================
87
-
88
39
  interface PrivateLayoutProviderProps {
89
40
  children: React.ReactNode;
90
41
  sidebar: SidebarConfig | undefined;
91
42
  header: HeaderConfig | undefined;
92
43
  pathname: string;
93
44
  isMobile: boolean;
94
- state: 'expanded' | 'collapsed';
95
45
  }
96
46
 
97
- // ============================================================================
98
- // Provider
99
- // ============================================================================
100
-
101
47
  export function PrivateLayoutProvider({
102
48
  children,
103
49
  sidebar,
104
50
  header,
105
51
  pathname,
106
52
  isMobile,
107
- state,
108
53
  }: PrivateLayoutProviderProps) {
109
54
  const homeHref = sidebar?.homeHref || '/';
110
55
  const brandTitle = header?.title?.trim() || 'Dashboard';
@@ -119,12 +64,7 @@ export function PrivateLayoutProvider({
119
64
  [sidebar?.groups],
120
65
  );
121
66
 
122
- const density: NavDensity = sidebar?.density ?? 'default';
123
- const isExpanded = state === 'expanded';
124
- const isCollapsed = state === 'collapsed';
125
-
126
- const menuNav = isCollapsed ? RAIL_NAV : DENSITY[density];
127
-
67
+ // The most specific item wins: `/a` is not active on `/a/b` when `/a/b` is its own item.
128
68
  const isActive = React.useCallback(
129
69
  (href: string) => {
130
70
  const matches = pathname === href || pathname.startsWith(`${href}/`);
@@ -139,66 +79,21 @@ export function PrivateLayoutProvider({
139
79
  [pathname, allItems],
140
80
  );
141
81
 
142
- const collapsedRail = !isMobile && isCollapsed;
143
-
144
- const hasMenuStart = sidebar?.menuStart != null && sidebar.menuStart !== false;
145
- const hasMenuEnd = sidebar?.menuEnd != null && sidebar.menuEnd !== false;
146
-
147
- const showMenuStart =
148
- hasMenuStart && (!collapsedRail || sidebar?.menuStartShowOnCollapsed === true);
149
- const showMenuEnd =
150
- hasMenuEnd && (!collapsedRail || sidebar?.menuEndShowOnCollapsed === true);
151
- const showFeatured = Boolean(sidebar?.featured) && !collapsedRail;
152
-
153
- const activeIndicator = sidebar?.activeIndicator ?? 'background';
154
- const groupLabelStyle = sidebar?.groupLabelStyle ?? 'uppercase';
155
-
156
82
  const value = useMemo(
157
83
  () => ({
158
84
  sidebar,
159
85
  header,
160
86
  pathname,
161
87
  isMobile,
162
- isExpanded,
163
- isCollapsed,
164
- density,
165
- menuNav,
166
- activeIndicator,
167
- groupLabelStyle,
88
+ geometry: isMobile ? NAV_GEOMETRY.mobile : NAV_GEOMETRY.desktop,
168
89
  allItems,
169
90
  isActive,
170
- showMenuStart,
171
- showMenuEnd,
172
- showFeatured,
173
91
  homeHref,
174
92
  brandTitle,
175
93
  brandMonogram,
176
94
  }),
177
- [
178
- sidebar,
179
- header,
180
- pathname,
181
- isMobile,
182
- isExpanded,
183
- isCollapsed,
184
- density,
185
- menuNav,
186
- activeIndicator,
187
- groupLabelStyle,
188
- allItems,
189
- isActive,
190
- showMenuStart,
191
- showMenuEnd,
192
- showFeatured,
193
- homeHref,
194
- brandTitle,
195
- brandMonogram,
196
- ],
95
+ [sidebar, header, pathname, isMobile, allItems, isActive, homeHref, brandTitle, brandMonogram],
197
96
  );
198
97
 
199
- return (
200
- <PrivateLayoutContext.Provider value={value}>
201
- {children}
202
- </PrivateLayoutContext.Provider>
203
- );
98
+ return <PrivateLayoutContext.Provider value={value}>{children}</PrivateLayoutContext.Provider>;
204
99
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sidebar row geometry. Two sizes only: a pointer-sized desktop row and a
3
+ * 44px touch row for the mobile drawer.
4
+ */
5
+
6
+ export const NAV_GEOMETRY = {
7
+ desktop: {
8
+ item: 'h-8 gap-2.5 px-2.5 text-base [&>svg]:size-4',
9
+ label: 'h-7 px-2.5 text-xs',
10
+ },
11
+ mobile: {
12
+ item: 'h-11 gap-3 px-3 text-lg [&>svg]:size-5',
13
+ label: 'h-8 px-3 text-sm',
14
+ },
15
+ } as const;
16
+
17
+ export type NavGeometry = (typeof NAV_GEOMETRY)[keyof typeof NAV_GEOMETRY];
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { useAuthGuard } from './useAuthGuard';
6
6
  export { useLayoutVisual } from './useLayoutVisual';
7
- export { useShellVisualState } from './useShellVisualState';
8
7
  export { useSidebarKeyboard } from './useSidebarKeyboard';
9
8
  export { useSidebarDefaultOpen } from './useSidebarDefaultOpen';
9
+ export { SidebarPeekProvider, usePeekPin, useSidebarPeek } from './useSidebarPeek';
10
+ export { SidebarWidthContext, useSidebarWidth, useSidebarWidthContext } from './useSidebarWidth';
@@ -12,7 +12,7 @@ import type { LayoutVisualConfig } from '../../types';
12
12
  export function resolveProviderStyle(
13
13
  visual: LayoutVisualConfig | undefined,
14
14
  ): React.CSSProperties | undefined {
15
- if ((visual?.variant ?? 'boxed') !== 'boxed') return undefined;
15
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return undefined;
16
16
  const inset = normaliseInset(visual?.inset);
17
17
  return {
18
18
  ['--app-shell-inset-x' as string]: `${inset.x}px`,
@@ -47,7 +47,7 @@ const BOXED_RADIUS_REM: Record<NonNullable<LayoutVisualConfig['radius']>, string
47
47
  };
48
48
 
49
49
  export function resolveInsetClassName(visual: LayoutVisualConfig | undefined): string {
50
- if ((visual?.variant ?? 'boxed') !== 'boxed') return 'flex flex-col';
50
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return 'flex flex-col';
51
51
  const border = visual?.border ?? true;
52
52
  return border ? `${BOXED_INSET_CLASS} ${BOXED_INSET_BORDER_CLASS}` : BOXED_INSET_CLASS;
53
53
  }
@@ -75,7 +75,7 @@ export function resolveProviderClassName(
75
75
  // the inner scroll-area (PrivateContent) is the only scroll surface. Without
76
76
  // this, SidebarProvider grows via min-h-svh and the whole page scrolls.
77
77
  const base = 'h-svh overflow-hidden';
78
- if ((visual?.variant ?? 'boxed') !== 'boxed') return base;
78
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return base;
79
79
  // `max-md:!bg-background` neutralises shadcn-sidebar's built-in
80
80
  // `has-[[data-variant=inset]]:bg-sidebar` below md so the mobile Drawer shell
81
81
  // doesn't paint the whole viewport with the canvas tint.
@@ -100,7 +100,7 @@ export interface LayoutVisualResult {
100
100
 
101
101
  export function useLayoutVisual(visual: LayoutVisualConfig | undefined): LayoutVisualResult {
102
102
  return useMemo(() => {
103
- const variant = visual?.variant ?? 'boxed';
103
+ const variant = visual?.variant ?? 'full-bleed';
104
104
  const sidebarVariant = variant === 'boxed' ? 'inset' : 'sidebar';
105
105
 
106
106
  return {
@@ -1,27 +1,11 @@
1
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)
2
+ * Sidebar focus navigation (ArrowUp/Down, Home, End between focusable rows).
3
+ * Cmd/Ctrl+B belongs to `SidebarProvider`; the mobile drawer closes itself on Escape.
13
4
  */
14
5
 
15
6
  'use client';
16
7
 
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
- }
8
+ import { useCallback, useRef } from 'react';
25
9
 
26
10
  const FOCUSABLE_SELECTOR = [
27
11
  'a[href]',
@@ -29,83 +13,39 @@ const FOCUSABLE_SELECTOR = [
29
13
  '[tabindex]:not([tabindex="-1"])',
30
14
  ].join(', ');
31
15
 
32
- export function useSidebarKeyboard({ enabled = true }: UseSidebarKeyboardOptions = {}) {
33
- const { toggleSidebar, setOpenMobile, isMobile, openMobile } = useSidebar();
16
+ export function useSidebarKeyboard() {
34
17
  const sidebarRef = useRef<HTMLElement | null>(null);
35
18
 
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
- }
19
+ const handleSidebarKeyDown = useCallback((e: React.KeyboardEvent<HTMLElement>) => {
20
+ const root = sidebarRef.current;
21
+ if (!root) return;
22
+
23
+ const focusables = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
24
+ (el) => el.offsetParent !== null,
25
+ );
26
+ const current = focusables.indexOf(document.activeElement as HTMLElement);
27
+ if (current === -1) return;
28
+
29
+ let next = current;
30
+ switch (e.key) {
31
+ case 'ArrowDown':
32
+ next = (current + 1) % focusables.length;
33
+ break;
34
+ case 'ArrowUp':
35
+ next = (current - 1 + focusables.length) % focusables.length;
36
+ break;
37
+ case 'Home':
38
+ next = 0;
39
+ break;
40
+ case 'End':
41
+ next = focusables.length - 1;
42
+ break;
43
+ default:
52
44
  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
- );
45
+ }
46
+ e.preventDefault();
47
+ focusables[next]?.focus();
48
+ }, []);
109
49
 
110
50
  const setSidebarRef = useCallback((node: HTMLElement | null) => {
111
51
  sidebarRef.current = node;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Peek: while the sidebar is collapsed, hovering its trigger zone floats the
3
+ * full sidebar over the page. The panel keeps itself open while the pointer is
4
+ * on it, or while a menu opened from it (account, workspace switcher) is open.
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, {
10
+ createContext,
11
+ useCallback,
12
+ useContext,
13
+ useEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
18
+
19
+ import { useSidebar } from '@djangocfg/ui-core/components';
20
+
21
+ // Opens on entry with no delay, then stays at least MIN_OPEN_MS even if the
22
+ // pointer only grazed the zone, so a user who didn't notice it still sees it.
23
+ const MIN_OPEN_MS = 900;
24
+ const CLOSE_DELAY_MS = 300;
25
+
26
+ interface PointerHandlers {
27
+ onPointerEnter: (e: React.PointerEvent) => void;
28
+ onPointerLeave: (e: React.PointerEvent) => void;
29
+ }
30
+
31
+ export interface SidebarPeek {
32
+ /** True while the sidebar is collapsed on desktop: the trigger zone shows. */
33
+ enabled: boolean;
34
+ open: boolean;
35
+ zoneProps: PointerHandlers;
36
+ panelProps: PointerHandlers;
37
+ close: () => void;
38
+ /** Keep the panel open while something launched from it is open. */
39
+ pin: (pinned: boolean) => void;
40
+ }
41
+
42
+ const PeekContext = createContext<SidebarPeek | null>(null);
43
+
44
+ export function useSidebarPeek(): SidebarPeek | null {
45
+ return useContext(PeekContext);
46
+ }
47
+
48
+ /** Pins the peek panel for as long as `active` is true. */
49
+ export function usePeekPin(active: boolean): void {
50
+ const peek = useContext(PeekContext);
51
+ const pin = peek?.pin;
52
+ useEffect(() => {
53
+ if (!pin) return;
54
+ pin(active);
55
+ return () => pin(false);
56
+ }, [pin, active]);
57
+ }
58
+
59
+ interface SidebarPeekProviderProps {
60
+ pathname: string;
61
+ children: React.ReactNode;
62
+ }
63
+
64
+ export function SidebarPeekProvider({ pathname, children }: SidebarPeekProviderProps) {
65
+ const { state, isMobile } = useSidebar();
66
+ const enabled = !isMobile && state === 'collapsed';
67
+
68
+ const [open, setOpen] = useState(false);
69
+ const pinned = useRef(false);
70
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
71
+ const openedAt = useRef(0);
72
+
73
+ const cancel = useCallback(() => {
74
+ if (timer.current) clearTimeout(timer.current);
75
+ timer.current = null;
76
+ }, []);
77
+
78
+ const scheduleClose = useCallback(() => {
79
+ cancel();
80
+ if (pinned.current) return;
81
+ const grace = openedAt.current + MIN_OPEN_MS - Date.now();
82
+ timer.current = setTimeout(() => setOpen(false), Math.max(CLOSE_DELAY_MS, grace));
83
+ }, [cancel]);
84
+
85
+ const show = useCallback(() => {
86
+ cancel();
87
+ setOpen((was) => {
88
+ if (!was) openedAt.current = Date.now();
89
+ return true;
90
+ });
91
+ }, [cancel]);
92
+
93
+ const close = useCallback(() => {
94
+ cancel();
95
+ setOpen(false);
96
+ }, [cancel]);
97
+
98
+ const pin = useCallback(
99
+ (value: boolean) => {
100
+ pinned.current = value;
101
+ if (!value) scheduleClose();
102
+ },
103
+ [scheduleClose],
104
+ );
105
+
106
+ // Hover is a mouse affordance; touch taps the trigger to dock instead.
107
+ const zoneProps = useMemo<PointerHandlers>(
108
+ () => ({
109
+ onPointerEnter: (e) => {
110
+ if (e.pointerType !== 'mouse') return;
111
+ show();
112
+ },
113
+ onPointerLeave: scheduleClose,
114
+ }),
115
+ [show, scheduleClose],
116
+ );
117
+
118
+ const panelProps = useMemo<PointerHandlers>(
119
+ () => ({
120
+ onPointerEnter: show,
121
+ onPointerLeave: scheduleClose,
122
+ }),
123
+ [show, scheduleClose],
124
+ );
125
+
126
+ useEffect(() => {
127
+ if (!enabled) close();
128
+ }, [enabled, close]);
129
+
130
+ useEffect(() => {
131
+ close();
132
+ }, [pathname, close]);
133
+
134
+ useEffect(() => {
135
+ if (!open) return;
136
+ const onKey = (e: KeyboardEvent) => {
137
+ if (e.key === 'Escape') close();
138
+ };
139
+ window.addEventListener('keydown', onKey);
140
+ return () => window.removeEventListener('keydown', onKey);
141
+ }, [open, close]);
142
+
143
+ useEffect(() => cancel, [cancel]);
144
+
145
+ const value = useMemo<SidebarPeek>(
146
+ () => ({ enabled, open: enabled && open, zoneProps, panelProps, close, pin }),
147
+ [enabled, open, zoneProps, panelProps, close, pin],
148
+ );
149
+
150
+ return <PeekContext.Provider value={value}>{children}</PeekContext.Provider>;
151
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Sidebar width: the user drags the edge, the choice is remembered.
3
+ *
4
+ * The width is one CSS variable (`--sidebar-width`) on the provider, so the
5
+ * docked sidebar, its spacer and the peek panel all follow it with no prop
6
+ * plumbing.
7
+ */
8
+
9
+ 'use client';
10
+
11
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
12
+ import type { CSSProperties } from 'react';
13
+
14
+ const STORAGE_KEY = 'cfg.layouts.sidebar-width';
15
+ const DEFAULT_WIDTH = 256; // 16rem
16
+ const MIN_WIDTH = 224; // 14rem
17
+ const MAX_WIDTH = 384; // 24rem
18
+
19
+ const clamp = (px: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(px)));
20
+
21
+ function readStored(): number | null {
22
+ try {
23
+ const raw = window.localStorage.getItem(STORAGE_KEY);
24
+ const px = raw ? Number(raw) : NaN;
25
+ return Number.isFinite(px) ? clamp(px) : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export interface SidebarWidth {
32
+ enabled: boolean;
33
+ width: number;
34
+ min: number;
35
+ max: number;
36
+ dragging: boolean;
37
+ setWidth: (px: number) => void;
38
+ setDragging: (value: boolean) => void;
39
+ reset: () => void;
40
+ /** CSS variables for the provider element. */
41
+ style: CSSProperties | undefined;
42
+ }
43
+
44
+ export function useSidebarWidth(enabled: boolean): SidebarWidth {
45
+ const [width, setWidthState] = useState(DEFAULT_WIDTH);
46
+ const [dragging, setDragging] = useState(false);
47
+
48
+ useEffect(() => {
49
+ if (!enabled) return;
50
+ const stored = readStored();
51
+ if (stored) setWidthState(stored);
52
+ }, [enabled]);
53
+
54
+ // Persist once the drag ends, not on every pointer move.
55
+ useEffect(() => {
56
+ if (!enabled || dragging) return;
57
+ try {
58
+ if (width === DEFAULT_WIDTH) window.localStorage.removeItem(STORAGE_KEY);
59
+ else window.localStorage.setItem(STORAGE_KEY, String(width));
60
+ } catch {
61
+ // Storage unavailable: the width simply lasts for the session.
62
+ }
63
+ }, [enabled, dragging, width]);
64
+
65
+ const setWidth = useCallback((px: number) => setWidthState(clamp(px)), []);
66
+ const reset = useCallback(() => setWidthState(DEFAULT_WIDTH), []);
67
+
68
+ const style = useMemo<CSSProperties | undefined>(
69
+ () => (enabled ? ({ '--sidebar-width': `${width}px` } as CSSProperties) : undefined),
70
+ [enabled, width],
71
+ );
72
+
73
+ return useMemo(
74
+ () => ({ enabled, width, min: MIN_WIDTH, max: MAX_WIDTH, dragging, setWidth, setDragging, reset, style }),
75
+ [enabled, width, dragging, setWidth, reset, style],
76
+ );
77
+ }
78
+
79
+ export const SidebarWidthContext = createContext<SidebarWidth | null>(null);
80
+
81
+ export function useSidebarWidthContext(): SidebarWidth | null {
82
+ return useContext(SidebarWidthContext);
83
+ }