@djangocfg/layouts 2.1.606 → 2.1.608
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 +10 -55
- package/src/layouts/AppLayout/index.ts +1 -0
- package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +17 -11
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +1 -1
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +1 -4
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +17 -3
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +21 -25
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +2 -2
- package/src/layouts/AuthLayout/styles/oauth.css +45 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
- package/src/layouts/PrivateLayout/README.md +23 -5
- package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +230 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +86 -19
- 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 +13 -8
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/context.tsx +15 -3
- package/src/layouts/PrivateLayout/geometry.test.ts +44 -0
- package/src/layouts/PrivateLayout/geometry.ts +40 -16
- package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
- 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/shared/ProjectContext.tsx +18 -0
- package/src/testing/MockAuthProvider.tsx +5 -2
- /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
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 { SidebarProvider } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
import { PrivateLayoutProvider } from '../context';
|
|
9
|
+
import { SIDEBAR_SPACING } from '../geometry';
|
|
10
|
+
import { SidebarBody } from './SidebarBody';
|
|
11
|
+
|
|
12
|
+
vi.mock('@djangocfg/api/auth', async () => ({
|
|
13
|
+
AuthContext: (await import('react')).createContext(undefined),
|
|
14
|
+
useAuth: () => ({ user: { email: 'a@b.c', display_username: 'Mark' } }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
let host: HTMLElement | null = null;
|
|
20
|
+
let root: Root | null = null;
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
window.matchMedia ??= ((query: string) => ({
|
|
24
|
+
matches: false, media: query, onchange: null,
|
|
25
|
+
addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false,
|
|
26
|
+
})) as typeof window.matchMedia;
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
act(() => root?.unmount());
|
|
30
|
+
host?.remove();
|
|
31
|
+
host = root = null;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function render(isMobile: boolean) {
|
|
35
|
+
host = document.createElement('div');
|
|
36
|
+
document.body.appendChild(host);
|
|
37
|
+
root = createRoot(host);
|
|
38
|
+
act(() =>
|
|
39
|
+
root!.render(
|
|
40
|
+
<SidebarProvider>
|
|
41
|
+
<PrivateLayoutProvider sidebar={{ groups: [{ label: 'A', items: [{ label: 'Home', href: '/' }] }] }} header={undefined} pathname="/" isMobile={isMobile}>
|
|
42
|
+
<SidebarBody />
|
|
43
|
+
</PrivateLayoutProvider>
|
|
44
|
+
</SidebarProvider>,
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
return host;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('SidebarBody zones', () => {
|
|
51
|
+
it.each([true, false])('header and footer are pinned (shrink-0), only the nav scrolls (mobile %s)', (isMobile) => {
|
|
52
|
+
const el = render(isMobile);
|
|
53
|
+
const header = el.querySelector<HTMLElement>('[data-sidebar="header"]')!;
|
|
54
|
+
const nav = el.querySelector<HTMLElement>('[data-sidebar="content"]')!;
|
|
55
|
+
const footer = nav.nextElementSibling as HTMLElement;
|
|
56
|
+
expect(header.className).toMatch(/\bshrink-0\b/);
|
|
57
|
+
expect(header.className).toContain('bg-sidebar');
|
|
58
|
+
expect(nav.className).toMatch(/\bmin-h-0\b/);
|
|
59
|
+
expect(nav.className).toMatch(/\bflex-1\b/);
|
|
60
|
+
expect(nav.className).toMatch(/\boverflow-y-auto\b/);
|
|
61
|
+
expect(nav.className).toMatch(/\boverscroll-contain\b/);
|
|
62
|
+
expect(footer.className).toMatch(/\bshrink-0\b/);
|
|
63
|
+
expect(footer.className).toMatch(/\bborder-t\b/);
|
|
64
|
+
expect(footer.className).toContain('bg-sidebar');
|
|
65
|
+
// Nothing else in the body scrolls.
|
|
66
|
+
expect(header.className).not.toMatch(/overflow-y-auto/);
|
|
67
|
+
expect(footer.className).not.toMatch(/overflow-y-auto/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('mobile footer respects the home indicator', () => {
|
|
71
|
+
expect(SIDEBAR_SPACING.mobile.footer).toContain('--safe-bottom');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -17,7 +17,7 @@ import { SidebarNavGroup } from './SidebarNavGroup';
|
|
|
17
17
|
import { SidebarSlots } from './SidebarSlots';
|
|
18
18
|
|
|
19
19
|
export function SidebarBody() {
|
|
20
|
-
const { sidebar, header, isMobile } = usePrivateLayoutContext();
|
|
20
|
+
const { sidebar, header, isMobile, accountInBar } = usePrivateLayoutContext();
|
|
21
21
|
|
|
22
22
|
const groups = useMemo(
|
|
23
23
|
() =>
|
|
@@ -33,20 +33,29 @@ export function SidebarBody() {
|
|
|
33
33
|
[sidebar?.groups],
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
+
// Three zones: pinned header, the ONLY scroller (nav), pinned footer. The header gets a border once the nav scrolls.
|
|
37
|
+
const [scrolled, setScrolled] = React.useState(false);
|
|
38
|
+
const onScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
39
|
+
const next = e.currentTarget.scrollTop > 0;
|
|
40
|
+
setScrolled((prev) => (prev === next ? prev : next));
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
36
43
|
return (
|
|
37
44
|
<>
|
|
38
|
-
<SidebarBrand />
|
|
45
|
+
<SidebarBrand bordered={scrolled} />
|
|
39
46
|
<div
|
|
40
47
|
data-sidebar="content"
|
|
48
|
+
onScroll={onScroll}
|
|
41
49
|
className={cn(
|
|
42
|
-
'flex min-h-0 flex-1 flex-col
|
|
50
|
+
'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain',
|
|
43
51
|
SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].body,
|
|
44
52
|
)}
|
|
45
53
|
>
|
|
46
54
|
<SidebarSlots />
|
|
47
55
|
{groups}
|
|
48
56
|
</div>
|
|
49
|
-
|
|
57
|
+
{/* On a phone the account lives in the top bar when it shows the avatar; no second entry point here. */}
|
|
58
|
+
{isMobile && accountInBar ? null : <PrivateSidebarAccount header={header} />}
|
|
50
59
|
</>
|
|
51
60
|
);
|
|
52
61
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React, { memo } from 'react';
|
|
9
9
|
|
|
10
|
-
import { Link } from '@djangocfg/ui-core/components';
|
|
10
|
+
import { Link, useSidebar } from '@djangocfg/ui-core/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
|
|
13
13
|
import { LucideIcon } from '../../../components';
|
|
@@ -17,41 +17,48 @@ import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
|
|
|
17
17
|
import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
|
|
18
18
|
|
|
19
19
|
const BRAND_LINK =
|
|
20
|
-
'flex min-w-0 items-center gap-2.5 rounded-lg py-1 outline-none ring-sidebar-ring focus-visible:ring-2';
|
|
20
|
+
'flex min-w-0 items-center gap-2.5 rounded-lg py-1 text-sidebar-foreground no-underline outline-none ring-sidebar-ring transition-opacity hover:no-underline hover:opacity-80 focus-visible:ring-2';
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** Brand name: semibold, one step larger on desktop (18px), 16px on a phone; one line, truncated. */
|
|
23
|
+
const BRAND_NAME = 'truncate text-base font-semibold tracking-tight md:text-lg';
|
|
24
|
+
|
|
25
|
+
function SidebarBrandRaw({ bordered = false }: { bordered?: boolean }) {
|
|
23
26
|
const { header, homeHref, brandTitle, brandMonogram, isMobile } = usePrivateLayoutContext();
|
|
24
27
|
|
|
28
|
+
const { isMobile: drawer, setOpenMobile } = useSidebar();
|
|
29
|
+
// On a phone the drawer closes on click, also when the target is the current page (no pathname change).
|
|
30
|
+
const closeDrawer = React.useCallback(() => {
|
|
31
|
+
if (drawer) setOpenMobile(false);
|
|
32
|
+
}, [drawer, setOpenMobile]);
|
|
33
|
+
|
|
25
34
|
const customBrand = header?.brand;
|
|
26
35
|
|
|
27
36
|
const brand =
|
|
28
37
|
customBrand != null && customBrand !== false ? (
|
|
29
38
|
typeof customBrand === 'string' ? (
|
|
30
|
-
<Link href={homeHref} className={BRAND_LINK}>
|
|
31
|
-
<span className=
|
|
39
|
+
<Link href={homeHref} className={BRAND_LINK} aria-label={customBrand} onClick={closeDrawer}>
|
|
40
|
+
<span className={BRAND_NAME}>{customBrand}</span>
|
|
32
41
|
</Link>
|
|
33
42
|
) : (
|
|
34
43
|
customBrand
|
|
35
44
|
)
|
|
36
45
|
) : (
|
|
37
|
-
<Link href={homeHref} className={BRAND_LINK}>
|
|
46
|
+
<Link href={homeHref} className={BRAND_LINK} aria-label={brandTitle || 'Home'} onClick={closeDrawer}>
|
|
38
47
|
<span
|
|
39
48
|
className={cn(
|
|
40
49
|
'flex shrink-0 items-center justify-center rounded-lg bg-sidebar-primary',
|
|
41
|
-
isMobile ? 'size-
|
|
50
|
+
isMobile ? 'size-7' : 'size-8',
|
|
42
51
|
)}
|
|
43
52
|
>
|
|
44
53
|
{header?.brandIcon ? (
|
|
45
|
-
<LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground" />
|
|
54
|
+
<LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground md:size-[18px]" />
|
|
46
55
|
) : (
|
|
47
|
-
<span className="text-
|
|
56
|
+
<span className="text-xs font-bold leading-none text-sidebar-primary-foreground">
|
|
48
57
|
{brandMonogram}
|
|
49
58
|
</span>
|
|
50
59
|
)}
|
|
51
60
|
</span>
|
|
52
|
-
<span className={
|
|
53
|
-
{brandTitle}
|
|
54
|
-
</span>
|
|
61
|
+
{brandTitle ? <span className={BRAND_NAME}>{brandTitle}</span> : null}
|
|
55
62
|
</Link>
|
|
56
63
|
);
|
|
57
64
|
|
|
@@ -62,12 +69,16 @@ function SidebarBrandRaw() {
|
|
|
62
69
|
return (
|
|
63
70
|
<div
|
|
64
71
|
data-sidebar="header"
|
|
65
|
-
className={cn(
|
|
72
|
+
className={cn(
|
|
73
|
+
'flex shrink-0 items-center gap-2 bg-sidebar shadow-[0_1px_0_0_transparent] transition-shadow',
|
|
74
|
+
bordered && 'shadow-[0_1px_0_0_color-mix(in_oklab,var(--sidebar-border)_60%,transparent)]',
|
|
75
|
+
SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].header)}
|
|
66
76
|
>
|
|
67
77
|
<div className="min-w-0 flex-1">
|
|
68
78
|
{header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger /> : brand}
|
|
69
79
|
</div>
|
|
70
|
-
{
|
|
80
|
+
{/* The desktop toggle collapses the pinned sidebar. The phone drawer has none: it closes by backdrop, swipe, Escape, or navigating. */}
|
|
81
|
+
{isMobile ? null : trailing}
|
|
71
82
|
</div>
|
|
72
83
|
);
|
|
73
84
|
}
|
|
@@ -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. */
|
|
@@ -43,13 +48,13 @@ function GroupDivider({ inset }: { inset: string }) {
|
|
|
43
48
|
role="separator"
|
|
44
49
|
aria-orientation="horizontal"
|
|
45
50
|
data-sidebar="group-divider"
|
|
46
|
-
className={cn(inset, 'mb-
|
|
51
|
+
className={cn(inset, 'mb-3 mt-3 h-px shrink-0 bg-sidebar-border')}
|
|
47
52
|
/>
|
|
48
53
|
);
|
|
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) {
|
|
@@ -98,7 +103,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
|
98
103
|
type="button"
|
|
99
104
|
aria-expanded={open}
|
|
100
105
|
className={cn(
|
|
101
|
-
'flex w-full items-center
|
|
106
|
+
'flex w-full items-center rounded-lg font-normal text-sidebar-foreground',
|
|
102
107
|
'transition-colors duration-150 hover:bg-sidebar-accent/60',
|
|
103
108
|
'outline-none ring-sidebar-ring focus-visible:ring-2',
|
|
104
109
|
geometry.item,
|
|
@@ -117,7 +122,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
|
117
122
|
</CollapsibleTrigger>
|
|
118
123
|
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
|
119
124
|
<SidebarGroupContent>
|
|
120
|
-
<SidebarMenu className="mt-
|
|
125
|
+
<SidebarMenu className="mt-1 gap-0.5">{rows}</SidebarMenu>
|
|
121
126
|
</SidebarGroupContent>
|
|
122
127
|
</CollapsibleContent>
|
|
123
128
|
</Collapsible>
|
|
@@ -66,7 +66,7 @@ function SidebarSettingsNavItem({ item, hideItemIcons }: { item: SidebarSettings
|
|
|
66
66
|
onClick={open}
|
|
67
67
|
aria-haspopup="dialog"
|
|
68
68
|
aria-current={active ? 'true' : undefined}
|
|
69
|
-
className={cn(ITEM_CLASS, geometry.item, hideItemIcons &&
|
|
69
|
+
className={cn(ITEM_CLASS, geometry.item, hideItemIcons && geometry.indent)}
|
|
70
70
|
>
|
|
71
71
|
<ItemContent item={item} hideItemIcons={hideItemIcons} />
|
|
72
72
|
</SidebarMenuButton>
|
|
@@ -83,7 +83,7 @@ function SidebarLinkNavItem({ item, hideItemIcons }: { item: SidebarLinkItem; hi
|
|
|
83
83
|
<SidebarMenuButton
|
|
84
84
|
asChild
|
|
85
85
|
isActive={active}
|
|
86
|
-
className={cn(ITEM_CLASS, geometry.item, hideItemIcons &&
|
|
86
|
+
className={cn(ITEM_CLASS, geometry.item, hideItemIcons && geometry.indent)}
|
|
87
87
|
>
|
|
88
88
|
<Link href={item.href} aria-current={active ? 'page' : undefined}>
|
|
89
89
|
<ItemContent item={item} hideItemIcons={hideItemIcons} />
|
|
@@ -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>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { NAV_GEOMETRY, SHELL_CLASS, SIDEBAR_SPACING } from './geometry';
|
|
4
|
+
|
|
5
|
+
const SIZES = /\btext-(base|lg|xl|2xl)\b/;
|
|
6
|
+
|
|
7
|
+
describe('sidebar geometry', () => {
|
|
8
|
+
for (const form of ['desktop', 'mobile'] as const) {
|
|
9
|
+
it(`${form}: rows use 14px text and a 16-20px icon, not a large font`, () => {
|
|
10
|
+
expect(NAV_GEOMETRY[form].item).toMatch(/\btext-sm\b/);
|
|
11
|
+
expect(NAV_GEOMETRY[form].item).not.toMatch(SIZES);
|
|
12
|
+
expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4|5)/);
|
|
13
|
+
expect(NAV_GEOMETRY[form].label).toMatch(/\btext-xs\b/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it(`${form}: horizontal inset is tight (8px), vertical body padding is roomier`, () => {
|
|
17
|
+
expect(NAV_GEOMETRY[form].inset).toBe('px-2');
|
|
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
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
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
|
+
expect(NAV_GEOMETRY.desktop.item).toMatch(/\bh-9\b/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('mobile header and footer never go under the status bar or home indicator', () => {
|
|
29
|
+
expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-top');
|
|
30
|
+
expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-bottom');
|
|
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
|
+
});
|
|
44
|
+
});
|
|
@@ -1,21 +1,26 @@
|
|
|
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 = {
|
|
7
7
|
desktop: {
|
|
8
|
-
item: 'h-
|
|
9
|
-
label: 'h-7 px-2
|
|
8
|
+
item: 'h-9 gap-2.5 px-2 text-sm [&>svg]:size-4',
|
|
9
|
+
label: 'h-7 px-2 text-xs',
|
|
10
10
|
/** Horizontal inset of groups, slots and the divider; equals the header and footer inset. */
|
|
11
11
|
inset: 'px-2',
|
|
12
12
|
dividerInset: 'mx-2',
|
|
13
|
+
/** Left padding of a row without an icon, so its text lines up with the text of rows that have one (8 + 16 + 10px). */
|
|
14
|
+
indent: 'pl-[34px]',
|
|
13
15
|
},
|
|
14
16
|
mobile: {
|
|
15
|
-
item: 'h-
|
|
16
|
-
label: 'h-
|
|
17
|
-
|
|
18
|
-
|
|
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. */
|
|
20
|
+
inset: 'px-2',
|
|
21
|
+
dividerInset: 'mx-2',
|
|
22
|
+
/** 10 + 20 + 10px. */
|
|
23
|
+
indent: 'pl-[40px]',
|
|
19
24
|
},
|
|
20
25
|
} as const;
|
|
21
26
|
|
|
@@ -23,22 +28,33 @@ export type NavGeometry = (typeof NAV_GEOMETRY)[keyof typeof NAV_GEOMETRY];
|
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* One spacing scale for the three blocks of the sidebar (header, list, footer). On mobile every
|
|
26
|
-
* block
|
|
27
|
-
*
|
|
31
|
+
* block keeps tight sides (0.5rem rows, 0.75rem header) and roomier verticals, with the top and
|
|
32
|
+
* bottom edges safe-area aware (never less than 1rem).
|
|
28
33
|
*/
|
|
29
34
|
export const SIDEBAR_SPACING = {
|
|
30
35
|
desktop: {
|
|
31
|
-
header: '
|
|
32
|
-
body: 'py-
|
|
33
|
-
footer: '
|
|
36
|
+
header: 'pl-4 pr-2 pb-2 pt-3',
|
|
37
|
+
body: 'gap-1 py-3',
|
|
38
|
+
footer: 'px-2 pt-2 pb-[max(0.5rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))]',
|
|
34
39
|
},
|
|
35
40
|
mobile: {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)))]',
|
|
39
48
|
},
|
|
40
49
|
} as const;
|
|
41
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
|
+
|
|
42
58
|
/** The one trigger button: same box and icon size on mobile and desktop. */
|
|
43
59
|
export const TRIGGER_CLASS = 'size-10 shrink-0 [&_svg]:!size-5';
|
|
44
60
|
|
|
@@ -48,3 +64,11 @@ export const SIDEBAR_MOTION_MS = 180;
|
|
|
48
64
|
/** Desktop hover-open timing: intent delay before opening, grace before closing. */
|
|
49
65
|
export const HOVER_OPEN_DELAY_MS = 400;
|
|
50
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
|
}
|
|
@@ -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';
|