@djangocfg/layouts 2.1.607 → 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/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/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 +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
|
@@ -11,27 +11,18 @@
|
|
|
11
11
|
|
|
12
12
|
'use client';
|
|
13
13
|
|
|
14
|
-
import { ChevronDown } from 'lucide-react';
|
|
15
14
|
import { Link } from '@djangocfg/ui-core/components';
|
|
16
15
|
import React, { memo } from 'react';
|
|
17
16
|
|
|
18
|
-
import { useAuth } from '@djangocfg/api/auth';
|
|
19
|
-
import {
|
|
20
|
-
Avatar,
|
|
21
|
-
AvatarFallback,
|
|
22
|
-
AvatarImage,
|
|
23
|
-
Button,
|
|
24
|
-
MenuBuilder,
|
|
25
|
-
} from '@djangocfg/ui-core/components';
|
|
26
|
-
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
27
|
-
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
28
17
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
18
|
+
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
19
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
29
20
|
|
|
30
21
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
22
|
+
import { AccountFooterTrigger, AccountMenu } from '../../../components/account';
|
|
31
23
|
import { usePeekPin } from '../hooks';
|
|
32
24
|
import { usePrivateLayoutContext } from '../context';
|
|
33
25
|
import { SIDEBAR_SPACING } from '../geometry';
|
|
34
|
-
import { useAccountMenuItems } from '../../SettingsLayout/hooks';
|
|
35
26
|
|
|
36
27
|
import type { HeaderConfig } from '../types';
|
|
37
28
|
|
|
@@ -39,72 +30,18 @@ interface PrivateSidebarAccountProps {
|
|
|
39
30
|
header?: HeaderConfig;
|
|
40
31
|
}
|
|
41
32
|
|
|
42
|
-
interface AccountView {
|
|
43
|
-
source: 'user' | 'dev-fallback';
|
|
44
|
-
displayName: string;
|
|
45
|
-
email: string | null;
|
|
46
|
-
avatarUrl: string;
|
|
47
|
-
plan: string | null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
33
|
function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
51
|
-
const { user } = useAuth();
|
|
52
34
|
const { setOpen: setSidebarOpen } = useSidebar();
|
|
53
35
|
const { isMobile } = usePrivateLayoutContext();
|
|
54
36
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
55
37
|
usePeekPin(isAccountMenuOpen);
|
|
56
38
|
|
|
57
|
-
const
|
|
58
|
-
if (!header?.groups?.length) return [];
|
|
59
|
-
return header.groups.flatMap((g) => g.items.filter((i) => i.href));
|
|
60
|
-
}, [header?.groups]);
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Single source of truth for what the footer renders. When `useAuth` returns
|
|
64
|
-
* a real user we use it; otherwise (in dev only) we fall back to a placeholder
|
|
65
|
-
* so the menu stays reachable for debugging. Production stays strict and the
|
|
66
|
-
* component returns null below.
|
|
67
|
-
*/
|
|
68
|
-
const account = React.useMemo<AccountView>(() => {
|
|
69
|
-
if (user) {
|
|
70
|
-
return {
|
|
71
|
-
source: 'user',
|
|
72
|
-
displayName: user.display_username || user.email || 'User',
|
|
73
|
-
email: user.email ?? null,
|
|
74
|
-
avatarUrl: user.avatar ?? '',
|
|
75
|
-
plan: header?.userPlan ?? null,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
return {
|
|
79
|
-
source: 'dev-fallback',
|
|
80
|
-
displayName: 'Guest (dev)',
|
|
81
|
-
email: null,
|
|
82
|
-
avatarUrl: '',
|
|
83
|
-
plan: header?.userPlan ?? 'No session',
|
|
84
|
-
};
|
|
85
|
-
}, [user, header?.userPlan]);
|
|
39
|
+
const { user } = useAuth();
|
|
86
40
|
|
|
87
41
|
const onSecondaryExpand = React.useCallback(() => {
|
|
88
42
|
setSidebarOpen(true);
|
|
89
43
|
}, [setSidebarOpen]);
|
|
90
44
|
|
|
91
|
-
const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
|
|
92
|
-
|
|
93
|
-
// The app's account links sit between the shared Settings shortcuts and Sign out.
|
|
94
|
-
const extraItems = React.useMemo<MenuItem[]>(() => {
|
|
95
|
-
const items: MenuItem[] = [];
|
|
96
|
-
if (accountLinks.length > 0) {
|
|
97
|
-
items.push({ kind: 'separator', id: 'sep-links' });
|
|
98
|
-
for (const link of accountLinks) {
|
|
99
|
-
items.push({ kind: 'item', id: link.href!, label: link.label, icon: link.icon, href: link.href });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return items;
|
|
103
|
-
}, [accountLinks]);
|
|
104
|
-
|
|
105
|
-
// Shared with every host: email, Settings, section shortcuts, Sign out (logout confirm opens over a CLOSED menu).
|
|
106
|
-
const menuItems = useAccountMenuItems({ extra: extraItems, label: headerLabelText });
|
|
107
|
-
|
|
108
45
|
// Hide entirely in production when there's no user (auth still loading or
|
|
109
46
|
// /me failed and the parent guard hasn't redirected yet). In dev keep a
|
|
110
47
|
// placeholder so the footer + Log out are reachable for debugging.
|
|
@@ -118,77 +55,33 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
118
55
|
// production because `isDev` makes this guard statically dead in development.
|
|
119
56
|
if (!user && !isDev) return null;
|
|
120
57
|
|
|
121
|
-
const userInitial = (account.displayName.charAt(0) || '?').toUpperCase();
|
|
122
58
|
const secondary = header?.footerSecondaryAction;
|
|
123
59
|
|
|
124
|
-
const triggerClassName = cn(
|
|
125
|
-
'group h-8 min-w-0 max-w-full justify-start gap-2 rounded-lg px-2 text-left font-normal select-none',
|
|
126
|
-
// Full-width row unless a secondary action shares the footer.
|
|
127
|
-
!secondary && 'w-full',
|
|
128
|
-
'hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
|
|
129
|
-
// No focus ring on the menu trigger — the open menu is the affordance.
|
|
130
|
-
'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
|
131
|
-
isMobile && 'h-11',
|
|
132
|
-
);
|
|
133
|
-
|
|
134
60
|
const secondaryButton = secondary ? (
|
|
135
61
|
<SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
|
|
136
62
|
) : null;
|
|
137
63
|
|
|
138
|
-
const dropdownContentClass = 'w-60 p-1.5';
|
|
139
|
-
// Always open UPWARD over the avatar (Claude pattern). The footer sits at the
|
|
140
|
-
// bottom of the rail in both expanded and collapsed states, so `top` reads
|
|
141
|
-
// correctly either way (vs. `right`, which floated off to the side).
|
|
142
|
-
const dropdownSide = 'top' as const;
|
|
143
|
-
const avatarClass = cn('shrink-0', isMobile ? 'size-7' : 'size-6');
|
|
144
|
-
|
|
145
|
-
// Like Claude: "Name · Plan ⌄". The email lives in the menu header.
|
|
146
|
-
const expandedMeta = (
|
|
147
|
-
<>
|
|
148
|
-
<span className="flex min-w-0 items-baseline gap-1 text-left">
|
|
149
|
-
<span className="truncate text-sm text-sidebar-foreground">{account.displayName}</span>
|
|
150
|
-
{account.plan ? (
|
|
151
|
-
<span className="shrink-0 text-xs text-sidebar-foreground/50">· {account.plan}</span>
|
|
152
|
-
) : null}
|
|
153
|
-
</span>
|
|
154
|
-
<ChevronDown
|
|
155
|
-
className={cn(
|
|
156
|
-
'size-4 shrink-0 text-sidebar-foreground/40 transition-transform duration-150 group-data-[state=open]:rotate-180',
|
|
157
|
-
!secondary && 'ml-auto',
|
|
158
|
-
)}
|
|
159
|
-
aria-hidden
|
|
160
|
-
/>
|
|
161
|
-
</>
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
const triggerButton = (
|
|
165
|
-
<Button type="button" variant="ghost" className={triggerClassName}>
|
|
166
|
-
<Avatar className={avatarClass}>
|
|
167
|
-
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
168
|
-
<AvatarFallback className="text-xs font-medium">{userInitial}</AvatarFallback>
|
|
169
|
-
</Avatar>
|
|
170
|
-
{expandedMeta}
|
|
171
|
-
</Button>
|
|
172
|
-
);
|
|
173
|
-
|
|
174
64
|
const wrapperClass = cn(
|
|
175
|
-
'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60',
|
|
65
|
+
'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60 bg-sidebar',
|
|
176
66
|
SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].footer,
|
|
177
67
|
);
|
|
178
68
|
|
|
179
69
|
return (
|
|
180
70
|
<div className={wrapperClass}>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
71
|
+
{/* Always opens UPWARD over the avatar (Claude pattern): the footer sits at the bottom of the rail. */}
|
|
72
|
+
<AccountMenu
|
|
73
|
+
groups={header?.groups}
|
|
74
|
+
plan={header?.userPlan}
|
|
75
|
+
signIn="none"
|
|
76
|
+
devFallback
|
|
77
|
+
side="top"
|
|
186
78
|
align="start"
|
|
187
79
|
sideOffset={8}
|
|
188
|
-
|
|
80
|
+
open={isAccountMenuOpen}
|
|
81
|
+
onOpenChange={setIsAccountMenuOpen}
|
|
189
82
|
>
|
|
190
|
-
{
|
|
191
|
-
</
|
|
83
|
+
<AccountFooterTrigger hasSecondary={Boolean(secondary)} className={isMobile ? 'h-10 px-2.5' : undefined} avatarClassName={isMobile ? 'size-7' : undefined} />
|
|
84
|
+
</AccountMenu>
|
|
192
85
|
{secondaryButton}
|
|
193
86
|
</div>
|
|
194
87
|
);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one sidebar button: same box, icon and position rules on mobile and desktop.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* In the top bar it opens the sidebar; inside the sidebar header it closes or collapses it. Both are
|
|
4
|
+
* `toggleSidebar`. No tooltip (the Cmd/Ctrl+B binding lives in ui-core's sidebar provider). The labels are plain
|
|
5
|
+
* English: the layouts have no message catalogue (only a locale hook).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
'use client';
|
|
@@ -29,7 +30,6 @@ export const PrivateSidebarTrigger = React.forwardRef<
|
|
|
29
30
|
data-sidebar="trigger"
|
|
30
31
|
variant="ghost"
|
|
31
32
|
size="icon"
|
|
32
|
-
title={label}
|
|
33
33
|
aria-label={label}
|
|
34
34
|
className={cn(TRIGGER_CLASS, className)}
|
|
35
35
|
onClick={(event) => {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account avatar for the top bar: the shared `AccountMenu` (the same items as the sidebar footer) opened downward
|
|
3
|
+
* and aligned to the right edge. Rendered only for a signed-in user; the caller decides whether the bar shows it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
import { AccountAvatarTrigger, AccountMenu } from '../../../components/account';
|
|
11
|
+
|
|
12
|
+
import type { HeaderConfig } from '../types';
|
|
13
|
+
|
|
14
|
+
export function PrivateTopBarAccount({ header }: { header?: HeaderConfig }) {
|
|
15
|
+
return (
|
|
16
|
+
<AccountMenu
|
|
17
|
+
groups={header?.groups}
|
|
18
|
+
plan={header?.userPlan}
|
|
19
|
+
signIn="none"
|
|
20
|
+
side="bottom"
|
|
21
|
+
align="end"
|
|
22
|
+
sideOffset={6}
|
|
23
|
+
>
|
|
24
|
+
<AccountAvatarTrigger />
|
|
25
|
+
</AccountMenu>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -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 overflow-y-auto overflow-x-hidden',
|
|
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. */
|
|
@@ -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
|
});
|