@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
@@ -1,20 +1,25 @@
1
1
  /**
2
- * Private layout main column: the scroll surface, plus a top bar holding the sidebar trigger
3
- * whenever the sidebar is not docked (collapsed on desktop, always on mobile). The bar is in
4
- * flow and safe-area aware, so no page content (padded or full-bleed) sits under the trigger.
5
- * On desktop (fine pointer) the trigger sits in a hover zone that peeks the sidebar open after a short intent delay.
2
+ * Private layout main column: the scroll surface, plus ONE top bar whenever the sidebar is not visible: always on a
3
+ * phone (below md), on desktop while the sidebar is collapsed. Same markup and props at every width: sidebar toggle,
4
+ * title, actions, account avatar. Height `--layout-bar-height` = safe-top + 3rem; it overlays the column and the
5
+ * content has that height as top padding, so pages need to know nothing about it. Below md it slides away on
6
+ * scroll-down (same `useNavbarScroll` policy as PublicLayout, driven by the content scroller); on desktop it is static.
7
+ * `--layout-bar-height` is `0px` only when no bar is shown (docked sidebar, or no sidebar at all).
6
8
  */
7
9
 
8
10
  'use client';
9
11
 
10
12
  import React, { ReactNode } from 'react';
11
13
 
12
- import { useSidebar } from '@djangocfg/ui-core/components';
14
+ import { Link, useSidebar } from '@djangocfg/ui-core/components';
13
15
  import { cn } from '@djangocfg/ui-core/lib';
14
16
 
15
17
  import type { LayoutVisualConfig } from '../../types';
16
- import { useSidebarPeek } from '../hooks';
18
+ import type { HeaderConfig } from '../types';
19
+ import { LAYOUT_BAR_HEIGHT } from '../geometry';
20
+ import { useNavbarScroll } from '../../PublicLayout/hooks/useNavbarScroll';
17
21
  import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
22
+ import { PrivateTopBarAccount } from './PrivateTopBarAccount';
18
23
 
19
24
  interface PrivateContentProps {
20
25
  children: ReactNode;
@@ -28,6 +33,18 @@ interface PrivateContentProps {
28
33
  scroll?: 'auto' | 'hidden';
29
34
  /** When false, no mobile hamburger (e.g. layout without a sidebar). Default true. */
30
35
  hasSidebar?: boolean;
36
+ /** A change scrolls the content surface back to the top (the document no longer scrolls, so the router cannot). */
37
+ pathname?: string;
38
+ /** Bar title next to the toggle (`topBar.title`, else `header.title`). */
39
+ title?: ReactNode;
40
+ /** When set, the title is a router link to this href (the default `header.title` fallback only). */
41
+ titleHref?: string;
42
+ /** Bar right-hand actions (`topBar.actions`), left of the account avatar. */
43
+ actions?: ReactNode;
44
+ /** Show the account avatar at the right end of the bar (signed-in user and `topBar.account !== false`). */
45
+ showAccount?: boolean;
46
+ /** Account menu config (links, plan) for the avatar. */
47
+ header?: HeaderConfig;
31
48
  /** Visual config from PrivateLayout — controls maxWidth in boxed mode. */
32
49
  visual?: LayoutVisualConfig;
33
50
  }
@@ -45,26 +62,52 @@ export function PrivateContent({
45
62
  scroll = 'auto',
46
63
  hasSidebar = true,
47
64
  visual,
65
+ pathname,
66
+ title,
67
+ titleHref,
68
+ actions,
69
+ showAccount = false,
70
+ header,
48
71
  }: PrivateContentProps) {
49
- const { isMobile, state } = useSidebar();
50
- const peek = useSidebarPeek();
72
+ const scrollRef = React.useRef<HTMLDivElement>(null);
73
+ const lastPath = React.useRef(pathname);
74
+ React.useEffect(() => {
75
+ if (lastPath.current === pathname) return;
76
+ lastPath.current = pathname;
77
+ scrollRef.current?.scrollTo?.({ top: 0 });
78
+ }, [pathname]);
51
79
 
52
- const showTrigger = hasSidebar && (isMobile || state === 'collapsed');
80
+ const { isMobile, state, openMobile } = useSidebar();
53
81
 
54
- // The trigger lives in its own in-flow bar (below), so content never sits under it, with or without
55
- // content padding. Without a bar the scroll surface starts at the top edge, so keep the bar's room only.
56
- const topPadding = showTrigger
57
- ? 'pt-2 sm:pt-4 lg:pt-4'
82
+ const showBar = hasSidebar && (isMobile || state === 'collapsed');
83
+ const mobileBar = showBar && isMobile;
84
+
85
+ // Auto-hide: the content scroller (the document never scrolls) drives the shared navbar policy. The bar stays put
86
+ // while the drawer is open or a field has focus (iOS keyboard), and when there is nothing to scroll.
87
+ const { hidden: scrolledAway } = useNavbarScroll({ hideNavOnScroll: mobileBar, target: scrollRef, respectReducedMotion: false });
88
+ const [fieldFocused, setFieldFocused] = React.useState(false);
89
+ const onFocusChange = (e: React.FocusEvent) => {
90
+ const el = e.target as HTMLElement;
91
+ setFieldFocused(
92
+ e.type === 'focus' && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable),
93
+ );
94
+ };
95
+ const barHidden = mobileBar && scrolledAway && !openMobile && !fieldFocused;
96
+
97
+ const topPadding = showBar
98
+ ? 'pt-[calc(var(--layout-bar-height)+1rem)]'
58
99
  : hasSidebar
59
100
  ? 'pt-6 lg:pt-8'
60
101
  : 'pt-4 sm:pt-6 lg:pt-8';
61
102
 
62
103
  const scrollAreaClass = cn(
63
104
  'min-h-0 flex-1 bg-background',
105
+ // The bar overlays the column, so every page starts below it.
106
+ showBar && padding === 'none' && 'pt-[var(--layout-bar-height)]',
64
107
  scroll === 'auto' ? 'overflow-y-auto' : 'flex flex-col overflow-hidden',
65
108
  padding === 'default' && scroll === 'auto' && [
66
109
  'px-4 sm:px-6 lg:px-8',
67
- 'pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] lg:pb-[calc(2rem+env(safe-area-inset-bottom,0px))]',
110
+ 'pb-[calc(1rem+var(--safe-bottom,env(safe-area-inset-bottom,0px)))] sm:pb-[calc(1.5rem+var(--safe-bottom,env(safe-area-inset-bottom,0px)))] lg:pb-[calc(2rem+var(--safe-bottom,env(safe-area-inset-bottom,0px)))]',
68
111
  topPadding,
69
112
  ],
70
113
  );
@@ -72,22 +115,39 @@ export function PrivateContent({
72
115
  const innerWidthClass = MAX_WIDTH_CLASS[visual?.maxWidth ?? 'none'];
73
116
 
74
117
  return (
75
- <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
76
- {showTrigger ? (
118
+ <div
119
+ className="relative flex min-h-0 min-w-0 flex-1 flex-col"
120
+ style={{ '--layout-bar-height': showBar ? LAYOUT_BAR_HEIGHT : '0px' } as React.CSSProperties}
121
+ >
122
+ {showBar ? (
77
123
  <div
78
124
  data-sidebar="topbar"
79
- className="relative z-20 flex h-12 shrink-0 box-content items-center bg-background pl-2 pt-[env(safe-area-inset-top,0px)]"
125
+ data-hidden={barHidden ? 'true' : 'false'}
126
+ className="absolute inset-x-0 top-0 z-20 box-border flex h-[var(--layout-bar-height)] shrink-0 items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none data-[hidden=true]:-translate-y-full border-b border-border bg-background pl-[calc(var(--safe-left,env(safe-area-inset-left,0px))+0.75rem)] pr-[calc(var(--safe-right,env(safe-area-inset-right,0px))+0.75rem)] pt-[var(--safe-top,env(safe-area-inset-top,0px))]"
80
127
  >
81
- <div
82
- data-sidebar="trigger-zone"
83
- {...(isMobile || !peek?.hoverEnabled ? undefined : peek.zoneProps)}
84
- className="flex size-12 items-center justify-center"
85
- >
86
- <PrivateSidebarTrigger className="text-muted-foreground hover:text-foreground" />
87
- </div>
128
+ <PrivateSidebarTrigger className="text-muted-foreground hover:text-foreground" />
129
+ <span className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight">
130
+ {titleHref ? (
131
+ <Link
132
+ href={titleHref}
133
+ aria-label={typeof title === 'string' && title ? title : 'Home'}
134
+ className="rounded-md text-foreground no-underline outline-none ring-ring transition-opacity hover:no-underline hover:opacity-80 focus-visible:ring-2"
135
+ >
136
+ {title}
137
+ </Link>
138
+ ) : (
139
+ title
140
+ )}
141
+ </span>
142
+ {actions || showAccount ? (
143
+ <div data-sidebar="topbar-actions" className="flex shrink-0 items-center gap-2">
144
+ {actions}
145
+ {showAccount ? <PrivateTopBarAccount header={header} /> : null}
146
+ </div>
147
+ ) : null}
88
148
  </div>
89
149
  ) : null}
90
- <div className={scrollAreaClass}>
150
+ <div ref={scrollRef} className={scrollAreaClass} onFocusCapture={onFocusChange} onBlurCapture={onFocusChange}>
91
151
  {innerWidthClass ? <div className={innerWidthClass}>{children}</div> : children}
92
152
  </div>
93
153
  </div>
@@ -20,9 +20,11 @@ interface PrivateSidebarProps {
20
20
  header?: HeaderConfig;
21
21
  pathname?: string;
22
22
  variant?: 'sidebar' | 'inset';
23
+ /** The top bar shows the account avatar (phone drawer then has no footer account). */
24
+ accountInBar?: boolean;
23
25
  }
24
26
 
25
- export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'sidebar' }: PrivateSidebarProps) {
27
+ export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'sidebar', accountInBar = false }: PrivateSidebarProps) {
26
28
  const { state, isMobile, setOpenMobile } = useSidebar();
27
29
  const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
28
30
 
@@ -31,7 +33,7 @@ export function PrivateSidebar({ sidebar, header, pathname = '', variant = 'side
31
33
  }, [pathname, isMobile, setOpenMobile]);
32
34
 
33
35
  return (
34
- <PrivateLayoutProvider sidebar={sidebar} header={header} pathname={pathname} isMobile={isMobile}>
36
+ <PrivateLayoutProvider sidebar={sidebar} header={header} pathname={pathname} isMobile={isMobile} accountInBar={accountInBar}>
35
37
  <Sidebar
36
38
  ref={setSidebarRef}
37
39
  collapsible="offcanvas"
@@ -11,27 +11,18 @@
11
11
 
12
12
  'use client';
13
13
 
14
- import { ChevronDown } from 'lucide-react';
15
14
  import { Link } from '@djangocfg/ui-core/components';
16
15
  import React, { memo } from 'react';
17
16
 
18
- import { useAuth } from '@djangocfg/api/auth';
19
- import {
20
- Avatar,
21
- AvatarFallback,
22
- AvatarImage,
23
- Button,
24
- MenuBuilder,
25
- } from '@djangocfg/ui-core/components';
26
- import type { MenuItem } from '@djangocfg/ui-core/components';
27
- import { cn, isDev } from '@djangocfg/ui-core/lib';
28
17
  import { useSidebar } from '@djangocfg/ui-core/components';
18
+ import { cn, isDev } from '@djangocfg/ui-core/lib';
19
+ import { useAuth } from '@djangocfg/api/auth';
29
20
 
30
21
  import { LucideIcon as LucideIconRender } from '../../../components';
22
+ import { AccountFooterTrigger, AccountMenu } from '../../../components/account';
31
23
  import { usePeekPin } from '../hooks';
32
24
  import { usePrivateLayoutContext } from '../context';
33
25
  import { SIDEBAR_SPACING } from '../geometry';
34
- import { useAccountMenuItems } from '../../SettingsLayout/hooks';
35
26
 
36
27
  import type { HeaderConfig } from '../types';
37
28
 
@@ -39,72 +30,18 @@ interface PrivateSidebarAccountProps {
39
30
  header?: HeaderConfig;
40
31
  }
41
32
 
42
- interface AccountView {
43
- source: 'user' | 'dev-fallback';
44
- displayName: string;
45
- email: string | null;
46
- avatarUrl: string;
47
- plan: string | null;
48
- }
49
-
50
33
  function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
51
- const { user } = useAuth();
52
34
  const { setOpen: setSidebarOpen } = useSidebar();
53
35
  const { isMobile } = usePrivateLayoutContext();
54
36
  const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
55
37
  usePeekPin(isAccountMenuOpen);
56
38
 
57
- const accountLinks = React.useMemo(() => {
58
- if (!header?.groups?.length) return [];
59
- return header.groups.flatMap((g) => g.items.filter((i) => i.href));
60
- }, [header?.groups]);
61
-
62
- /**
63
- * Single source of truth for what the footer renders. When `useAuth` returns
64
- * a real user we use it; otherwise (in dev only) we fall back to a placeholder
65
- * so the menu stays reachable for debugging. Production stays strict and the
66
- * component returns null below.
67
- */
68
- const account = React.useMemo<AccountView>(() => {
69
- if (user) {
70
- return {
71
- source: 'user',
72
- displayName: user.display_username || user.email || 'User',
73
- email: user.email ?? null,
74
- avatarUrl: user.avatar ?? '',
75
- plan: header?.userPlan ?? null,
76
- };
77
- }
78
- return {
79
- source: 'dev-fallback',
80
- displayName: 'Guest (dev)',
81
- email: null,
82
- avatarUrl: '',
83
- plan: header?.userPlan ?? 'No session',
84
- };
85
- }, [user, header?.userPlan]);
39
+ const { user } = useAuth();
86
40
 
87
41
  const onSecondaryExpand = React.useCallback(() => {
88
42
  setSidebarOpen(true);
89
43
  }, [setSidebarOpen]);
90
44
 
91
- const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
92
-
93
- // The app's account links sit between the shared Settings shortcuts and Sign out.
94
- const extraItems = React.useMemo<MenuItem[]>(() => {
95
- const items: MenuItem[] = [];
96
- if (accountLinks.length > 0) {
97
- items.push({ kind: 'separator', id: 'sep-links' });
98
- for (const link of accountLinks) {
99
- items.push({ kind: 'item', id: link.href!, label: link.label, icon: link.icon, href: link.href });
100
- }
101
- }
102
- return items;
103
- }, [accountLinks]);
104
-
105
- // Shared with every host: email, Settings, section shortcuts, Sign out (logout confirm opens over a CLOSED menu).
106
- const menuItems = useAccountMenuItems({ extra: extraItems, label: headerLabelText });
107
-
108
45
  // Hide entirely in production when there's no user (auth still loading or
109
46
  // /me failed and the parent guard hasn't redirected yet). In dev keep a
110
47
  // placeholder so the footer + Log out are reachable for debugging.
@@ -118,77 +55,33 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
118
55
  // production because `isDev` makes this guard statically dead in development.
119
56
  if (!user && !isDev) return null;
120
57
 
121
- const userInitial = (account.displayName.charAt(0) || '?').toUpperCase();
122
58
  const secondary = header?.footerSecondaryAction;
123
59
 
124
- const triggerClassName = cn(
125
- 'group h-8 min-w-0 max-w-full justify-start gap-2 rounded-lg px-2 text-left font-normal select-none',
126
- // Full-width row unless a secondary action shares the footer.
127
- !secondary && 'w-full',
128
- 'hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
129
- // No focus ring on the menu trigger — the open menu is the affordance.
130
- 'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
131
- isMobile && 'h-11',
132
- );
133
-
134
60
  const secondaryButton = secondary ? (
135
61
  <SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
136
62
  ) : null;
137
63
 
138
- const dropdownContentClass = 'w-60 p-1.5';
139
- // Always open UPWARD over the avatar (Claude pattern). The footer sits at the
140
- // bottom of the rail in both expanded and collapsed states, so `top` reads
141
- // correctly either way (vs. `right`, which floated off to the side).
142
- const dropdownSide = 'top' as const;
143
- const avatarClass = cn('shrink-0', isMobile ? 'size-7' : 'size-6');
144
-
145
- // Like Claude: "Name · Plan ⌄". The email lives in the menu header.
146
- const expandedMeta = (
147
- <>
148
- <span className="flex min-w-0 items-baseline gap-1 text-left">
149
- <span className="truncate text-sm text-sidebar-foreground">{account.displayName}</span>
150
- {account.plan ? (
151
- <span className="shrink-0 text-xs text-sidebar-foreground/50">· {account.plan}</span>
152
- ) : null}
153
- </span>
154
- <ChevronDown
155
- className={cn(
156
- 'size-4 shrink-0 text-sidebar-foreground/40 transition-transform duration-150 group-data-[state=open]:rotate-180',
157
- !secondary && 'ml-auto',
158
- )}
159
- aria-hidden
160
- />
161
- </>
162
- );
163
-
164
- const triggerButton = (
165
- <Button type="button" variant="ghost" className={triggerClassName}>
166
- <Avatar className={avatarClass}>
167
- <AvatarImage src={account.avatarUrl} alt={account.displayName} />
168
- <AvatarFallback className="text-xs font-medium">{userInitial}</AvatarFallback>
169
- </Avatar>
170
- {expandedMeta}
171
- </Button>
172
- );
173
-
174
64
  const wrapperClass = cn(
175
- 'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60',
65
+ 'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60 bg-sidebar',
176
66
  SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].footer,
177
67
  );
178
68
 
179
69
  return (
180
70
  <div className={wrapperClass}>
181
- <MenuBuilder
182
- items={menuItems}
183
- open={isAccountMenuOpen}
184
- onOpenChange={setIsAccountMenuOpen}
185
- side={dropdownSide}
71
+ {/* Always opens UPWARD over the avatar (Claude pattern): the footer sits at the bottom of the rail. */}
72
+ <AccountMenu
73
+ groups={header?.groups}
74
+ plan={header?.userPlan}
75
+ signIn="none"
76
+ devFallback
77
+ side="top"
186
78
  align="start"
187
79
  sideOffset={8}
188
- contentClassName={dropdownContentClass}
80
+ open={isAccountMenuOpen}
81
+ onOpenChange={setIsAccountMenuOpen}
189
82
  >
190
- {triggerButton}
191
- </MenuBuilder>
83
+ <AccountFooterTrigger hasSecondary={Boolean(secondary)} className={isMobile ? 'h-10 px-2.5' : undefined} avatarClassName={isMobile ? 'size-7' : undefined} />
84
+ </AccountMenu>
192
85
  {secondaryButton}
193
86
  </div>
194
87
  );
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The one sidebar button: same box, icon and position rules on mobile and desktop.
3
- * Floating over the content it opens the sidebar; inside the sidebar header it closes or
4
- * collapses it. Both are `toggleSidebar`.
3
+ * In the top bar it opens the sidebar; inside the sidebar header it closes or collapses it. Both are
4
+ * `toggleSidebar`. No tooltip (the Cmd/Ctrl+B binding lives in ui-core's sidebar provider). The labels are plain
5
+ * English: the layouts have no message catalogue (only a locale hook).
5
6
  */
6
7
 
7
8
  'use client';
@@ -29,7 +30,6 @@ export const PrivateSidebarTrigger = React.forwardRef<
29
30
  data-sidebar="trigger"
30
31
  variant="ghost"
31
32
  size="icon"
32
- title={label}
33
33
  aria-label={label}
34
34
  className={cn(TRIGGER_CLASS, className)}
35
35
  onClick={(event) => {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Account avatar for the top bar: the shared `AccountMenu` (the same items as the sidebar footer) opened downward
3
+ * and aligned to the right edge. Rendered only for a signed-in user; the caller decides whether the bar shows it.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+
10
+ import { AccountAvatarTrigger, AccountMenu } from '../../../components/account';
11
+
12
+ import type { HeaderConfig } from '../types';
13
+
14
+ export function PrivateTopBarAccount({ header }: { header?: HeaderConfig }) {
15
+ return (
16
+ <AccountMenu
17
+ groups={header?.groups}
18
+ plan={header?.userPlan}
19
+ signIn="none"
20
+ side="bottom"
21
+ align="end"
22
+ sideOffset={6}
23
+ >
24
+ <AccountAvatarTrigger />
25
+ </AccountMenu>
26
+ );
27
+ }
@@ -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 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-8' : '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={'truncate text-base font-medium tracking-tight'}>
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
  }