@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
|
@@ -0,0 +1,208 @@
|
|
|
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 { AuthContext } from '@djangocfg/api/auth';
|
|
7
|
+
import { LinkProvider, type LinkComponentProps } from '@djangocfg/ui-core/components';
|
|
8
|
+
|
|
9
|
+
import { ProjectNameProvider } from '../../shared/ProjectContext';
|
|
10
|
+
import { PrivateLayout } from './PrivateLayout';
|
|
11
|
+
|
|
12
|
+
vi.mock('next/navigation', () => ({ usePathname: () => '/', useRouter: () => ({ push: () => {}, replace: () => {} }), useSearchParams: () => new URLSearchParams() }));
|
|
13
|
+
vi.mock('../SettingsLayout/SettingsDialog', () => ({ SettingsDialog: () => null }));
|
|
14
|
+
vi.mock('@djangocfg/api/auth', async () => ({
|
|
15
|
+
AuthContext: (await import('react')).createContext(undefined),
|
|
16
|
+
useAuth: () => ({ user: null }),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('./hooks/useAuthGuard', () => ({ useAuthGuard: () => ({ isLoading: false, isForbidden: false, loadingText: '' }) }));
|
|
19
|
+
|
|
20
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
22
|
+
let phone = true;
|
|
23
|
+
let host: HTMLElement | null = null;
|
|
24
|
+
let root: Root | null = null;
|
|
25
|
+
const navigated: string[] = [];
|
|
26
|
+
|
|
27
|
+
/** A router-style Link: records the navigation instead of loading a page. */
|
|
28
|
+
function RouterLink({ href, children, onClick, ...rest }: LinkComponentProps) {
|
|
29
|
+
return (
|
|
30
|
+
<a
|
|
31
|
+
href={href}
|
|
32
|
+
{...(rest as object)}
|
|
33
|
+
onClick={(e) => {
|
|
34
|
+
onClick?.(e);
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
navigated.push(href);
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</a>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
window.matchMedia = ((query: string) => ({
|
|
46
|
+
matches: query.includes('hover: hover') ? !phone : phone,
|
|
47
|
+
media: query, onchange: null,
|
|
48
|
+
addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false,
|
|
49
|
+
})) as typeof window.matchMedia;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
act(() => root?.unmount());
|
|
54
|
+
host?.remove();
|
|
55
|
+
host = root = null;
|
|
56
|
+
phone = true;
|
|
57
|
+
navigated.length = 0;
|
|
58
|
+
document.cookie = 'sidebar_state=; path=/; max-age=0';
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function mount(props: Partial<React.ComponentProps<typeof PrivateLayout>>, project?: string, landing?: string) {
|
|
62
|
+
host = document.createElement('div');
|
|
63
|
+
document.body.appendChild(host);
|
|
64
|
+
root = createRoot(host);
|
|
65
|
+
await act(async () =>
|
|
66
|
+
root!.render(
|
|
67
|
+
<AuthContext.Provider value={(landing ? { routes: { auth: '/auth', defaultCallback: landing, defaultAuthCallback: '/auth' } } : undefined) as never}>
|
|
68
|
+
<LinkProvider value={RouterLink}>
|
|
69
|
+
<ProjectNameProvider value={project}>
|
|
70
|
+
<PrivateLayout requireAuth={false} sidebar={{ groups: [{ label: 'A', items: [{ label: 'Docs', href: '/docs' }] }] }} {...props}>
|
|
71
|
+
<p>page</p>
|
|
72
|
+
</PrivateLayout>
|
|
73
|
+
</ProjectNameProvider>
|
|
74
|
+
</LinkProvider>
|
|
75
|
+
</AuthContext.Provider>,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const bar = () => document.querySelector('[data-sidebar="topbar"]');
|
|
81
|
+
const barLink = () => bar()?.querySelector('a') ?? null;
|
|
82
|
+
|
|
83
|
+
async function openDrawer() {
|
|
84
|
+
await act(async () => {
|
|
85
|
+
(document.querySelector('[data-sidebar="trigger"]') as HTMLElement).click();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const drawerBrand = () =>
|
|
89
|
+
document.querySelector('[data-sidebar="header"] a') as HTMLAnchorElement | null;
|
|
90
|
+
|
|
91
|
+
describe('brand link and title resolution', () => {
|
|
92
|
+
it('bar title links to "/" by default', async () => {
|
|
93
|
+
await mount({ header: { title: 'Acme' } });
|
|
94
|
+
expect(barLink()?.getAttribute('href')).toBe('/');
|
|
95
|
+
expect(barLink()?.textContent).toBe('Acme');
|
|
96
|
+
expect(barLink()?.getAttribute('aria-label')).toBe('Acme');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('header.href sets the target; click goes through the router link', async () => {
|
|
100
|
+
await mount({ header: { title: 'Acme', href: '/app' } });
|
|
101
|
+
expect(barLink()?.getAttribute('href')).toBe('/app');
|
|
102
|
+
await act(async () => barLink()!.click());
|
|
103
|
+
expect(navigated).toEqual(['/app']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('topBar.title stays as given, not linkified', async () => {
|
|
107
|
+
await mount({ header: { title: 'Acme' }, topBar: { title: 'Calls' } });
|
|
108
|
+
expect(bar()?.textContent).toContain('Calls');
|
|
109
|
+
expect(barLink()).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('drawer brand links home and closes the drawer on click', async () => {
|
|
113
|
+
await mount({ header: { title: 'Acme', href: '/app' } });
|
|
114
|
+
await openDrawer();
|
|
115
|
+
const link = drawerBrand()!;
|
|
116
|
+
expect(link.getAttribute('href')).toBe('/app');
|
|
117
|
+
expect(link.textContent).toContain('Acme');
|
|
118
|
+
expect(document.querySelector('[data-sidebar="sidebar"][data-mobile="true"], [role="dialog"]')).not.toBeNull();
|
|
119
|
+
await act(async () => link.click());
|
|
120
|
+
expect(navigated).toEqual(['/app']);
|
|
121
|
+
expect(document.querySelector('[role="dialog"][data-state="open"]')).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('a custom brand node is rendered as given, not wrapped in a link', async () => {
|
|
125
|
+
await mount({ header: { title: 'Acme', brand: <b data-testid="mine">Mine</b> } }, undefined);
|
|
126
|
+
phone = false;
|
|
127
|
+
await act(async () => root?.unmount());
|
|
128
|
+
host?.remove();
|
|
129
|
+
document.cookie = 'sidebar_state=true; path=/';
|
|
130
|
+
await mount({ header: { title: 'Acme', brand: <b data-testid="mine">Mine</b> } });
|
|
131
|
+
const mine = document.querySelector('[data-testid="mine"]')!;
|
|
132
|
+
expect(mine.closest('a')).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('no header.title: the project name from the provider is shown and linked', async () => {
|
|
136
|
+
await mount({}, 'Proj');
|
|
137
|
+
expect(barLink()?.textContent).toBe('Proj');
|
|
138
|
+
expect(barLink()?.getAttribute('href')).toBe('/');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('explicit header.title wins over the project name', async () => {
|
|
142
|
+
await mount({ header: { title: 'Acme' } }, 'Proj');
|
|
143
|
+
expect(barLink()?.textContent).toBe('Acme');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('no provider and no title: no text, no crash, no empty bar link', async () => {
|
|
147
|
+
await mount({});
|
|
148
|
+
expect(bar()).not.toBeNull();
|
|
149
|
+
expect(barLink()).toBeNull();
|
|
150
|
+
await openDrawer();
|
|
151
|
+
const link = drawerBrand();
|
|
152
|
+
expect(link?.getAttribute('aria-label')).toBe('Home');
|
|
153
|
+
expect(link?.textContent?.trim().length).toBeLessThanOrEqual(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('home href resolution: header.href > sidebar.homeHref > auth defaultCallback > "/"', () => {
|
|
157
|
+
it('auth provider defaultCallback is the target when nothing else is set', async () => {
|
|
158
|
+
await mount({ header: { title: 'Acme' } }, undefined, '/workspace');
|
|
159
|
+
expect(barLink()?.getAttribute('href')).toBe('/workspace');
|
|
160
|
+
await openDrawer();
|
|
161
|
+
expect(drawerBrand()?.getAttribute('href')).toBe('/workspace');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('sidebar.homeHref beats defaultCallback; header.href beats both', async () => {
|
|
165
|
+
await mount({ header: { title: 'Acme' }, sidebar: { homeHref: '/home', groups: [] } }, undefined, '/workspace');
|
|
166
|
+
expect(barLink()?.getAttribute('href')).toBe('/home');
|
|
167
|
+
await act(async () => root?.unmount());
|
|
168
|
+
host?.remove();
|
|
169
|
+
await mount({ header: { title: 'Acme', href: '/x' }, sidebar: { homeHref: '/home', groups: [] } }, undefined, '/workspace');
|
|
170
|
+
expect(barLink()?.getAttribute('href')).toBe('/x');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('outside the auth provider: "/", no crash', async () => {
|
|
174
|
+
await mount({ header: { title: 'Acme' } });
|
|
175
|
+
expect(barLink()?.getAttribute('href')).toBe('/');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('sidebar header toggle', () => {
|
|
180
|
+
it('phone drawer header is the brand only: no toggle button, the link stays, accessible name present', async () => {
|
|
181
|
+
await mount({ header: { title: 'Acme' } });
|
|
182
|
+
await openDrawer();
|
|
183
|
+
const header = document.querySelector('[data-sidebar="header"]')!;
|
|
184
|
+
expect(header.querySelector('button')).toBeNull();
|
|
185
|
+
expect(header.querySelector('[data-sidebar="trigger"]')).toBeNull();
|
|
186
|
+
expect(header.querySelector('a')?.textContent).toContain('Acme');
|
|
187
|
+
// The primitive keeps an sr-only title for screen readers.
|
|
188
|
+
expect(document.querySelector('[role="dialog"] .sr-only')?.textContent).toContain('Sidebar');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('phone drawer closes on Escape', async () => {
|
|
192
|
+
await mount({ header: { title: 'Acme' } });
|
|
193
|
+
await openDrawer();
|
|
194
|
+
expect(document.querySelector('[role="dialog"][data-state="open"]')).not.toBeNull();
|
|
195
|
+
await act(async () => {
|
|
196
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
197
|
+
});
|
|
198
|
+
expect(document.querySelector('[role="dialog"][data-state="open"]')).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('desktop docked sidebar header keeps its toggle', async () => {
|
|
202
|
+
phone = false;
|
|
203
|
+
document.cookie = 'sidebar_state=true; path=/';
|
|
204
|
+
await mount({ header: { title: 'Acme' } });
|
|
205
|
+
expect(document.querySelector('[data-sidebar="header"] [data-sidebar="trigger"]')).not.toBeNull();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React, { act } from 'react';
|
|
3
3
|
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
-
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
|
|
6
6
|
import { SidebarProvider } from '@djangocfg/ui-core/components';
|
|
7
7
|
|
|
8
8
|
import { PrivateContent } from './PrivateContent';
|
|
9
9
|
|
|
10
|
+
vi.mock('@djangocfg/api/auth', () => ({ useAuth: () => ({ user: { email: 'a@b.c', display_username: 'Mark', avatar: null } }) }));
|
|
11
|
+
vi.mock('next/navigation', () => ({ usePathname: () => '/', useRouter: () => ({ push: () => {}, replace: () => {} }), useSearchParams: () => new URLSearchParams() }));
|
|
12
|
+
|
|
10
13
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
14
|
|
|
15
|
+
let phone = false;
|
|
12
16
|
let host: HTMLElement | null = null;
|
|
13
17
|
let root: Root | null = null;
|
|
14
18
|
|
|
15
19
|
beforeAll(() => {
|
|
16
20
|
window.matchMedia ??= ((query: string) => ({
|
|
17
|
-
matches:
|
|
21
|
+
matches: phone,
|
|
18
22
|
media: query,
|
|
19
23
|
onchange: null,
|
|
20
24
|
addEventListener: () => {},
|
|
@@ -29,17 +33,19 @@ afterEach(() => {
|
|
|
29
33
|
act(() => root?.unmount());
|
|
30
34
|
host?.remove();
|
|
31
35
|
host = root = null;
|
|
36
|
+
phone = false;
|
|
32
37
|
});
|
|
33
38
|
|
|
34
|
-
function render(defaultOpen: boolean, padding: 'none' | 'default') {
|
|
39
|
+
function render(defaultOpen: boolean, padding: 'none' | 'default', title?: React.ReactNode, actions?: React.ReactNode, showAccount = false) {
|
|
35
40
|
host = document.createElement('div');
|
|
36
41
|
document.body.appendChild(host);
|
|
37
42
|
root = createRoot(host);
|
|
38
43
|
act(() =>
|
|
39
44
|
root!.render(
|
|
40
45
|
<SidebarProvider defaultOpen={defaultOpen}>
|
|
41
|
-
<PrivateContent padding={padding} scroll={padding === 'none' ? 'hidden' : 'auto'}>
|
|
46
|
+
<PrivateContent title={title} actions={actions} showAccount={showAccount} padding={padding} scroll={padding === 'none' ? 'hidden' : 'auto'}>
|
|
42
47
|
<p>page</p>
|
|
48
|
+
<input aria-label="field" />
|
|
43
49
|
</PrivateContent>
|
|
44
50
|
</SidebarProvider>,
|
|
45
51
|
),
|
|
@@ -48,18 +54,177 @@ function render(defaultOpen: boolean, padding: 'none' | 'default') {
|
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
describe('PrivateContent top bar', () => {
|
|
51
|
-
it.each(['none', 'default'] as const)('
|
|
57
|
+
it.each(['none', 'default'] as const)('desktop, sidebar collapsed: the same overlay bar, content offset by its real height (padding %s)', (padding) => {
|
|
52
58
|
const el = render(false, padding);
|
|
53
59
|
const bar = el.querySelector('[data-sidebar="topbar"]')!;
|
|
60
|
+
const col = bar.parentElement as HTMLElement;
|
|
54
61
|
expect(bar).not.toBeNull();
|
|
55
|
-
expect(
|
|
56
|
-
expect(bar.className).
|
|
62
|
+
expect(col.style.getPropertyValue('--layout-bar-height')).toContain('3rem');
|
|
63
|
+
expect(bar.className).toContain('h-[var(--layout-bar-height)]');
|
|
64
|
+
expect(bar.className).toContain('pt-[var(--safe-top');
|
|
65
|
+
expect(bar.className).toMatch(/\babsolute\b/);
|
|
57
66
|
expect(bar.querySelector('[data-sidebar="trigger"]')).not.toBeNull();
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
expect((bar.nextElementSibling as HTMLElement).className).toContain('layout-bar-height');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('draws no bar while the sidebar is docked, and the bar height is 0', () => {
|
|
71
|
+
const el = render(true, 'default');
|
|
72
|
+
expect(el.querySelector('[data-sidebar="topbar"]')).toBeNull();
|
|
73
|
+
expect(el.querySelector<HTMLElement>('[style*="--layout-bar-height"]')!.style.getPropertyValue('--layout-bar-height')).toBe('0px');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each(['none', 'default'] as const)('phone + padding %s: one fixed-height in-flow bar, the same for every page', (padding) => {
|
|
77
|
+
phone = true;
|
|
78
|
+
const el = render(false, padding, 'Acme');
|
|
79
|
+
const bar = el.querySelector('[data-sidebar="topbar"]')!;
|
|
80
|
+
const col = bar.parentElement as HTMLElement;
|
|
81
|
+
expect(col.style.getPropertyValue('--layout-bar-height')).toBe('calc(var(--safe-top, env(safe-area-inset-top, 0px)) + 3rem)');
|
|
82
|
+
expect(bar.className).toContain('h-[var(--layout-bar-height)]');
|
|
83
|
+
expect(bar.className).toMatch(/\bborder-b\b/);
|
|
84
|
+
expect(bar.className).toContain('bg-background');
|
|
85
|
+
expect(bar.className).toContain('pt-[var(--safe-top');
|
|
86
|
+
expect(bar.className).toContain('pl-[calc(var(--safe-left');
|
|
87
|
+
expect(bar.className).toContain('pr-[calc(var(--safe-right');
|
|
88
|
+
// Overlays the column (transform-hidden), so hiding never reflows; the content pads by the bar height.
|
|
89
|
+
expect(bar.className).toMatch(/\babsolute\b/);
|
|
90
|
+
expect(bar.className).toContain('transition-transform');
|
|
91
|
+
expect((bar.nextElementSibling as HTMLElement).className).toContain('layout-bar-height');
|
|
92
|
+
expect(bar.querySelector('[data-sidebar="trigger"]')!.className).not.toMatch(/\b(rounded-full|border|shadow)/);
|
|
93
|
+
expect(bar.textContent).toBe('Acme');
|
|
94
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
95
|
+
// Content is the next sibling: it starts under the bar and carries no trigger offsets.
|
|
96
|
+
const scroller = bar.nextElementSibling as HTMLElement;
|
|
97
|
+
expect(scroller.className).not.toContain('layout-trigger');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('phone without a title shows the toggle only; desktop sets the bar height to 0', () => {
|
|
101
|
+
phone = true;
|
|
102
|
+
expect(render(false, 'default').querySelector('[data-sidebar="topbar"]')!.textContent).toBe('');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('top bar is shrink-0 and the content below it is flex-1 min-h-0 (document cannot grow)', () => {
|
|
106
|
+
const el = render(false, 'default');
|
|
107
|
+
const bar = el.querySelector('[data-sidebar="topbar"]')!;
|
|
108
|
+
const col = bar.parentElement!;
|
|
109
|
+
const scroller = bar.nextElementSibling as HTMLElement;
|
|
110
|
+
expect(bar.className).toMatch(/\bshrink-0\b/);
|
|
111
|
+
expect(col.className).toMatch(/\bmin-h-0\b/);
|
|
112
|
+
expect(scroller.className).toMatch(/\bmin-h-0\b/);
|
|
113
|
+
expect(scroller.className).toMatch(/\bflex-1\b/);
|
|
114
|
+
expect(scroller.className).toMatch(/\boverflow-y-auto\b/);
|
|
60
115
|
});
|
|
61
116
|
|
|
62
|
-
|
|
63
|
-
|
|
117
|
+
describe('phone auto-hide', () => {
|
|
118
|
+
function mountPhone(isPhone = true) {
|
|
119
|
+
phone = isPhone;
|
|
120
|
+
// Deferred like a real frame: useScroll's store expects the id to be assigned before the callback runs.
|
|
121
|
+
let id = 0;
|
|
122
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
123
|
+
void Promise.resolve().then(() => cb(0));
|
|
124
|
+
return ++id;
|
|
125
|
+
});
|
|
126
|
+
const el = render(false, 'default', 'Acme');
|
|
127
|
+
const bar = el.querySelector('[data-sidebar="topbar"]') as HTMLElement;
|
|
128
|
+
const scroller = bar.nextElementSibling as HTMLElement;
|
|
129
|
+
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 3000 });
|
|
130
|
+
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 800 });
|
|
131
|
+
const scrollTo = (top: number) =>
|
|
132
|
+
act(async () => {
|
|
133
|
+
scroller.scrollTop = top;
|
|
134
|
+
scroller.dispatchEvent(new Event('scroll'));
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
await Promise.resolve();
|
|
137
|
+
});
|
|
138
|
+
return { el, bar, scroller, scrollTo };
|
|
139
|
+
}
|
|
140
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
141
|
+
|
|
142
|
+
it('hides on scroll down, shows on scroll up and at the top', async () => {
|
|
143
|
+
const { bar, scrollTo } = mountPhone();
|
|
144
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
145
|
+
await scrollTo(120);
|
|
146
|
+
await scrollTo(200);
|
|
147
|
+
expect(bar.getAttribute('data-hidden')).toBe('true');
|
|
148
|
+
await scrollTo(180);
|
|
149
|
+
await scrollTo(150);
|
|
150
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
151
|
+
await scrollTo(300);
|
|
152
|
+
await scrollTo(400);
|
|
153
|
+
expect(bar.getAttribute('data-hidden')).toBe('true');
|
|
154
|
+
await scrollTo(0);
|
|
155
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('desktop (sidebar collapsed): the bar is static and never hides', async () => {
|
|
159
|
+
const { bar, scrollTo } = mountPhone(false);
|
|
160
|
+
await scrollTo(120);
|
|
161
|
+
await scrollTo(400);
|
|
162
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('stays visible while the drawer is open', async () => {
|
|
166
|
+
const { bar, scrollTo } = mountPhone();
|
|
167
|
+
act(() => (bar.querySelector('[data-sidebar="trigger"]') as HTMLElement).click());
|
|
168
|
+
await scrollTo(120);
|
|
169
|
+
await scrollTo(400);
|
|
170
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('stays visible while a field has focus', async () => {
|
|
174
|
+
const { el, bar, scrollTo } = mountPhone();
|
|
175
|
+
act(() => (el.querySelector('input') as HTMLInputElement).focus());
|
|
176
|
+
await scrollTo(120);
|
|
177
|
+
await scrollTo(400);
|
|
178
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('never hides when the content is not scrollable', async () => {
|
|
182
|
+
const { bar, scroller, scrollTo } = mountPhone();
|
|
183
|
+
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 800 });
|
|
184
|
+
await scrollTo(120);
|
|
185
|
+
await scrollTo(400);
|
|
186
|
+
expect(bar.getAttribute('data-hidden')).toBe('false');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('topBar content', () => {
|
|
191
|
+
it('renders title and right-hand actions; the toggle stays first and does not shrink', () => {
|
|
192
|
+
phone = true;
|
|
193
|
+
const bar = render(false, 'default', 'Calls', <button>New</button>).querySelector('[data-sidebar="topbar"]')!;
|
|
194
|
+
const children = [...bar.children];
|
|
195
|
+
expect(children[0].getAttribute('data-sidebar')).toBe('trigger');
|
|
196
|
+
expect(children[0].className).toMatch(/\bshrink-0\b/);
|
|
197
|
+
const title = children[1] as HTMLElement;
|
|
198
|
+
expect(title.textContent).toBe('Calls');
|
|
199
|
+
expect(title.className).toContain('truncate');
|
|
200
|
+
expect(title.className).toContain('flex-1');
|
|
201
|
+
const actions = children[2] as HTMLElement;
|
|
202
|
+
expect(actions.className).toMatch(/\bshrink-0\b/);
|
|
203
|
+
expect(actions.className).toContain('items-center');
|
|
204
|
+
expect(actions.textContent).toBe('New');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('desktop, sidebar collapsed: same markup, title and actions', () => {
|
|
208
|
+
const bar = render(false, 'default', 'Calls', <button>New</button>).querySelector('[data-sidebar="topbar"]')!;
|
|
209
|
+
expect(bar.children[0].getAttribute('data-sidebar')).toBe('trigger');
|
|
210
|
+
expect(bar.children[1].textContent).toBe('Calls');
|
|
211
|
+
expect(bar.children[2].textContent).toBe('New');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('account avatar: after the actions, in the actions container, only with showAccount', () => {
|
|
215
|
+
phone = true;
|
|
216
|
+
const bar = render(false, 'default', 'Calls', <button>New</button>, true).querySelector('[data-sidebar="topbar"]')!;
|
|
217
|
+
const actions = bar.querySelector('[data-sidebar="topbar-actions"]')!;
|
|
218
|
+
expect(actions.children[0].textContent).toBe('New');
|
|
219
|
+
expect(actions.children[1].getAttribute('data-sidebar')).toBe('topbar-account');
|
|
220
|
+
expect(actions.children[1].getAttribute('aria-label')).toBe('User menu');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('no avatar without showAccount', () => {
|
|
224
|
+
phone = true;
|
|
225
|
+
const bar = render(false, 'default', 'Calls').querySelector('[data-sidebar="topbar"]')!;
|
|
226
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
227
|
+
expect(bar.querySelector('[data-sidebar="topbar-actions"]')).toBeNull();
|
|
228
|
+
});
|
|
64
229
|
});
|
|
65
230
|
});
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private layout main column: the scroll surface, plus
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Private layout main column: the scroll surface, plus ONE top bar whenever the sidebar is not visible: always on a
|
|
3
|
+
* phone (below md), on desktop while the sidebar is collapsed. Same markup and props at every width: sidebar toggle,
|
|
4
|
+
* title, actions, account avatar. Height `--layout-bar-height` = safe-top + 3rem; it overlays the column and the
|
|
5
|
+
* content has that height as top padding, so pages need to know nothing about it. Below md it slides away on
|
|
6
|
+
* scroll-down (same `useNavbarScroll` policy as PublicLayout, driven by the content scroller); on desktop it is static.
|
|
7
|
+
* `--layout-bar-height` is `0px` only when no bar is shown (docked sidebar, or no sidebar at all).
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
'use client';
|
|
9
11
|
|
|
10
12
|
import React, { ReactNode } from 'react';
|
|
11
13
|
|
|
12
|
-
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
14
|
+
import { Link, useSidebar } from '@djangocfg/ui-core/components';
|
|
13
15
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
16
|
|
|
15
17
|
import type { LayoutVisualConfig } from '../../types';
|
|
16
|
-
import {
|
|
18
|
+
import type { HeaderConfig } from '../types';
|
|
19
|
+
import { LAYOUT_BAR_HEIGHT } from '../geometry';
|
|
20
|
+
import { useNavbarScroll } from '../../PublicLayout/hooks/useNavbarScroll';
|
|
17
21
|
import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
|
|
22
|
+
import { PrivateTopBarAccount } from './PrivateTopBarAccount';
|
|
18
23
|
|
|
19
24
|
interface PrivateContentProps {
|
|
20
25
|
children: ReactNode;
|
|
@@ -28,6 +33,18 @@ interface PrivateContentProps {
|
|
|
28
33
|
scroll?: 'auto' | 'hidden';
|
|
29
34
|
/** When false, no mobile hamburger (e.g. layout without a sidebar). Default true. */
|
|
30
35
|
hasSidebar?: boolean;
|
|
36
|
+
/** A change scrolls the content surface back to the top (the document no longer scrolls, so the router cannot). */
|
|
37
|
+
pathname?: string;
|
|
38
|
+
/** Bar title next to the toggle (`topBar.title`, else `header.title`). */
|
|
39
|
+
title?: ReactNode;
|
|
40
|
+
/** When set, the title is a router link to this href (the default `header.title` fallback only). */
|
|
41
|
+
titleHref?: string;
|
|
42
|
+
/** Bar right-hand actions (`topBar.actions`), left of the account avatar. */
|
|
43
|
+
actions?: ReactNode;
|
|
44
|
+
/** Show the account avatar at the right end of the bar (signed-in user and `topBar.account !== false`). */
|
|
45
|
+
showAccount?: boolean;
|
|
46
|
+
/** Account menu config (links, plan) for the avatar. */
|
|
47
|
+
header?: HeaderConfig;
|
|
31
48
|
/** Visual config from PrivateLayout — controls maxWidth in boxed mode. */
|
|
32
49
|
visual?: LayoutVisualConfig;
|
|
33
50
|
}
|
|
@@ -45,26 +62,52 @@ export function PrivateContent({
|
|
|
45
62
|
scroll = 'auto',
|
|
46
63
|
hasSidebar = true,
|
|
47
64
|
visual,
|
|
65
|
+
pathname,
|
|
66
|
+
title,
|
|
67
|
+
titleHref,
|
|
68
|
+
actions,
|
|
69
|
+
showAccount = false,
|
|
70
|
+
header,
|
|
48
71
|
}: PrivateContentProps) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
72
|
+
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
73
|
+
const lastPath = React.useRef(pathname);
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
if (lastPath.current === pathname) return;
|
|
76
|
+
lastPath.current = pathname;
|
|
77
|
+
scrollRef.current?.scrollTo?.({ top: 0 });
|
|
78
|
+
}, [pathname]);
|
|
51
79
|
|
|
52
|
-
const
|
|
80
|
+
const { isMobile, state, openMobile } = useSidebar();
|
|
53
81
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
82
|
+
const showBar = hasSidebar && (isMobile || state === 'collapsed');
|
|
83
|
+
const mobileBar = showBar && isMobile;
|
|
84
|
+
|
|
85
|
+
// Auto-hide: the content scroller (the document never scrolls) drives the shared navbar policy. The bar stays put
|
|
86
|
+
// while the drawer is open or a field has focus (iOS keyboard), and when there is nothing to scroll.
|
|
87
|
+
const { hidden: scrolledAway } = useNavbarScroll({ hideNavOnScroll: mobileBar, target: scrollRef, respectReducedMotion: false });
|
|
88
|
+
const [fieldFocused, setFieldFocused] = React.useState(false);
|
|
89
|
+
const onFocusChange = (e: React.FocusEvent) => {
|
|
90
|
+
const el = e.target as HTMLElement;
|
|
91
|
+
setFieldFocused(
|
|
92
|
+
e.type === 'focus' && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable),
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
const barHidden = mobileBar && scrolledAway && !openMobile && !fieldFocused;
|
|
96
|
+
|
|
97
|
+
const topPadding = showBar
|
|
98
|
+
? 'pt-[calc(var(--layout-bar-height)+1rem)]'
|
|
58
99
|
: hasSidebar
|
|
59
100
|
? 'pt-6 lg:pt-8'
|
|
60
101
|
: 'pt-4 sm:pt-6 lg:pt-8';
|
|
61
102
|
|
|
62
103
|
const scrollAreaClass = cn(
|
|
63
104
|
'min-h-0 flex-1 bg-background',
|
|
105
|
+
// The bar overlays the column, so every page starts below it.
|
|
106
|
+
showBar && padding === 'none' && 'pt-[var(--layout-bar-height)]',
|
|
64
107
|
scroll === 'auto' ? 'overflow-y-auto' : 'flex flex-col overflow-hidden',
|
|
65
108
|
padding === 'default' && scroll === 'auto' && [
|
|
66
109
|
'px-4 sm:px-6 lg:px-8',
|
|
67
|
-
'pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] lg:pb-[calc(2rem+env(safe-area-inset-bottom,0px))]',
|
|
110
|
+
'pb-[calc(1rem+var(--safe-bottom,env(safe-area-inset-bottom,0px)))] sm:pb-[calc(1.5rem+var(--safe-bottom,env(safe-area-inset-bottom,0px)))] lg:pb-[calc(2rem+var(--safe-bottom,env(safe-area-inset-bottom,0px)))]',
|
|
68
111
|
topPadding,
|
|
69
112
|
],
|
|
70
113
|
);
|
|
@@ -72,22 +115,39 @@ export function PrivateContent({
|
|
|
72
115
|
const innerWidthClass = MAX_WIDTH_CLASS[visual?.maxWidth ?? 'none'];
|
|
73
116
|
|
|
74
117
|
return (
|
|
75
|
-
<div
|
|
76
|
-
|
|
118
|
+
<div
|
|
119
|
+
className="relative flex min-h-0 min-w-0 flex-1 flex-col"
|
|
120
|
+
style={{ '--layout-bar-height': showBar ? LAYOUT_BAR_HEIGHT : '0px' } as React.CSSProperties}
|
|
121
|
+
>
|
|
122
|
+
{showBar ? (
|
|
77
123
|
<div
|
|
78
124
|
data-sidebar="topbar"
|
|
79
|
-
|
|
125
|
+
data-hidden={barHidden ? 'true' : 'false'}
|
|
126
|
+
className="absolute inset-x-0 top-0 z-20 box-border flex h-[var(--layout-bar-height)] shrink-0 items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none data-[hidden=true]:-translate-y-full border-b border-border bg-background pl-[calc(var(--safe-left,env(safe-area-inset-left,0px))+0.75rem)] pr-[calc(var(--safe-right,env(safe-area-inset-right,0px))+0.75rem)] pt-[var(--safe-top,env(safe-area-inset-top,0px))]"
|
|
80
127
|
>
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
128
|
+
<PrivateSidebarTrigger className="text-muted-foreground hover:text-foreground" />
|
|
129
|
+
<span className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight">
|
|
130
|
+
{titleHref ? (
|
|
131
|
+
<Link
|
|
132
|
+
href={titleHref}
|
|
133
|
+
aria-label={typeof title === 'string' && title ? title : 'Home'}
|
|
134
|
+
className="rounded-md text-foreground no-underline outline-none ring-ring transition-opacity hover:no-underline hover:opacity-80 focus-visible:ring-2"
|
|
135
|
+
>
|
|
136
|
+
{title}
|
|
137
|
+
</Link>
|
|
138
|
+
) : (
|
|
139
|
+
title
|
|
140
|
+
)}
|
|
141
|
+
</span>
|
|
142
|
+
{actions || showAccount ? (
|
|
143
|
+
<div data-sidebar="topbar-actions" className="flex shrink-0 items-center gap-2">
|
|
144
|
+
{actions}
|
|
145
|
+
{showAccount ? <PrivateTopBarAccount header={header} /> : null}
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
88
148
|
</div>
|
|
89
149
|
) : null}
|
|
90
|
-
<div className={scrollAreaClass}>
|
|
150
|
+
<div ref={scrollRef} className={scrollAreaClass} onFocusCapture={onFocusChange} onBlurCapture={onFocusChange}>
|
|
91
151
|
{innerWidthClass ? <div className={innerWidthClass}>{children}</div> : children}
|
|
92
152
|
</div>
|
|
93
153
|
</div>
|
|
@@ -20,9 +20,11 @@ interface PrivateSidebarProps {
|
|
|
20
20
|
header?: HeaderConfig;
|
|
21
21
|
pathname?: string;
|
|
22
22
|
variant?: 'sidebar' | 'inset';
|
|
23
|
+
/** The top bar shows the account avatar (phone drawer then has no footer account). */
|
|
24
|
+
accountInBar?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'sidebar' }: PrivateSidebarProps) {
|
|
27
|
+
export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'sidebar', accountInBar = false }: PrivateSidebarProps) {
|
|
26
28
|
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
27
29
|
const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
|
|
28
30
|
|
|
@@ -31,7 +33,7 @@ export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'side
|
|
|
31
33
|
}, [pathname, isMobile, setOpenMobile]);
|
|
32
34
|
|
|
33
35
|
return (
|
|
34
|
-
<PrivateLayoutProvider sidebar={sidebar} header={header} pathname={pathname} isMobile={isMobile}>
|
|
36
|
+
<PrivateLayoutProvider sidebar={sidebar} header={header} pathname={pathname} isMobile={isMobile} accountInBar={accountInBar}>
|
|
35
37
|
<Sidebar
|
|
36
38
|
ref={setSidebarRef}
|
|
37
39
|
collapsible="offcanvas"
|