@djangocfg/layouts 2.1.590 → 2.1.592
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/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 +32 -62
- 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/SettingsLayout/README.md +23 -10
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +14 -8
- package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
- 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 +52 -105
- package/src/layouts/SettingsLayout/components/index.ts +0 -1
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
- 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 +0 -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 +12 -4
- package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
- package/src/layouts/SettingsLayout/sections/index.ts +2 -2
- package/src/layouts/SettingsLayout/types.ts +12 -4
- package/src/layouts/types/layout.types.ts +5 -6
- 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,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
|
+
}
|
|
@@ -88,26 +88,24 @@ export interface SidebarConfig {
|
|
|
88
88
|
* (above `footer` + account block).
|
|
89
89
|
*/
|
|
90
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
|
-
*/
|
|
91
|
+
/** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail. */
|
|
96
92
|
menuStartShowOnCollapsed?: boolean;
|
|
97
|
-
/**
|
|
93
|
+
/** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail. */
|
|
98
94
|
menuEndShowOnCollapsed?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Let the user drag the sidebar's right edge (14–24rem, remembered; double-click
|
|
97
|
+
* resets). Default `true`.
|
|
98
|
+
*/
|
|
99
|
+
resizable?: boolean;
|
|
99
100
|
/** Custom footer component rendered at the bottom of the sidebar */
|
|
100
101
|
footer?: ReactNode;
|
|
101
|
-
/**
|
|
102
|
+
/** @deprecated No effect: the active item is always a surface. */
|
|
102
103
|
activeIndicator?: SidebarActiveIndicator;
|
|
103
|
-
/**
|
|
104
|
+
/** @deprecated No effect: group labels are always sentence case. */
|
|
104
105
|
groupLabelStyle?: SidebarGroupLabelStyle;
|
|
105
106
|
/** Featured CTA tile rendered below all groups, above `menuEnd`. */
|
|
106
107
|
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
|
-
*/
|
|
108
|
+
/** @deprecated No effect: rows are one desktop size and one 44px touch size. */
|
|
111
109
|
density?: 'comfortable' | 'default' | 'compact';
|
|
112
110
|
}
|
|
113
111
|
|
|
@@ -167,11 +165,7 @@ export interface HeaderConfig {
|
|
|
167
165
|
brandLetter?: string;
|
|
168
166
|
/** User menu groups (account panel in the sidebar footer) */
|
|
169
167
|
groups?: UserMenuConfig['groups'];
|
|
170
|
-
/**
|
|
171
|
-
* Behaviour of the footer account button.
|
|
172
|
-
* - `'menu'` (default) — opens a DropdownMenu with account links, locale/theme controls, and sign-out.
|
|
173
|
-
* - `'dialog'` — opens the global ProfileDialog (managed via Zustand store).
|
|
174
|
-
*/
|
|
168
|
+
/** @deprecated No effect: the footer account button always opens the account menu. */
|
|
175
169
|
accountAction?: 'menu' | 'dialog';
|
|
176
170
|
/** Auth page path (for sign in button) */
|
|
177
171
|
authPath?: string;
|
|
@@ -213,9 +207,8 @@ export interface PrivateLayoutProps {
|
|
|
213
207
|
*/
|
|
214
208
|
contentScroll?: 'auto' | 'hidden';
|
|
215
209
|
/**
|
|
216
|
-
* Visual style of the shell. Defaults to `'
|
|
217
|
-
* sidebar
|
|
218
|
-
* edge-to-edge layout.
|
|
210
|
+
* Visual style of the shell. Defaults to `'full-bleed'` (flat, Claude-style:
|
|
211
|
+
* sidebar + border + page). Pass `{ variant: 'boxed' }` for the inset rounded card.
|
|
219
212
|
*/
|
|
220
213
|
visual?: LayoutVisualConfig;
|
|
221
214
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# SettingsLayout
|
|
2
2
|
|
|
3
|
-
A Claude.ai-style **settings surface**: a
|
|
3
|
+
A Claude.ai-style **settings surface**: a grouped section rail (search appears from 10 sections, `searchable`) on the
|
|
4
4
|
left and a scrollable detail panel on the right. Available as a **global dialog**
|
|
5
5
|
(URL-hash driven, zustand-backed, openable from anywhere) or an **inline form**.
|
|
6
6
|
|
|
@@ -127,7 +127,7 @@ ordering/labelling, or just name an ad-hoc `group` on a section:
|
|
|
127
127
|
|
|
128
128
|
```ts
|
|
129
129
|
const groups = [
|
|
130
|
-
{ id: 'general',
|
|
130
|
+
{ id: 'general', order: 0 },
|
|
131
131
|
{ id: 'workspace', label: 'Workspace', order: 1 },
|
|
132
132
|
];
|
|
133
133
|
```
|
|
@@ -139,16 +139,19 @@ toggled via `builtins`:
|
|
|
139
139
|
|
|
140
140
|
| id | flag | default | content |
|
|
141
141
|
| ----------- | -------------------- | ------- | ------------------------------------ |
|
|
142
|
-
| `account` | `builtins.account` | `true` | avatar
|
|
143
|
-
| `
|
|
142
|
+
| `account` | `builtins.account` | `true` | avatar, inline-editable profile/work fields, **Security** block (two-factor auth; hide with `builtins.security: false`) and a **collapsible Danger zone** (delete account) |
|
|
143
|
+
| `general` | `builtins.general` | `true` | **General** block: **Theme** (`ThemeSegmented`), **Time zone** (`TimezoneSelect` → `useTimezone`, sent as `X-Timezone`) and **Language** (shown when a layout i18n context exists); host rows from `generalRows` follow in a second block ("Other") |
|
|
144
144
|
| `api-keys` | `builtins.apiKeys` | `true` | API key display / regenerate / test (row-style) |
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
`generalRows` (on `SettingsDialog` / `SettingsForm` / `PrivateLayout settings`) takes
|
|
147
|
+
your own `SettingRow`s for preferences only the host knows (e.g. a product-specific toggle); the layout adds none itself.
|
|
148
|
+
|
|
149
|
+
Rail order: **Account** (opens first), **Preferences** (id `general`), then Platform → API keys. Built-ins are merged **ahead of** app sections. They reuse the localized labels
|
|
147
150
|
and save/validation logic from `ProfileProvider`, so behaviour stays in one place.
|
|
148
151
|
The built-in bodies are built from the ui-core `SettingRow`/`SettingsBlock`
|
|
149
152
|
primitives (see below) — flat rows with hairline dividers, no card frames.
|
|
150
153
|
|
|
151
|
-
> The
|
|
154
|
+
> The Account security block and `api-keys` body hit the real backend (2FA status, API key
|
|
152
155
|
> retrieval). With no backend (e.g. Storybook) they show an error/empty state —
|
|
153
156
|
> that's expected; gate them with `builtins` where there's no API.
|
|
154
157
|
|
|
@@ -215,17 +218,18 @@ SettingsLayout/
|
|
|
215
218
|
│ └── useSettingsSections.ts pure: merge/order/group/filter sections
|
|
216
219
|
├── components/
|
|
217
220
|
│ ├── SettingsShell.tsx desktop rail+panel vs mobile tabs+panel (useIsMobile)
|
|
218
|
-
│ ├──
|
|
221
|
+
│ ├── SettingsShell.tsx header bar + rail + panel (desktop), tabs + panel (mobile)
|
|
222
|
+
│ ├── SettingsNav.tsx desktop rail: search + grouped items
|
|
219
223
|
│ ├── SettingsNavItem.tsx one rail row
|
|
220
224
|
│ ├── SettingsTabs.tsx mobile horizontal tab strip (text chips, auto-scroll active)
|
|
221
225
|
│ ├── SettingsSearch.tsx filter box
|
|
222
226
|
│ └── SettingsPanel.tsx header + independently-scrolling body
|
|
223
227
|
└── sections/
|
|
224
|
-
├── builtins.tsx account/
|
|
225
|
-
├──
|
|
228
|
+
├── builtins.tsx general/account/api-keys factory
|
|
229
|
+
├── GeneralSection.tsx Appearance + Language + host `generalRows`
|
|
230
|
+
├── AccountSection.tsx avatar + fields + 2FA + danger zone (row-style)
|
|
226
231
|
├── PreferencesRows.tsx Language dropdown + ThemeSegmented as SettingRows
|
|
227
232
|
├── DeleteAccountRow.tsx compact destructive row (inside collapsible Danger zone)
|
|
228
|
-
├── SecuritySection.tsx 2FA wrapper
|
|
229
233
|
└── ApiKeysSection.tsx API-keys wrapper
|
|
230
234
|
```
|
|
231
235
|
|
|
@@ -256,3 +260,12 @@ exported too; the row primitives come from `@djangocfg/ui-core/components`.
|
|
|
256
260
|
2. `useProfileDialogStore.open({ initialTab })` → `useSettingsDialog().open(id)`.
|
|
257
261
|
3. Mount `<SettingsDialog/>` instead of `<ProfileDialog/>` (or pass `settings` to
|
|
258
262
|
PrivateLayout). The account button can be repointed to `useSettingsDialog()`.
|
|
263
|
+
|
|
264
|
+
## Localization
|
|
265
|
+
|
|
266
|
+
Built-in text (rail labels, Preferences, Security, API key) comes from the
|
|
267
|
+
self-contained dictionaries in `i18n/` (17 locales, `useSettingsCopy`), following the
|
|
268
|
+
`@djangocfg/i18n` provider the host mounts, with English as the fallback. Widgets a host adds as sections (for example `mcpSettingsSection()` from `@djangocfg/widget-mcp`)
|
|
269
|
+
follow the same provider. Text the host passes in (`sections`, `groups`, `generalRows`, titles) is the
|
|
270
|
+
host's to translate; profile field labels still come from the central `layouts.profilePage.*`
|
|
271
|
+
catalogue. A new built-in string goes into every `i18n/locales/*.ts` (`tsc` names a missing key).
|
|
@@ -13,6 +13,7 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
13
13
|
|
|
14
14
|
import { SettingsForm } from './SettingsForm';
|
|
15
15
|
import { useSettingsUrl } from './hooks/useSettingsUrl';
|
|
16
|
+
import { useSettingsCopy } from './i18n';
|
|
16
17
|
import { buildBuiltinSections } from './sections';
|
|
17
18
|
import { useSettingsDialogStore } from './store';
|
|
18
19
|
import type { SettingsDialogProps } from './types';
|
|
@@ -34,8 +35,9 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
34
35
|
sections: appSections = [],
|
|
35
36
|
groups,
|
|
36
37
|
builtins = {},
|
|
38
|
+
generalRows,
|
|
37
39
|
initialSection,
|
|
38
|
-
searchable =
|
|
40
|
+
searchable = 'auto',
|
|
39
41
|
enableDeleteAccount = true,
|
|
40
42
|
}) => {
|
|
41
43
|
const isOpen = useSettingsDialogStore((s) => s.isOpen);
|
|
@@ -43,15 +45,16 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
43
45
|
const close = useSettingsDialogStore((s) => s.close);
|
|
44
46
|
const setSection = useSettingsDialogStore((s) => s.setSection);
|
|
45
47
|
const isMobile = useIsMobile();
|
|
48
|
+
const copy = useSettingsCopy();
|
|
46
49
|
|
|
47
50
|
// Set of valid ids, so a bad URL hash falls back gracefully instead of
|
|
48
51
|
// selecting a non-existent section. Cheap to recompute; built-ins are pure.
|
|
49
52
|
const knownIds = useMemo(() => {
|
|
50
|
-
const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
|
|
53
|
+
const builtin = buildBuiltinSections({ builtins, enableDeleteAccount, copy });
|
|
51
54
|
const ids = new Set<string>();
|
|
52
55
|
[...builtin, ...appSections].forEach((s) => ids.add(s.id));
|
|
53
56
|
return ids;
|
|
54
|
-
}, [builtins, enableDeleteAccount, appSections]);
|
|
57
|
+
}, [builtins, enableDeleteAccount, appSections, copy]);
|
|
55
58
|
|
|
56
59
|
const resolveSection = useCallback(
|
|
57
60
|
(id: string | null) => (id && knownIds.has(id) ? id : null),
|
|
@@ -60,22 +63,24 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
60
63
|
|
|
61
64
|
useSettingsUrl({ urlKey, enabled: syncUrl, resolveSection });
|
|
62
65
|
|
|
63
|
-
const resolvedTitle = title ??
|
|
66
|
+
const resolvedTitle = title ?? copy.nav.title;
|
|
64
67
|
|
|
65
68
|
return (
|
|
66
69
|
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
|
67
70
|
<DialogContent
|
|
68
|
-
// Desktop:
|
|
71
|
+
// Desktop: large centred card, proportional to the viewport.
|
|
69
72
|
// Mobile: near-fullscreen sheet (small inset) so the phone settings get
|
|
70
73
|
// the whole screen, like Claude. Elevated `bg-card` lifts it off the page.
|
|
71
74
|
className={cn(
|
|
72
|
-
'flex max-w-none flex-col gap-0 overflow-hidden border border-
|
|
75
|
+
'flex max-w-none flex-col gap-0 overflow-hidden border border-foreground/5 bg-card p-0',
|
|
73
76
|
isMobile
|
|
74
77
|
? 'h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)]'
|
|
75
|
-
:
|
|
78
|
+
// Claude proportions: 1024×800 at most, 1.28:1 at every size (ui-core
|
|
79
|
+
// DialogContent derives the width from the viewport height).
|
|
80
|
+
: '[--aspect-dialog:1.28] [--width-dialog:1024px]',
|
|
76
81
|
)}
|
|
77
82
|
>
|
|
78
|
-
{/* Accessible label — the visible heading
|
|
83
|
+
{/* Accessible label — the visible heading is the shell header bar. */}
|
|
79
84
|
<DialogTitle className="sr-only">
|
|
80
85
|
{typeof resolvedTitle === 'string' ? resolvedTitle : 'Settings'}
|
|
81
86
|
</DialogTitle>
|
|
@@ -89,6 +94,7 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
89
94
|
sections={appSections}
|
|
90
95
|
groups={groups}
|
|
91
96
|
builtins={builtins}
|
|
97
|
+
generalRows={generalRows}
|
|
92
98
|
initialSection={initialSection}
|
|
93
99
|
searchable={searchable}
|
|
94
100
|
enableDeleteAccount={enableDeleteAccount}
|
|
@@ -8,7 +8,8 @@ import { Preloader } from '@djangocfg/ui-core/components';
|
|
|
8
8
|
|
|
9
9
|
import { SettingsShell } from './components';
|
|
10
10
|
import { SettingsProvider } from './context';
|
|
11
|
-
import {
|
|
11
|
+
import { useSettingsCopy } from './i18n';
|
|
12
|
+
import { buildBuiltinSections, BUILTIN_GROUP, PLATFORM_GROUP } from './sections';
|
|
12
13
|
import type { SettingsFormProps, SettingsSection } from './types';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -30,8 +31,9 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
|
|
|
30
31
|
sections: appSections = [],
|
|
31
32
|
groups,
|
|
32
33
|
builtins = {},
|
|
34
|
+
generalRows,
|
|
33
35
|
initialSection,
|
|
34
|
-
searchable =
|
|
36
|
+
searchable = 'auto',
|
|
35
37
|
enableDeleteAccount = true,
|
|
36
38
|
activeSection,
|
|
37
39
|
onSectionChange,
|
|
@@ -40,21 +42,23 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
|
|
|
40
42
|
bare = false,
|
|
41
43
|
}: SettingsFormProps) => {
|
|
42
44
|
const t = useAppT();
|
|
45
|
+
const copy = useSettingsCopy();
|
|
43
46
|
const { user, isLoading } = useAuth();
|
|
44
47
|
|
|
45
48
|
// Built-ins first, then app sections. Memoized so JSX identity is stable.
|
|
46
49
|
const allSections = useMemo<SettingsSection[]>(() => {
|
|
47
|
-
const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
|
|
50
|
+
const builtin = buildBuiltinSections({ builtins, enableDeleteAccount, generalRows, copy });
|
|
48
51
|
return [...builtin, ...appSections];
|
|
49
|
-
}, [builtins, enableDeleteAccount, appSections]);
|
|
52
|
+
}, [builtins, enableDeleteAccount, generalRows, appSections, copy]);
|
|
50
53
|
|
|
51
54
|
// Ensure the built-in group exists/ordered even if the app passed its own groups.
|
|
52
55
|
const allGroups = useMemo(() => {
|
|
53
56
|
const provided = groups ?? [];
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const missing = [BUILTIN_GROUP, { ...PLATFORM_GROUP, label: copy.nav.platform }].filter(
|
|
58
|
+
(b) => !provided.some((g) => g.id === b.id),
|
|
59
|
+
);
|
|
60
|
+
return [...missing, ...provided];
|
|
61
|
+
}, [groups, copy]);
|
|
58
62
|
|
|
59
63
|
// Uncontrolled selection fallback.
|
|
60
64
|
const [localActive, setLocalActive] = useState<string | null>(initialSection ?? null);
|
|
@@ -82,7 +86,7 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
// Default to "Settings" — this surface is broader than the old Profile page.
|
|
85
|
-
const resolvedTitle = title ??
|
|
89
|
+
const resolvedTitle = title ?? copy.nav.title;
|
|
86
90
|
|
|
87
91
|
return (
|
|
88
92
|
<SettingsProvider
|