@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,213 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { PrivateLayout } from './PrivateLayout';
|
|
7
|
+
|
|
8
|
+
vi.mock('next/navigation', () => ({ usePathname: () => '/', useRouter: () => ({ push: () => {}, replace: () => {} }), useSearchParams: () => new URLSearchParams() }));
|
|
9
|
+
|
|
10
|
+
// PrivateLayout lazy-loads the settings dialog without its own Suspense; keep the test to the shell.
|
|
11
|
+
vi.mock('../SettingsLayout/SettingsDialog', () => ({ SettingsDialog: () => null }));
|
|
12
|
+
let currentUser: { email: string; display_username: string; avatar: null } | null = { email: 'a@b.c', display_username: 'Mark', avatar: null };
|
|
13
|
+
vi.mock('@djangocfg/api/auth', async () => ({
|
|
14
|
+
AuthContext: (await import('react')).createContext(undefined),
|
|
15
|
+
useAuth: () => ({ user: currentUser }),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('./hooks/useAuthGuard', () => ({ useAuthGuard: () => ({ isLoading: false, isForbidden: false, loadingText: '' }) }));
|
|
18
|
+
|
|
19
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
let phone = true;
|
|
22
|
+
let host: HTMLElement | null = null;
|
|
23
|
+
let root: Root | null = null;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
window.matchMedia = ((query: string) => ({
|
|
27
|
+
// A phone has no precise hover pointer; a desktop does.
|
|
28
|
+
matches: query.includes('hover: hover') ? !phone : phone,
|
|
29
|
+
media: query,
|
|
30
|
+
onchange: null,
|
|
31
|
+
addEventListener: () => {},
|
|
32
|
+
removeEventListener: () => {},
|
|
33
|
+
addListener: () => {},
|
|
34
|
+
removeListener: () => {},
|
|
35
|
+
dispatchEvent: () => false,
|
|
36
|
+
})) as typeof window.matchMedia;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
act(() => root?.unmount());
|
|
41
|
+
host?.remove();
|
|
42
|
+
host = root = null;
|
|
43
|
+
phone = true;
|
|
44
|
+
currentUser = { email: 'a@b.c', display_username: 'Mark', avatar: null };
|
|
45
|
+
document.cookie = 'sidebar_state=; path=/; max-age=0';
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function mount(props: Partial<React.ComponentProps<typeof PrivateLayout>>) {
|
|
49
|
+
host = document.createElement('div');
|
|
50
|
+
document.body.appendChild(host);
|
|
51
|
+
root = createRoot(host);
|
|
52
|
+
await act(async () =>
|
|
53
|
+
root!.render(
|
|
54
|
+
<PrivateLayout
|
|
55
|
+
requireAuth={false}
|
|
56
|
+
sidebar={{ groups: [{ label: 'A', items: [{ label: 'Home', href: '/' }] }] }}
|
|
57
|
+
header={{ title: 'Acme' }}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
<p>page</p>
|
|
61
|
+
</PrivateLayout>,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
return document.querySelector('[data-sidebar="topbar"]');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('PrivateLayout topBar', () => {
|
|
68
|
+
it('title falls back to header.title', async () => {
|
|
69
|
+
expect((await mount({ topBar: { account: false } }))?.textContent).toBe('Acme');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('topBar.title overrides it and topBar.actions render on the right', async () => {
|
|
73
|
+
const bar = (await mount({ topBar: { title: 'Calls', actions: <button>New</button> } }))!;
|
|
74
|
+
expect(bar.textContent).toBe('CallsNewM');
|
|
75
|
+
expect(bar.querySelector('[data-sidebar="topbar-actions"]')!.firstElementChild?.textContent).toBe('New');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('desktop, sidebar pinned open: no bar, the account stays in the sidebar footer', async () => {
|
|
79
|
+
phone = false;
|
|
80
|
+
document.cookie = 'sidebar_state=true; path=/';
|
|
81
|
+
await mount({ topBar: { title: 'Calls', actions: <button>New</button> } });
|
|
82
|
+
expect(document.querySelector('[data-sidebar="topbar"]')).toBeNull();
|
|
83
|
+
expect(document.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
84
|
+
expect(document.body.textContent).toContain('Mark');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('desktop, sidebar collapsed: the same bar with title, actions and avatar', async () => {
|
|
88
|
+
phone = false;
|
|
89
|
+
document.cookie = 'sidebar_state=false; path=/';
|
|
90
|
+
const bar = (await mount({ topBar: { title: 'Calls', actions: <button>New</button> } }))!;
|
|
91
|
+
expect(bar.children[1].textContent).toBe('Calls');
|
|
92
|
+
expect(bar.querySelector('[data-sidebar="topbar-actions"]')!.children[1].getAttribute('data-sidebar')).toBe('topbar-account');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('account', () => {
|
|
96
|
+
it('avatar present when signed in', async () => {
|
|
97
|
+
const bar = (await mount({}))!;
|
|
98
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('no avatar without a session', async () => {
|
|
102
|
+
currentUser = null;
|
|
103
|
+
const bar = (await mount({}))!;
|
|
104
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('no avatar with topBar.account === false', async () => {
|
|
108
|
+
const bar = (await mount({ topBar: { account: false } }))!;
|
|
109
|
+
expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('bar avatar and sidebar footer open the same menu items', async () => {
|
|
113
|
+
const labels = async (el: Element) => {
|
|
114
|
+
await act(async () => {
|
|
115
|
+
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
|
|
116
|
+
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
|
|
117
|
+
(el as HTMLElement).click();
|
|
118
|
+
});
|
|
119
|
+
const items = [...document.querySelectorAll('[role="menuitem"]')].map((i) => i.textContent);
|
|
120
|
+
await act(async () => {
|
|
121
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
122
|
+
});
|
|
123
|
+
return items;
|
|
124
|
+
};
|
|
125
|
+
const bar = (await mount({}))!;
|
|
126
|
+
const fromBar = await labels(bar.querySelector('[data-sidebar="topbar-account"]')!);
|
|
127
|
+
expect(fromBar.length).toBeGreaterThan(0);
|
|
128
|
+
await act(async () => root?.unmount());
|
|
129
|
+
host?.remove();
|
|
130
|
+
phone = false;
|
|
131
|
+
document.cookie = 'sidebar_state=true; path=/';
|
|
132
|
+
await mount({});
|
|
133
|
+
const footer = [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Mark'))!;
|
|
134
|
+
expect(await labels(footer)).toEqual(fromBar);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
async function openDrawer() {
|
|
138
|
+
await act(async () => {
|
|
139
|
+
(document.querySelector('[data-sidebar="trigger"]') as HTMLElement).click();
|
|
140
|
+
});
|
|
141
|
+
return document.querySelector('[data-sidebar="sidebar"][data-mobile="true"]') ?? document.body;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it('phone drawer has no footer account while the bar carries the avatar', async () => {
|
|
145
|
+
await mount({});
|
|
146
|
+
const drawer = await openDrawer();
|
|
147
|
+
expect(drawer.textContent).toContain('Home');
|
|
148
|
+
expect(document.body.textContent).not.toContain('Mark');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('phone drawer shows the footer account again with topBar.account === false', async () => {
|
|
152
|
+
await mount({ topBar: { account: false } });
|
|
153
|
+
await openDrawer();
|
|
154
|
+
expect(document.body.textContent).toContain('Mark');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('toggle', () => {
|
|
159
|
+
it('no tooltip and no native title on desktop or phone; aria-label stays', async () => {
|
|
160
|
+
for (const isPhone of [false, true]) {
|
|
161
|
+
phone = isPhone;
|
|
162
|
+
document.cookie = 'sidebar_state=false; path=/';
|
|
163
|
+
const bar = (await mount({}))!;
|
|
164
|
+
const trigger = bar.querySelector<HTMLElement>('[data-sidebar="trigger"]')!;
|
|
165
|
+
expect(trigger.hasAttribute('data-state')).toBe(false);
|
|
166
|
+
expect(trigger.hasAttribute('title')).toBe(false);
|
|
167
|
+
expect(trigger.getAttribute('aria-label')).toBe('Open sidebar');
|
|
168
|
+
await act(async () => {
|
|
169
|
+
trigger.focus();
|
|
170
|
+
trigger.dispatchEvent(new FocusEvent('focus'));
|
|
171
|
+
});
|
|
172
|
+
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
|
173
|
+
await act(async () => root?.unmount());
|
|
174
|
+
host?.remove();
|
|
175
|
+
host = root = null;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('hover-open', () => {
|
|
181
|
+
const enter = (el: Element) =>
|
|
182
|
+
act(async () => {
|
|
183
|
+
const ev = new Event('pointerenter', { bubbles: false }) as Event & { pointerType?: string };
|
|
184
|
+
ev.pointerType = 'mouse';
|
|
185
|
+
el.dispatchEvent(ev);
|
|
186
|
+
// React's onPointerEnter listens to pointerover.
|
|
187
|
+
const over = new Event('pointerover', { bubbles: true }) as Event & { pointerType?: string };
|
|
188
|
+
over.pointerType = 'mouse';
|
|
189
|
+
el.dispatchEvent(over);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the bar toggle does not open the hover overlay; the left edge zone does', async () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
try {
|
|
195
|
+
phone = false;
|
|
196
|
+
document.cookie = 'sidebar_state=false; path=/';
|
|
197
|
+
const bar = (await mount({}))!;
|
|
198
|
+
const trigger = bar.querySelector('[data-sidebar="trigger"]')!;
|
|
199
|
+
await enter(trigger);
|
|
200
|
+
await act(async () => { vi.advanceTimersByTime(1000); });
|
|
201
|
+
expect(document.querySelector('[data-sidebar="peek"]')).toBeNull();
|
|
202
|
+
|
|
203
|
+
const edge = document.querySelector('[data-sidebar="peek-edge"]')!;
|
|
204
|
+
expect(edge).not.toBeNull();
|
|
205
|
+
await enter(edge);
|
|
206
|
+
await act(async () => { vi.advanceTimersByTime(1000); });
|
|
207
|
+
expect(document.querySelector('[data-sidebar="peek"]')).not.toBeNull();
|
|
208
|
+
} finally {
|
|
209
|
+
vi.useRealTimers();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -21,7 +21,7 @@ interface SidebarItemBase {
|
|
|
21
21
|
badge?: string | number;
|
|
22
22
|
/** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
|
|
23
23
|
badgeVariant?: 'count' | 'pill';
|
|
24
|
-
/**
|
|
24
|
+
/** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail, and rows show no tooltip. */
|
|
25
25
|
tooltip?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -128,7 +128,7 @@ export interface SidebarConfig<Id extends string = string> {
|
|
|
128
128
|
groupLabelStyle?: SidebarGroupLabelStyle;
|
|
129
129
|
/** Featured CTA tile rendered below all groups, above `menuEnd`. */
|
|
130
130
|
featured?: SidebarFeaturedConfig;
|
|
131
|
-
/** @deprecated No effect: rows are one desktop size and one 44px
|
|
131
|
+
/** @deprecated No effect: rows are one desktop size (36px) and one phone size (40px with a 44px hit area). */
|
|
132
132
|
density?: 'comfortable' | 'default' | 'compact';
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -172,6 +172,13 @@ export interface HeaderConfig {
|
|
|
172
172
|
* Takes priority over `brand`, `title`, `brandIcon`, `brandLetter`.
|
|
173
173
|
*/
|
|
174
174
|
switcher?: SidebarBrandSwitcherConfig;
|
|
175
|
+
/**
|
|
176
|
+
* Where the product name (sidebar header brand, phone drawer header, and the default top bar title) links to,
|
|
177
|
+
* through the router. Resolution: `header.href` > `sidebar.homeHref` > the auth route `routes.defaultCallback` configured on
|
|
178
|
+
* `BaseApp` (`auth.routes`, default `/private`) > `/` (outside the auth provider). A custom `brand` node is rendered as given (never
|
|
179
|
+
* wrapped in a second link); a string `brand` is linkified.
|
|
180
|
+
*/
|
|
181
|
+
href?: string;
|
|
175
182
|
/** Custom header brand node (same idea as PublicNavbar `brand`). */
|
|
176
183
|
brand?: ReactNode;
|
|
177
184
|
/** Shown next to the logo when the sidebar is expanded */
|
|
@@ -209,12 +216,30 @@ export interface HeaderConfig {
|
|
|
209
216
|
// PrivateLayout Props
|
|
210
217
|
// ============================================================================
|
|
211
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Top bar content. One bar at every width, shown whenever the sidebar is not visible (always on a phone; on desktop
|
|
221
|
+
* while the sidebar is collapsed; never while it is docked). The sidebar toggle is always on the left and not configurable.
|
|
222
|
+
*/
|
|
223
|
+
export interface PrivateTopBarConfig {
|
|
224
|
+
/** Truncated next to the toggle. Defaults to `header.title` (the product name). */
|
|
225
|
+
title?: ReactNode;
|
|
226
|
+
/** Right end of the bar, vertically centred. Keep it to a couple of compact controls. */
|
|
227
|
+
actions?: ReactNode;
|
|
228
|
+
/**
|
|
229
|
+
* Account avatar at the right end of the bar for a signed-in user (the same menu as the sidebar footer). Default
|
|
230
|
+
* `true`. `false` removes the avatar from the bar and keeps the account in the phone drawer's footer.
|
|
231
|
+
*/
|
|
232
|
+
account?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
212
235
|
export interface PrivateLayoutProps {
|
|
213
236
|
children: ReactNode;
|
|
214
237
|
/** Sidebar configuration */
|
|
215
238
|
sidebar?: SidebarConfig;
|
|
216
239
|
/** Title + account links (no top navbar — title is used in the sidebar chrome) */
|
|
217
240
|
header?: HeaderConfig;
|
|
241
|
+
/** Top bar content (title override, right-hand actions, account avatar). The bar shows only while the sidebar is not visible. */
|
|
242
|
+
topBar?: PrivateTopBarConfig;
|
|
218
243
|
/**
|
|
219
244
|
* Path for active nav highlighting. With `@djangocfg/nextjs` i18n routing, pass `usePathname()` from
|
|
220
245
|
* `@djangocfg/nextjs/i18n/navigation` (no `/[locale]` segment). If omitted, uses `next/navigation` (includes locale).
|
|
@@ -1,53 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* User
|
|
3
|
-
*
|
|
4
|
-
* Flexible user menu component with group-based structure (like footer)
|
|
5
|
-
* Uses useAuth() hook for authentication state
|
|
2
|
+
* User menu for the public navbar and its phone drawer. A thin wrapper over the shared account menu
|
|
3
|
+
* (`components/account`): `desktop` is the avatar dropdown, `mobile` the inline list. Props are unchanged.
|
|
6
4
|
*
|
|
7
5
|
* @example
|
|
8
|
-
*
|
|
9
|
-
* import { Settings, LogOut, User } from 'lucide-react';
|
|
10
|
-
*
|
|
11
|
-
* <UserMenu
|
|
12
|
-
* groups={[
|
|
13
|
-
* {
|
|
14
|
-
* title: 'Account',
|
|
15
|
-
* items: [
|
|
16
|
-
* { label: 'Profile', href: '/profile', icon: Settings },
|
|
17
|
-
* { label: 'Dashboard', href: '/dashboard', icon: User }
|
|
18
|
-
* ]
|
|
19
|
-
* },
|
|
20
|
-
* {
|
|
21
|
-
* items: [
|
|
22
|
-
* { label: 'Sign Out', onClick: () => logout(), icon: LogOut, variant: 'destructive' }
|
|
23
|
-
* ]
|
|
24
|
-
* }
|
|
25
|
-
* ]}
|
|
26
|
-
* authPath="/auth"
|
|
27
|
-
* />
|
|
28
|
-
* ```
|
|
6
|
+
* <UserMenu groups={[{ title: 'Account', items: [{ label: 'Profile', href: '/profile' }] }]} authPath="/auth" />
|
|
29
7
|
*/
|
|
30
8
|
|
|
31
9
|
'use client';
|
|
32
10
|
|
|
33
|
-
import
|
|
34
|
-
import { Link } from '@djangocfg/ui-core/components';
|
|
35
|
-
import React, { useMemo } from 'react';
|
|
36
|
-
|
|
37
|
-
import { useAuth } from '@djangocfg/api/auth';
|
|
38
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
39
|
-
|
|
40
|
-
import { useLogout } from '../../../hooks';
|
|
41
|
-
import {
|
|
42
|
-
Button,
|
|
43
|
-
LanguageFlag,
|
|
44
|
-
MenuBuilder,
|
|
45
|
-
} from '@djangocfg/ui-core/components';
|
|
46
|
-
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
47
|
-
|
|
48
|
-
import { LOCALE_LABELS } from '../../_components/LocaleSwitcher';
|
|
49
|
-
import { UserAvatar } from './UserAvatar';
|
|
11
|
+
import React from 'react';
|
|
50
12
|
|
|
13
|
+
import { AccountAvatarTrigger, AccountInline, AccountMenu } from '../../../components/account';
|
|
51
14
|
import type { UserMenuGroup, UserMenuLocaleConfig } from '../../types';
|
|
52
15
|
|
|
53
16
|
export interface UserMenuProps {
|
|
@@ -61,270 +24,34 @@ export interface UserMenuProps {
|
|
|
61
24
|
disableAuth?: boolean;
|
|
62
25
|
/**
|
|
63
26
|
* Language switching inside this menu (desktop: submenu; mobile: button row).
|
|
64
|
-
* Prefer this over nesting `<LocaleSwitcher />` in the same dropdown
|
|
27
|
+
* Prefer this over nesting `<LocaleSwitcher />` in the same dropdown: nested menus close each other.
|
|
65
28
|
*/
|
|
66
29
|
i18n?: UserMenuLocaleConfig;
|
|
67
30
|
}
|
|
68
31
|
|
|
69
|
-
export function UserMenu({
|
|
70
|
-
variant = 'desktop',
|
|
71
|
-
groups,
|
|
72
|
-
authPath,
|
|
73
|
-
disableAuth = false,
|
|
74
|
-
i18n,
|
|
75
|
-
}: UserMenuProps) {
|
|
76
|
-
const { user, isAuthenticated, routes } = useAuth();
|
|
77
|
-
const resolvedAuthPath = authPath ?? routes.auth;
|
|
78
|
-
const handleLogout = useLogout();
|
|
32
|
+
export function UserMenu({ variant = 'desktop', groups, authPath, disableAuth = false, i18n }: UserMenuProps) {
|
|
79
33
|
const [mounted, setMounted] = React.useState(false);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const labels = useMemo(() => ({
|
|
83
|
-
signIn: t('layouts.profile.login'),
|
|
84
|
-
signOut: t('layouts.profile.signOut'),
|
|
85
|
-
userMenu: t('layouts.profile.userMenu'),
|
|
86
|
-
language: t('layouts.profile.language'),
|
|
87
|
-
}), [t]);
|
|
88
|
-
|
|
89
|
-
React.useEffect(() => {
|
|
90
|
-
setMounted(true);
|
|
91
|
-
}, []);
|
|
92
|
-
|
|
93
|
-
/** Profile links only; sign out rendered separately so we can insert locale UI between. */
|
|
94
|
-
const profileGroups: UserMenuGroup[] = React.useMemo(() => {
|
|
95
|
-
if (groups && groups.length > 0) return [...groups];
|
|
96
|
-
return [];
|
|
97
|
-
}, [groups]);
|
|
98
|
-
|
|
99
|
-
const signOutItem = React.useMemo(
|
|
100
|
-
() => ({
|
|
101
|
-
label: labels.signOut,
|
|
102
|
-
onClick: handleLogout,
|
|
103
|
-
icon: LogOut,
|
|
104
|
-
variant: 'destructive' as const,
|
|
105
|
-
}),
|
|
106
|
-
[handleLogout, labels.signOut],
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
/** Prepared locale UI data — avoid `i18n && i18n.locales.length` chains in JSX. */
|
|
110
|
-
const localeMenu = React.useMemo(() => {
|
|
111
|
-
const codes = i18n?.locales;
|
|
112
|
-
if (!i18n || !codes?.length) return null;
|
|
113
|
-
return {
|
|
114
|
-
codes,
|
|
115
|
-
current: i18n.locale,
|
|
116
|
-
onChange: i18n.onLocaleChange,
|
|
117
|
-
};
|
|
118
|
-
}, [i18n]);
|
|
119
|
-
|
|
120
|
-
const localeLabel = (code: string) => LOCALE_LABELS[code] || code.toUpperCase();
|
|
121
|
-
|
|
122
|
-
if (!mounted) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!isAuthenticated || !user) {
|
|
127
|
-
// Guest user - show sign in button
|
|
128
|
-
if (variant === 'mobile') {
|
|
129
|
-
return (
|
|
130
|
-
<div className="pt-4 border-t border-border/50">
|
|
131
|
-
<Link
|
|
132
|
-
href={resolvedAuthPath}
|
|
133
|
-
className="group flex items-center justify-between rounded-control px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
|
|
134
|
-
>
|
|
135
|
-
<span>{labels.signIn}</span>
|
|
136
|
-
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
|
137
|
-
</Link>
|
|
138
|
-
</div>
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
if (disableAuth) {
|
|
142
|
-
return (
|
|
143
|
-
<Button variant="default" size="sm" className="h-9 rounded-full px-5" disabled>
|
|
144
|
-
{labels.signIn}
|
|
145
|
-
</Button>
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
return (
|
|
149
|
-
<Link href={resolvedAuthPath}>
|
|
150
|
-
<Button variant="default" size="sm" className="h-9 rounded-full px-5">
|
|
151
|
-
{labels.signIn}
|
|
152
|
-
</Button>
|
|
153
|
-
</Link>
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Authenticated user
|
|
158
|
-
const displayName = user.display_username || user.email || 'User';
|
|
159
|
-
const userAvatar = user.avatar || '';
|
|
34
|
+
React.useEffect(() => setMounted(true), []);
|
|
35
|
+
if (!mounted) return null;
|
|
160
36
|
|
|
161
37
|
if (variant === 'mobile') {
|
|
162
|
-
return
|
|
163
|
-
<div className="space-y-3">
|
|
164
|
-
<div className="flex items-center gap-3 px-4 py-3 rounded-[var(--radius-popover)] bg-accent/30">
|
|
165
|
-
<UserAvatar src={userAvatar} name={displayName} size="md" />
|
|
166
|
-
<div className="flex-1 min-w-0">
|
|
167
|
-
<p className="text-sm font-semibold text-foreground truncate">
|
|
168
|
-
{displayName}
|
|
169
|
-
</p>
|
|
170
|
-
<p className="text-xs text-muted-foreground truncate">
|
|
171
|
-
{user.email}
|
|
172
|
-
</p>
|
|
173
|
-
</div>
|
|
174
|
-
</div>
|
|
175
|
-
<div className="space-y-1">
|
|
176
|
-
{profileGroups.map((group, groupIndex) => (
|
|
177
|
-
<div key={groupIndex}>
|
|
178
|
-
{group.title && (
|
|
179
|
-
<div className="px-4 pt-2 pb-1">
|
|
180
|
-
<p className="text-[11px] font-normal text-muted-foreground/70">
|
|
181
|
-
{group.title}
|
|
182
|
-
</p>
|
|
183
|
-
</div>
|
|
184
|
-
)}
|
|
185
|
-
{group.items.map((item, itemIndex) => {
|
|
186
|
-
const Icon = item.icon;
|
|
187
|
-
const isDestructive = item.variant === 'destructive';
|
|
188
|
-
const baseClasses = `flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-sm transition-colors w-full text-left ${
|
|
189
|
-
isDestructive
|
|
190
|
-
? 'text-destructive hover:bg-destructive/10'
|
|
191
|
-
: 'text-foreground hover:bg-accent hover:text-accent-foreground'
|
|
192
|
-
}`;
|
|
193
|
-
|
|
194
|
-
if (item.onClick) {
|
|
195
|
-
return (
|
|
196
|
-
<button
|
|
197
|
-
key={itemIndex}
|
|
198
|
-
onClick={item.onClick}
|
|
199
|
-
className={baseClasses}
|
|
200
|
-
>
|
|
201
|
-
{Icon && <Icon className="h-4 w-4" />}
|
|
202
|
-
{item.label}
|
|
203
|
-
</button>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (item.href) {
|
|
208
|
-
return (
|
|
209
|
-
<Link key={itemIndex} href={item.href} className={baseClasses}>
|
|
210
|
-
{Icon && <Icon className="h-4 w-4" />}
|
|
211
|
-
{item.label}
|
|
212
|
-
</Link>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return null;
|
|
217
|
-
})}
|
|
218
|
-
</div>
|
|
219
|
-
))}
|
|
220
|
-
{localeMenu && (
|
|
221
|
-
<div className="border-t border-border/50 px-4 pt-3">
|
|
222
|
-
<p className="text-[11px] font-normal text-muted-foreground/70">
|
|
223
|
-
{labels.language}
|
|
224
|
-
</p>
|
|
225
|
-
<div className="mt-2 flex flex-wrap gap-2">
|
|
226
|
-
{localeMenu.codes.map((code) => {
|
|
227
|
-
const active = code === localeMenu.current;
|
|
228
|
-
return (
|
|
229
|
-
<button
|
|
230
|
-
key={code}
|
|
231
|
-
type="button"
|
|
232
|
-
onClick={() => localeMenu.onChange(code)}
|
|
233
|
-
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
|
234
|
-
active
|
|
235
|
-
? 'border-primary bg-accent text-foreground'
|
|
236
|
-
: 'border-border/60 text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
237
|
-
}`}
|
|
238
|
-
>
|
|
239
|
-
<LanguageFlag code={code} className="h-3 w-4" rounded />
|
|
240
|
-
{localeLabel(code)}
|
|
241
|
-
</button>
|
|
242
|
-
);
|
|
243
|
-
})}
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
)}
|
|
247
|
-
<div>
|
|
248
|
-
<button
|
|
249
|
-
type="button"
|
|
250
|
-
onClick={signOutItem.onClick}
|
|
251
|
-
className="flex w-full items-center gap-3 rounded-sm px-4 py-3 text-left text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
|
|
252
|
-
>
|
|
253
|
-
<LogOut className="h-4 w-4" />
|
|
254
|
-
{signOutItem.label}
|
|
255
|
-
</button>
|
|
256
|
-
</div>
|
|
257
|
-
</div>
|
|
258
|
-
</div>
|
|
259
|
-
);
|
|
38
|
+
return <AccountInline groups={groups} authPath={authPath} i18n={i18n} />;
|
|
260
39
|
}
|
|
261
40
|
|
|
262
|
-
// Desktop variant — declarative menu via the shared MenuBuilder.
|
|
263
|
-
const menuItems: MenuItem[] = [
|
|
264
|
-
{
|
|
265
|
-
kind: 'label',
|
|
266
|
-
id: 'identity',
|
|
267
|
-
label: (
|
|
268
|
-
<div className="flex flex-col space-y-1">
|
|
269
|
-
<p className="text-sm font-medium leading-none">{displayName}</p>
|
|
270
|
-
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
|
271
|
-
</div>
|
|
272
|
-
),
|
|
273
|
-
},
|
|
274
|
-
// App-provided link groups (each its own section with a trailing separator).
|
|
275
|
-
...profileGroups.map((group, i): MenuItem => ({
|
|
276
|
-
kind: 'section',
|
|
277
|
-
id: `group-${i}`,
|
|
278
|
-
label: group.title,
|
|
279
|
-
items: group.items.map((item, j): MenuItem => ({
|
|
280
|
-
kind: 'item',
|
|
281
|
-
id: item.href ?? `${i}-${j}`,
|
|
282
|
-
label: item.label,
|
|
283
|
-
icon: item.icon,
|
|
284
|
-
href: item.href,
|
|
285
|
-
onSelect: item.onClick ? () => item.onClick!() : undefined,
|
|
286
|
-
variant: item.variant === 'destructive' ? 'destructive' : 'default',
|
|
287
|
-
})),
|
|
288
|
-
})),
|
|
289
|
-
// Language submenu — radio group of locales (flag + label + checkmark).
|
|
290
|
-
...(localeMenu
|
|
291
|
-
? [{
|
|
292
|
-
kind: 'submenu' as const,
|
|
293
|
-
id: 'language',
|
|
294
|
-
label: labels.language,
|
|
295
|
-
icon: Languages,
|
|
296
|
-
items: [{
|
|
297
|
-
kind: 'radio-group' as const,
|
|
298
|
-
id: 'locale',
|
|
299
|
-
value: localeMenu.current,
|
|
300
|
-
onValueChange: localeMenu.onChange,
|
|
301
|
-
options: localeMenu.codes.map((code) => ({
|
|
302
|
-
id: code,
|
|
303
|
-
value: code,
|
|
304
|
-
label: localeLabel(code),
|
|
305
|
-
icon: <LanguageFlag code={code} className="h-3 w-4 shrink-0" rounded />,
|
|
306
|
-
})),
|
|
307
|
-
}],
|
|
308
|
-
}]
|
|
309
|
-
: []),
|
|
310
|
-
{ kind: 'separator', id: 'sep-logout' },
|
|
311
|
-
{
|
|
312
|
-
kind: 'item',
|
|
313
|
-
id: 'signout',
|
|
314
|
-
label: signOutItem.label,
|
|
315
|
-
icon: LogOut,
|
|
316
|
-
variant: 'destructive',
|
|
317
|
-
onSelect: () => signOutItem.onClick(),
|
|
318
|
-
},
|
|
319
|
-
];
|
|
320
|
-
|
|
321
41
|
return (
|
|
322
|
-
<
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
42
|
+
<AccountMenu
|
|
43
|
+
groups={groups}
|
|
44
|
+
i18n={i18n}
|
|
45
|
+
authPath={authPath}
|
|
46
|
+
settings={false}
|
|
47
|
+
identity="card"
|
|
48
|
+
signIn={disableAuth ? 'disabled' : 'button'}
|
|
49
|
+
side="bottom"
|
|
50
|
+
align="end"
|
|
51
|
+
sideOffset={4}
|
|
52
|
+
contentClassName="w-56"
|
|
53
|
+
>
|
|
54
|
+
<AccountAvatarTrigger ring />
|
|
55
|
+
</AccountMenu>
|
|
328
56
|
);
|
|
329
57
|
}
|
|
330
|
-
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { UserMenu } from './UserMenu';
|
|
2
2
|
export type { UserMenuProps } from './UserMenu';
|
|
3
|
-
export { UserAvatar } from '
|
|
4
|
-
export type { UserAvatarProps } from '
|
|
3
|
+
export { UserAvatar } from '../../../components/account/UserAvatar';
|
|
4
|
+
export type { UserAvatarProps } from '../../../components/account/UserAvatar';
|