@djangocfg/layouts 2.1.358 → 2.1.360

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.358",
3
+ "version": "2.1.360",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -84,13 +84,13 @@
84
84
  "check": "tsc --noEmit"
85
85
  },
86
86
  "peerDependencies": {
87
- "@djangocfg/api": "^2.1.358",
88
- "@djangocfg/centrifugo": "^2.1.358",
89
- "@djangocfg/debuger": "^2.1.358",
90
- "@djangocfg/i18n": "^2.1.358",
91
- "@djangocfg/monitor": "^2.1.358",
92
- "@djangocfg/ui-core": "^2.1.358",
93
- "@djangocfg/ui-nextjs": "^2.1.358",
87
+ "@djangocfg/api": "^2.1.360",
88
+ "@djangocfg/centrifugo": "^2.1.360",
89
+ "@djangocfg/debuger": "^2.1.360",
90
+ "@djangocfg/i18n": "^2.1.360",
91
+ "@djangocfg/monitor": "^2.1.360",
92
+ "@djangocfg/ui-core": "^2.1.360",
93
+ "@djangocfg/ui-nextjs": "^2.1.360",
94
94
  "@hookform/resolvers": "^5.2.2",
95
95
  "consola": "^3.4.2",
96
96
  "lucide-react": "^0.545.0",
@@ -121,15 +121,15 @@
121
121
  "uuid": "^11.1.0"
122
122
  },
123
123
  "devDependencies": {
124
- "@djangocfg/api": "^2.1.358",
125
- "@djangocfg/centrifugo": "^2.1.358",
126
- "@djangocfg/debuger": "^2.1.358",
127
- "@djangocfg/i18n": "^2.1.358",
128
- "@djangocfg/monitor": "^2.1.358",
129
- "@djangocfg/typescript-config": "^2.1.358",
130
- "@djangocfg/ui-core": "^2.1.358",
131
- "@djangocfg/ui-nextjs": "^2.1.358",
132
- "@djangocfg/ui-tools": "^2.1.358",
124
+ "@djangocfg/api": "^2.1.360",
125
+ "@djangocfg/centrifugo": "^2.1.360",
126
+ "@djangocfg/debuger": "^2.1.360",
127
+ "@djangocfg/i18n": "^2.1.360",
128
+ "@djangocfg/monitor": "^2.1.360",
129
+ "@djangocfg/typescript-config": "^2.1.360",
130
+ "@djangocfg/ui-core": "^2.1.360",
131
+ "@djangocfg/ui-nextjs": "^2.1.360",
132
+ "@djangocfg/ui-tools": "^2.1.360",
133
133
  "@types/node": "^24.7.2",
134
134
  "@types/react": "^19.1.0",
135
135
  "@types/react-dom": "^19.1.0",
@@ -38,6 +38,7 @@ export interface PrivateLayoutConfiguratorData {
38
38
  userPlan: string;
39
39
  showSecondaryAction: boolean;
40
40
  accountAction: 'menu' | 'dialog';
41
+ showSwitcher: boolean;
41
42
  };
42
43
  }
43
44
 
@@ -133,6 +134,11 @@ export const privateLayoutConfiguratorSchema: CustomJsonSchema7 = {
133
134
  enum: ['menu', 'dialog'],
134
135
  description: "'menu' opens a dropdown; 'dialog' opens the global ProfileDialog.",
135
136
  },
137
+ showSwitcher: {
138
+ type: 'boolean',
139
+ title: 'Brand switcher',
140
+ description: 'Replace the static brand with a workspace/account dropdown.',
141
+ },
136
142
  },
137
143
  },
138
144
  },
@@ -172,6 +178,7 @@ export const privateLayoutConfiguratorUiSchema: CustomJsonUiSchema7 = {
172
178
  header: {
173
179
  'ui:collapsible': true,
174
180
  showSecondaryAction: { 'ui:widget': 'switch' },
181
+ showSwitcher: { 'ui:widget': 'switch' },
175
182
  accountAction: {
176
183
  'ui:widget': 'radio',
177
184
  'ui:options': { inline: true },
@@ -198,5 +205,6 @@ export const defaultPrivateLayoutConfiguratorData: PrivateLayoutConfiguratorData
198
205
  userPlan: 'Pro plan',
199
206
  showSecondaryAction: false,
200
207
  accountAction: 'menu',
208
+ showSwitcher: false,
201
209
  },
202
210
  };
@@ -35,8 +35,12 @@ export type {
35
35
  SidebarActiveIndicator,
36
36
  SidebarGroupLabelStyle,
37
37
  SidebarFeaturedConfig,
38
+ SidebarBrandSwitcherConfig,
39
+ SidebarBrandSwitcherItem,
38
40
  } from './types';
39
41
 
42
+ export { SidebarBrandSwitcher } from './components';
43
+
40
44
  export { PrivateLayoutProps };
41
45
 
42
46
  // Lazy-load ProfileDialog so the profile bundle is only fetched when opened.
@@ -119,16 +119,62 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
119
119
 
120
120
  | Field | Type | Notes |
121
121
  |---|---|---|
122
- | `brand`, `title`, `brandIcon`, `brandLetter` | | Sidebar header brand. |
122
+ | `switcher` | `SidebarBrandSwitcherConfig` | Brand switcher dropdown (replaces `brand`/`title`/`brandIcon` when set). |
123
+ | `brand`, `title`, `brandIcon`, `brandLetter` | — | Static sidebar header brand (used when `switcher` is not set). |
123
124
  | `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
124
125
  | `authPath` | `string` | Sign-in redirect target. |
125
126
  | `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
127
+ | `accountAction` | `'menu' \| 'dialog'` | Footer button behaviour. `'menu'` opens dropdown; `'dialog'` opens global ProfileDialog. Default `'menu'`. |
126
128
  | `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
127
129
 
128
130
  The footer button opens a popover (`DropdownMenu`, `side="top"`) with: email, account links, **Language** (opens fullscreen `LocaleSwitcherDialog` if `LayoutI18nProvider` is mounted), **Theme** (cycles `light → dark → system`), and **Log out**. In dev mode the footer renders a `Guest (dev)` placeholder when there's no authenticated user, so debug controls stay reachable; in production the block hides itself.
129
131
 
130
132
  ---
131
133
 
134
+ ## Brand switcher
135
+
136
+ Replace the static brand header with a workspace/account/project switcher:
137
+
138
+ ```tsx
139
+ import { SidebarBrandSwitcherConfig } from '@djangocfg/layouts';
140
+
141
+ const switcher: SidebarBrandSwitcherConfig = {
142
+ items: [
143
+ { label: 'My Workspace', description: 'Personal', href: '/', active: true },
144
+ { label: 'Team Workspace', description: 'Pro plan', href: '/team' },
145
+ { label: 'Client Project', onSelect: () => switchProject('client') },
146
+ ],
147
+ addLabel: 'Add workspace',
148
+ onAdd: () => openCreateDialog(),
149
+ };
150
+
151
+ <PrivateLayout header={{ switcher }} ...>
152
+ ```
153
+
154
+ `SidebarBrandSwitcherConfig`:
155
+
156
+ | Field | Type | Notes |
157
+ |---|---|---|
158
+ | `items` | `SidebarBrandSwitcherItem[]` | List of workspaces / accounts / projects. |
159
+ | `addLabel` | `string` | Label for the "add new" action at the bottom. Omit to hide. |
160
+ | `onAdd` | `() => void` | Called when "add new" is clicked. |
161
+
162
+ `SidebarBrandSwitcherItem`:
163
+
164
+ | Field | Type | Notes |
165
+ |---|---|---|
166
+ | `label` | `string` | Display name. |
167
+ | `avatar` | `string` | Image URL. Falls back to `monogram` / first letter of `label`. |
168
+ | `monogram` | `string` | Single letter override for the avatar fallback. |
169
+ | `description` | `string` | Secondary line under the label (plan, role, etc.). |
170
+ | `href` | `string` | Navigate on select. |
171
+ | `onSelect` | `() => void` | Callback on select (alternative to `href`). |
172
+ | `active` | `boolean` | Mark as currently selected (shows checkmark in dropdown). |
173
+
174
+ On the collapsed icon-rail the switcher renders only the active item's avatar — no dropdown trigger.
175
+
176
+ ---
177
+
132
178
  ## Sample
133
179
 
134
180
  ```tsx
@@ -1,10 +1,3 @@
1
- /**
2
- * Private Sidebar
3
- *
4
- * Composed from smaller components: SidebarBrand, SidebarNavGroup, SidebarSlots.
5
- * Uses PrivateLayoutContext for all UI state.
6
- */
7
-
8
1
  'use client';
9
2
 
10
3
  import React from 'react';
@@ -19,7 +12,7 @@ import { cn } from '@djangocfg/ui-core/lib';
19
12
 
20
13
  import { PrivateSidebarAccount } from './PrivateSidebarAccount';
21
14
  import { PrivateLayoutProvider } from '../context';
22
- import { useHoverExpand, useShellVisualState, useSidebarKeyboard } from '../hooks';
15
+ import { useShellVisualState, useSidebarKeyboard } from '../hooks';
23
16
  import type { HeaderConfig, SidebarConfig } from '../types';
24
17
  import { SidebarBrand } from './SidebarBrand';
25
18
  import { SidebarNavGroup } from './SidebarNavGroup';
@@ -29,11 +22,6 @@ interface PrivateSidebarProps {
29
22
  sidebar: SidebarConfig;
30
23
  header?: HeaderConfig;
31
24
  pathname?: string;
32
- /**
33
- * shadcn-sidebar `variant`. Used to trigger the inset/boxed visual:
34
- * `'inset'` makes the sidebar wrapper paint `bg-sidebar` and lets `SidebarInset`
35
- * float as a rounded card. Default `'sidebar'` (full-bleed).
36
- */
37
25
  variant?: 'sidebar' | 'inset';
38
26
  }
39
27
 
@@ -43,18 +31,13 @@ export function PrivateSidebar({
43
31
  pathname: pathnameProp,
44
32
  variant = 'sidebar',
45
33
  }: PrivateSidebarProps) {
46
- const { state, isMobile, setOpenMobile, setOpen } = useSidebar();
34
+ const { state, isMobile, setOpenMobile } = useSidebar();
47
35
  const pathname = pathnameProp ?? '';
48
36
 
49
37
  React.useEffect(() => {
50
38
  if (isMobile) setOpenMobile(false);
51
39
  }, [pathname, isMobile, setOpenMobile]);
52
40
 
53
- const collapsedRail = !isMobile && state === 'collapsed';
54
- const { isHoverExpanded, onMouseEnter, onMouseLeave, setHoverExpanded } = useHoverExpand({
55
- enabled: collapsedRail,
56
- });
57
-
58
41
  return (
59
42
  <PrivateLayoutProvider
60
43
  sidebar={sidebar}
@@ -62,80 +45,39 @@ export function PrivateSidebar({
62
45
  pathname={pathname}
63
46
  isMobile={isMobile}
64
47
  state={state}
65
- isHoverExpanded={isHoverExpanded}
66
48
  >
67
49
  <PrivateSidebarInner
68
50
  sidebar={sidebar}
69
51
  header={header}
70
52
  variant={variant}
71
- collapsedRail={collapsedRail}
72
- setHoverExpanded={setHoverExpanded}
73
- onMouseEnter={onMouseEnter}
74
- onMouseLeave={onMouseLeave}
75
53
  />
76
54
  </PrivateLayoutProvider>
77
55
  );
78
56
  }
79
57
 
80
- // ---------------------------------------------------------------------------
81
- // Inner component — runs inside PrivateLayoutProvider so useShellVisualState
82
- // can safely consume the context.
83
- // ---------------------------------------------------------------------------
84
-
85
58
  interface PrivateSidebarInnerProps {
86
59
  sidebar: SidebarConfig;
87
60
  header?: HeaderConfig;
88
61
  variant: 'sidebar' | 'inset';
89
- collapsedRail: boolean;
90
- setHoverExpanded: (value: boolean) => void;
91
- onMouseEnter: () => void;
92
- onMouseLeave: () => void;
93
62
  }
94
63
 
95
- function PrivateSidebarInner({
96
- sidebar,
97
- header,
98
- variant,
99
- collapsedRail,
100
- setHoverExpanded,
101
- onMouseEnter,
102
- onMouseLeave,
103
- }: PrivateSidebarInnerProps) {
64
+ function PrivateSidebarInner({ sidebar, header, variant }: PrivateSidebarInnerProps) {
104
65
  const layoutVariant = variant === 'inset' ? 'boxed' : 'full-bleed';
105
66
  const { modifiers } = useShellVisualState(layoutVariant);
106
67
  const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
107
68
 
108
- const railExpandHintClass = collapsedRail ? 'cursor-pointer' : undefined;
109
-
110
- /** Click on the collapsed rail acts like a hover — temporary expand, not persistent. */
111
- const expandOnRailClick = React.useCallback(
112
- (event: React.MouseEvent<HTMLDivElement>) => {
113
- const interactive = (event.target as Element | null)?.closest(
114
- 'a, button, [role="menuitem"], [data-no-expand]',
115
- );
116
- if (interactive) return;
117
- setHoverExpanded(true);
118
- },
119
- [setHoverExpanded],
120
- );
121
-
122
69
  const sidebarRootClass = React.useMemo(
123
70
  () =>
124
71
  cn(
125
- railExpandHintClass,
126
72
  '[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
127
73
  modifiers.sidebarRoot,
128
74
  modifiers.sidebarInner.map((m) => `[&>[data-sidebar=sidebar]]:${m}`),
129
75
  ),
130
- [railExpandHintClass, modifiers],
76
+ [modifiers],
131
77
  );
132
78
 
133
79
  const sidebarContentClass = React.useMemo(
134
- () =>
135
- cn(
136
- 'gap-2',
137
- modifiers.sidebarContent,
138
- ),
80
+ () => cn('gap-2', modifiers.sidebarContent),
139
81
  [modifiers.sidebarContent],
140
82
  );
141
83
 
@@ -159,9 +101,6 @@ function PrivateSidebarInner({
159
101
  collapsible="icon"
160
102
  variant={variant}
161
103
  className={sidebarRootClass}
162
- onClick={collapsedRail ? expandOnRailClick : undefined}
163
- onMouseEnter={onMouseEnter}
164
- onMouseLeave={onMouseLeave}
165
104
  onKeyDown={handleSidebarKeyDown}
166
105
  >
167
106
  <SidebarBrand />
@@ -36,7 +36,6 @@ import { useLogout } from '../../../hooks';
36
36
  import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
37
37
  import { LucideIcon as LucideIconRender } from '../../../components';
38
38
  import { useShellVisualState } from '../hooks';
39
- import { blockSidebarCollapse, allowSidebarCollapse } from '../hooks/useHoverExpand';
40
39
  import { useProfileDialogStore } from '../../ProfileLayout/ProfileDialog/store';
41
40
 
42
41
  import type { HeaderConfig } from '../types';
@@ -223,11 +222,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
223
222
  <div className={wrapperClass}>
224
223
  <DropdownMenu
225
224
  open={isAccountMenuOpen}
226
- onOpenChange={(open) => {
227
- setIsAccountMenuOpen(open);
228
- if (open) blockSidebarCollapse();
229
- else allowSidebarCollapse();
230
- }}
225
+ onOpenChange={setIsAccountMenuOpen}
231
226
  >
232
227
  <DropdownMenuTrigger asChild>
233
228
  {triggerButton}
@@ -15,9 +15,10 @@ import { cn } from '@djangocfg/ui-core/lib';
15
15
  import { LucideIcon } from '../../../components';
16
16
  import { usePrivateLayoutContext } from '../context';
17
17
  import { useShellVisualState } from '../hooks';
18
+ import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
18
19
 
19
20
  function SidebarBrandRaw() {
20
- const { header, homeHref, brandTitle, brandMonogram, isMobile, isHoverExpanded } =
21
+ const { header, homeHref, brandTitle, brandMonogram, isMobile } =
21
22
  usePrivateLayoutContext();
22
23
  const { content } = useShellVisualState();
23
24
 
@@ -86,14 +87,19 @@ function SidebarBrandRaw() {
86
87
 
87
88
  const collapsedHeader = useMemo(
88
89
  () => (
89
- <div className="flex justify-center py-1">
90
- <Link
91
- href={homeHref}
92
- className="flex h-7 w-7 items-center justify-center rounded-md bg-sidebar-primary outline-none ring-sidebar-ring focus-visible:ring-2"
93
- aria-label={brandTitle}
94
- >
95
- {brandMark}
96
- </Link>
90
+ <div className="group/collapsed-brand flex justify-center py-1">
91
+ <div className="relative h-7 w-7">
92
+ <Link
93
+ href={homeHref}
94
+ 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"
95
+ aria-label={brandTitle}
96
+ >
97
+ {brandMark}
98
+ </Link>
99
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-brand:opacity-100">
100
+ <SidebarTrigger aria-label="Expand sidebar" />
101
+ </div>
102
+ </div>
97
103
  </div>
98
104
  ),
99
105
  [homeHref, brandTitle, brandMark],
@@ -136,11 +142,24 @@ function SidebarBrandRaw() {
136
142
  [customBrand, homeHref, brandMark, brandTitle],
137
143
  );
138
144
 
139
- const sidebarHeaderContent = isMobile
140
- ? mobileHeader
141
- : content.showLabels
142
- ? expandedHeader
143
- : collapsedHeader;
145
+ // Switcher mode: trigger embedded inside switcher row on desktop expanded
146
+ const switcherContent = header?.switcher
147
+ ? (
148
+ <div className="mb-2">
149
+ <SidebarBrandSwitcher
150
+ config={header.switcher}
151
+ showCollapseTrigger={content.showLabels && !isMobile}
152
+ />
153
+ </div>
154
+ )
155
+ : null;
156
+
157
+ const sidebarHeaderContent = switcherContent
158
+ ?? (isMobile
159
+ ? mobileHeader
160
+ : content.showLabels
161
+ ? expandedHeader
162
+ : collapsedHeader);
144
163
 
145
164
  const sidebarHeaderClass = useMemo(
146
165
  () =>
@@ -149,11 +168,8 @@ function SidebarBrandRaw() {
149
168
  isMobile
150
169
  ? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
151
170
  : 'pt-3.5',
152
- // Hover-expanded overlay: SidebarHeader from ui-core forces paddingLeft/Right to 0
153
- // when state is collapsed. Override it so content has breathing room.
154
- !isMobile && isHoverExpanded && '!px-2',
155
171
  ),
156
- [isMobile, isHoverExpanded],
172
+ [isMobile],
157
173
  );
158
174
 
159
175
  return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Sidebar Brand Switcher
3
+ *
4
+ * Dropdown for switching workspaces / accounts / projects.
5
+ * Renders in the sidebar header area, replacing the static brand block.
6
+ *
7
+ * Collapsed rail: shows only the active item's avatar/monogram (no dropdown trigger).
8
+ * Hover-expanded / mobile: shows full trigger + dropdown.
9
+ */
10
+
11
+ 'use client';
12
+
13
+ import React, { memo, useMemo } from 'react';
14
+ import { Check, ChevronsUpDown, Plus } from 'lucide-react';
15
+
16
+ import {
17
+ Avatar,
18
+ AvatarFallback,
19
+ AvatarImage,
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ SidebarTrigger,
26
+ } from '@djangocfg/ui-core/components';
27
+ import { Link } from '@djangocfg/ui-core/components';
28
+ import { cn } from '@djangocfg/ui-core/lib';
29
+
30
+ import { useShellVisualState } from '../hooks';
31
+ import type { SidebarBrandSwitcherConfig, SidebarBrandSwitcherItem } from '../types';
32
+
33
+ interface SidebarBrandSwitcherProps {
34
+ config: SidebarBrandSwitcherConfig;
35
+ /** Show the sidebar collapse toggle inside the switcher row (desktop expanded only). */
36
+ showCollapseTrigger?: boolean;
37
+ }
38
+
39
+ function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSwitcherProps) {
40
+ const { content } = useShellVisualState();
41
+ const [open, setOpen] = React.useState(false);
42
+
43
+ const activeItem = useMemo(
44
+ () => config.items.find((i) => i.active) ?? config.items[0] ?? null,
45
+ [config.items],
46
+ );
47
+
48
+ const onOpenChange = React.useCallback((next: boolean) => {
49
+ setOpen(next);
50
+ }, []);
51
+
52
+ if (!activeItem) return null;
53
+
54
+ const activeMonogram = (
55
+ activeItem.monogram?.charAt(0) ||
56
+ activeItem.label.charAt(0) ||
57
+ '?'
58
+ ).toUpperCase();
59
+
60
+ const activeAvatar = (
61
+ <Avatar className="h-7 w-7 shrink-0 rounded-md">
62
+ <AvatarImage src={activeItem.avatar} alt={activeItem.label} />
63
+ <AvatarFallback className="rounded-md bg-sidebar-primary text-[11px] font-bold text-sidebar-primary-foreground">
64
+ {activeMonogram}
65
+ </AvatarFallback>
66
+ </Avatar>
67
+ );
68
+
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
+ return (
96
+ <div className="flex items-center gap-1">
97
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
98
+ <DropdownMenuTrigger asChild>
99
+ <button
100
+ type="button"
101
+ className={cn(
102
+ 'group/switcher flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2',
103
+ 'text-left transition-colors',
104
+ 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
105
+ 'outline-none ring-sidebar-ring focus-visible:ring-2',
106
+ )}
107
+ data-no-expand
108
+ >
109
+ {activeAvatar}
110
+ <span className="flex min-w-0 flex-1 flex-col">
111
+ <span className="truncate text-sm font-semibold leading-tight text-sidebar-foreground">
112
+ {activeItem.label}
113
+ </span>
114
+ {activeItem.description ? (
115
+ <span className="truncate text-xs leading-snug text-sidebar-foreground/55">
116
+ {activeItem.description}
117
+ </span>
118
+ ) : null}
119
+ </span>
120
+ <ChevronsUpDown
121
+ className="h-4 w-4 shrink-0 text-sidebar-foreground/40 transition-colors group-hover/switcher:text-sidebar-foreground/70"
122
+ aria-hidden
123
+ />
124
+ </button>
125
+ </DropdownMenuTrigger>
126
+
127
+ <DropdownMenuContent
128
+ side="bottom"
129
+ align="start"
130
+ sideOffset={4}
131
+ className="min-w-52 p-1.5"
132
+ >
133
+ {config.items.map((item) => (
134
+ <SwitcherItem key={item.label} item={item} onClose={() => onOpenChange(false)} />
135
+ ))}
136
+
137
+ {config.addLabel ? (
138
+ <>
139
+ <DropdownMenuSeparator />
140
+ <DropdownMenuItem
141
+ onSelect={() => {
142
+ onOpenChange(false);
143
+ config.onAdd?.();
144
+ }}
145
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground"
146
+ >
147
+ <Plus className="h-4 w-4 shrink-0" aria-hidden />
148
+ <span className="truncate">{config.addLabel}</span>
149
+ </DropdownMenuItem>
150
+ </>
151
+ ) : null}
152
+ </DropdownMenuContent>
153
+ </DropdownMenu>
154
+
155
+ {showCollapseTrigger ? (
156
+ <SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" data-no-expand />
157
+ ) : null}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ interface SwitcherItemProps {
163
+ item: SidebarBrandSwitcherItem;
164
+ onClose: () => void;
165
+ }
166
+
167
+ function SwitcherItem({ item, onClose }: SwitcherItemProps) {
168
+ const monogram = (
169
+ item.monogram?.charAt(0) ||
170
+ item.label.charAt(0) ||
171
+ '?'
172
+ ).toUpperCase();
173
+
174
+ const handleSelect = React.useCallback(() => {
175
+ onClose();
176
+ item.onSelect?.();
177
+ }, [item, onClose]);
178
+
179
+ const inner = (
180
+ <>
181
+ <Avatar className="h-6 w-6 shrink-0 rounded-md">
182
+ <AvatarImage src={item.avatar} alt={item.label} />
183
+ <AvatarFallback className="rounded-md bg-sidebar-primary text-[10px] font-bold text-sidebar-primary-foreground">
184
+ {monogram}
185
+ </AvatarFallback>
186
+ </Avatar>
187
+ <span className="flex min-w-0 flex-1 flex-col">
188
+ <span className="truncate text-sm font-medium">{item.label}</span>
189
+ {item.description ? (
190
+ <span className="truncate text-xs text-muted-foreground">{item.description}</span>
191
+ ) : null}
192
+ </span>
193
+ {item.active ? (
194
+ <Check className="h-4 w-4 shrink-0 text-primary" aria-hidden />
195
+ ) : null}
196
+ </>
197
+ );
198
+
199
+ if (item.href && !item.onSelect) {
200
+ return (
201
+ <DropdownMenuItem asChild>
202
+ <Link
203
+ href={item.href}
204
+ onClick={onClose}
205
+ className="flex items-center gap-2 rounded-md px-2 py-1.5"
206
+ >
207
+ {inner}
208
+ </Link>
209
+ </DropdownMenuItem>
210
+ );
211
+ }
212
+
213
+ return (
214
+ <DropdownMenuItem
215
+ onSelect={handleSelect}
216
+ className="flex items-center gap-2 rounded-md px-2 py-1.5"
217
+ >
218
+ {inner}
219
+ </DropdownMenuItem>
220
+ );
221
+ }
222
+
223
+ export const SidebarBrandSwitcher = memo(SidebarBrandSwitcherRaw);
@@ -5,6 +5,7 @@
5
5
  export { PrivateSidebar } from './PrivateSidebar';
6
6
  export { PrivateContent } from './PrivateContent';
7
7
  export { SidebarBrand } from './SidebarBrand';
8
+ export { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
8
9
  export { SidebarNavGroup } from './SidebarNavGroup';
9
10
  export { SidebarNavItem } from './SidebarNavItem';
10
11
  export { SidebarSlots } from './SidebarSlots';