@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.
- package/package.json +12 -12
- package/src/components/errors/ErrorLayout.tsx +1 -164
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
- package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
- package/src/components/errors/errorGlyphs.tsx +167 -0
- package/src/layouts/AppLayout/BaseApp.tsx +1 -1
- package/src/layouts/AppLayout/index.ts +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
- package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/layoutContext.ts +25 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
- package/src/layouts/PrivateLayout/README.md +20 -12
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
- package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
- package/src/layouts/PrivateLayout/components/index.ts +0 -4
- package/src/layouts/PrivateLayout/context.tsx +10 -115
- package/src/layouts/PrivateLayout/geometry.ts +17 -0
- package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
- package/src/layouts/PrivateLayout/types.ts +13 -20
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
- package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
- package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
- package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
- package/src/layouts/PublicLayout/shared/index.ts +1 -1
- package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
- package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
- package/src/layouts/SettingsLayout/README.md +23 -10
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
- package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
- package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
- package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
- package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
- package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
- package/src/layouts/SettingsLayout/components/index.ts +0 -1
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
- package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
- package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
- package/src/layouts/SettingsLayout/index.ts +3 -1
- package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
- package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
- package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
- package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
- package/src/layouts/SettingsLayout/sections/index.ts +2 -2
- package/src/layouts/SettingsLayout/store.ts +12 -0
- package/src/layouts/SettingsLayout/types.ts +12 -4
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
- package/src/layouts/types/layout.types.ts +5 -6
- package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
- package/src/styles/dashboard.css +10 -0
- package/src/layouts/PrivateLayout/density.ts +0 -48
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
- package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
- package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
|
@@ -1,76 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PrivateLayout Context
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? '
|
|
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 ?? '
|
|
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 ?? '
|
|
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 ?? '
|
|
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
|
|
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,
|
|
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(
|
|
33
|
-
const { toggleSidebar, setOpenMobile, isMobile, openMobile } = useSidebar();
|
|
16
|
+
export function useSidebarKeyboard() {
|
|
34
17
|
const sidebarRef = useRef<HTMLElement | null>(null);
|
|
35
18
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
+
}
|