@djangocfg/layouts 2.1.607 → 2.1.609

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.
Files changed (54) hide show
  1. package/README.md +4 -0
  2. package/package.json +12 -12
  3. package/src/components/account/AccountInline.tsx +122 -0
  4. package/src/components/account/AccountMenu.test.tsx +110 -0
  5. package/src/components/account/AccountMenu.tsx +117 -0
  6. package/src/components/account/accountContext.ts +14 -0
  7. package/src/components/account/index.ts +13 -0
  8. package/src/components/account/triggers.tsx +100 -0
  9. package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
  10. package/src/components/account/useAccountModel.tsx +76 -0
  11. package/src/components/errors/AppErrorFallback.test.tsx +33 -0
  12. package/src/components/errors/AppErrorFallback.tsx +42 -0
  13. package/src/components/errors/ErrorBoundary.tsx +4 -41
  14. package/src/components/errors/ErrorLayout.tsx +17 -1
  15. package/src/components/errors/README.md +6 -1
  16. package/src/components/errors/chunkReload.test.ts +89 -0
  17. package/src/components/errors/index.ts +2 -0
  18. package/src/layouts/AppLayout/BaseApp.tsx +15 -55
  19. package/src/layouts/AppLayout/index.ts +1 -0
  20. package/src/layouts/AppLayout/pwaMeta.test.ts +36 -0
  21. package/src/layouts/AppLayout/pwaMeta.tsx +40 -0
  22. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
  23. package/src/layouts/PrivateLayout/README.md +22 -6
  24. package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
  25. package/src/layouts/PrivateLayout/components/ForbiddenState.tsx +1 -1
  26. package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
  27. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
  28. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
  29. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
  30. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
  31. package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
  32. package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
  33. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
  34. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
  35. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
  36. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
  37. package/src/layouts/PrivateLayout/context.tsx +15 -3
  38. package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
  39. package/src/layouts/PrivateLayout/geometry.ts +30 -10
  40. package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
  41. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  42. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
  43. package/src/layouts/PrivateLayout/index.ts +2 -0
  44. package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
  45. package/src/layouts/PrivateLayout/types.ts +27 -2
  46. package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
  47. package/src/layouts/PublicLayout/components/index.ts +2 -2
  48. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
  49. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
  50. package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
  51. package/src/layouts/types/layout.types.ts +7 -0
  52. package/src/shared/ProjectContext.tsx +18 -0
  53. package/src/testing/MockAuthProvider.tsx +5 -2
  54. /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
@@ -14,7 +14,13 @@
14
14
 
15
15
  import React from 'react';
16
16
 
17
+ import { useAuth } from '@djangocfg/api/auth';
18
+ import { useLandingRoute } from './hooks/useLandingRoute';
19
+ import { useProjectName } from '../../shared/ProjectContext';
17
20
  import { Preloader } from '@djangocfg/ui-core/components';
21
+ import { cn } from '@djangocfg/ui-core/lib';
22
+
23
+ import { SHELL_CLASS } from './geometry';
18
24
  import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
19
25
 
20
26
  import { ForbiddenState, PrivateContent, PrivateSidebar } from './components';
@@ -55,6 +61,7 @@ export function PrivateLayout({
55
61
  children,
56
62
  sidebar,
57
63
  header,
64
+ topBar,
58
65
  pathname,
59
66
  contentPadding = 'default',
60
67
  contentScroll = 'auto',
@@ -73,9 +80,26 @@ export function PrivateLayout({
73
80
  const { providerStyle, providerClassName, insetClassName, sidebarVariant } =
74
81
  useLayoutVisual(visual);
75
82
 
83
+ // The account avatar lives in the top bar for a signed-in user unless `topBar.account === false`.
84
+ const { user } = useAuth();
85
+ // Bar title: topBar.title > header.title > the BaseApp project name > nothing. Only the last two link home.
86
+ const projectName = useProjectName();
87
+ const landing = useLandingRoute();
88
+ const barTitle = topBar?.title ?? (header?.title?.trim() || projectName || undefined);
89
+ const barHref = header?.href || sidebar?.homeHref || landing || '/';
90
+ const accountInBar = Boolean(user) && topBar?.account !== false;
91
+
76
92
  const defaultOpen = useSidebarDefaultOpen();
77
93
  const sidebarWidth = useSidebarWidth(Boolean(sidebar) && sidebar?.resizable !== false);
78
94
 
95
+ // Marks <html> while a private shell is mounted: base.css locks html/body (no document scroll, no rubber band)
96
+ // in an installed app, for private pages only. Public pages never carry it and stay scrollable.
97
+ React.useEffect(() => {
98
+ const root = document.documentElement;
99
+ root.setAttribute('data-private-shell', '');
100
+ return () => root.removeAttribute('data-private-shell');
101
+ }, []);
102
+
79
103
  if (isLoading) {
80
104
  return (
81
105
  <Preloader
@@ -99,7 +123,7 @@ export function PrivateLayout({
99
123
  <SidebarProvider
100
124
  defaultOpen={defaultOpen}
101
125
  style={{ ...providerStyle, ...sidebarWidth.style }}
102
- className={providerClassName}
126
+ className={cn(SHELL_CLASS, providerClassName)}
103
127
  data-sidebar-resizing={sidebarWidth.dragging || undefined}
104
128
  >
105
129
  <SidebarWidthContext.Provider value={sidebarWidth}>
@@ -110,6 +134,7 @@ export function PrivateLayout({
110
134
  header={header}
111
135
  pathname={pathname}
112
136
  variant={sidebarVariant}
137
+ accountInBar={accountInBar}
113
138
  />
114
139
  )}
115
140
 
@@ -118,6 +143,12 @@ export function PrivateLayout({
118
143
  padding={contentPadding}
119
144
  scroll={contentScroll}
120
145
  hasSidebar={Boolean(sidebar)}
146
+ pathname={pathname}
147
+ title={barTitle}
148
+ titleHref={topBar?.title == null && barTitle ? barHref : undefined}
149
+ actions={topBar?.actions}
150
+ showAccount={accountInBar}
151
+ header={header}
121
152
  visual={visual}
122
153
  >
123
154
  {children}
@@ -1,6 +1,6 @@
1
1
  # PrivateLayout
2
2
 
3
- Authenticated app shell — a docked, resizable sidebar + scrollable content area. Collapsed, a trigger remains and hovering it peeks the sidebar open over the page; on mobile it is a drawer.
3
+ Authenticated app shell — a docked, resizable sidebar + scrollable content area. Collapsed, a top bar remains and resting at the left screen edge peeks the sidebar open over the page (the toggle itself only clicks); on mobile it is a drawer.
4
4
 
5
5
  ```tsx
6
6
  <PrivateLayout
@@ -137,7 +137,7 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
137
137
  | Field | Type | Notes |
138
138
  |---|---|---|
139
139
  | `groups` | `SidebarGroupConfig[]` | Nav items grouped under labels. Set `group.label = ''` to render flat (no header). |
140
- | `homeHref` | `string` | Brand link target. |
140
+ | `homeHref` | `string` | Brand link target. Order: `header.href` > `sidebar.homeHref` > auth `routes.defaultCallback` (the private landing page set in `BaseApp` `auth.routes`) > `/`. |
141
141
  | `menuStart` / `menuEnd` | `ReactNode` | Free slots above / below `groups` in the scrollable column. |
142
142
  | `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` | Deprecated, no effect (no icon rail). |
143
143
  | `resizable` | `boolean` | Drag the sidebar edge (14–24rem, remembered under `cfg.layouts.sidebar-width`; double-click resets). Default `true`. |
@@ -163,7 +163,7 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
163
163
 
164
164
  | Field | Type | Notes |
165
165
  |---|---|---|
166
- | `label`, `href`, `icon`, `tooltip` | — | Standard nav item fields. |
166
+ | `label`, `href`, `icon` | — | Standard nav item fields (`tooltip` is deprecated, no effect). |
167
167
  | `settingsSection` | section id | Instead of `href`: a row that opens the Settings dialog on that section. Rendered only while the mounted dialog lists the section (no dead door), active while the dialog shows it. Type it with `SidebarItem<AppSectionId>` (see [SettingsLayout](../SettingsLayout/README.md#typed-section-ids)). |
168
168
  | `badge` | `string \| number` | Trailing badge content. |
169
169
  | `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
@@ -279,7 +279,23 @@ const header: HeaderConfig = {
279
279
  | State | Behaviour |
280
280
  |---|---|
281
281
  | Docked | Width from `--sidebar-width`; drag the right edge, arrow keys, double-click to reset. |
282
- | Collapsed (desktop) | Hidden. A trigger stays top-left in a 48px top bar (in flow, so content never sits under it); hovering the trigger (or the free area around it) floats the sidebar over the page. The panel stays while the pointer is on it or a menu opened from it is open; Esc, navigation and moving away close it. Clicking the trigger docks it. |
283
- | Mobile (< md) | Drawer up to 22rem: 44px rows, 14px text, 18px icons, 8px side padding, 12px vertical body padding, close button. The drawer header and footer respect the safe areas (at least 1rem). The trigger is a 40px ghost button in a 48px in-flow top bar (also for `contentPadding="none"` pages) that adds `env(safe-area-inset-top)`. |
282
+ | Collapsed (desktop) | Hidden. The top bar (toggle top-left, title, actions, avatar) shows; resting the pointer at the left screen edge (not on the toggle) floats the sidebar over the page. The panel stays while the pointer is on it or a menu opened from it is open; Esc, navigation and moving away close it. Clicking the trigger docks it. |
283
+ | Mobile (< md) | Drawer 88vw wide (max 22rem, the dimmed strip taps it closed) and full height (`fixed inset-y-0`: the real visible area in a browser tab and in an iOS standalone PWA): 40px rows (44px hit area), 14px text, 20px icons, 8px container gutter + 10px row padding (icon column and logo at 18px; dividers, highlighted rows and footer share the 8px gutter), header is the brand only (48px under safe-top, no toggle or close button; it closes by backdrop tap, swipe, Escape or navigating). Three zones: pinned header (gets a bottom hairline once the nav scrolls), the nav as the ONLY scroller (`overscroll-contain`), pinned footer with a full-width top border and its own background; the drawer panel itself is `overflow-hidden` (`DrawerContent bare`). Header and footer respect the safe areas on top, bottom and sides (at least 1rem vertically). On a phone (below md) the layout draws a plain top bar: `safe-top + 3rem` tall (`--layout-bar-height`, `0px` from md up and without a sidebar), `bg-background` with a 1px bottom border, holding the sidebar toggle (the same ghost button as in the sidebar header) and `header.title`. It overlays the content column and slides away (`transform`, 200ms) on scroll-down and back on scroll-up or at the top, using the same `useNavbarScroll` policy as `PublicLayout` driven by the content scroller; it stays while the drawer is open, a field has focus, or the page is not scrollable. Content starts below the bar for every page, so pages do nothing; apps may use `--layout-bar-height` to offset their own sticky elements. `PrivateLayout` takes `topBar={{ title, actions }}` (types: `PrivateTopBarConfig`): `title` replaces `header.title` in the bar, `actions` is a ReactNode on the right (vertically centred, never shrinks the toggle; the title takes the rest and truncates). The same bar shows on desktop while the sidebar is collapsed (static, no auto-hide) and carries the account avatar for a signed-in user (`topBar.account={false}` to opt out); on a phone the drawer then has no footer account. Recommended actions are compact ghost icon `Button`s (36px); several are fine, keep them few. The toggle stays left and is not configurable. It is rendered by the phone bar only; desktop shows nothing (the sidebar is the navigation). |
284
284
 
285
- Row metrics (`geometry.ts`): desktop rows 36px, mobile rows 44px (touch target), both `text-sm` with 16px (desktop) or 18px (mobile) icons and group labels in `text-xs`. Safe-area insets only exist when the host app sets `viewportFit: "cover"` in its Next.js `viewport` export; without it the offsets fall back to the fixed minimums.
285
+ Row metrics (`geometry.ts`): desktop rows 36px, mobile rows 40px (with a 44px hit area from an invisible `::after`), both `text-sm` with 16px (desktop) or 20px (mobile) icons and group labels in `text-xs`. Safe-area insets only exist when the host app sets `viewportFit: "cover"` in its Next.js `viewport` export; without it the offsets fall back to the fixed minimums. For an installed iOS PWA also set `appleWebApp.statusBarStyle` (`black-translucent` draws the page under the status bar and relies on the insets; `default` keeps it below). The drawer height is pinned with `inset-y-0` rather than `100dvh`, which can disagree with the visible area in standalone mode.
286
+
287
+
288
+ ## Installed app (PWA) support
289
+
290
+ - Detection: `useDisplayMode()` / `useIsPwa()` from `@djangocfg/ui-core/hooks` (the `display-mode` media query, iOS `navigator.standalone`, Android TWA referrer). The server and hydration render say `browser`; for first-paint styling use CSS: `UiProviders` sets `<html data-display-mode="browser|standalone|fullscreen|minimal-ui|window-controls-overlay">`.
291
+ - Tokens (ui-core `base.css`): `--safe-top/right/bottom/left` (= `--app-safe-*` if set, else `env(safe-area-inset-*)`), `--app-height: 100dvh`. Tokens are for layout and spacing; the hook is for behaviour (hide an "install app" prompt, show a back button).
292
+ - Host requirements for a good PWA: `viewport-fit=cover` (`viewport.viewportFit = 'cover'`), `appleWebApp: { capable: true, statusBarStyle }` (`black-translucent` draws the page under the status bar and needs the insets; `default` keeps it below), a manifest with `display: 'standalone'`, and `themeColor`. `createViewport({ themeColor, viewportFit: 'cover' })` and `createManifestMetadata({ ..., statusBarStyle })` from `@djangocfg/nextjs` set them.
293
+ - Storybook: toolbar items "Display mode" and "Safe area" emulate an installed iPhone; see the PhonePwa* stories.
294
+
295
+ ## The shell never scrolls the document
296
+
297
+ `PrivateLayout` is exactly one dynamic viewport tall (`h-[var(--app-height,100dvh)] overflow-hidden`, `SHELL_CLASS`): the (desktop) trigger bar is `shrink-0`, the content below it is `flex-1 min-h-0` and owns the scroll (per `contentScroll`). While mounted it sets `data-private-shell` on `<html>`; in an installed app base.css then locks `html`/`body` (`height: 100%; overflow: hidden; overscroll-behavior: none`), so pressing and dragging the top bar cannot move the page. Public pages never carry the attribute and stay scrollable. Consequences for pages: sticky elements and `window.scrollTo` now relate to the content region, not the window; the layout scrolls its content back to the top when `pathname` changes. Do not wrap the layout in `min-h-screen` / `h-screen`. On iOS < 16, `overscroll-behavior` is ignored, so a rubber band at the very edge can only be ruled out on a real device.
298
+
299
+ ## Brand title and link
300
+
301
+ The product name is semibold (16px on desktop, 14px on a phone, theme type scale), one line, truncated. Title order: `header.title` > `BaseApp` `project` name > none (monogram only). The brand (sidebar header, phone drawer header) and the default top bar title link to `header.href` (default `sidebar.homeHref`, else `/`) through the router `Link` (target order: `header.href` > `sidebar.homeHref` > auth `defaultCallback` > `/`); the drawer closes on click. A custom `header.brand` node is not wrapped; `topBar.title` is shown as given.
@@ -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
+ });
@@ -36,7 +36,7 @@ export function ForbiddenState({
36
36
  return (
37
37
  <div
38
38
  role="alert"
39
- className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"
39
+ className="flex min-h-[var(--app-height,100svh)] flex-col items-center justify-center gap-4 p-6 text-center"
40
40
  >
41
41
  <div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
42
42
  <ShieldAlert className="size-6 text-destructive" />
@@ -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: false,
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)('reserves an in-flow, safe-area aware bar for the trigger (padding %s)', (padding) => {
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(bar.className).toContain('env(safe-area-inset-top');
56
- expect(bar.className).not.toMatch(/\babsolute\b/);
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
- // The page is a sibling after the bar, never underneath it.
59
- expect(bar.nextElementSibling?.textContent).toBe('page');
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
- it('draws no bar while the sidebar is docked', () => {
63
- expect(render(true, 'default').querySelector('[data-sidebar="topbar"]')).toBeNull();
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
  });