@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
@@ -27,10 +27,15 @@ import { isSettingsItem, isSidebarGroupVisible, sidebarItemKey, visibleSidebarIt
27
27
  import type { SidebarGroupConfig } from '../types';
28
28
  import { SidebarNavItem } from './SidebarNavItem';
29
29
 
30
- const groupClassFor = (inset: string) =>
30
+ const groupClassFor = (inset: string, insetRule: boolean) =>
31
31
  cn(
32
- `gap-0 ${inset} py-1.5`,
33
- '[&:not(:first-child)]:mt-1.5 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-3',
32
+ // Phone drawer (insetRule): half the air between groups.
33
+ `gap-0 ${inset} ${insetRule ? 'py-1' : 'py-1.5'}`,
34
+ insetRule ? '[&:not(:first-child)]:mt-0.5 [&:not(:first-child)]:pt-1.5' : '[&:not(:first-child)]:mt-1.5 [&:not(:first-child)]:pt-3',
35
+ // Phone drawer: the hairline is inset to the row gutter (8px). Desktop keeps its full-width border.
36
+ insetRule
37
+ ? '[&:not(:first-child)]:before:absolute [&:not(:first-child)]:before:inset-x-2 [&:not(:first-child)]:before:top-0 [&:not(:first-child)]:before:h-px [&:not(:first-child)]:before:bg-sidebar-border/60'
38
+ : '[&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60',
34
39
  );
35
40
 
36
41
  /** A `separated` group draws its own, stronger divider (below), so it drops the hairline and takes more air. */
@@ -49,7 +54,7 @@ function GroupDivider({ inset }: { inset: string }) {
49
54
  }
50
55
 
51
56
  function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
52
- const { isActive, geometry } = usePrivateLayoutContext();
57
+ const { isActive, geometry, isMobile } = usePrivateLayoutContext();
53
58
 
54
59
  const hasLabel = group.label.trim().length > 0;
55
60
  const isCollapsible = Boolean(group.collapsible) && hasLabel;
@@ -84,7 +89,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
84
89
  ));
85
90
  if (!visible) return null;
86
91
 
87
- const groupClass = group.separated ? separatedGroupClassFor(geometry.inset) : groupClassFor(geometry.inset);
92
+ const groupClass = group.separated ? separatedGroupClassFor(geometry.inset) : groupClassFor(geometry.inset, isMobile);
88
93
  const divider = group.separated ? <GroupDivider inset={geometry.dividerInset} /> : null;
89
94
 
90
95
  if (isCollapsible) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Peek panel: the full sidebar floated over the page while it is collapsed.
3
- * Opened by hover intent on the left edge (or the trigger zone), held by `SidebarPeekProvider`,
3
+ * Opened by hover intent on the left screen edge strip (the toggle button itself does not open it), held by `SidebarPeekProvider`,
4
4
  * slides in and out (it stays mounted while the exit animation runs).
5
5
  */
6
6
 
@@ -7,6 +7,8 @@
7
7
 
8
8
  'use client';
9
9
 
10
+ import { useLandingRoute } from './hooks/useLandingRoute';
11
+ import { useProjectName } from '../../shared/ProjectContext';
10
12
  import React, { createContext, useContext, useMemo } from 'react';
11
13
 
12
14
  import { NAV_GEOMETRY } from './geometry';
@@ -19,6 +21,8 @@ export interface PrivateLayoutContextValue {
19
21
  header: HeaderConfig | undefined;
20
22
  pathname: string;
21
23
  isMobile: boolean;
24
+ /** The top bar carries the account avatar, so the phone drawer drops its footer account (one entry point). */
25
+ accountInBar: boolean;
22
26
  geometry: NavGeometry;
23
27
  /** The route rows; Settings launchers have no route and take no part in the active-route check. */
24
28
  allItems: SidebarLinkItem[];
@@ -44,6 +48,7 @@ interface PrivateLayoutProviderProps {
44
48
  header: HeaderConfig | undefined;
45
49
  pathname: string;
46
50
  isMobile: boolean;
51
+ accountInBar?: boolean;
47
52
  }
48
53
 
49
54
  export function PrivateLayoutProvider({
@@ -52,9 +57,15 @@ export function PrivateLayoutProvider({
52
57
  header,
53
58
  pathname,
54
59
  isMobile,
60
+ accountInBar = false,
55
61
  }: PrivateLayoutProviderProps) {
56
- const homeHref = sidebar?.homeHref || '/';
57
- const brandTitle = header?.title?.trim() || 'Dashboard';
62
+ // header.href > sidebar.homeHref > the private landing route AppLayout/BaseApp configures (`auth.routes.defaultCallback`,
63
+ // resolved by the auth provider; `undefined` outside it) > '/'.
64
+ const landing = useLandingRoute();
65
+ const homeHref = header?.href || sidebar?.homeHref || landing || '/';
66
+ // header.title > the project name from BaseApp > nothing (monogram only).
67
+ const projectName = useProjectName();
68
+ const brandTitle = header?.title?.trim() || projectName || '';
58
69
  const brandMonogram = (
59
70
  header?.brandLetter?.trim().charAt(0) ||
60
71
  brandTitle.charAt(0) ||
@@ -87,6 +98,7 @@ export function PrivateLayoutProvider({
87
98
  header,
88
99
  pathname,
89
100
  isMobile,
101
+ accountInBar,
90
102
  geometry: isMobile ? NAV_GEOMETRY.mobile : NAV_GEOMETRY.desktop,
91
103
  allItems,
92
104
  isActive,
@@ -94,7 +106,7 @@ export function PrivateLayoutProvider({
94
106
  brandTitle,
95
107
  brandMonogram,
96
108
  }),
97
- [sidebar, header, pathname, isMobile, allItems, isActive, homeHref, brandTitle, brandMonogram],
109
+ [sidebar, header, pathname, isMobile, accountInBar, allItems, isActive, homeHref, brandTitle, brandMonogram],
98
110
  );
99
111
 
100
112
  return <PrivateLayoutContext.Provider value={value}>{children}</PrivateLayoutContext.Provider>;
@@ -1,27 +1,27 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { NAV_GEOMETRY, SIDEBAR_SPACING } from './geometry';
3
+ import { NAV_GEOMETRY, SHELL_CLASS, SIDEBAR_SPACING } from './geometry';
4
4
 
5
5
  const SIZES = /\btext-(base|lg|xl|2xl)\b/;
6
6
 
7
7
  describe('sidebar geometry', () => {
8
8
  for (const form of ['desktop', 'mobile'] as const) {
9
- it(`${form}: rows use 14px text and a 16-18px icon, not a large font`, () => {
9
+ it(`${form}: rows use 14px text and a 16-20px icon, not a large font`, () => {
10
10
  expect(NAV_GEOMETRY[form].item).toMatch(/\btext-sm\b/);
11
11
  expect(NAV_GEOMETRY[form].item).not.toMatch(SIZES);
12
- expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4|\[18px\])/);
12
+ expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4|5)/);
13
13
  expect(NAV_GEOMETRY[form].label).toMatch(/\btext-xs\b/);
14
14
  });
15
15
 
16
16
  it(`${form}: horizontal inset is tight (8px), vertical body padding is roomier`, () => {
17
17
  expect(NAV_GEOMETRY[form].inset).toBe('px-2');
18
- expect(NAV_GEOMETRY[form].item).toMatch(/\bpx-2\b/);
19
- expect(SIDEBAR_SPACING[form].body).toMatch(/\bpy-3\b/);
18
+ expect(NAV_GEOMETRY[form].item).toMatch(form === 'mobile' ? /\bpx-2\.5\b/ : /\bpx-2\b/);
19
+ expect(SIDEBAR_SPACING[form].body).toMatch(form === 'mobile' ? /\bpy-2\b/ : /\bpy-3\b/);
20
20
  });
21
21
  }
22
22
 
23
- it('mobile rows keep a 44px touch target; desktop rows are 36px', () => {
24
- expect(NAV_GEOMETRY.mobile.item).toMatch(/\bh-11\b/);
23
+ it('mobile rows are 40px with a 44px hit area (see touch-safe.css); desktop rows are 36px', () => {
24
+ expect(NAV_GEOMETRY.mobile.item).toMatch(/\bh-10\b/);
25
25
  expect(NAV_GEOMETRY.desktop.item).toMatch(/\bh-9\b/);
26
26
  });
27
27
 
@@ -29,4 +29,16 @@ describe('sidebar geometry', () => {
29
29
  expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-top');
30
30
  expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-bottom');
31
31
  });
32
+
33
+ it('the shell is one dynamic viewport tall and clips, so the document never scrolls', () => {
34
+ expect(SHELL_CLASS).toContain('h-[var(--app-height,100dvh)]');
35
+ expect(SHELL_CLASS).toContain('overflow-hidden');
36
+ expect(SHELL_CLASS).toContain('min-h-0');
37
+ expect(SHELL_CLASS).not.toMatch(/min-h-(screen|svh)/);
38
+ });
39
+
40
+ it('mobile header and footer also respect the side safe areas (landscape notch)', () => {
41
+ expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-left');
42
+ expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-right');
43
+ });
32
44
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Sidebar geometry. Row sizes (a pointer-sized desktop row, a 44px touch row for the mobile
3
- * drawer), one horizontal inset shared by every block, one trigger size, and the motion timing.
2
+ * Sidebar geometry. Row sizes (a pointer-sized 36px desktop row, a 40px row for the mobile
3
+ * drawer, whose 44px hit area comes from an invisible ::after in ui-core touch-safe.css), one horizontal inset shared by every block, one trigger size, and the motion timing.
4
4
  */
5
5
 
6
6
  export const NAV_GEOMETRY = {
@@ -14,12 +14,13 @@ export const NAV_GEOMETRY = {
14
14
  indent: 'pl-[34px]',
15
15
  },
16
16
  mobile: {
17
- item: 'h-11 gap-2.5 px-2 text-sm [&>svg]:size-[18px]',
18
- label: 'h-8 px-2 text-xs',
17
+ item: 'h-10 gap-2.5 px-2.5 text-sm [&>svg]:size-5',
18
+ label: 'h-7 px-2.5 text-xs',
19
+ /** One 8px gutter for rows, dividers, header and footer; rows add 10px, so the icon column (and the logo) sit at 18px. */
19
20
  inset: 'px-2',
20
21
  dividerInset: 'mx-2',
21
- /** 8 + 18 + 10px. */
22
- indent: 'pl-9',
22
+ /** 10 + 20 + 10px. */
23
+ indent: 'pl-[40px]',
23
24
  },
24
25
  } as const;
25
26
 
@@ -34,15 +35,26 @@ export const SIDEBAR_SPACING = {
34
35
  desktop: {
35
36
  header: 'pl-4 pr-2 pb-2 pt-3',
36
37
  body: 'gap-1 py-3',
37
- footer: 'px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
38
+ footer: 'px-2 pt-2 pb-[max(0.5rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))]',
38
39
  },
39
40
  mobile: {
40
- header: 'pl-4 pr-2 pb-3 pt-[max(1rem,env(safe-area-inset-top,0px))]',
41
- body: 'gap-1 py-3',
42
- footer: 'px-2 pt-3 pb-[max(1rem,env(safe-area-inset-bottom,0px))]',
41
+ // The same row height as the top bar: 48px under the safe-top inset, the brand vertically centred, its left edge on the
42
+ // nav icon column (18px). No toggle: the drawer closes by backdrop, swipe, Escape or navigation.
43
+ header:
44
+ 'box-border h-[calc(var(--safe-top,env(safe-area-inset-top,0px))+3rem)] pt-[var(--safe-top,env(safe-area-inset-top,0px))] pl-[calc(var(--safe-left,env(safe-area-inset-left,0px))+1.125rem)] pr-[calc(var(--safe-right,env(safe-area-inset-right,0px))+0.75rem)]',
45
+ body: 'gap-1 py-2',
46
+ footer:
47
+ 'pt-2 pb-[max(0.5rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))] pl-[max(0.5rem,var(--safe-left,env(safe-area-inset-left,0px)))] pr-[max(0.5rem,var(--safe-right,env(safe-area-inset-right,0px)))]',
43
48
  },
44
49
  } as const;
45
50
 
51
+ /**
52
+ * The private app shell is exactly one dynamic viewport tall and clips, so the DOCUMENT never scrolls: the top bar
53
+ * is `shrink-0`, the content below it is `flex-1 min-h-0` and owns the scroll (see `PrivateContent`).
54
+ * (`SidebarProvider` defaults to `min-h-svh`, which let content grow the page past the viewport.)
55
+ */
56
+ export const SHELL_CLASS = 'h-[var(--app-height,100dvh)] max-h-[var(--app-height,100dvh)] min-h-0 overflow-hidden';
57
+
46
58
  /** The one trigger button: same box and icon size on mobile and desktop. */
47
59
  export const TRIGGER_CLASS = 'size-10 shrink-0 [&_svg]:!size-5';
48
60
 
@@ -52,3 +64,11 @@ export const SIDEBAR_MOTION_MS = 180;
52
64
  /** Desktop hover-open timing: intent delay before opening, grace before closing. */
53
65
  export const HOVER_OPEN_DELAY_MS = 400;
54
66
  export const HOVER_CLOSE_DELAY_MS = 250;
67
+
68
+ /**
69
+ * Fixed-height top bar: safe-top + 3rem. `--layout-bar-height` is set on the layout's main column to this value whenever
70
+ * the bar is shown (always on a phone; on desktop while the sidebar is collapsed) and to `0px` when it is not (desktop
71
+ * with the sidebar docked, or no sidebar); content starts right under the bar, pages need to know nothing about it.
72
+ * Apps may use it to offset their own sticky elements.
73
+ */
74
+ export const LAYOUT_BAR_HEIGHT = 'calc(var(--safe-top, env(safe-area-inset-top, 0px)) + 3rem)';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The private landing route AppLayout/BaseApp configures: `auth.routes.defaultCallback`, resolved by the auth
3
+ * provider (default `/private`). Returns `undefined` outside the provider, so stories and tests fall back to `/`.
4
+ * Reads the context directly: `useAuth()` returns default state outside the provider, which would leak the
5
+ * framework default.
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import { useContext } from 'react';
11
+
12
+ import { AuthContext } from '@djangocfg/api/auth';
13
+
14
+ export function useLandingRoute(): string | undefined {
15
+ return useContext(AuthContext)?.routes?.defaultCallback || undefined;
16
+ }
@@ -27,7 +27,7 @@ export function resolveProviderStyle(
27
27
  * extract these classes at build time.
28
28
  */
29
29
  const BOXED_INSET_CLASS = [
30
- 'flex flex-col',
30
+ 'flex min-h-0 min-w-0 flex-col',
31
31
  'md:peer-data-[variant=inset]:my-[var(--app-shell-inset-y)]',
32
32
  'md:peer-data-[variant=inset]:mr-[var(--app-shell-inset-x)]',
33
33
  'md:peer-data-[variant=inset]:rounded-[var(--app-shell-radius)]',
@@ -47,7 +47,7 @@ const BOXED_RADIUS_REM: Record<NonNullable<LayoutVisualConfig['radius']>, string
47
47
  };
48
48
 
49
49
  export function resolveInsetClassName(visual: LayoutVisualConfig | undefined): string {
50
- if ((visual?.variant ?? 'full-bleed') !== 'boxed') return 'flex flex-col';
50
+ if ((visual?.variant ?? 'full-bleed') !== 'boxed') return 'flex min-h-0 min-w-0 flex-col';
51
51
  const border = visual?.border ?? true;
52
52
  return border ? `${BOXED_INSET_CLASS} ${BOXED_INSET_BORDER_CLASS}` : BOXED_INSET_CLASS;
53
53
  }
@@ -71,10 +71,10 @@ const BOXED_BG_CLASS: Record<NonNullable<LayoutVisualConfig['background']>, stri
71
71
  export function resolveProviderClassName(
72
72
  visual: LayoutVisualConfig | undefined,
73
73
  ): string | undefined {
74
- // h-svh + overflow-hidden: lock the shell to exactly one viewport height so
74
+ // h-[var(--app-height)] + overflow-hidden: lock the shell to exactly one viewport height so
75
75
  // the inner scroll-area (PrivateContent) is the only scroll surface. Without
76
76
  // this, SidebarProvider grows via min-h-svh and the whole page scrolls.
77
- const base = 'h-svh overflow-hidden';
77
+ const base = 'h-[var(--app-height,100svh)] overflow-hidden';
78
78
  if ((visual?.variant ?? 'full-bleed') !== 'boxed') return base;
79
79
  // `max-md:!bg-background` neutralises shadcn-sidebar's built-in
80
80
  // `has-[[data-variant=inset]]:bg-sidebar` below md so the mobile Drawer shell
@@ -18,7 +18,7 @@ import { usePresence } from './usePresence';
18
18
  type PointerHandlers = ReturnType<typeof useHoverIntent>['zoneProps'];
19
19
 
20
20
  export interface SidebarPeek extends Presence {
21
- /** True while the sidebar is collapsed on desktop: the trigger zone shows. */
21
+ /** True while the sidebar is collapsed on desktop: the left-edge hot zone is mounted. */
22
22
  enabled: boolean;
23
23
  /** Hover-open is live: collapsed, desktop, and a fine (mouse/pen) pointer. */
24
24
  hoverEnabled: boolean;
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
6
  export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
7
+ export { LAYOUT_BAR_HEIGHT } from './geometry';
7
8
  export { mcpSidebarItem, mcpSidebarGroup } from './mcpSidebar';
8
9
  export type { McpSidebarItemOptions, McpSidebarGroupOptions } from './mcpSidebar';
9
10
  export { SidebarBrandSwitcher, ForbiddenState } from './components';
@@ -21,4 +22,5 @@ export type {
21
22
  SidebarFeaturedConfig,
22
23
  SidebarBrandSwitcherConfig,
23
24
  SidebarBrandSwitcherItem,
25
+ PrivateTopBarConfig,
24
26
  } from './types';
@@ -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
- /** Collapsed rail: shown in tooltip; defaults to `label`. */
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 touch size. */
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).