@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,230 @@
|
|
|
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 { PrivateContent } from './PrivateContent';
|
|
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
|
+
|
|
13
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
let phone = false;
|
|
16
|
+
let host: HTMLElement | null = null;
|
|
17
|
+
let root: Root | null = null;
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
window.matchMedia ??= ((query: string) => ({
|
|
21
|
+
matches: phone,
|
|
22
|
+
media: query,
|
|
23
|
+
onchange: null,
|
|
24
|
+
addEventListener: () => {},
|
|
25
|
+
removeEventListener: () => {},
|
|
26
|
+
addListener: () => {},
|
|
27
|
+
removeListener: () => {},
|
|
28
|
+
dispatchEvent: () => false,
|
|
29
|
+
})) as typeof window.matchMedia;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
act(() => root?.unmount());
|
|
34
|
+
host?.remove();
|
|
35
|
+
host = root = null;
|
|
36
|
+
phone = false;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function render(defaultOpen: boolean, padding: 'none' | 'default', title?: React.ReactNode, actions?: React.ReactNode, showAccount = false) {
|
|
40
|
+
host = document.createElement('div');
|
|
41
|
+
document.body.appendChild(host);
|
|
42
|
+
root = createRoot(host);
|
|
43
|
+
act(() =>
|
|
44
|
+
root!.render(
|
|
45
|
+
<SidebarProvider defaultOpen={defaultOpen}>
|
|
46
|
+
<PrivateContent title={title} actions={actions} showAccount={showAccount} padding={padding} scroll={padding === 'none' ? 'hidden' : 'auto'}>
|
|
47
|
+
<p>page</p>
|
|
48
|
+
<input aria-label="field" />
|
|
49
|
+
</PrivateContent>
|
|
50
|
+
</SidebarProvider>,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
return host;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('PrivateContent top bar', () => {
|
|
57
|
+
it.each(['none', 'default'] as const)('desktop, sidebar collapsed: the same overlay bar, content offset by its real height (padding %s)', (padding) => {
|
|
58
|
+
const el = render(false, padding);
|
|
59
|
+
const bar = el.querySelector('[data-sidebar="topbar"]')!;
|
|
60
|
+
const col = bar.parentElement as HTMLElement;
|
|
61
|
+
expect(bar).not.toBeNull();
|
|
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/);
|
|
66
|
+
expect(bar.querySelector('[data-sidebar="trigger"]')).not.toBeNull();
|
|
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/);
|
|
115
|
+
});
|
|
116
|
+
|
|
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
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private layout main column: the scroll surface, plus the sidebar
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
'use client';
|
|
8
11
|
|
|
9
12
|
import React, { ReactNode } from 'react';
|
|
10
13
|
|
|
11
|
-
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
14
|
+
import { Link, useSidebar } from '@djangocfg/ui-core/components';
|
|
12
15
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
16
|
|
|
14
17
|
import type { LayoutVisualConfig } from '../../types';
|
|
15
|
-
import {
|
|
18
|
+
import type { HeaderConfig } from '../types';
|
|
19
|
+
import { LAYOUT_BAR_HEIGHT } from '../geometry';
|
|
20
|
+
import { useNavbarScroll } from '../../PublicLayout/hooks/useNavbarScroll';
|
|
16
21
|
import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
|
|
22
|
+
import { PrivateTopBarAccount } from './PrivateTopBarAccount';
|
|
17
23
|
|
|
18
24
|
interface PrivateContentProps {
|
|
19
25
|
children: ReactNode;
|
|
@@ -27,6 +33,18 @@ interface PrivateContentProps {
|
|
|
27
33
|
scroll?: 'auto' | 'hidden';
|
|
28
34
|
/** When false, no mobile hamburger (e.g. layout without a sidebar). Default true. */
|
|
29
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;
|
|
30
48
|
/** Visual config from PrivateLayout — controls maxWidth in boxed mode. */
|
|
31
49
|
visual?: LayoutVisualConfig;
|
|
32
50
|
}
|
|
@@ -44,25 +62,52 @@ export function PrivateContent({
|
|
|
44
62
|
scroll = 'auto',
|
|
45
63
|
hasSidebar = true,
|
|
46
64
|
visual,
|
|
65
|
+
pathname,
|
|
66
|
+
title,
|
|
67
|
+
titleHref,
|
|
68
|
+
actions,
|
|
69
|
+
showAccount = false,
|
|
70
|
+
header,
|
|
47
71
|
}: PrivateContentProps) {
|
|
48
|
-
const
|
|
49
|
-
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]);
|
|
50
79
|
|
|
51
|
-
const
|
|
80
|
+
const { isMobile, state, openMobile } = useSidebar();
|
|
52
81
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
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)]'
|
|
56
99
|
: hasSidebar
|
|
57
|
-
? '
|
|
100
|
+
? 'pt-6 lg:pt-8'
|
|
58
101
|
: 'pt-4 sm:pt-6 lg:pt-8';
|
|
59
102
|
|
|
60
103
|
const scrollAreaClass = cn(
|
|
61
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)]',
|
|
62
107
|
scroll === 'auto' ? 'overflow-y-auto' : 'flex flex-col overflow-hidden',
|
|
63
108
|
padding === 'default' && scroll === 'auto' && [
|
|
64
109
|
'px-4 sm:px-6 lg:px-8',
|
|
65
|
-
'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)))]',
|
|
66
111
|
topPadding,
|
|
67
112
|
],
|
|
68
113
|
);
|
|
@@ -70,17 +115,39 @@ export function PrivateContent({
|
|
|
70
115
|
const innerWidthClass = MAX_WIDTH_CLASS[visual?.maxWidth ?? 'none'];
|
|
71
116
|
|
|
72
117
|
return (
|
|
73
|
-
<div
|
|
74
|
-
|
|
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 ? (
|
|
75
123
|
<div
|
|
76
|
-
data-sidebar="
|
|
77
|
-
{
|
|
78
|
-
className="absolute
|
|
124
|
+
data-sidebar="topbar"
|
|
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))]"
|
|
79
127
|
>
|
|
80
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}
|
|
81
148
|
</div>
|
|
82
149
|
) : null}
|
|
83
|
-
<div className={scrollAreaClass}>
|
|
150
|
+
<div ref={scrollRef} className={scrollAreaClass} onFocusCapture={onFocusChange} onBlurCapture={onFocusChange}>
|
|
84
151
|
{innerWidthClass ? <div className={innerWidthClass}>{children}</div> : children}
|
|
85
152
|
</div>
|
|
86
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"
|
|
@@ -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-base 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
|
+
}
|