@djangocfg/layouts 2.1.607 → 2.1.609
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/README.md +4 -0
- package/package.json +12 -12
- package/src/components/account/AccountInline.tsx +122 -0
- package/src/components/account/AccountMenu.test.tsx +110 -0
- package/src/components/account/AccountMenu.tsx +117 -0
- package/src/components/account/accountContext.ts +14 -0
- package/src/components/account/index.ts +13 -0
- package/src/components/account/triggers.tsx +100 -0
- package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
- package/src/components/account/useAccountModel.tsx +76 -0
- package/src/components/errors/AppErrorFallback.test.tsx +33 -0
- package/src/components/errors/AppErrorFallback.tsx +42 -0
- package/src/components/errors/ErrorBoundary.tsx +4 -41
- package/src/components/errors/ErrorLayout.tsx +17 -1
- package/src/components/errors/README.md +6 -1
- package/src/components/errors/chunkReload.test.ts +89 -0
- package/src/components/errors/index.ts +2 -0
- package/src/layouts/AppLayout/BaseApp.tsx +15 -55
- package/src/layouts/AppLayout/index.ts +1 -0
- package/src/layouts/AppLayout/pwaMeta.test.ts +36 -0
- package/src/layouts/AppLayout/pwaMeta.tsx +40 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
- package/src/layouts/PrivateLayout/README.md +22 -6
- package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
- package/src/layouts/PrivateLayout/components/ForbiddenState.tsx +1 -1
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
- package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
- package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/context.tsx +15 -3
- package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
- package/src/layouts/PrivateLayout/geometry.ts +30 -10
- package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/index.ts +2 -0
- package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
- package/src/layouts/PrivateLayout/types.ts +27 -2
- package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
- package/src/layouts/PublicLayout/components/index.ts +2 -2
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
- package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
- package/src/layouts/types/layout.types.ts +7 -0
- package/src/shared/ProjectContext.tsx +18 -0
- package/src/testing/MockAuthProvider.tsx +5 -2
- /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
|
@@ -27,10 +27,15 @@ import { isSettingsItem, isSidebarGroupVisible, sidebarItemKey, visibleSidebarIt
|
|
|
27
27
|
import type { SidebarGroupConfig } from '../types';
|
|
28
28
|
import { SidebarNavItem } from './SidebarNavItem';
|
|
29
29
|
|
|
30
|
-
const groupClassFor = (inset: string) =>
|
|
30
|
+
const groupClassFor = (inset: string, insetRule: boolean) =>
|
|
31
31
|
cn(
|
|
32
|
-
|
|
33
|
-
'
|
|
32
|
+
// Phone drawer (insetRule): half the air between groups.
|
|
33
|
+
`gap-0 ${inset} ${insetRule ? 'py-1' : 'py-1.5'}`,
|
|
34
|
+
insetRule ? '[&:not(:first-child)]:mt-0.5 [&:not(:first-child)]:pt-1.5' : '[&:not(:first-child)]:mt-1.5 [&:not(:first-child)]:pt-3',
|
|
35
|
+
// Phone drawer: the hairline is inset to the row gutter (8px). Desktop keeps its full-width border.
|
|
36
|
+
insetRule
|
|
37
|
+
? '[&:not(:first-child)]:before:absolute [&:not(:first-child)]:before:inset-x-2 [&:not(:first-child)]:before:top-0 [&:not(:first-child)]:before:h-px [&:not(:first-child)]:before:bg-sidebar-border/60'
|
|
38
|
+
: '[&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60',
|
|
34
39
|
);
|
|
35
40
|
|
|
36
41
|
/** A `separated` group draws its own, stronger divider (below), so it drops the hairline and takes more air. */
|
|
@@ -49,7 +54,7 @@ function GroupDivider({ inset }: { inset: string }) {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
52
|
-
const { isActive, geometry } = usePrivateLayoutContext();
|
|
57
|
+
const { isActive, geometry, isMobile } = usePrivateLayoutContext();
|
|
53
58
|
|
|
54
59
|
const hasLabel = group.label.trim().length > 0;
|
|
55
60
|
const isCollapsible = Boolean(group.collapsible) && hasLabel;
|
|
@@ -84,7 +89,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
|
84
89
|
));
|
|
85
90
|
if (!visible) return null;
|
|
86
91
|
|
|
87
|
-
const groupClass = group.separated ? separatedGroupClassFor(geometry.inset) : groupClassFor(geometry.inset);
|
|
92
|
+
const groupClass = group.separated ? separatedGroupClassFor(geometry.inset) : groupClassFor(geometry.inset, isMobile);
|
|
88
93
|
const divider = group.separated ? <GroupDivider inset={geometry.dividerInset} /> : null;
|
|
89
94
|
|
|
90
95
|
if (isCollapsible) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Peek panel: the full sidebar floated over the page while it is collapsed.
|
|
3
|
-
* Opened by hover intent on the left edge (
|
|
3
|
+
* Opened by hover intent on the left screen edge strip (the toggle button itself does not open it), held by `SidebarPeekProvider`,
|
|
4
4
|
* slides in and out (it stays mounted while the exit animation runs).
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
'use client';
|
|
9
9
|
|
|
10
|
+
import { useLandingRoute } from './hooks/useLandingRoute';
|
|
11
|
+
import { useProjectName } from '../../shared/ProjectContext';
|
|
10
12
|
import React, { createContext, useContext, useMemo } from 'react';
|
|
11
13
|
|
|
12
14
|
import { NAV_GEOMETRY } from './geometry';
|
|
@@ -19,6 +21,8 @@ export interface PrivateLayoutContextValue {
|
|
|
19
21
|
header: HeaderConfig | undefined;
|
|
20
22
|
pathname: string;
|
|
21
23
|
isMobile: boolean;
|
|
24
|
+
/** The top bar carries the account avatar, so the phone drawer drops its footer account (one entry point). */
|
|
25
|
+
accountInBar: boolean;
|
|
22
26
|
geometry: NavGeometry;
|
|
23
27
|
/** The route rows; Settings launchers have no route and take no part in the active-route check. */
|
|
24
28
|
allItems: SidebarLinkItem[];
|
|
@@ -44,6 +48,7 @@ interface PrivateLayoutProviderProps {
|
|
|
44
48
|
header: HeaderConfig | undefined;
|
|
45
49
|
pathname: string;
|
|
46
50
|
isMobile: boolean;
|
|
51
|
+
accountInBar?: boolean;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
export function PrivateLayoutProvider({
|
|
@@ -52,9 +57,15 @@ export function PrivateLayoutProvider({
|
|
|
52
57
|
header,
|
|
53
58
|
pathname,
|
|
54
59
|
isMobile,
|
|
60
|
+
accountInBar = false,
|
|
55
61
|
}: PrivateLayoutProviderProps) {
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
// header.href > sidebar.homeHref > the private landing route AppLayout/BaseApp configures (`auth.routes.defaultCallback`,
|
|
63
|
+
// resolved by the auth provider; `undefined` outside it) > '/'.
|
|
64
|
+
const landing = useLandingRoute();
|
|
65
|
+
const homeHref = header?.href || sidebar?.homeHref || landing || '/';
|
|
66
|
+
// header.title > the project name from BaseApp > nothing (monogram only).
|
|
67
|
+
const projectName = useProjectName();
|
|
68
|
+
const brandTitle = header?.title?.trim() || projectName || '';
|
|
58
69
|
const brandMonogram = (
|
|
59
70
|
header?.brandLetter?.trim().charAt(0) ||
|
|
60
71
|
brandTitle.charAt(0) ||
|
|
@@ -87,6 +98,7 @@ export function PrivateLayoutProvider({
|
|
|
87
98
|
header,
|
|
88
99
|
pathname,
|
|
89
100
|
isMobile,
|
|
101
|
+
accountInBar,
|
|
90
102
|
geometry: isMobile ? NAV_GEOMETRY.mobile : NAV_GEOMETRY.desktop,
|
|
91
103
|
allItems,
|
|
92
104
|
isActive,
|
|
@@ -94,7 +106,7 @@ export function PrivateLayoutProvider({
|
|
|
94
106
|
brandTitle,
|
|
95
107
|
brandMonogram,
|
|
96
108
|
}),
|
|
97
|
-
[sidebar, header, pathname, isMobile, allItems, isActive, homeHref, brandTitle, brandMonogram],
|
|
109
|
+
[sidebar, header, pathname, isMobile, accountInBar, allItems, isActive, homeHref, brandTitle, brandMonogram],
|
|
98
110
|
);
|
|
99
111
|
|
|
100
112
|
return <PrivateLayoutContext.Provider value={value}>{children}</PrivateLayoutContext.Provider>;
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { NAV_GEOMETRY, SIDEBAR_SPACING } from './geometry';
|
|
3
|
+
import { NAV_GEOMETRY, SHELL_CLASS, SIDEBAR_SPACING } from './geometry';
|
|
4
4
|
|
|
5
5
|
const SIZES = /\btext-(base|lg|xl|2xl)\b/;
|
|
6
6
|
|
|
7
7
|
describe('sidebar geometry', () => {
|
|
8
8
|
for (const form of ['desktop', 'mobile'] as const) {
|
|
9
|
-
it(`${form}: rows use 14px text and a 16-
|
|
9
|
+
it(`${form}: rows use 14px text and a 16-20px icon, not a large font`, () => {
|
|
10
10
|
expect(NAV_GEOMETRY[form].item).toMatch(/\btext-sm\b/);
|
|
11
11
|
expect(NAV_GEOMETRY[form].item).not.toMatch(SIZES);
|
|
12
|
-
expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4
|
|
12
|
+
expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4|5)/);
|
|
13
13
|
expect(NAV_GEOMETRY[form].label).toMatch(/\btext-xs\b/);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it(`${form}: horizontal inset is tight (8px), vertical body padding is roomier`, () => {
|
|
17
17
|
expect(NAV_GEOMETRY[form].inset).toBe('px-2');
|
|
18
|
-
expect(NAV_GEOMETRY[form].item).toMatch(/\bpx-2\b/);
|
|
19
|
-
expect(SIDEBAR_SPACING[form].body).toMatch(/\bpy-3\b/);
|
|
18
|
+
expect(NAV_GEOMETRY[form].item).toMatch(form === 'mobile' ? /\bpx-2\.5\b/ : /\bpx-2\b/);
|
|
19
|
+
expect(SIDEBAR_SPACING[form].body).toMatch(form === 'mobile' ? /\bpy-2\b/ : /\bpy-3\b/);
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
it('mobile rows
|
|
24
|
-
expect(NAV_GEOMETRY.mobile.item).toMatch(/\bh-
|
|
23
|
+
it('mobile rows are 40px with a 44px hit area (see touch-safe.css); desktop rows are 36px', () => {
|
|
24
|
+
expect(NAV_GEOMETRY.mobile.item).toMatch(/\bh-10\b/);
|
|
25
25
|
expect(NAV_GEOMETRY.desktop.item).toMatch(/\bh-9\b/);
|
|
26
26
|
});
|
|
27
27
|
|
|
@@ -29,4 +29,16 @@ describe('sidebar geometry', () => {
|
|
|
29
29
|
expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-top');
|
|
30
30
|
expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-bottom');
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
it('the shell is one dynamic viewport tall and clips, so the document never scrolls', () => {
|
|
34
|
+
expect(SHELL_CLASS).toContain('h-[var(--app-height,100dvh)]');
|
|
35
|
+
expect(SHELL_CLASS).toContain('overflow-hidden');
|
|
36
|
+
expect(SHELL_CLASS).toContain('min-h-0');
|
|
37
|
+
expect(SHELL_CLASS).not.toMatch(/min-h-(screen|svh)/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('mobile header and footer also respect the side safe areas (landscape notch)', () => {
|
|
41
|
+
expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-left');
|
|
42
|
+
expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-right');
|
|
43
|
+
});
|
|
32
44
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar geometry. Row sizes (a pointer-sized desktop row, a
|
|
3
|
-
* drawer), one horizontal inset shared by every block, one trigger size, and the motion timing.
|
|
2
|
+
* Sidebar geometry. Row sizes (a pointer-sized 36px desktop row, a 40px row for the mobile
|
|
3
|
+
* drawer, whose 44px hit area comes from an invisible ::after in ui-core touch-safe.css), one horizontal inset shared by every block, one trigger size, and the motion timing.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const NAV_GEOMETRY = {
|
|
@@ -14,12 +14,13 @@ export const NAV_GEOMETRY = {
|
|
|
14
14
|
indent: 'pl-[34px]',
|
|
15
15
|
},
|
|
16
16
|
mobile: {
|
|
17
|
-
item: 'h-
|
|
18
|
-
label: 'h-
|
|
17
|
+
item: 'h-10 gap-2.5 px-2.5 text-sm [&>svg]:size-5',
|
|
18
|
+
label: 'h-7 px-2.5 text-xs',
|
|
19
|
+
/** One 8px gutter for rows, dividers, header and footer; rows add 10px, so the icon column (and the logo) sit at 18px. */
|
|
19
20
|
inset: 'px-2',
|
|
20
21
|
dividerInset: 'mx-2',
|
|
21
|
-
/**
|
|
22
|
-
indent: 'pl-
|
|
22
|
+
/** 10 + 20 + 10px. */
|
|
23
|
+
indent: 'pl-[40px]',
|
|
23
24
|
},
|
|
24
25
|
} as const;
|
|
25
26
|
|
|
@@ -34,15 +35,26 @@ export const SIDEBAR_SPACING = {
|
|
|
34
35
|
desktop: {
|
|
35
36
|
header: 'pl-4 pr-2 pb-2 pt-3',
|
|
36
37
|
body: 'gap-1 py-3',
|
|
37
|
-
footer: 'px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
|
|
38
|
+
footer: 'px-2 pt-2 pb-[max(0.5rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))]',
|
|
38
39
|
},
|
|
39
40
|
mobile: {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// The same row height as the top bar: 48px under the safe-top inset, the brand vertically centred, its left edge on the
|
|
42
|
+
// nav icon column (18px). No toggle: the drawer closes by backdrop, swipe, Escape or navigation.
|
|
43
|
+
header:
|
|
44
|
+
'box-border h-[calc(var(--safe-top,env(safe-area-inset-top,0px))+3rem)] pt-[var(--safe-top,env(safe-area-inset-top,0px))] pl-[calc(var(--safe-left,env(safe-area-inset-left,0px))+1.125rem)] pr-[calc(var(--safe-right,env(safe-area-inset-right,0px))+0.75rem)]',
|
|
45
|
+
body: 'gap-1 py-2',
|
|
46
|
+
footer:
|
|
47
|
+
'pt-2 pb-[max(0.5rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))] pl-[max(0.5rem,var(--safe-left,env(safe-area-inset-left,0px)))] pr-[max(0.5rem,var(--safe-right,env(safe-area-inset-right,0px)))]',
|
|
43
48
|
},
|
|
44
49
|
} as const;
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The private app shell is exactly one dynamic viewport tall and clips, so the DOCUMENT never scrolls: the top bar
|
|
53
|
+
* is `shrink-0`, the content below it is `flex-1 min-h-0` and owns the scroll (see `PrivateContent`).
|
|
54
|
+
* (`SidebarProvider` defaults to `min-h-svh`, which let content grow the page past the viewport.)
|
|
55
|
+
*/
|
|
56
|
+
export const SHELL_CLASS = 'h-[var(--app-height,100dvh)] max-h-[var(--app-height,100dvh)] min-h-0 overflow-hidden';
|
|
57
|
+
|
|
46
58
|
/** The one trigger button: same box and icon size on mobile and desktop. */
|
|
47
59
|
export const TRIGGER_CLASS = 'size-10 shrink-0 [&_svg]:!size-5';
|
|
48
60
|
|
|
@@ -52,3 +64,11 @@ export const SIDEBAR_MOTION_MS = 180;
|
|
|
52
64
|
/** Desktop hover-open timing: intent delay before opening, grace before closing. */
|
|
53
65
|
export const HOVER_OPEN_DELAY_MS = 400;
|
|
54
66
|
export const HOVER_CLOSE_DELAY_MS = 250;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fixed-height top bar: safe-top + 3rem. `--layout-bar-height` is set on the layout's main column to this value whenever
|
|
70
|
+
* the bar is shown (always on a phone; on desktop while the sidebar is collapsed) and to `0px` when it is not (desktop
|
|
71
|
+
* with the sidebar docked, or no sidebar); content starts right under the bar, pages need to know nothing about it.
|
|
72
|
+
* Apps may use it to offset their own sticky elements.
|
|
73
|
+
*/
|
|
74
|
+
export const LAYOUT_BAR_HEIGHT = 'calc(var(--safe-top, env(safe-area-inset-top, 0px)) + 3rem)';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The private landing route AppLayout/BaseApp configures: `auth.routes.defaultCallback`, resolved by the auth
|
|
3
|
+
* provider (default `/private`). Returns `undefined` outside the provider, so stories and tests fall back to `/`.
|
|
4
|
+
* Reads the context directly: `useAuth()` returns default state outside the provider, which would leak the
|
|
5
|
+
* framework default.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import { useContext } from 'react';
|
|
11
|
+
|
|
12
|
+
import { AuthContext } from '@djangocfg/api/auth';
|
|
13
|
+
|
|
14
|
+
export function useLandingRoute(): string | undefined {
|
|
15
|
+
return useContext(AuthContext)?.routes?.defaultCallback || undefined;
|
|
16
|
+
}
|
|
@@ -27,7 +27,7 @@ export function resolveProviderStyle(
|
|
|
27
27
|
* extract these classes at build time.
|
|
28
28
|
*/
|
|
29
29
|
const BOXED_INSET_CLASS = [
|
|
30
|
-
'flex flex-col',
|
|
30
|
+
'flex min-h-0 min-w-0 flex-col',
|
|
31
31
|
'md:peer-data-[variant=inset]:my-[var(--app-shell-inset-y)]',
|
|
32
32
|
'md:peer-data-[variant=inset]:mr-[var(--app-shell-inset-x)]',
|
|
33
33
|
'md:peer-data-[variant=inset]:rounded-[var(--app-shell-radius)]',
|
|
@@ -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 ?? 'full-bleed') !== 'boxed') return 'flex flex-col';
|
|
50
|
+
if ((visual?.variant ?? 'full-bleed') !== 'boxed') return 'flex min-h-0 min-w-0 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
|
}
|
|
@@ -71,10 +71,10 @@ const BOXED_BG_CLASS: Record<NonNullable<LayoutVisualConfig['background']>, stri
|
|
|
71
71
|
export function resolveProviderClassName(
|
|
72
72
|
visual: LayoutVisualConfig | undefined,
|
|
73
73
|
): string | undefined {
|
|
74
|
-
// h-
|
|
74
|
+
// h-[var(--app-height)] + overflow-hidden: lock the shell to exactly one viewport height so
|
|
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
|
-
const base = 'h-
|
|
77
|
+
const base = 'h-[var(--app-height,100svh)] overflow-hidden';
|
|
78
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
|
|
@@ -18,7 +18,7 @@ import { usePresence } from './usePresence';
|
|
|
18
18
|
type PointerHandlers = ReturnType<typeof useHoverIntent>['zoneProps'];
|
|
19
19
|
|
|
20
20
|
export interface SidebarPeek extends Presence {
|
|
21
|
-
/** True while the sidebar is collapsed on desktop: the
|
|
21
|
+
/** True while the sidebar is collapsed on desktop: the left-edge hot zone is mounted. */
|
|
22
22
|
enabled: boolean;
|
|
23
23
|
/** Hover-open is live: collapsed, desktop, and a fine (mouse/pen) pointer. */
|
|
24
24
|
hoverEnabled: boolean;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
export { PrivateLayout } from './PrivateLayout';
|
|
6
6
|
export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
|
|
7
|
+
export { LAYOUT_BAR_HEIGHT } from './geometry';
|
|
7
8
|
export { mcpSidebarItem, mcpSidebarGroup } from './mcpSidebar';
|
|
8
9
|
export type { McpSidebarItemOptions, McpSidebarGroupOptions } from './mcpSidebar';
|
|
9
10
|
export { SidebarBrandSwitcher, ForbiddenState } from './components';
|
|
@@ -21,4 +22,5 @@ export type {
|
|
|
21
22
|
SidebarFeaturedConfig,
|
|
22
23
|
SidebarBrandSwitcherConfig,
|
|
23
24
|
SidebarBrandSwitcherItem,
|
|
25
|
+
PrivateTopBarConfig,
|
|
24
26
|
} from './types';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { PrivateLayout } from './PrivateLayout';
|
|
7
|
+
|
|
8
|
+
vi.mock('next/navigation', () => ({ usePathname: () => '/', useRouter: () => ({ push: () => {}, replace: () => {} }), useSearchParams: () => new URLSearchParams() }));
|
|
9
|
+
|
|
10
|
+
// PrivateLayout lazy-loads the settings dialog without its own Suspense; keep the test to the shell.
|
|
11
|
+
vi.mock('../SettingsLayout/SettingsDialog', () => ({ SettingsDialog: () => null }));
|
|
12
|
+
let currentUser: { email: string; display_username: string; avatar: null } | null = { email: 'a@b.c', display_username: 'Mark', avatar: null };
|
|
13
|
+
vi.mock('@djangocfg/api/auth', async () => ({
|
|
14
|
+
AuthContext: (await import('react')).createContext(undefined),
|
|
15
|
+
useAuth: () => ({ user: currentUser }),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('./hooks/useAuthGuard', () => ({ useAuthGuard: () => ({ isLoading: false, isForbidden: false, loadingText: '' }) }));
|
|
18
|
+
|
|
19
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
let phone = true;
|
|
22
|
+
let host: HTMLElement | null = null;
|
|
23
|
+
let root: Root | null = null;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
window.matchMedia = ((query: string) => ({
|
|
27
|
+
// A phone has no precise hover pointer; a desktop does.
|
|
28
|
+
matches: query.includes('hover: hover') ? !phone : phone,
|
|
29
|
+
media: query,
|
|
30
|
+
onchange: null,
|
|
31
|
+
addEventListener: () => {},
|
|
32
|
+
removeEventListener: () => {},
|
|
33
|
+
addListener: () => {},
|
|
34
|
+
removeListener: () => {},
|
|
35
|
+
dispatchEvent: () => false,
|
|
36
|
+
})) as typeof window.matchMedia;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
act(() => root?.unmount());
|
|
41
|
+
host?.remove();
|
|
42
|
+
host = root = null;
|
|
43
|
+
phone = true;
|
|
44
|
+
currentUser = { email: 'a@b.c', display_username: 'Mark', avatar: null };
|
|
45
|
+
document.cookie = 'sidebar_state=; path=/; max-age=0';
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function mount(props: Partial<React.ComponentProps<typeof PrivateLayout>>) {
|
|
49
|
+
host = document.createElement('div');
|
|
50
|
+
document.body.appendChild(host);
|
|
51
|
+
root = createRoot(host);
|
|
52
|
+
await act(async () =>
|
|
53
|
+
root!.render(
|
|
54
|
+
<PrivateLayout
|
|
55
|
+
requireAuth={false}
|
|
56
|
+
sidebar={{ groups: [{ label: 'A', items: [{ label: 'Home', href: '/' }] }] }}
|
|
57
|
+
header={{ title: 'Acme' }}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
<p>page</p>
|
|
61
|
+
</PrivateLayout>,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
return document.querySelector('[data-sidebar="topbar"]');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('PrivateLayout topBar', () => {
|
|
68
|
+
it('title falls back to header.title', async () => {
|
|
69
|
+
expect((await mount({ topBar: { account: false } }))?.textContent).toBe('Acme');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('topBar.title overrides it and topBar.actions render on the right', async () => {
|
|
73
|
+
const bar = (await mount({ topBar: { title: 'Calls', actions: <button>New</button> } }))!;
|
|
74
|
+
expect(bar.textContent).toBe('CallsNewM');
|
|
75
|
+
expect(bar.querySelector('[data-sidebar="topbar-actions"]')!.firstElementChild?.textContent).toBe('New');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('desktop, sidebar pinned open: no bar, the account stays in the sidebar footer', async () => {
|
|
79
|
+
phone = false;
|
|
80
|
+
document.cookie = 'sidebar_state=true; path=/';
|
|
81
|
+
await mount({ topBar: { title: 'Calls', actions: <button>New</button> } });
|
|
82
|
+
expect(document.querySelector('[data-sidebar="topbar"]')).toBeNull();
|
|
83
|
+
expect(document.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
84
|
+
expect(document.body.textContent).toContain('Mark');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('desktop, sidebar collapsed: the same bar with title, actions and avatar', async () => {
|
|
88
|
+
phone = false;
|
|
89
|
+
document.cookie = 'sidebar_state=false; path=/';
|
|
90
|
+
const bar = (await mount({ topBar: { title: 'Calls', actions: <button>New</button> } }))!;
|
|
91
|
+
expect(bar.children[1].textContent).toBe('Calls');
|
|
92
|
+
expect(bar.querySelector('[data-sidebar="topbar-actions"]')!.children[1].getAttribute('data-sidebar')).toBe('topbar-account');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('account', () => {
|
|
96
|
+
it('avatar present when signed in', async () => {
|
|
97
|
+
const bar = (await mount({}))!;
|
|
98
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('no avatar without a session', async () => {
|
|
102
|
+
currentUser = null;
|
|
103
|
+
const bar = (await mount({}))!;
|
|
104
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('no avatar with topBar.account === false', async () => {
|
|
108
|
+
const bar = (await mount({ topBar: { account: false } }))!;
|
|
109
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('bar avatar and sidebar footer open the same menu items', async () => {
|
|
113
|
+
const labels = async (el: Element) => {
|
|
114
|
+
await act(async () => {
|
|
115
|
+
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
|
|
116
|
+
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
|
|
117
|
+
(el as HTMLElement).click();
|
|
118
|
+
});
|
|
119
|
+
const items = [...document.querySelectorAll('[role="menuitem"]')].map((i) => i.textContent);
|
|
120
|
+
await act(async () => {
|
|
121
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
122
|
+
});
|
|
123
|
+
return items;
|
|
124
|
+
};
|
|
125
|
+
const bar = (await mount({}))!;
|
|
126
|
+
const fromBar = await labels(bar.querySelector('[data-sidebar="topbar-account"]')!);
|
|
127
|
+
expect(fromBar.length).toBeGreaterThan(0);
|
|
128
|
+
await act(async () => root?.unmount());
|
|
129
|
+
host?.remove();
|
|
130
|
+
phone = false;
|
|
131
|
+
document.cookie = 'sidebar_state=true; path=/';
|
|
132
|
+
await mount({});
|
|
133
|
+
const footer = [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Mark'))!;
|
|
134
|
+
expect(await labels(footer)).toEqual(fromBar);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
async function openDrawer() {
|
|
138
|
+
await act(async () => {
|
|
139
|
+
(document.querySelector('[data-sidebar="trigger"]') as HTMLElement).click();
|
|
140
|
+
});
|
|
141
|
+
return document.querySelector('[data-sidebar="sidebar"][data-mobile="true"]') ?? document.body;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it('phone drawer has no footer account while the bar carries the avatar', async () => {
|
|
145
|
+
await mount({});
|
|
146
|
+
const drawer = await openDrawer();
|
|
147
|
+
expect(drawer.textContent).toContain('Home');
|
|
148
|
+
expect(document.body.textContent).not.toContain('Mark');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('phone drawer shows the footer account again with topBar.account === false', async () => {
|
|
152
|
+
await mount({ topBar: { account: false } });
|
|
153
|
+
await openDrawer();
|
|
154
|
+
expect(document.body.textContent).toContain('Mark');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('toggle', () => {
|
|
159
|
+
it('no tooltip and no native title on desktop or phone; aria-label stays', async () => {
|
|
160
|
+
for (const isPhone of [false, true]) {
|
|
161
|
+
phone = isPhone;
|
|
162
|
+
document.cookie = 'sidebar_state=false; path=/';
|
|
163
|
+
const bar = (await mount({}))!;
|
|
164
|
+
const trigger = bar.querySelector<HTMLElement>('[data-sidebar="trigger"]')!;
|
|
165
|
+
expect(trigger.hasAttribute('data-state')).toBe(false);
|
|
166
|
+
expect(trigger.hasAttribute('title')).toBe(false);
|
|
167
|
+
expect(trigger.getAttribute('aria-label')).toBe('Open sidebar');
|
|
168
|
+
await act(async () => {
|
|
169
|
+
trigger.focus();
|
|
170
|
+
trigger.dispatchEvent(new FocusEvent('focus'));
|
|
171
|
+
});
|
|
172
|
+
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
|
173
|
+
await act(async () => root?.unmount());
|
|
174
|
+
host?.remove();
|
|
175
|
+
host = root = null;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('hover-open', () => {
|
|
181
|
+
const enter = (el: Element) =>
|
|
182
|
+
act(async () => {
|
|
183
|
+
const ev = new Event('pointerenter', { bubbles: false }) as Event & { pointerType?: string };
|
|
184
|
+
ev.pointerType = 'mouse';
|
|
185
|
+
el.dispatchEvent(ev);
|
|
186
|
+
// React's onPointerEnter listens to pointerover.
|
|
187
|
+
const over = new Event('pointerover', { bubbles: true }) as Event & { pointerType?: string };
|
|
188
|
+
over.pointerType = 'mouse';
|
|
189
|
+
el.dispatchEvent(over);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the bar toggle does not open the hover overlay; the left edge zone does', async () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
try {
|
|
195
|
+
phone = false;
|
|
196
|
+
document.cookie = 'sidebar_state=false; path=/';
|
|
197
|
+
const bar = (await mount({}))!;
|
|
198
|
+
const trigger = bar.querySelector('[data-sidebar="trigger"]')!;
|
|
199
|
+
await enter(trigger);
|
|
200
|
+
await act(async () => { vi.advanceTimersByTime(1000); });
|
|
201
|
+
expect(document.querySelector('[data-sidebar="peek"]')).toBeNull();
|
|
202
|
+
|
|
203
|
+
const edge = document.querySelector('[data-sidebar="peek-edge"]')!;
|
|
204
|
+
expect(edge).not.toBeNull();
|
|
205
|
+
await enter(edge);
|
|
206
|
+
await act(async () => { vi.advanceTimersByTime(1000); });
|
|
207
|
+
expect(document.querySelector('[data-sidebar="peek"]')).not.toBeNull();
|
|
208
|
+
} finally {
|
|
209
|
+
vi.useRealTimers();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -21,7 +21,7 @@ interface SidebarItemBase {
|
|
|
21
21
|
badge?: string | number;
|
|
22
22
|
/** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
|
|
23
23
|
badgeVariant?: 'count' | 'pill';
|
|
24
|
-
/**
|
|
24
|
+
/** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail, and rows show no tooltip. */
|
|
25
25
|
tooltip?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -128,7 +128,7 @@ export interface SidebarConfig<Id extends string = string> {
|
|
|
128
128
|
groupLabelStyle?: SidebarGroupLabelStyle;
|
|
129
129
|
/** Featured CTA tile rendered below all groups, above `menuEnd`. */
|
|
130
130
|
featured?: SidebarFeaturedConfig;
|
|
131
|
-
/** @deprecated No effect: rows are one desktop size and one 44px
|
|
131
|
+
/** @deprecated No effect: rows are one desktop size (36px) and one phone size (40px with a 44px hit area). */
|
|
132
132
|
density?: 'comfortable' | 'default' | 'compact';
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -172,6 +172,13 @@ export interface HeaderConfig {
|
|
|
172
172
|
* Takes priority over `brand`, `title`, `brandIcon`, `brandLetter`.
|
|
173
173
|
*/
|
|
174
174
|
switcher?: SidebarBrandSwitcherConfig;
|
|
175
|
+
/**
|
|
176
|
+
* Where the product name (sidebar header brand, phone drawer header, and the default top bar title) links to,
|
|
177
|
+
* through the router. Resolution: `header.href` > `sidebar.homeHref` > the auth route `routes.defaultCallback` configured on
|
|
178
|
+
* `BaseApp` (`auth.routes`, default `/private`) > `/` (outside the auth provider). A custom `brand` node is rendered as given (never
|
|
179
|
+
* wrapped in a second link); a string `brand` is linkified.
|
|
180
|
+
*/
|
|
181
|
+
href?: string;
|
|
175
182
|
/** Custom header brand node (same idea as PublicNavbar `brand`). */
|
|
176
183
|
brand?: ReactNode;
|
|
177
184
|
/** Shown next to the logo when the sidebar is expanded */
|
|
@@ -209,12 +216,30 @@ export interface HeaderConfig {
|
|
|
209
216
|
// PrivateLayout Props
|
|
210
217
|
// ============================================================================
|
|
211
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Top bar content. One bar at every width, shown whenever the sidebar is not visible (always on a phone; on desktop
|
|
221
|
+
* while the sidebar is collapsed; never while it is docked). The sidebar toggle is always on the left and not configurable.
|
|
222
|
+
*/
|
|
223
|
+
export interface PrivateTopBarConfig {
|
|
224
|
+
/** Truncated next to the toggle. Defaults to `header.title` (the product name). */
|
|
225
|
+
title?: ReactNode;
|
|
226
|
+
/** Right end of the bar, vertically centred. Keep it to a couple of compact controls. */
|
|
227
|
+
actions?: ReactNode;
|
|
228
|
+
/**
|
|
229
|
+
* Account avatar at the right end of the bar for a signed-in user (the same menu as the sidebar footer). Default
|
|
230
|
+
* `true`. `false` removes the avatar from the bar and keeps the account in the phone drawer's footer.
|
|
231
|
+
*/
|
|
232
|
+
account?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
212
235
|
export interface PrivateLayoutProps {
|
|
213
236
|
children: ReactNode;
|
|
214
237
|
/** Sidebar configuration */
|
|
215
238
|
sidebar?: SidebarConfig;
|
|
216
239
|
/** Title + account links (no top navbar — title is used in the sidebar chrome) */
|
|
217
240
|
header?: HeaderConfig;
|
|
241
|
+
/** Top bar content (title override, right-hand actions, account avatar). The bar shows only while the sidebar is not visible. */
|
|
242
|
+
topBar?: PrivateTopBarConfig;
|
|
218
243
|
/**
|
|
219
244
|
* Path for active nav highlighting. With `@djangocfg/nextjs` i18n routing, pass `usePathname()` from
|
|
220
245
|
* `@djangocfg/nextjs/i18n/navigation` (no `/[locale]` segment). If omitted, uses `next/navigation` (includes locale).
|