@djangocfg/layouts 2.1.319 → 2.1.320

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.319",
3
+ "version": "2.1.320",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,14 +74,14 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.319",
78
- "@djangocfg/centrifugo": "^2.1.319",
79
- "@djangocfg/debuger": "^2.1.319",
80
- "@djangocfg/i18n": "^2.1.319",
81
- "@djangocfg/monitor": "^2.1.319",
82
- "@djangocfg/ui-core": "^2.1.319",
83
- "@djangocfg/ui-nextjs": "^2.1.319",
84
- "@djangocfg/ui-tools": "^2.1.319",
77
+ "@djangocfg/api": "^2.1.320",
78
+ "@djangocfg/centrifugo": "^2.1.320",
79
+ "@djangocfg/debuger": "^2.1.320",
80
+ "@djangocfg/i18n": "^2.1.320",
81
+ "@djangocfg/monitor": "^2.1.320",
82
+ "@djangocfg/ui-core": "^2.1.320",
83
+ "@djangocfg/ui-nextjs": "^2.1.320",
84
+ "@djangocfg/ui-tools": "^2.1.320",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -111,15 +111,15 @@
111
111
  "uuid": "^11.1.0"
112
112
  },
113
113
  "devDependencies": {
114
- "@djangocfg/api": "^2.1.319",
115
- "@djangocfg/centrifugo": "^2.1.319",
116
- "@djangocfg/debuger": "^2.1.319",
117
- "@djangocfg/i18n": "^2.1.319",
118
- "@djangocfg/monitor": "^2.1.319",
119
- "@djangocfg/typescript-config": "^2.1.319",
120
- "@djangocfg/ui-core": "^2.1.319",
121
- "@djangocfg/ui-nextjs": "^2.1.319",
122
- "@djangocfg/ui-tools": "^2.1.319",
114
+ "@djangocfg/api": "^2.1.320",
115
+ "@djangocfg/centrifugo": "^2.1.320",
116
+ "@djangocfg/debuger": "^2.1.320",
117
+ "@djangocfg/i18n": "^2.1.320",
118
+ "@djangocfg/monitor": "^2.1.320",
119
+ "@djangocfg/typescript-config": "^2.1.320",
120
+ "@djangocfg/ui-core": "^2.1.320",
121
+ "@djangocfg/ui-nextjs": "^2.1.320",
122
+ "@djangocfg/ui-tools": "^2.1.320",
123
123
  "@types/node": "^24.7.2",
124
124
  "@types/react": "^19.1.0",
125
125
  "@types/react-dom": "^19.1.0",
@@ -86,6 +86,17 @@ export interface HeaderConfig {
86
86
  groups?: UserMenuConfig['groups'];
87
87
  /** Auth page path (for sign in button) */
88
88
  authPath?: string;
89
+ /** Subtitle under the display name in the sidebar footer (e.g. "Max plan"). */
90
+ userPlan?: string;
91
+ /** Optional secondary action button rendered inside the footer trigger (e.g. Get apps download button). */
92
+ footerSecondaryAction?: {
93
+ icon: string | LucideIconType;
94
+ href?: string;
95
+ onClick?: () => void;
96
+ ariaLabel: string;
97
+ /** Show pulsing accent dot on the action (Claude-style "new"). */
98
+ pulse?: boolean;
99
+ };
89
100
  }
90
101
 
91
102
  export interface PrivateLayoutProps {
@@ -47,12 +47,13 @@ function navDensityFromCount(n: number): NavDensity {
47
47
  * Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
48
48
  */
49
49
  const navItemClass = cn(
50
- 'border-0 font-medium shadow-none transition-colors',
50
+ 'group/nav border-0 font-medium shadow-none transition-colors',
51
51
  'text-sidebar-foreground/80',
52
52
  'data-[active=true]:font-semibold data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
53
53
  'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
54
54
  'data-[active=true]:hover:bg-sidebar-accent',
55
55
  '[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
56
+ '[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
56
57
  'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
57
58
  );
58
59
 
@@ -180,6 +181,7 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
180
181
 
181
182
  return sidebar.groups.map((group) => {
182
183
  if (group.dynamic && group.items.length === 0) return null;
184
+ const hasLabel = Boolean(group.label && group.label.trim().length > 0);
183
185
  const items = group.items.map((item: SidebarItem) => {
184
186
  const iconProp = typeof item.icon === 'string' ? item.icon : item.icon;
185
187
  const tooltipText = item.tooltip ?? item.label;
@@ -202,9 +204,12 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
202
204
  );
203
205
  });
204
206
 
207
+ const groupKey = group.label || `__flat_${group.items.map((i) => i.href).join('|')}`;
205
208
  return (
206
- <SidebarGroup key={group.label} className={sidebarGroupClass}>
207
- <SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
209
+ <SidebarGroup key={groupKey} className={sidebarGroupClass}>
210
+ {hasLabel ? (
211
+ <SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
212
+ ) : null}
208
213
  <SidebarGroupContent>
209
214
  <SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
210
215
  </SidebarGroupContent>
@@ -305,11 +310,16 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
305
310
  const railExpandHintClass =
306
311
  !isMobile && state === 'collapsed' ? 'cursor-pointer' : undefined;
307
312
 
313
+ const sidebarRootClass = cn(
314
+ railExpandHintClass,
315
+ '[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
316
+ );
317
+
308
318
  return (
309
319
  <Sidebar
310
320
  collapsible="icon"
311
321
  variant={variant}
312
- className={railExpandHintClass}
322
+ className={sidebarRootClass}
313
323
  onClick={expandOnRailClick}
314
324
  >
315
325
  <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Account block: collapsible trigger + expanded card (email, links, footer row:
3
- * sign out on the left, locale + theme toggles on the right).
2
+ * Sidebar account: rich footer trigger (avatar + name + plan + optional secondary
3
+ * action) opens a popover (DropdownMenu) upward with email, account links, locale +
4
+ * theme controls, and sign-out. Replaces the legacy inline collapsible.
4
5
  */
5
6
 
6
7
  'use client';
7
8
 
8
- import { ChevronDown, LogOut } from 'lucide-react';
9
+ import { ChevronRight, ChevronsUpDown, Globe, LogOut, Monitor, Moon, Sun } from 'lucide-react';
9
10
  import { Link } from '@djangocfg/ui-core/components';
10
11
  import React from 'react';
11
12
 
@@ -16,65 +17,47 @@ import {
16
17
  AvatarFallback,
17
18
  AvatarImage,
18
19
  Button,
19
- Collapsible,
20
- CollapsibleContent,
21
- CollapsibleTrigger,
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuLabel,
24
+ DropdownMenuSeparator,
25
+ DropdownMenuTrigger,
22
26
  } from '@djangocfg/ui-core/components';
23
- import { cn } from '@djangocfg/ui-core/lib';
27
+ import { cn, isDev } from '@djangocfg/ui-core/lib';
24
28
  import { useSidebar } from '@djangocfg/ui-nextjs/components';
25
- import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
29
+ import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
26
30
 
27
31
  import { useLogout } from '../../hooks';
28
- import { LocaleSwitcher } from './LocaleSwitcher';
32
+ import { LocaleSwitcherDialog } from './locale-switcher';
33
+ import { getLocaleMeta } from './locale-switcher/localeMeta';
29
34
  import { useLayoutI18nOptional } from '../AppLayout/LayoutI18nProvider';
35
+ import { LucideIcon as LucideIconRender } from '../../components';
30
36
 
31
37
  import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
32
38
 
33
- /** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
34
- function isPointerFromRadixOverlay(target: EventTarget | null): boolean {
35
- if (!target || !(target instanceof Element)) return false;
36
- return Boolean(
37
- target.closest('[data-radix-popper-content-wrapper]') ||
38
- target.closest('[data-radix-dropdown-menu-content]') ||
39
- target.closest('[data-radix-select-content]') ||
40
- target.closest('[data-radix-popover-content]'),
41
- );
42
- }
43
-
44
39
  interface PrivateSidebarAccountProps {
45
40
  header?: HeaderConfig;
46
41
  }
47
42
 
43
+ interface AccountView {
44
+ source: 'user' | 'dev-fallback';
45
+ displayName: string;
46
+ email: string | null;
47
+ avatarUrl: string;
48
+ plan: string | null;
49
+ }
50
+
48
51
  export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
49
52
  const { user } = useAuth();
50
53
  const handleLogout = useLogout();
51
54
  const t = useAppT();
52
55
  const layoutI18n = useLayoutI18nOptional();
53
56
  const { state, setOpen: setSidebarOpen } = useSidebar();
54
- const [accountOpen, setAccountOpen] = React.useState(false);
55
- const accountRootRef = React.useRef<HTMLDivElement>(null);
57
+ const { theme, setTheme } = useThemeContext();
58
+ const [langDialogOpen, setLangDialogOpen] = React.useState(false);
56
59
  const narrow = state === 'collapsed';
57
60
 
58
- React.useEffect(() => {
59
- if (state === 'collapsed') setAccountOpen(false);
60
- }, [state]);
61
-
62
- React.useEffect(() => {
63
- if (!accountOpen) return;
64
-
65
- const handlePointerDown = (event: PointerEvent) => {
66
- const root = accountRootRef.current;
67
- const target = event.target;
68
- if (!(target instanceof Node)) return;
69
- if (root?.contains(target)) return;
70
- if (isPointerFromRadixOverlay(target)) return;
71
- setAccountOpen(false);
72
- };
73
-
74
- document.addEventListener('pointerdown', handlePointerDown);
75
- return () => document.removeEventListener('pointerdown', handlePointerDown);
76
- }, [accountOpen]);
77
-
78
61
  const signOutLabel = t('layouts.profile.signOut');
79
62
 
80
63
  const accountLinks = React.useMemo(() => {
@@ -82,134 +65,280 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
82
65
  return header.groups.flatMap((g) => g.items.filter((i) => i.href));
83
66
  }, [header?.groups]);
84
67
 
85
- if (!user) {
86
- return null;
87
- }
68
+ /**
69
+ * Single source of truth for what the footer renders. When `useAuth` returns
70
+ * a real user we use it; otherwise (in dev only) we fall back to a placeholder
71
+ * so the menu stays reachable for debugging. Production stays strict and the
72
+ * component returns null below.
73
+ */
74
+ const account = React.useMemo<AccountView>(() => {
75
+ if (user) {
76
+ return {
77
+ source: 'user',
78
+ displayName: user.display_username || user.email || 'User',
79
+ email: user.email ?? null,
80
+ avatarUrl: user.avatar ?? '',
81
+ plan: header?.userPlan ?? null,
82
+ };
83
+ }
84
+ return {
85
+ source: 'dev-fallback',
86
+ displayName: 'Guest (dev)',
87
+ email: null,
88
+ avatarUrl: '',
89
+ plan: header?.userPlan ?? 'No session',
90
+ };
91
+ }, [user, header?.userPlan]);
88
92
 
89
- const displayName = user.display_username || user.email || 'User';
90
- const userInitial = displayName.charAt(0).toUpperCase();
91
- const userAvatar = user.avatar || '';
92
- const hasEmailOrLinks = Boolean(user.email) || accountLinks.length > 0;
93
+ const onTriggerInteract = React.useCallback(() => {
94
+ if (narrow) setSidebarOpen(true);
95
+ }, [narrow, setSidebarOpen]);
96
+
97
+ const onSecondaryExpand = React.useCallback(() => {
98
+ setSidebarOpen(true);
99
+ }, [setSidebarOpen]);
100
+
101
+ const onLogoutSelect = React.useCallback((e: Event) => {
102
+ e.preventDefault();
103
+ handleLogout();
104
+ }, [handleLogout]);
105
+
106
+ const onLanguageSelect = React.useCallback((e: Event) => {
107
+ // Keep the dropdown closed (default behaviour) but defer dialog mount to
108
+ // the next tick so Radix has time to unmount the dropdown overlay first
109
+ // (avoids the "two open overlays steal focus" bug).
110
+ e.preventDefault();
111
+ setTimeout(() => setLangDialogOpen(true), 0);
112
+ }, []);
113
+
114
+ const onThemeSelect = React.useCallback((e: Event) => {
115
+ e.preventDefault();
116
+ // Cycle: light → dark → system → light
117
+ const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
118
+ setTheme(next);
119
+ }, [theme, setTheme]);
120
+
121
+ // Hide entirely in production when there's no user (auth still loading or
122
+ // /me failed and the parent guard hasn't redirected yet). In dev keep a
123
+ // placeholder so the footer + Log out are reachable for debugging.
124
+ // NOTE: this early-return must stay AFTER all hooks above to keep hook order stable.
125
+ if (!user && !isDev) return null;
126
+
127
+ const userInitial = (account.displayName.charAt(0) || '?').toUpperCase();
128
+ const secondary = header?.footerSecondaryAction;
93
129
 
94
130
  const triggerClassName = cn(
95
- 'h-auto min-h-10 w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-accent',
96
- narrow && 'justify-center px-0',
97
- );
98
- const chevronClassName = cn(
99
- 'h-4 w-4 shrink-0 text-sidebar-foreground/65 transition-transform duration-200',
100
- accountOpen && 'rotate-180',
131
+ 'group h-auto w-full gap-3 rounded-lg px-2 py-2 text-left',
132
+ 'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
133
+ narrow ? 'justify-center px-0 py-1.5' : 'min-h-14',
101
134
  );
102
- const accountPanelClassName = cn(
103
- 'mt-2 flex flex-col gap-3 rounded-lg border border-sidebar-border/60 bg-sidebar-accent/45 p-3 shadow-sm',
104
- 'dark:border-sidebar-border dark:bg-sidebar-accent/20',
135
+
136
+ const secondaryButton = secondary && !narrow ? (
137
+ <SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
138
+ ) : null;
139
+
140
+ const dropdownContentClass = cn(
141
+ 'p-1.5',
142
+ narrow ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
105
143
  );
106
- const accountActionsClassName = cn(
107
- 'flex min-h-10 items-center gap-2',
108
- hasEmailOrLinks && 'border-t border-sidebar-border/50 pt-3',
144
+ const dropdownSide: 'top' | 'right' = narrow ? 'right' : 'top';
145
+ const avatarClass = cn(
146
+ 'h-9 w-9 shrink-0 border border-transparent transition-colors',
147
+ 'group-hover:border-sidebar-border/70',
109
148
  );
110
149
 
111
- const onAccountTriggerClick = React.useCallback(() => {
112
- if (narrow) setSidebarOpen(true);
113
- }, [narrow, setSidebarOpen]);
150
+ const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
151
+ const headerLabel = headerLabelText ? (
152
+ <DropdownMenuLabel className="truncate px-2 py-1.5 text-xs font-normal text-muted-foreground">
153
+ {headerLabelText}
154
+ </DropdownMenuLabel>
155
+ ) : null;
114
156
 
115
- const accountLinkRows = React.useMemo(
116
- () =>
117
- accountLinks.map((item) => {
118
- const Icon = item.icon;
119
- return (
120
- <Link
121
- key={item.href}
122
- href={item.href!}
123
- className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm leading-snug text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
124
- >
125
- {Icon ? <Icon className="h-4 w-4 shrink-0 text-sidebar-foreground/65" /> : null}
126
- <span className="truncate">{item.label}</span>
127
- </Link>
128
- );
129
- }),
130
- [accountLinks],
131
- );
157
+ const accountLinksItems = accountLinks.map((item) => {
158
+ const Icon = item.icon;
159
+ return (
160
+ <DropdownMenuItem key={item.href} asChild>
161
+ <Link
162
+ href={item.href!}
163
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
164
+ >
165
+ {Icon ? <Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> : null}
166
+ <span className="truncate">{item.label}</span>
167
+ </Link>
168
+ </DropdownMenuItem>
169
+ );
170
+ });
171
+ const accountLinksBlock = accountLinks.length > 0 ? (
172
+ <>
173
+ {headerLabel ? <DropdownMenuSeparator /> : null}
174
+ {accountLinksItems}
175
+ </>
176
+ ) : null;
132
177
 
133
- const emailBlock = user.email ? (
134
- <p className="truncate text-xs leading-snug text-sidebar-foreground/65">{user.email}</p>
178
+ const currentLocaleLabel = layoutI18n
179
+ ? getLocaleMeta(layoutI18n.locale).native
180
+ : null;
181
+ const languageItem = layoutI18n ? (
182
+ <DropdownMenuItem
183
+ onSelect={onLanguageSelect}
184
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
185
+ >
186
+ <Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
187
+ <span className="flex-1 truncate">Language</span>
188
+ <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
189
+ {currentLocaleLabel}
190
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
191
+ </span>
192
+ </DropdownMenuItem>
135
193
  ) : null;
136
194
 
137
- const accountLinksNav =
138
- accountLinks.length > 0 ? (
139
- <nav className="flex max-h-40 flex-col gap-0.5 overflow-y-auto" aria-label="Account">
140
- {accountLinkRows}
141
- </nav>
142
- ) : null;
195
+ const themeIcon = theme === 'dark'
196
+ ? <Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
197
+ : theme === 'light'
198
+ ? <Sun className="h-4 w-4 shrink-0 text-muted-foreground" />
199
+ : <Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />;
200
+ const themeValueLabel = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System';
201
+ const themeItem = (
202
+ <DropdownMenuItem
203
+ onSelect={onThemeSelect}
204
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
205
+ >
206
+ {themeIcon}
207
+ <span className="flex-1 truncate">Theme</span>
208
+ <span className="ml-auto shrink-0 text-xs text-muted-foreground">{themeValueLabel}</span>
209
+ </DropdownMenuItem>
210
+ );
143
211
 
144
- const expandedTriggerMeta = narrow ? null : (
212
+ const expandedMeta = narrow ? null : (
145
213
  <>
146
- <span className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-tight text-sidebar-foreground">
147
- {displayName}
214
+ <span className="flex min-w-0 flex-1 flex-col text-left">
215
+ <span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
216
+ {account.displayName}
217
+ </span>
218
+ {account.plan ? (
219
+ <span className="truncate text-xs leading-snug text-sidebar-foreground/60">
220
+ {account.plan}
221
+ </span>
222
+ ) : null}
223
+ </span>
224
+ <span className="flex shrink-0 items-center gap-1.5">
225
+ {secondaryButton}
226
+ <ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
148
227
  </span>
149
- <ChevronDown className={chevronClassName} aria-hidden />
150
228
  </>
151
229
  );
152
230
 
153
- const localeThemeGroup = layoutI18n ? (
154
- <LocaleSwitcher
155
- variant="dropdown"
156
- buttonVariant="ghost"
157
- size="icon"
158
- showTriggerLabel={false}
159
- showIcon={false}
160
- className="h-8 w-8 shrink-0 text-base leading-none"
161
- />
162
- ) : null;
163
-
164
231
  return (
165
- <div ref={accountRootRef} className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
166
- <Collapsible open={accountOpen} onOpenChange={setAccountOpen} className="w-full min-w-0">
167
- <CollapsibleTrigger asChild>
232
+ <div className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
233
+ <DropdownMenu>
234
+ <DropdownMenuTrigger asChild>
168
235
  <Button
169
236
  type="button"
170
237
  variant="ghost"
171
- aria-expanded={accountOpen}
172
- aria-label={narrow ? 'Account' : undefined}
238
+ aria-label={narrow ? account.displayName : undefined}
173
239
  className={triggerClassName}
174
- onClick={onAccountTriggerClick}
240
+ onClick={onTriggerInteract}
175
241
  >
176
- <Avatar className="h-8 w-8 shrink-0">
177
- <AvatarImage src={userAvatar} alt={displayName} />
178
- <AvatarFallback className="text-xs">{userInitial}</AvatarFallback>
242
+ <Avatar className={avatarClass}>
243
+ <AvatarImage src={account.avatarUrl} alt={account.displayName} />
244
+ <AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
179
245
  </Avatar>
180
- {expandedTriggerMeta}
246
+ {expandedMeta}
181
247
  </Button>
182
- </CollapsibleTrigger>
183
-
184
- <CollapsibleContent className="overflow-hidden data-[state=closed]:hidden">
185
- <div className={accountPanelClassName}>
186
- {emailBlock}
187
- {accountLinksNav}
188
-
189
- <div className={accountActionsClassName}>
190
- <button
191
- type="button"
192
- onClick={handleLogout}
193
- className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
194
- >
195
- <LogOut className="h-4 w-4 shrink-0" />
196
- <span className="truncate">{signOutLabel}</span>
197
- </button>
198
- <div
199
- className="flex shrink-0 items-center gap-0.5 border-l border-sidebar-border/50 pl-2"
200
- role="group"
201
- aria-label="Language and theme"
202
- >
203
- {localeThemeGroup}
204
- <ThemeToggle
205
- size="compact"
206
- className="h-8 w-8 shrink-0 focus-visible:ring-1 focus-visible:ring-sidebar-ring focus-visible:ring-offset-0"
207
- />
208
- </div>
209
- </div>
210
- </div>
211
- </CollapsibleContent>
212
- </Collapsible>
248
+ </DropdownMenuTrigger>
249
+
250
+ <DropdownMenuContent
251
+ side={dropdownSide}
252
+ align="start"
253
+ sideOffset={8}
254
+ className={dropdownContentClass}
255
+ >
256
+ {headerLabel}
257
+ {accountLinksBlock}
258
+
259
+ <DropdownMenuSeparator />
260
+ {languageItem}
261
+ {themeItem}
262
+
263
+ <DropdownMenuSeparator />
264
+ <DropdownMenuItem
265
+ onSelect={onLogoutSelect}
266
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive focus:bg-destructive/10 focus:text-destructive"
267
+ >
268
+ <LogOut className="h-4 w-4 shrink-0" />
269
+ <span className="truncate">{signOutLabel}</span>
270
+ </DropdownMenuItem>
271
+ </DropdownMenuContent>
272
+ </DropdownMenu>
273
+
274
+ {layoutI18n ? (
275
+ <LocaleSwitcherDialog
276
+ open={langDialogOpen}
277
+ onOpenChange={setLangDialogOpen}
278
+ locale={layoutI18n.locale}
279
+ locales={layoutI18n.locales}
280
+ onChange={layoutI18n.onLocaleChange}
281
+ brand={layoutI18n.brand}
282
+ i18nLabels={layoutI18n.dialogLabels}
283
+ />
284
+ ) : null}
213
285
  </div>
214
286
  );
215
287
  }
288
+
289
+ interface SecondaryActionProps {
290
+ action: NonNullable<HeaderConfig['footerSecondaryAction']>;
291
+ onParentExpand: () => void;
292
+ }
293
+
294
+ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
295
+ const handleClick = (e: React.MouseEvent) => {
296
+ // Don't open the parent dropdown when interacting with the secondary action.
297
+ e.stopPropagation();
298
+ e.preventDefault();
299
+ onParentExpand();
300
+ action.onClick?.();
301
+ };
302
+
303
+ const inner = (
304
+ <span
305
+ className={cn(
306
+ 'relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
307
+ 'border border-sidebar-border/40 bg-transparent text-sidebar-foreground/70',
308
+ 'transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
309
+ )}
310
+ >
311
+ <LucideIconRender icon={action.icon} className="h-4 w-4" />
312
+ {action.pulse ? (
313
+ <span className="pointer-events-none absolute -right-0.5 -top-0.5 flex h-2 w-2">
314
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
315
+ <span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
316
+ </span>
317
+ ) : null}
318
+ </span>
319
+ );
320
+
321
+ if (action.href) {
322
+ return (
323
+ <Link
324
+ href={action.href}
325
+ aria-label={action.ariaLabel}
326
+ onClick={handleClick}
327
+ data-no-expand
328
+ >
329
+ {inner}
330
+ </Link>
331
+ );
332
+ }
333
+
334
+ return (
335
+ <button
336
+ type="button"
337
+ aria-label={action.ariaLabel}
338
+ onClick={handleClick}
339
+ data-no-expand
340
+ >
341
+ {inner}
342
+ </button>
343
+ );
344
+ }