@djangocfg/layouts 2.1.591 → 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,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar Nav Item
|
|
3
|
-
*
|
|
4
|
-
* Individual navigation item with icon, label, badge, and active state.
|
|
5
|
-
* Memoized with shallow item comparison so badge number changes still re-render.
|
|
2
|
+
* Sidebar Nav Item — icon, label, optional badge. Active state is a surface
|
|
3
|
+
* plus `aria-current`; weight never changes, so labels do not jump.
|
|
6
4
|
*/
|
|
7
5
|
|
|
8
6
|
'use client';
|
|
9
7
|
|
|
10
|
-
import React, { memo
|
|
8
|
+
import React, { memo } from 'react';
|
|
11
9
|
|
|
12
10
|
import {
|
|
13
11
|
Link,
|
|
@@ -19,7 +17,6 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
19
17
|
|
|
20
18
|
import { LucideIcon } from '../../../components';
|
|
21
19
|
import { usePrivateLayoutContext } from '../context';
|
|
22
|
-
import { useShellVisualState } from '../hooks';
|
|
23
20
|
import type { SidebarItem } from '../types';
|
|
24
21
|
|
|
25
22
|
interface SidebarNavItemProps {
|
|
@@ -27,111 +24,51 @@ interface SidebarNavItemProps {
|
|
|
27
24
|
hideItemIcons?: boolean;
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
() =>
|
|
38
|
-
!hideItemIcons && item.icon ? (
|
|
39
|
-
<LucideIcon icon={item.icon} className={menuNav.iconClass} />
|
|
40
|
-
) : null,
|
|
41
|
-
[hideItemIcons, item.icon, menuNav.iconClass],
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
const hasBadge = Boolean(item.badge);
|
|
27
|
+
const ITEM_CLASS = cn(
|
|
28
|
+
'rounded-lg border-0 font-normal shadow-none transition-colors duration-150',
|
|
29
|
+
'text-sidebar-foreground/90 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
|
|
30
|
+
'data-[active=true]:bg-sidebar-accent data-[active=true]:font-normal data-[active=true]:text-sidebar-foreground',
|
|
31
|
+
'[&>svg]:text-sidebar-foreground/80 [&>svg]:opacity-100 hover:[&>svg]:text-sidebar-foreground',
|
|
32
|
+
'data-[active=true]:[&>svg]:text-sidebar-foreground',
|
|
33
|
+
);
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<span className="absolute right-1 top-1 flex h-2 w-2">
|
|
50
|
-
<span className="inline-flex h-full w-full rounded-full bg-primary" />
|
|
51
|
-
</span>
|
|
52
|
-
) : null,
|
|
53
|
-
[content.showBadgeText, hasBadge],
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const expandedBadgeNode = useMemo(
|
|
57
|
-
() =>
|
|
58
|
-
content.showBadgeText && hasBadge ? (
|
|
59
|
-
<SidebarMenuBadge
|
|
60
|
-
className={cn(
|
|
61
|
-
item.badgeVariant === 'pill'
|
|
62
|
-
? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
|
|
63
|
-
: undefined,
|
|
64
|
-
)}
|
|
65
|
-
>
|
|
66
|
-
{item.badge}
|
|
67
|
-
</SidebarMenuBadge>
|
|
68
|
-
) : null,
|
|
69
|
-
[content.showBadgeText, hasBadge, item.badgeVariant, item.badge],
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
const isItemActive = isActive(item.href);
|
|
73
|
-
|
|
74
|
-
const buttonClass = useMemo(
|
|
75
|
-
() =>
|
|
76
|
-
cn(
|
|
77
|
-
'group/nav relative border-0 font-medium shadow-none transition-colors',
|
|
78
|
-
'text-sidebar-foreground/80',
|
|
79
|
-
'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
|
|
80
|
-
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
81
|
-
'[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
|
|
82
|
-
'[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
|
|
83
|
-
'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
|
|
84
|
-
hideItemIcons && 'pl-8',
|
|
85
|
-
content.showLabels && '[&>span]:!inline !p-2 !justify-start',
|
|
86
|
-
),
|
|
87
|
-
[hideItemIcons, content.showLabels],
|
|
88
|
-
);
|
|
35
|
+
function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
|
|
36
|
+
const { isActive, geometry } = usePrivateLayoutContext();
|
|
37
|
+
const active = isActive(item.href);
|
|
89
38
|
|
|
90
39
|
return (
|
|
91
40
|
<SidebarMenuItem>
|
|
92
41
|
<SidebarMenuButton
|
|
93
42
|
asChild
|
|
94
|
-
isActive={
|
|
95
|
-
|
|
96
|
-
tooltip={content.showTooltips ? tooltipText : undefined}
|
|
97
|
-
className={buttonClass}
|
|
43
|
+
isActive={active}
|
|
44
|
+
className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
|
|
98
45
|
>
|
|
99
|
-
<Link href={item.href}>
|
|
100
|
-
{
|
|
46
|
+
<Link href={item.href} aria-current={active ? 'page' : undefined}>
|
|
47
|
+
{!hideItemIcons && item.icon ? <LucideIcon icon={item.icon} /> : null}
|
|
101
48
|
<span>{item.label}</span>
|
|
102
|
-
{
|
|
49
|
+
{item.badge ? (
|
|
50
|
+
<SidebarMenuBadge
|
|
51
|
+
className={cn(
|
|
52
|
+
item.badgeVariant === 'pill' && 'rounded-md bg-primary/15 px-1.5 font-medium text-primary',
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{item.badge}
|
|
56
|
+
</SidebarMenuBadge>
|
|
57
|
+
) : null}
|
|
103
58
|
</Link>
|
|
104
59
|
</SidebarMenuButton>
|
|
105
|
-
{collapsedBadgeDot}
|
|
106
60
|
</SidebarMenuItem>
|
|
107
61
|
);
|
|
108
62
|
}
|
|
109
63
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
a.badgeVariant === b.badgeVariant &&
|
|
122
|
-
a.tooltip === b.tooltip
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Memoised leaf component. Re-renders only when:
|
|
128
|
-
* - `hideItemIcons` changes, or
|
|
129
|
-
* - any field inside `item` changes (badge count, label, href, etc.).
|
|
130
|
-
* Parent re-renders with the same item data are skipped.
|
|
131
|
-
*/
|
|
132
|
-
export const SidebarNavItem = memo(SidebarNavItemRaw, (prev, next) => {
|
|
133
|
-
return (
|
|
134
|
-
prev.hideItemIcons === next.hideItemIcons &&
|
|
135
|
-
itemShallowEqual(prev.item, next.item)
|
|
136
|
-
);
|
|
137
|
-
});
|
|
64
|
+
const itemEqual = (a: SidebarItem, b: SidebarItem) =>
|
|
65
|
+
a.href === b.href &&
|
|
66
|
+
a.label === b.label &&
|
|
67
|
+
a.icon === b.icon &&
|
|
68
|
+
a.badge === b.badge &&
|
|
69
|
+
a.badgeVariant === b.badgeVariant;
|
|
70
|
+
|
|
71
|
+
export const SidebarNavItem = memo(
|
|
72
|
+
SidebarNavItemRaw,
|
|
73
|
+
(prev, next) => prev.hideItemIcons === next.hideItemIcons && itemEqual(prev.item, next.item),
|
|
74
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peek panel: the full sidebar floated over the page while it is collapsed.
|
|
3
|
+
* Opened and held by `SidebarPeekProvider`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
import { useSidebarPeek } from '../hooks';
|
|
11
|
+
import { SidebarBody } from './SidebarBody';
|
|
12
|
+
import { SidebarResizeHandle } from './SidebarResizeHandle';
|
|
13
|
+
|
|
14
|
+
export function SidebarPeek() {
|
|
15
|
+
const peek = useSidebarPeek();
|
|
16
|
+
if (!peek?.open) return null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
data-sidebar="peek"
|
|
21
|
+
role="complementary"
|
|
22
|
+
aria-label="Navigation"
|
|
23
|
+
{...peek.panelProps}
|
|
24
|
+
className={[
|
|
25
|
+
'fixed bottom-2 left-2 top-2 z-50 flex w-(--sidebar-width) flex-col overflow-hidden',
|
|
26
|
+
'rounded-xl border border-sidebar-border bg-sidebar text-sidebar-foreground shadow-2xl',
|
|
27
|
+
'animate-in fade-in-0 slide-in-from-left-4 duration-150',
|
|
28
|
+
].join(' ')}
|
|
29
|
+
>
|
|
30
|
+
<SidebarBody />
|
|
31
|
+
<SidebarResizeHandle />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag handle on the sidebar's right edge. Pointer drag, arrow keys and
|
|
3
|
+
* double-click-to-reset; the width itself lives in `useSidebarWidth`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useRef } from 'react';
|
|
9
|
+
|
|
10
|
+
import { useSidebarWidthContext } from '../hooks';
|
|
11
|
+
|
|
12
|
+
const KEY_STEP = 16;
|
|
13
|
+
|
|
14
|
+
export function SidebarResizeHandle() {
|
|
15
|
+
const sidebarWidth = useSidebarWidthContext();
|
|
16
|
+
const origin = useRef<{ x: number; width: number } | null>(null);
|
|
17
|
+
|
|
18
|
+
if (!sidebarWidth?.enabled) return null;
|
|
19
|
+
const { width, min, max, dragging, setWidth, setDragging, reset } = sidebarWidth;
|
|
20
|
+
|
|
21
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
22
|
+
if (e.button !== 0) return;
|
|
23
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
24
|
+
origin.current = { x: e.clientX, width };
|
|
25
|
+
setDragging(true);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
29
|
+
if (!origin.current) return;
|
|
30
|
+
setWidth(origin.current.width + e.clientX - origin.current.x);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const endDrag = () => {
|
|
34
|
+
origin.current = null;
|
|
35
|
+
setDragging(false);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
39
|
+
const next =
|
|
40
|
+
e.key === 'ArrowLeft' ? width - KEY_STEP
|
|
41
|
+
: e.key === 'ArrowRight' ? width + KEY_STEP
|
|
42
|
+
: e.key === 'Home' ? min
|
|
43
|
+
: e.key === 'End' ? max
|
|
44
|
+
: null;
|
|
45
|
+
if (next === null) return;
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
setWidth(next);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
role="separator"
|
|
54
|
+
aria-orientation="vertical"
|
|
55
|
+
aria-label="Resize sidebar"
|
|
56
|
+
aria-valuemin={min}
|
|
57
|
+
aria-valuemax={max}
|
|
58
|
+
aria-valuenow={width}
|
|
59
|
+
tabIndex={0}
|
|
60
|
+
data-dragging={dragging || undefined}
|
|
61
|
+
onPointerDown={onPointerDown}
|
|
62
|
+
onPointerMove={onPointerMove}
|
|
63
|
+
onPointerUp={endDrag}
|
|
64
|
+
onPointerCancel={endDrag}
|
|
65
|
+
onDoubleClick={reset}
|
|
66
|
+
onKeyDown={onKeyDown}
|
|
67
|
+
className={[
|
|
68
|
+
'absolute inset-y-0 right-0 z-20 hidden w-2 cursor-col-resize touch-none outline-none md:block',
|
|
69
|
+
// 2px primary line on the border; fades in after a short hover delay.
|
|
70
|
+
'after:absolute after:inset-y-0 after:right-0 after:w-0.5 after:bg-transparent',
|
|
71
|
+
'after:transition-colors after:duration-150',
|
|
72
|
+
'hover:after:bg-primary/70 hover:after:delay-100 focus-visible:after:bg-primary data-[dragging]:after:bg-primary',
|
|
73
|
+
].join(' ')}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -1,71 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Renders optional content slots: menuStart, menuEnd, featured CTA, footer.
|
|
2
|
+
* Optional slots around the nav groups: menuStart, featured, menuEnd, footer.
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
'use client';
|
|
8
6
|
|
|
9
|
-
import React, { memo
|
|
7
|
+
import React, { memo } from 'react';
|
|
10
8
|
|
|
11
|
-
import { SidebarFeatured } from './SidebarFeatured';
|
|
12
9
|
import { usePrivateLayoutContext } from '../context';
|
|
13
|
-
import {
|
|
10
|
+
import { SidebarFeatured } from './SidebarFeatured';
|
|
11
|
+
|
|
12
|
+
const SLOT = 'w-full min-w-0 shrink-0 px-2';
|
|
14
13
|
|
|
15
14
|
function SidebarSlotsRaw() {
|
|
16
15
|
const { sidebar } = usePrivateLayoutContext();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const menuStartSlot = useMemo(
|
|
20
|
-
() =>
|
|
21
|
-
sidebar?.menuStart ? (
|
|
22
|
-
<div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuStart}</div>
|
|
23
|
-
) : null,
|
|
24
|
-
[sidebar?.menuStart],
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
const menuEndSlot = useMemo(
|
|
28
|
-
() =>
|
|
29
|
-
sidebar?.menuEnd ? (
|
|
30
|
-
<div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuEnd}</div>
|
|
31
|
-
) : null,
|
|
32
|
-
[sidebar?.menuEnd],
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
const featuredSlot = useMemo(
|
|
36
|
-
() =>
|
|
37
|
-
sidebar?.featured ? (
|
|
38
|
-
<div className="w-full min-w-0 shrink-0 px-2">
|
|
39
|
-
<SidebarFeatured config={sidebar.featured} />
|
|
40
|
-
</div>
|
|
41
|
-
) : null,
|
|
42
|
-
[sidebar?.featured],
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const footerExtra = useMemo(
|
|
46
|
-
() =>
|
|
47
|
-
sidebar?.footer ? (
|
|
48
|
-
<div className="mb-2">{sidebar.footer}</div>
|
|
49
|
-
) : null,
|
|
50
|
-
[sidebar?.footer],
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
if (content.hideSlots) return null;
|
|
16
|
+
if (!sidebar) return null;
|
|
54
17
|
|
|
55
18
|
return (
|
|
56
19
|
<>
|
|
57
|
-
{
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
20
|
+
{sidebar.menuStart ? <div className={SLOT}>{sidebar.menuStart}</div> : null}
|
|
21
|
+
{sidebar.featured ? (
|
|
22
|
+
<div className={SLOT}>
|
|
23
|
+
<SidebarFeatured config={sidebar.featured} />
|
|
24
|
+
</div>
|
|
25
|
+
) : null}
|
|
26
|
+
{sidebar.menuEnd ? <div className={SLOT}>{sidebar.menuEnd}</div> : null}
|
|
27
|
+
{sidebar.footer ? <div className={SLOT}>{sidebar.footer}</div> : null}
|
|
61
28
|
</>
|
|
62
29
|
);
|
|
63
30
|
}
|
|
64
31
|
|
|
65
|
-
/**
|
|
66
|
-
* Memoised slots wrapper. Skips re-renders when the sidebar config object
|
|
67
|
-
* reference stays the same. The slot ReactNodes themselves (menuStart,
|
|
68
|
-
* menuEnd, footer) are *not* deeply compared — they remain reactive because
|
|
69
|
-
* the consumer is expected to pass a new ReactNode when slot content changes.
|
|
70
|
-
*/
|
|
71
32
|
export const SidebarSlots = memo(SidebarSlotsRaw);
|
|
@@ -6,8 +6,4 @@ export { PrivateSidebar } from './PrivateSidebar';
|
|
|
6
6
|
export { PrivateContent } from './PrivateContent';
|
|
7
7
|
export { ForbiddenState } from './ForbiddenState';
|
|
8
8
|
export type { ForbiddenStateProps } from './ForbiddenState';
|
|
9
|
-
export { SidebarBrand } from './SidebarBrand';
|
|
10
9
|
export { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
|
|
11
|
-
export { SidebarNavGroup } from './SidebarNavGroup';
|
|
12
|
-
export { SidebarNavItem } from './SidebarNavItem';
|
|
13
|
-
export { SidebarSlots } from './SidebarSlots';
|
|
@@ -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 {
|