@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.
Files changed (58) 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 +10 -55
  19. package/src/layouts/AppLayout/index.ts +1 -0
  20. package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +17 -11
  21. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +1 -1
  22. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +1 -4
  23. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +17 -3
  24. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +21 -25
  25. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +2 -2
  26. package/src/layouts/AuthLayout/styles/oauth.css +45 -0
  27. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
  28. package/src/layouts/PrivateLayout/README.md +23 -5
  29. package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
  30. package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +230 -0
  31. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +86 -19
  32. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
  33. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
  34. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
  35. package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
  36. package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
  37. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
  38. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
  39. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +13 -8
  40. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +2 -2
  41. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
  42. package/src/layouts/PrivateLayout/context.tsx +15 -3
  43. package/src/layouts/PrivateLayout/geometry.test.ts +44 -0
  44. package/src/layouts/PrivateLayout/geometry.ts +40 -16
  45. package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
  46. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
  47. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
  48. package/src/layouts/PrivateLayout/index.ts +2 -0
  49. package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
  50. package/src/layouts/PrivateLayout/types.ts +27 -2
  51. package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
  52. package/src/layouts/PublicLayout/components/index.ts +2 -2
  53. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
  54. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
  55. package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
  56. package/src/shared/ProjectContext.tsx +18 -0
  57. package/src/testing/MockAuthProvider.tsx +5 -2
  58. /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
@@ -0,0 +1,73 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { SidebarProvider } from '@djangocfg/ui-core/components';
7
+
8
+ import { PrivateLayoutProvider } from '../context';
9
+ import { SIDEBAR_SPACING } from '../geometry';
10
+ import { SidebarBody } from './SidebarBody';
11
+
12
+ vi.mock('@djangocfg/api/auth', async () => ({
13
+ AuthContext: (await import('react')).createContext(undefined),
14
+ useAuth: () => ({ user: { email: 'a@b.c', display_username: 'Mark' } }),
15
+ }));
16
+
17
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
+
19
+ let host: HTMLElement | null = null;
20
+ let root: Root | null = null;
21
+
22
+ beforeAll(() => {
23
+ window.matchMedia ??= ((query: string) => ({
24
+ matches: false, media: query, onchange: null,
25
+ addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false,
26
+ })) as typeof window.matchMedia;
27
+ });
28
+ afterEach(() => {
29
+ act(() => root?.unmount());
30
+ host?.remove();
31
+ host = root = null;
32
+ });
33
+
34
+ function render(isMobile: boolean) {
35
+ host = document.createElement('div');
36
+ document.body.appendChild(host);
37
+ root = createRoot(host);
38
+ act(() =>
39
+ root!.render(
40
+ <SidebarProvider>
41
+ <PrivateLayoutProvider sidebar={{ groups: [{ label: 'A', items: [{ label: 'Home', href: '/' }] }] }} header={undefined} pathname="/" isMobile={isMobile}>
42
+ <SidebarBody />
43
+ </PrivateLayoutProvider>
44
+ </SidebarProvider>,
45
+ ),
46
+ );
47
+ return host;
48
+ }
49
+
50
+ describe('SidebarBody zones', () => {
51
+ it.each([true, false])('header and footer are pinned (shrink-0), only the nav scrolls (mobile %s)', (isMobile) => {
52
+ const el = render(isMobile);
53
+ const header = el.querySelector<HTMLElement>('[data-sidebar="header"]')!;
54
+ const nav = el.querySelector<HTMLElement>('[data-sidebar="content"]')!;
55
+ const footer = nav.nextElementSibling as HTMLElement;
56
+ expect(header.className).toMatch(/\bshrink-0\b/);
57
+ expect(header.className).toContain('bg-sidebar');
58
+ expect(nav.className).toMatch(/\bmin-h-0\b/);
59
+ expect(nav.className).toMatch(/\bflex-1\b/);
60
+ expect(nav.className).toMatch(/\boverflow-y-auto\b/);
61
+ expect(nav.className).toMatch(/\boverscroll-contain\b/);
62
+ expect(footer.className).toMatch(/\bshrink-0\b/);
63
+ expect(footer.className).toMatch(/\bborder-t\b/);
64
+ expect(footer.className).toContain('bg-sidebar');
65
+ // Nothing else in the body scrolls.
66
+ expect(header.className).not.toMatch(/overflow-y-auto/);
67
+ expect(footer.className).not.toMatch(/overflow-y-auto/);
68
+ });
69
+
70
+ it('mobile footer respects the home indicator', () => {
71
+ expect(SIDEBAR_SPACING.mobile.footer).toContain('--safe-bottom');
72
+ });
73
+ });
@@ -17,7 +17,7 @@ import { SidebarNavGroup } from './SidebarNavGroup';
17
17
  import { SidebarSlots } from './SidebarSlots';
18
18
 
19
19
  export function SidebarBody() {
20
- const { sidebar, header, isMobile } = usePrivateLayoutContext();
20
+ const { sidebar, header, isMobile, accountInBar } = usePrivateLayoutContext();
21
21
 
22
22
  const groups = useMemo(
23
23
  () =>
@@ -33,20 +33,29 @@ export function SidebarBody() {
33
33
  [sidebar?.groups],
34
34
  );
35
35
 
36
+ // Three zones: pinned header, the ONLY scroller (nav), pinned footer. The header gets a border once the nav scrolls.
37
+ const [scrolled, setScrolled] = React.useState(false);
38
+ const onScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
39
+ const next = e.currentTarget.scrollTop > 0;
40
+ setScrolled((prev) => (prev === next ? prev : next));
41
+ }, []);
42
+
36
43
  return (
37
44
  <>
38
- <SidebarBrand />
45
+ <SidebarBrand bordered={scrolled} />
39
46
  <div
40
47
  data-sidebar="content"
48
+ onScroll={onScroll}
41
49
  className={cn(
42
- 'flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden',
50
+ 'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain',
43
51
  SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].body,
44
52
  )}
45
53
  >
46
54
  <SidebarSlots />
47
55
  {groups}
48
56
  </div>
49
- <PrivateSidebarAccount header={header} />
57
+ {/* On a phone the account lives in the top bar when it shows the avatar; no second entry point here. */}
58
+ {isMobile && accountInBar ? null : <PrivateSidebarAccount header={header} />}
50
59
  </>
51
60
  );
52
61
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import React, { memo } from 'react';
9
9
 
10
- import { Link } from '@djangocfg/ui-core/components';
10
+ import { Link, useSidebar } from '@djangocfg/ui-core/components';
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
 
13
13
  import { LucideIcon } from '../../../components';
@@ -17,41 +17,48 @@ import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
17
17
  import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
18
18
 
19
19
  const BRAND_LINK =
20
- 'flex min-w-0 items-center gap-2.5 rounded-lg py-1 outline-none ring-sidebar-ring focus-visible:ring-2';
20
+ 'flex min-w-0 items-center gap-2.5 rounded-lg py-1 text-sidebar-foreground no-underline outline-none ring-sidebar-ring transition-opacity hover:no-underline hover:opacity-80 focus-visible:ring-2';
21
21
 
22
- function SidebarBrandRaw() {
22
+ /** Brand name: semibold, one step larger on desktop (18px), 16px on a phone; one line, truncated. */
23
+ const BRAND_NAME = 'truncate text-base font-semibold tracking-tight md:text-lg';
24
+
25
+ function SidebarBrandRaw({ bordered = false }: { bordered?: boolean }) {
23
26
  const { header, homeHref, brandTitle, brandMonogram, isMobile } = usePrivateLayoutContext();
24
27
 
28
+ const { isMobile: drawer, setOpenMobile } = useSidebar();
29
+ // On a phone the drawer closes on click, also when the target is the current page (no pathname change).
30
+ const closeDrawer = React.useCallback(() => {
31
+ if (drawer) setOpenMobile(false);
32
+ }, [drawer, setOpenMobile]);
33
+
25
34
  const customBrand = header?.brand;
26
35
 
27
36
  const brand =
28
37
  customBrand != null && customBrand !== false ? (
29
38
  typeof customBrand === 'string' ? (
30
- <Link href={homeHref} className={BRAND_LINK}>
31
- <span className="truncate text-base font-medium tracking-tight">{customBrand}</span>
39
+ <Link href={homeHref} className={BRAND_LINK} aria-label={customBrand} onClick={closeDrawer}>
40
+ <span className={BRAND_NAME}>{customBrand}</span>
32
41
  </Link>
33
42
  ) : (
34
43
  customBrand
35
44
  )
36
45
  ) : (
37
- <Link href={homeHref} className={BRAND_LINK}>
46
+ <Link href={homeHref} className={BRAND_LINK} aria-label={brandTitle || 'Home'} onClick={closeDrawer}>
38
47
  <span
39
48
  className={cn(
40
49
  'flex shrink-0 items-center justify-center rounded-lg bg-sidebar-primary',
41
- isMobile ? 'size-9' : 'size-7',
50
+ isMobile ? 'size-7' : 'size-8',
42
51
  )}
43
52
  >
44
53
  {header?.brandIcon ? (
45
- <LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground" />
54
+ <LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground md:size-[18px]" />
46
55
  ) : (
47
- <span className="text-[11px] font-bold leading-none text-sidebar-primary-foreground">
56
+ <span className="text-xs font-bold leading-none text-sidebar-primary-foreground">
48
57
  {brandMonogram}
49
58
  </span>
50
59
  )}
51
60
  </span>
52
- <span className={cn('truncate font-medium tracking-tight', isMobile ? 'text-lg' : 'text-base')}>
53
- {brandTitle}
54
- </span>
61
+ {brandTitle ? <span className={BRAND_NAME}>{brandTitle}</span> : null}
55
62
  </Link>
56
63
  );
57
64
 
@@ -62,12 +69,16 @@ function SidebarBrandRaw() {
62
69
  return (
63
70
  <div
64
71
  data-sidebar="header"
65
- className={cn('flex shrink-0 items-center gap-2', SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].header)}
72
+ className={cn(
73
+ 'flex shrink-0 items-center gap-2 bg-sidebar shadow-[0_1px_0_0_transparent] transition-shadow',
74
+ bordered && 'shadow-[0_1px_0_0_color-mix(in_oklab,var(--sidebar-border)_60%,transparent)]',
75
+ SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].header)}
66
76
  >
67
77
  <div className="min-w-0 flex-1">
68
78
  {header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger /> : brand}
69
79
  </div>
70
- {trailing}
80
+ {/* The desktop toggle collapses the pinned sidebar. The phone drawer has none: it closes by backdrop, swipe, Escape, or navigating. */}
81
+ {isMobile ? null : trailing}
71
82
  </div>
72
83
  );
73
84
  }
@@ -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`,
33
- '[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-2',
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. */
@@ -43,13 +48,13 @@ function GroupDivider({ inset }: { inset: string }) {
43
48
  role="separator"
44
49
  aria-orientation="horizontal"
45
50
  data-sidebar="group-divider"
46
- className={cn(inset, 'mb-2 mt-3 h-px shrink-0 bg-sidebar-border')}
51
+ className={cn(inset, 'mb-3 mt-3 h-px shrink-0 bg-sidebar-border')}
47
52
  />
48
53
  );
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) {
@@ -98,7 +103,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
98
103
  type="button"
99
104
  aria-expanded={open}
100
105
  className={cn(
101
- 'flex w-full items-center gap-2.5 rounded-lg font-normal text-sidebar-foreground',
106
+ 'flex w-full items-center rounded-lg font-normal text-sidebar-foreground',
102
107
  'transition-colors duration-150 hover:bg-sidebar-accent/60',
103
108
  'outline-none ring-sidebar-ring focus-visible:ring-2',
104
109
  geometry.item,
@@ -117,7 +122,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
117
122
  </CollapsibleTrigger>
118
123
  <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
119
124
  <SidebarGroupContent>
120
- <SidebarMenu className="mt-0.5 gap-0.5">{rows}</SidebarMenu>
125
+ <SidebarMenu className="mt-1 gap-0.5">{rows}</SidebarMenu>
121
126
  </SidebarGroupContent>
122
127
  </CollapsibleContent>
123
128
  </Collapsible>
@@ -66,7 +66,7 @@ function SidebarSettingsNavItem({ item, hideItemIcons }: { item: SidebarSettings
66
66
  onClick={open}
67
67
  aria-haspopup="dialog"
68
68
  aria-current={active ? 'true' : undefined}
69
- className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
69
+ className={cn(ITEM_CLASS, geometry.item, hideItemIcons && geometry.indent)}
70
70
  >
71
71
  <ItemContent item={item} hideItemIcons={hideItemIcons} />
72
72
  </SidebarMenuButton>
@@ -83,7 +83,7 @@ function SidebarLinkNavItem({ item, hideItemIcons }: { item: SidebarLinkItem; hi
83
83
  <SidebarMenuButton
84
84
  asChild
85
85
  isActive={active}
86
- className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
86
+ className={cn(ITEM_CLASS, geometry.item, hideItemIcons && geometry.indent)}
87
87
  >
88
88
  <Link href={item.href} aria-current={active ? 'page' : undefined}>
89
89
  <ItemContent item={item} hideItemIcons={hideItemIcons} />
@@ -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>;
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { NAV_GEOMETRY, SHELL_CLASS, SIDEBAR_SPACING } from './geometry';
4
+
5
+ const SIZES = /\btext-(base|lg|xl|2xl)\b/;
6
+
7
+ describe('sidebar geometry', () => {
8
+ for (const form of ['desktop', 'mobile'] as const) {
9
+ it(`${form}: rows use 14px text and a 16-20px icon, not a large font`, () => {
10
+ expect(NAV_GEOMETRY[form].item).toMatch(/\btext-sm\b/);
11
+ expect(NAV_GEOMETRY[form].item).not.toMatch(SIZES);
12
+ expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4|5)/);
13
+ expect(NAV_GEOMETRY[form].label).toMatch(/\btext-xs\b/);
14
+ });
15
+
16
+ it(`${form}: horizontal inset is tight (8px), vertical body padding is roomier`, () => {
17
+ expect(NAV_GEOMETRY[form].inset).toBe('px-2');
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
+ });
21
+ }
22
+
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
+ expect(NAV_GEOMETRY.desktop.item).toMatch(/\bh-9\b/);
26
+ });
27
+
28
+ it('mobile header and footer never go under the status bar or home indicator', () => {
29
+ expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-top');
30
+ expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-bottom');
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
+ });
44
+ });
@@ -1,21 +1,26 @@
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 = {
7
7
  desktop: {
8
- item: 'h-8 gap-2.5 px-2.5 text-base [&>svg]:size-4',
9
- label: 'h-7 px-2.5 text-xs',
8
+ item: 'h-9 gap-2.5 px-2 text-sm [&>svg]:size-4',
9
+ label: 'h-7 px-2 text-xs',
10
10
  /** Horizontal inset of groups, slots and the divider; equals the header and footer inset. */
11
11
  inset: 'px-2',
12
12
  dividerInset: 'mx-2',
13
+ /** Left padding of a row without an icon, so its text lines up with the text of rows that have one (8 + 16 + 10px). */
14
+ indent: 'pl-[34px]',
13
15
  },
14
16
  mobile: {
15
- item: 'h-11 gap-3 px-3 text-lg [&>svg]:size-5',
16
- label: 'h-8 px-3 text-sm',
17
- inset: 'px-3',
18
- dividerInset: 'mx-3',
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. */
20
+ inset: 'px-2',
21
+ dividerInset: 'mx-2',
22
+ /** 10 + 20 + 10px. */
23
+ indent: 'pl-[40px]',
19
24
  },
20
25
  } as const;
21
26
 
@@ -23,22 +28,33 @@ export type NavGeometry = (typeof NAV_GEOMETRY)[keyof typeof NAV_GEOMETRY];
23
28
 
24
29
  /**
25
30
  * One spacing scale for the three blocks of the sidebar (header, list, footer). On mobile every
26
- * block uses the same 0.75rem unit on the sides and the safe-area aware edge; the list gives
27
- * 0.5rem between blocks.
31
+ * block keeps tight sides (0.5rem rows, 0.75rem header) and roomier verticals, with the top and
32
+ * bottom edges safe-area aware (never less than 1rem).
28
33
  */
29
34
  export const SIDEBAR_SPACING = {
30
35
  desktop: {
31
- header: 'px-3 pb-1 pt-3',
32
- body: 'py-2',
33
- footer: 'p-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
36
+ header: 'pl-4 pr-2 pb-2 pt-3',
37
+ body: 'gap-1 py-3',
38
+ footer: 'px-2 pt-2 pb-[max(0.5rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))]',
34
39
  },
35
40
  mobile: {
36
- header: 'px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top,0px))]',
37
- body: 'py-2',
38
- footer: 'p-3 pb-[max(0.75rem,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)))]',
39
48
  },
40
49
  } as const;
41
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
+
42
58
  /** The one trigger button: same box and icon size on mobile and desktop. */
43
59
  export const TRIGGER_CLASS = 'size-10 shrink-0 [&_svg]:!size-5';
44
60
 
@@ -48,3 +64,11 @@ export const SIDEBAR_MOTION_MS = 180;
48
64
  /** Desktop hover-open timing: intent delay before opening, grace before closing. */
49
65
  export const HOVER_OPEN_DELAY_MS = 400;
50
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
  }
@@ -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';