@djangocfg/layouts 2.1.591 → 2.1.593

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 (100) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  14. package/src/layouts/PrivateLayout/README.md +20 -12
  15. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  16. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  17. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
  18. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  19. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  20. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  21. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  22. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  23. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  24. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  25. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  26. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  27. package/src/layouts/PrivateLayout/context.tsx +10 -115
  28. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  29. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  30. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  31. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  32. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  33. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  34. package/src/layouts/PrivateLayout/types.ts +13 -20
  35. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  36. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  37. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  38. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  39. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  40. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  41. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  42. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  43. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  44. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  45. package/src/layouts/SettingsLayout/README.md +23 -10
  46. package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
  47. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  48. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  49. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  50. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  51. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  52. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  53. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  54. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  55. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  56. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  57. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
  58. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  59. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  60. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  61. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  62. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  63. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  64. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  65. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  66. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  67. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  68. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  69. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  70. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  71. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  72. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  73. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  74. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  75. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  76. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  77. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  78. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  79. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  80. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  81. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  82. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  83. package/src/layouts/SettingsLayout/index.ts +3 -1
  84. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  85. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  86. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  87. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
  88. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  89. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  90. package/src/layouts/SettingsLayout/store.ts +12 -0
  91. package/src/layouts/SettingsLayout/types.ts +12 -4
  92. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  93. package/src/layouts/types/layout.types.ts +5 -6
  94. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
  95. package/src/styles/dashboard.css +10 -0
  96. package/src/layouts/PrivateLayout/density.ts +0 -48
  97. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  98. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  99. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  100. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -11,7 +11,7 @@
11
11
 
12
12
  'use client';
13
13
 
14
- import { ChevronsUpDown, Languages, LogOut, Settings as SettingsIcon } from 'lucide-react';
14
+ import { ChevronDown, Languages } from 'lucide-react';
15
15
  import { Link } from '@djangocfg/ui-core/components';
16
16
  import React, { memo } from 'react';
17
17
 
@@ -29,12 +29,12 @@ import type { MenuItem } from '@djangocfg/ui-core/components';
29
29
  import { cn, isDev } from '@djangocfg/ui-core/lib';
30
30
  import { useSidebar } from '@djangocfg/ui-core/components';
31
31
 
32
- import { useLogout } from '../../../hooks';
33
- import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
32
+ import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
34
33
  import { LucideIcon as LucideIconRender } from '../../../components';
35
34
  import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
36
- import { useShellVisualState } from '../hooks';
37
- import { useSettingsDialogStore } from '../../SettingsLayout/store';
35
+ import { usePeekPin } from '../hooks';
36
+ import { usePrivateLayoutContext } from '../context';
37
+ import { useAccountMenuItems } from '../../SettingsLayout/hooks';
38
38
 
39
39
  import type { HeaderConfig } from '../types';
40
40
 
@@ -52,14 +52,12 @@ interface AccountView {
52
52
 
53
53
  function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
54
54
  const { user } = useAuth();
55
- const handleLogout = useLogout();
56
55
  const t = useAppT();
57
56
  const layoutI18n = useLayoutI18nOptional();
58
57
  const { setOpen: setSidebarOpen } = useSidebar();
59
- const { content } = useShellVisualState();
58
+ const { isMobile } = usePrivateLayoutContext();
60
59
  const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
61
-
62
- const signOutLabel = t('layouts.profile.signOut');
60
+ usePeekPin(isAccountMenuOpen);
63
61
 
64
62
  const accountLinks = React.useMemo(() => {
65
63
  if (!header?.groups?.length) return [];
@@ -91,43 +89,15 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
91
89
  };
92
90
  }, [user, header?.userPlan]);
93
91
 
94
- const onTriggerInteract = React.useCallback(() => {
95
- if (content.isAccountCompact) setSidebarOpen(true);
96
- }, [content.isAccountCompact, setSidebarOpen]);
97
-
98
92
  const onSecondaryExpand = React.useCallback(() => {
99
93
  setSidebarOpen(true);
100
94
  }, [setSidebarOpen]);
101
95
 
102
96
  const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
103
97
 
104
- // `accountAction: 'dialog'` opens the global SettingsDialog (mounted in
105
- // PrivateLayout). Requires `settings` to be passed to PrivateLayout.
106
- const openSettingsDialog = React.useCallback(() => {
107
- useSettingsDialogStore.getState().open();
108
- }, []);
109
-
110
- // ── Declarative account menu (Claude-style) — fed to MenuBuilder ──
111
- const menuItems = React.useMemo<MenuItem[]>(() => {
98
+ // Language submenu and the app's account links sit between the shared Settings shortcuts and Sign out.
99
+ const extraItems = React.useMemo<MenuItem[]>(() => {
112
100
  const items: MenuItem[] = [];
113
-
114
- // Email header.
115
- if (headerLabelText) {
116
- items.push({ kind: 'label', id: 'email', label: headerLabelText });
117
- }
118
-
119
- // Settings → opens the global SettingsDialog (⌘, like Claude).
120
- items.push({
121
- kind: 'item',
122
- id: 'settings',
123
- label: t('layouts.profile.settings') || 'Settings',
124
- icon: SettingsIcon,
125
- shortcut: '⇧⌘,',
126
- onSelect: () => useSettingsDialogStore.getState().open(),
127
- });
128
-
129
- // Language → submenu with a radio group of locales (flag + native name,
130
- // checkmark on the active one). Only when an i18n layout context is present.
131
101
  if (layoutI18n && layoutI18n.locales.length > 1) {
132
102
  items.push({
133
103
  kind: 'submenu',
@@ -153,36 +123,17 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
153
123
  ],
154
124
  });
155
125
  }
156
-
157
- // App-provided account links (from header.groups).
158
126
  if (accountLinks.length > 0) {
159
127
  items.push({ kind: 'separator', id: 'sep-links' });
160
128
  for (const link of accountLinks) {
161
- items.push({
162
- kind: 'item',
163
- id: link.href!,
164
- label: link.label,
165
- icon: link.icon,
166
- href: link.href,
167
- });
129
+ items.push({ kind: 'item', id: link.href!, label: link.label, icon: link.icon, href: link.href });
168
130
  }
169
131
  }
170
-
171
- // Log out (destructive). Let the menu close normally (no preventDefault) —
172
- // the logout confirm dialog should appear over a CLOSED menu, not under a
173
- // still-open one.
174
- items.push({ kind: 'separator', id: 'sep-logout' });
175
- items.push({
176
- kind: 'item',
177
- id: 'logout',
178
- label: signOutLabel,
179
- icon: LogOut,
180
- variant: 'destructive',
181
- onSelect: () => handleLogout(),
182
- });
183
-
184
132
  return items;
185
- }, [headerLabelText, t, layoutI18n, accountLinks, signOutLabel, handleLogout]);
133
+ }, [t, layoutI18n, accountLinks]);
134
+
135
+ // Shared with every host: email, Settings, section shortcuts, Sign out (logout confirm opens over a CLOSED menu).
136
+ const menuItems = useAccountMenuItems({ extra: extraItems, label: headerLabelText });
186
137
 
187
138
  // Hide entirely in production when there's no user (auth still loading or
188
139
  // /me failed and the parent guard hasn't redirected yet). In dev keep a
@@ -201,80 +152,57 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
201
152
  const secondary = header?.footerSecondaryAction;
202
153
 
203
154
  const triggerClassName = cn(
204
- 'group h-auto w-full gap-3 rounded-lg px-3 py-3 text-left select-none',
205
- 'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
155
+ 'group h-8 min-w-0 max-w-full justify-start gap-2 rounded-lg px-2 text-left font-normal select-none',
156
+ // Full-width row unless a secondary action shares the footer.
157
+ !secondary && 'w-full',
158
+ 'hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
206
159
  // No focus ring on the menu trigger — the open menu is the affordance.
207
160
  'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
208
- content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
161
+ isMobile && 'h-11',
209
162
  );
210
163
 
211
- const secondaryButton = secondary && !content.isAccountCompact ? (
164
+ const secondaryButton = secondary ? (
212
165
  <SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
213
166
  ) : null;
214
167
 
215
- const dropdownContentClass = cn(
216
- 'p-1.5',
217
- content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
218
- );
168
+ const dropdownContentClass = 'w-60 p-1.5';
219
169
  // Always open UPWARD over the avatar (Claude pattern). The footer sits at the
220
170
  // bottom of the rail in both expanded and collapsed states, so `top` reads
221
171
  // correctly either way (vs. `right`, which floated off to the side).
222
172
  const dropdownSide = 'top' as const;
223
- const avatarClass = cn(
224
- 'h-9 w-9 shrink-0 border border-transparent transition-colors',
225
- 'group-hover:border-sidebar-border/70',
226
- );
173
+ const avatarClass = cn('shrink-0', isMobile ? 'size-7' : 'size-6');
227
174
 
228
- const expandedMeta = content.isAccountCompact ? null : (
175
+ // Like Claude: "Name · Plan ⌄". The email lives in the menu header.
176
+ const expandedMeta = (
229
177
  <>
230
- <span className="flex min-w-0 flex-1 flex-col text-left">
231
- <span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
232
- {account.displayName}
233
- </span>
178
+ <span className="flex min-w-0 items-baseline gap-1 text-left">
179
+ <span className="truncate text-base text-sidebar-foreground">{account.displayName}</span>
234
180
  {account.plan ? (
235
- <span className="truncate text-xs leading-snug text-sidebar-foreground/60">
236
- {account.plan}
237
- </span>
238
- ) : null}
239
- </span>
240
- <span className="flex shrink-0 items-center gap-1.5">
241
- {secondaryButton}
242
- {header?.accountAction !== 'dialog' ? (
243
- <ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
181
+ <span className="shrink-0 text-xs text-sidebar-foreground/50">· {account.plan}</span>
244
182
  ) : null}
245
183
  </span>
184
+ <ChevronDown
185
+ className={cn(
186
+ 'size-4 shrink-0 text-sidebar-foreground/40 transition-transform duration-150 group-data-[state=open]:rotate-180',
187
+ !secondary && 'ml-auto',
188
+ )}
189
+ aria-hidden
190
+ />
246
191
  </>
247
192
  );
248
193
 
249
194
  const triggerButton = (
250
- <Button
251
- type="button"
252
- variant="ghost"
253
- aria-label={content.isAccountCompact ? account.displayName : undefined}
254
- className={triggerClassName}
255
- onClick={header?.accountAction === 'dialog' ? openSettingsDialog : onTriggerInteract}
256
- >
195
+ <Button type="button" variant="ghost" className={triggerClassName}>
257
196
  <Avatar className={avatarClass}>
258
197
  <AvatarImage src={account.avatarUrl} alt={account.displayName} />
259
- <AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
198
+ <AvatarFallback className="text-xs font-medium">{userInitial}</AvatarFallback>
260
199
  </Avatar>
261
200
  {expandedMeta}
262
201
  </Button>
263
202
  );
264
203
 
265
- const wrapperClass = cn(
266
- 'w-full min-w-0',
267
- content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
268
- );
269
-
270
- // Dialog mode: simple button that opens the global SettingsDialog
271
- if (header?.accountAction === 'dialog') {
272
- return (
273
- <div className={wrapperClass}>
274
- {triggerButton}
275
- </div>
276
- );
277
- }
204
+ const wrapperClass =
205
+ 'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]';
278
206
 
279
207
  return (
280
208
  <div className={wrapperClass}>
@@ -289,6 +217,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
289
217
  >
290
218
  {triggerButton}
291
219
  </MenuBuilder>
220
+ {secondaryButton}
292
221
  </div>
293
222
  );
294
223
  }
@@ -322,8 +251,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
322
251
  const inner = (
323
252
  <span
324
253
  className={cn(
325
- 'relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
326
- 'border border-sidebar-border/40 bg-transparent text-sidebar-foreground/70',
254
+ 'relative inline-flex size-7 shrink-0 items-center justify-center rounded-md',
255
+ 'bg-transparent text-sidebar-foreground/60',
327
256
  'transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
328
257
  )}
329
258
  >
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The sidebar's content: brand header, scrollable nav, account footer.
3
+ * Rendered by the docked sidebar, the peek panel and the mobile drawer alike.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useMemo } from 'react';
9
+
10
+ import { usePrivateLayoutContext } from '../context';
11
+ import { PrivateSidebarAccount } from './PrivateSidebarAccount';
12
+ import { SidebarBrand } from './SidebarBrand';
13
+ import { SidebarNavGroup } from './SidebarNavGroup';
14
+ import { SidebarSlots } from './SidebarSlots';
15
+
16
+ export function SidebarBody() {
17
+ const { sidebar, header } = usePrivateLayoutContext();
18
+
19
+ const groups = useMemo(
20
+ () =>
21
+ (sidebar?.groups ?? []).map((group) =>
22
+ group.dynamic && group.items.length === 0 ? null : (
23
+ <SidebarNavGroup
24
+ key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
25
+ group={group}
26
+ />
27
+ ),
28
+ ),
29
+ [sidebar?.groups],
30
+ );
31
+
32
+ return (
33
+ <>
34
+ <SidebarBrand />
35
+ <div
36
+ data-sidebar="content"
37
+ className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden py-2"
38
+ >
39
+ <SidebarSlots />
40
+ {groups}
41
+ </div>
42
+ <PrivateSidebarAccount header={header} />
43
+ </>
44
+ );
45
+ }
@@ -1,179 +1,91 @@
1
1
  /**
2
- * Sidebar Brand Component
3
- *
4
- * Renders the sidebar header with brand mark + title.
5
- * Three modes: expanded (desktop), collapsed rail (desktop), mobile drawer.
2
+ * Sidebar header: brand (or workspace switcher) on the left, one control on the
3
+ * right — collapse on desktop, close on the mobile drawer.
6
4
  */
7
5
 
8
6
  'use client';
9
7
 
10
- import React, { memo, useMemo } from 'react';
8
+ import React, { memo } from 'react';
9
+ import { X } from 'lucide-react';
11
10
 
12
- import { Link, SidebarHeader, SidebarTrigger } from '@djangocfg/ui-core/components';
11
+ import { Button, Link, SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
13
12
  import { cn } from '@djangocfg/ui-core/lib';
14
13
 
15
14
  import { LucideIcon } from '../../../components';
16
15
  import { usePrivateLayoutContext } from '../context';
17
- import { useShellVisualState } from '../hooks';
18
16
  import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
19
17
 
20
- const HEADER_ROW_CLASS = 'flex items-center gap-2 mb-2';
18
+ const BRAND_LINK =
19
+ 'flex min-w-0 items-center gap-2.5 rounded-lg py-1 outline-none ring-sidebar-ring focus-visible:ring-2';
21
20
 
22
21
  function SidebarBrandRaw() {
23
- const { header, homeHref, brandTitle, brandMonogram, isMobile } =
24
- usePrivateLayoutContext();
25
- const { content } = useShellVisualState();
26
-
27
- const brandMark = useMemo(
28
- () =>
29
- header?.brandIcon ? (
30
- <LucideIcon
31
- icon={header.brandIcon}
32
- className="h-4 w-4 text-sidebar-primary-foreground"
33
- />
34
- ) : (
35
- <span className="text-[11px] font-bold leading-none tracking-tight text-sidebar-primary-foreground">
36
- {brandMonogram}
37
- </span>
38
- ),
39
- [header?.brandIcon, brandMonogram],
40
- );
22
+ const { header, homeHref, brandTitle, brandMonogram, isMobile } = usePrivateLayoutContext();
23
+ const { setOpenMobile } = useSidebar();
41
24
 
42
25
  const customBrand = header?.brand;
43
26
 
44
- const headerRowClass = HEADER_ROW_CLASS;
45
-
46
- const expandedHeader = useMemo(
47
- () => (
48
- <div className={headerRowClass}>
49
- <div className="min-w-0 flex-1">
50
- {customBrand != null && customBrand !== false ? (
51
- typeof customBrand === 'string' ? (
52
- <Link
53
- href={homeHref}
54
- className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
55
- >
56
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
57
- {customBrand}
58
- </span>
59
- </Link>
60
- ) : (
61
- customBrand
62
- )
63
- ) : (
64
- <Link
65
- href={homeHref}
66
- className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
67
- >
68
- <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
69
- {brandMark}
70
- </div>
71
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
72
- {brandTitle}
73
- </span>
74
- </Link>
27
+ const brand =
28
+ customBrand != null && customBrand !== false ? (
29
+ typeof customBrand === 'string' ? (
30
+ <Link href={homeHref} className={BRAND_LINK}>
31
+ <span className="truncate text-base font-medium tracking-tight">{customBrand}</span>
32
+ </Link>
33
+ ) : (
34
+ customBrand
35
+ )
36
+ ) : (
37
+ <Link href={homeHref} className={BRAND_LINK}>
38
+ <span
39
+ className={cn(
40
+ 'flex shrink-0 items-center justify-center rounded-lg bg-sidebar-primary',
41
+ isMobile ? 'size-9' : 'size-7',
75
42
  )}
76
- </div>
77
- {!isMobile && <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />}
78
- </div>
79
- ),
80
- [headerRowClass, customBrand, homeHref, brandMark, brandTitle, isMobile],
81
- );
82
-
83
- const collapsedHeader = useMemo(
84
- () => (
85
- <div className="group/collapsed-brand flex justify-center py-1">
86
- <div className="relative h-7 w-7">
87
- <Link
88
- href={homeHref}
89
- className="absolute inset-0 flex items-center justify-center rounded-md bg-sidebar-primary outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-brand:opacity-0"
90
- aria-label={brandTitle}
91
- >
92
- {brandMark}
93
- </Link>
94
- <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-brand:opacity-100">
95
- <SidebarTrigger aria-label="Expand sidebar" />
96
- </div>
97
- </div>
98
- </div>
99
- ),
100
- [homeHref, brandTitle, brandMark],
101
- );
102
-
103
- /** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
104
- const mobileHeader = useMemo(
105
- () => (
106
- <div className="flex items-center gap-3">
107
- <div className="min-w-0 flex-1">
108
- {customBrand != null && customBrand !== false ? (
109
- typeof customBrand === 'string' ? (
110
- <Link
111
- href={homeHref}
112
- className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
113
- >
114
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
115
- {customBrand}
116
- </span>
117
- </Link>
118
- ) : (
119
- customBrand
120
- )
43
+ >
44
+ {header?.brandIcon ? (
45
+ <LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground" />
121
46
  ) : (
122
- <Link
123
- href={homeHref}
124
- className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
125
- >
126
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
127
- {brandMark}
128
- </div>
129
- <span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
130
- {brandTitle}
131
- </span>
132
- </Link>
47
+ <span className="text-[11px] font-bold leading-none text-sidebar-primary-foreground">
48
+ {brandMonogram}
49
+ </span>
133
50
  )}
134
- </div>
135
- </div>
136
- ),
137
- [customBrand, homeHref, brandMark, brandTitle],
51
+ </span>
52
+ <span className={cn('truncate font-medium tracking-tight', isMobile ? 'text-lg' : 'text-base')}>
53
+ {brandTitle}
54
+ </span>
55
+ </Link>
56
+ );
57
+
58
+ const trailing = isMobile ? (
59
+ <Button
60
+ type="button"
61
+ variant="ghost"
62
+ size="icon"
63
+ className="size-11 shrink-0 text-sidebar-foreground/70"
64
+ aria-label="Close menu"
65
+ onClick={() => setOpenMobile(false)}
66
+ >
67
+ <X className="size-5" />
68
+ </Button>
69
+ ) : header?.switcher ? null : (
70
+ <SidebarTrigger className="shrink-0 text-sidebar-foreground/60 hover:text-sidebar-foreground" />
138
71
  );
139
72
 
140
- // Switcher mode: trigger embedded inside switcher row on desktop expanded
141
- const switcherContent = header?.switcher
142
- ? (
143
- <div className="mb-2">
144
- <SidebarBrandSwitcher
145
- config={header.switcher}
146
- showCollapseTrigger={content.showLabels && !isMobile}
147
- />
148
- </div>
149
- )
150
- : null;
151
-
152
- const sidebarHeaderContent = switcherContent
153
- ?? (isMobile
154
- ? mobileHeader
155
- : content.showLabels
156
- ? expandedHeader
157
- : collapsedHeader);
158
-
159
- const sidebarHeaderClass = useMemo(
160
- () =>
161
- cn(
162
- 'pb-2',
73
+ return (
74
+ <div
75
+ data-sidebar="header"
76
+ className={cn(
77
+ 'flex shrink-0 items-center gap-2',
163
78
  isMobile
164
- ? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
165
- : 'pt-3.5',
166
- ),
167
- [isMobile],
79
+ ? 'px-4 pb-2 pt-[max(0.5rem,env(safe-area-inset-top,0px))]'
80
+ : 'px-3 pb-1 pt-3',
81
+ )}
82
+ >
83
+ <div className="min-w-0 flex-1">
84
+ {header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger={!isMobile} /> : brand}
85
+ </div>
86
+ {trailing}
87
+ </div>
168
88
  );
169
-
170
- return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
171
89
  }
172
90
 
173
- /**
174
- * Memoised brand header. Re-renders only when context values change
175
- * (header config, brand title, mobile state, showLabels). The three
176
- * visual modes (expanded / collapsed rail / mobile) are pre-built with
177
- * useMemo so the JSX tree is stable across renders.
178
- */
179
91
  export const SidebarBrand = memo(SidebarBrandRaw);
@@ -4,8 +4,6 @@
4
4
  * Dropdown for switching workspaces / accounts / projects.
5
5
  * Renders in the sidebar header area, replacing the static brand block.
6
6
  *
7
- * Collapsed rail: shows only the active item's avatar/monogram (no dropdown trigger).
8
- * Hover-expanded / mobile: shows full trigger + dropdown.
9
7
  */
10
8
 
11
9
  'use client';
@@ -27,7 +25,7 @@ import {
27
25
  import { Link } from '@djangocfg/ui-core/components';
28
26
  import { cn } from '@djangocfg/ui-core/lib';
29
27
 
30
- import { useShellVisualState } from '../hooks';
28
+ import { usePeekPin } from '../hooks';
31
29
  import type { SidebarBrandSwitcherConfig, SidebarBrandSwitcherItem } from '../types';
32
30
 
33
31
  interface SidebarBrandSwitcherProps {
@@ -37,8 +35,8 @@ interface SidebarBrandSwitcherProps {
37
35
  }
38
36
 
39
37
  function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSwitcherProps) {
40
- const { content } = useShellVisualState();
41
38
  const [open, setOpen] = React.useState(false);
39
+ usePeekPin(open);
42
40
 
43
41
  const activeItem = useMemo(
44
42
  () => config.items.find((i) => i.active) ?? config.items[0] ?? null,
@@ -66,32 +64,6 @@ function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSw
66
64
  </Avatar>
67
65
  );
68
66
 
69
- // Collapsed rail — avatar with trigger on hover
70
- if (!content.showLabels) {
71
- return (
72
- <div className="group/collapsed-switcher flex justify-center py-1">
73
- <div className="relative h-7 w-7">
74
- {activeItem.href ? (
75
- <Link
76
- href={activeItem.href}
77
- className="absolute inset-0 flex items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-switcher:opacity-0"
78
- aria-label={activeItem.label}
79
- >
80
- {activeAvatar}
81
- </Link>
82
- ) : (
83
- <div className="absolute inset-0 flex items-center justify-center transition-opacity group-hover/collapsed-switcher:opacity-0">
84
- {activeAvatar}
85
- </div>
86
- )}
87
- <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-switcher:opacity-100">
88
- <SidebarTrigger aria-label="Expand sidebar" />
89
- </div>
90
- </div>
91
- </div>
92
- );
93
- }
94
-
95
67
  return (
96
68
  <div className="flex items-center gap-1">
97
69
  <DropdownMenu open={open} onOpenChange={onOpenChange}>