From 5e0563e567d9c24a7eb57203614b12e8df2d81fa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 16 Jul 2026 17:03:43 -0400 Subject: [PATCH 1/5] feat(ui): add UserButton account/org switcher --- .changeset/user-button-switcher.md | 5 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 14 + packages/swingset/src/stories/user-button.mdx | 115 +++ .../src/stories/user-button.stories.tsx | 113 +++ .../mosaic/components/user-button.view.tsx | 911 ++++++++++++++++++ packages/ui/src/mosaic/icons/registry.tsx | 33 + packages/ui/src/mosaic/primitives/popover.tsx | 28 + 8 files changed, 1220 insertions(+) create mode 100644 .changeset/user-button-switcher.md create mode 100644 packages/swingset/src/stories/user-button.mdx create mode 100644 packages/swingset/src/stories/user-button.stories.tsx create mode 100644 packages/ui/src/mosaic/components/user-button.view.tsx create mode 100644 packages/ui/src/mosaic/primitives/popover.tsx diff --git a/.changeset/user-button-switcher.md b/.changeset/user-button-switcher.md new file mode 100644 index 00000000000..89dce880dbe --- /dev/null +++ b/.changeset/user-button-switcher.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add the `UserButton` Mosaic component: an account and organization switcher that combines multi-session account switching with organization selection, suggestions, and invitations behind a single popover. Exposes the all-in-one `UserButton` plus the composable `UserButtonRoot`, `UserButtonTrigger`, and `UserButtonPopup` parts, and its slots are themeable via `appearance.elements`. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d54119bbc35..86dc71c7b55 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -41,6 +41,7 @@ const docModules: Record> = { popover: dynamic(() => import('../stories/popover.component.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), + 'user-button': dynamic(() => import('../stories/user-button.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3b1e386414c..84da733b0ac 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,12 @@ import { } from '../stories/text.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { + Default as UserButtonDefault, + meta as userButtonMeta, + MultipleSessions as UserButtonMultipleAccounts, + Personal as UserButtonPersonal, +} from '../stories/user-button.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -176,6 +182,13 @@ const itemModule: StoryModule = { Group: ItemGroup, }; +const userButtonModule: StoryModule = { + meta: userButtonMeta, + Default: UserButtonDefault, + Personal: UserButtonPersonal, + MultipleSessions: UserButtonMultipleAccounts, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -241,6 +254,7 @@ export const registry: StoryModule[] = [ popoverComponentModule, tabsComponentModule, textModule, + userButtonModule, // Primitives — alphabetical within the group. accordionModule, autocompleteModule, diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx new file mode 100644 index 00000000000..5dc6e4a38dd --- /dev/null +++ b/packages/swingset/src/stories/user-button.mdx @@ -0,0 +1,115 @@ +import * as UserButtonStories from './user-button.stories'; + +# UserButton + +The account & organization switcher that sits behind the user avatar. The active account sits at the +top with its organizations (including any **suggested** workspaces you can Join), and **additional +accounts** are listed below. Hovering the active account reveals **Sign out**; clicking any additional +account switches to it. **Settings / Members** open the combined profile modal, and the surface also +exposes **Add organization**, **Add account**, and **Sign out of all accounts**. + +It is the styled Mosaic component composed from the headless `@clerk/headless` popover primitive plus +slot recipes, and inherits the primitive's open/close behavior, focus management, and ARIA wiring. This +first pass is presentational: the parts render from the props on `UserButtonRoot` (mock data in the +examples). A `useUserButtonController()` that reads live Clerk resources is a drop-in follow-up. + +## Example + + + +## Usage + +The all-in-one `UserButton` renders the trigger and popup from a single prop-driven call: + +```tsx +import { UserButton } from '@clerk/ui/mosaic/components/user-button.view'; + + setActive({ organization: id })} + onSelectPersonal={() => setActive({ organization: null })} + onSwitchSession={sessionId => setActive({ session: sessionId })} + onSignOutAll={() => signOut()} +/> +``` + +For layouts that need to drop the popup into their own trigger, compose the parts directly. The data +and callbacks live on `UserButtonRoot` and are read from context by the leaves, so they take no props: + +```tsx +import { + UserButtonRoot, + UserButtonTrigger, + UserButtonPopup, +} from '@clerk/ui/mosaic/components/user-button.view'; + + + + + +``` + +The exports are flat (not `UserButton.Trigger`) so each part can declare its own `'use client'` +boundary without forcing the consumer's file to become a client component. + +## States & scenarios + +### Personal (no organizations) + +When the active account has no organizations, the header collapses to a personal layout — +**Manage account / Sign out** — and the organization list is omitted. The trigger still shows the +account avatar and name. + + + +### Personal & workspace + +One account has workspaces (organizations); an additional account is a personal account (no orgs). +Switching to it flips the surface to the personal layout. + + + +## Parts + +| Part | Description | +| ---------------------- | ---------------------------------------------------------------------------- | +| `UserButtonRoot` | Owns the data + callbacks and forwards popover open state to `Popover.Root`. | +| `UserButtonTrigger` | The sidebar trigger: active workspace avatar, name, plan badge, selector. | +| `UserButtonPopup` | The popover surface: header, workspace list, additional accounts, footer. | + +## Styling + +The component is themed with a Mosaic slot recipe (`userButtonRecipe`). Override any slot through +`appearance.elements` — e.g. `{ 'user-button-popup': { borderRadius: 24 } }`. + +| Slot | Description | +| ------------------------------ | ------------------------------------------------------- | +| `user-button-trigger` | The trigger button | +| `user-button-popup` | The popover surface | +| `user-button-header` | Active workspace header | +| `user-button-action` | Header action buttons (Settings / Members) | +| `user-button-group` | A divided section (workspace list, additional accounts) | +| `user-button-item` | A selectable workspace / account row | +| `user-button-avatar` | Org (square) / account (circle) avatar | +| `user-button-inline-button` | Row actions (Join / Accept) | +| `user-button-hover-action` | The hover-revealed Sign out on the active account | +| `user-button-footer` | Sign out of all accounts + branding | diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx new file mode 100644 index 00000000000..221766f9f39 --- /dev/null +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -0,0 +1,113 @@ +/** @jsxImportSource @emotion/react */ +import { UserButton, type UserButtonProps } from '@clerk/ui/mosaic/components/user-button.view'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './user-button.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'UserButton', + source: 'packages/ui/src/mosaic/components/user-button.view.tsx', +}; + +// The view is presentational, so the fixtures drive every state. All callbacks are wired as +// no-ops purely so each affordance renders (an unhandled action hides its control). +const handlers = { + onSelectOrganization: () => {}, + onSelectPersonal: () => {}, + onAcceptSuggestion: () => {}, + onAcceptInvitation: () => {}, + onSwitchSession: () => {}, + onSignOutSession: () => {}, + onSignOutAll: () => {}, + onManageOrganization: () => {}, + onManageMembers: () => {}, + onManageAccount: () => {}, + onCreateOrganization: () => {}, + onAddAccount: () => {}, + onUpgrade: () => {}, +} satisfies Partial; + +const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' }; + +export function Default(_args: Record) { + return ( + + ); +} + +export function Personal(_args: Record) { + return ( + + ); +} + +export function MultipleSessions(_args: Record) { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/components/user-button.view.tsx b/packages/ui/src/mosaic/components/user-button.view.tsx new file mode 100644 index 00000000000..ca1db9998a1 --- /dev/null +++ b/packages/ui/src/mosaic/components/user-button.view.tsx @@ -0,0 +1,911 @@ +'use client'; + +import type { PopoverProps } from '@clerk/headless/popover'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { useMosaicTheme } from '../MosaicProvider'; +import type { IconName } from '../icons/registry'; +import { Popover } from '../primitives/popover'; +import { defineSlotRecipe, useRecipe } from '../slot-recipe'; +import { Icon } from './icon'; + +// Prototype accent (Clerk brand purple) for the plan badge and the Upgrade link. The neutral +// Mosaic palette has no accent token yet; swap these for a token once one lands. +const ACCENT = '#6c47ff'; +const ACCENT_SOFT = 'color-mix(in oklab, #6c47ff 14%, transparent)'; + +// ─── Data contract ────────────────────────────────────────────────────────── +// Session-backed, discriminated resource rows. Intended to be 1:1 with a future +// `useUserButtonController()` output so the controller is a drop-in follow-up. + +export interface UserButtonSession { + sessionId: string; + userId: string; + name: string; + email: string; + imageUrl?: string; +} + +export interface UserButtonMembership { + kind: 'membership'; + organizationId: string; + name: string; + imageUrl?: string; + membersCount?: number; + planLabel?: string; + upgradeable?: boolean; + membershipRequestCount?: number; +} + +export interface UserButtonSuggestion { + kind: 'suggestion'; + id: string; + organizationId: string; + name: string; + imageUrl?: string; + status: 'pending' | 'accepted'; +} + +export interface UserButtonInvitation { + kind: 'invitation'; + id: string; + organizationId: string; + organizationName: string; + imageUrl?: string; +} + +export interface UserButtonData { + status: 'loading' | 'ready'; + activeSession: UserButtonSession; + /** `null` => the personal workspace is active. */ + activeOrganizationId: string | null; + /** Explicit; do not derive from `memberships.length`. */ + hasOrganizations: boolean; + memberships: UserButtonMembership[]; + suggestions: UserButtonSuggestion[]; + invitations: UserButtonInvitation[]; + additionalSessions: UserButtonSession[]; +} + +/** All optional. An unhandled action hides (or de-activates) the affordance it drives. */ +export interface UserButtonCallbacks { + onSelectOrganization?: (organizationId: string) => void; + onSelectPersonal?: () => void; + onAcceptSuggestion?: (suggestionId: string) => void; + onAcceptInvitation?: (invitationId: string) => void; + onSwitchSession?: (sessionId: string) => void; + onSignOutSession?: (sessionId: string) => void; + onSignOutAll?: () => void; + onManageOrganization?: () => void; + onManageMembers?: () => void; + onManageAccount?: () => void; + onCreateOrganization?: () => void; + onAddAccount?: () => void; + onUpgrade?: () => void; +} + +type UserButtonContextValue = UserButtonData & UserButtonCallbacks; + +// ─── Recipe ─────────────────────────────────────────────────────────────────── + +export const userButtonRecipe = defineSlotRecipe(theme => ({ + slots: { + trigger: { slot: 'user-button-trigger' }, + triggerName: { slot: 'user-button-trigger-name' }, + triggerBadge: { slot: 'user-button-trigger-badge' }, + popup: { slot: 'user-button-popup' }, + header: { slot: 'user-button-header' }, + headerName: { slot: 'user-button-header-name' }, + headerActions: { slot: 'user-button-header-actions' }, + action: { slot: 'user-button-action' }, + group: { slot: 'user-button-group' }, + groupLabel: { slot: 'user-button-group-label' }, + item: { slot: 'user-button-item' }, + select: { slot: 'user-button-select' }, + name: { slot: 'user-button-name' }, + secondary: { slot: 'user-button-secondary' }, + upgrade: { slot: 'user-button-upgrade' }, + suggestedBadge: { slot: 'user-button-suggested-badge' }, + inlineButton: { slot: 'user-button-inline-button' }, + hoverAction: { slot: 'user-button-hover-action' }, + add: { slot: 'user-button-add' }, + addIcon: { slot: 'user-button-add-icon' }, + footer: { slot: 'user-button-footer' }, + signOutAll: { slot: 'user-button-sign-out-all' }, + branding: { slot: 'user-button-branding' }, + avatar: { slot: 'user-button-avatar' }, + }, + base: { + trigger: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + width: '100%', + minWidth: 0, + padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`, + borderRadius: theme.rounded.md, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + color: theme.color.cardForeground, + ...theme.text('sm'), + _hover: { backgroundColor: theme.color.muted }, + }, + triggerName: { + fontWeight: theme.font.medium, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + triggerBadge: { + flexShrink: 0, + ...theme.text('xs'), + fontWeight: theme.font.medium, + padding: `${theme.spacing(0.5)} ${theme.spacing(1.5)}`, + borderRadius: theme.rounded.full, + backgroundColor: ACCENT_SOFT, + color: ACCENT, + whiteSpace: 'nowrap', + }, + popup: { + width: '20rem', + maxWidth: 'calc(100vw - 2rem)', + backgroundColor: theme.color.card, + color: theme.color.cardForeground, + border: `1px solid ${theme.color.border}`, + borderRadius: theme.rounded.lg, + boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)', + overflow: 'hidden', + ...theme.text('sm'), + }, + header: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + padding: theme.spacing(4), + }, + headerName: { + ...theme.text('sm'), + fontWeight: theme.font.semibold, + color: theme.color.cardForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + headerActions: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(2), + }, + action: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(2), + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + border: `1px solid ${theme.color.border}`, + background: theme.color.card, + color: theme.color.cardForeground, + ...theme.text('sm'), + fontWeight: theme.font.medium, + cursor: 'pointer', + _hover: { backgroundColor: theme.color.muted }, + }, + group: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + padding: theme.spacing(1.5), + borderTop: `1px solid ${theme.color.border}`, + }, + groupLabel: { + padding: `${theme.spacing(1.5)} ${theme.spacing(2)} ${theme.spacing(1)}`, + ...theme.text('xs'), + fontWeight: theme.font.medium, + color: theme.color.mutedForeground, + }, + item: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + position: 'relative', + _hover: { backgroundColor: theme.color.muted }, + // Hover-reveal sign out: hidden but focusable (kept in tab order); revealed on row hover + // or keyboard focus-within — never a bare `opacity: 0` that would strand the focused button. + '& [data-cl-slot="user-button-hover-action"]': { opacity: 0, pointerEvents: 'none' }, + '&:hover [data-cl-slot="user-button-hover-action"], &:focus-within [data-cl-slot="user-button-hover-action"]': + { opacity: 1, pointerEvents: 'auto' }, + }, + select: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(3), + flex: 1, + minWidth: 0, + background: 'none', + border: 'none', + padding: 0, + margin: 0, + font: 'inherit', + color: 'inherit', + textAlign: 'left', + cursor: 'pointer', + }, + name: { + ...theme.text('sm'), + fontWeight: theme.font.medium, + color: theme.color.cardForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + secondary: { + ...theme.text('xs'), + color: theme.color.mutedForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + upgrade: { + background: 'none', + border: 'none', + padding: 0, + font: 'inherit', + cursor: 'pointer', + color: ACCENT, + fontWeight: theme.font.medium, + }, + suggestedBadge: { + flexShrink: 0, + ...theme.text('xs'), + color: theme.color.mutedForeground, + padding: `${theme.spacing(0.5)} ${theme.spacing(1.5)}`, + borderRadius: theme.rounded.sm, + backgroundColor: theme.color.muted, + whiteSpace: 'nowrap', + }, + inlineButton: { + flexShrink: 0, + ...theme.text('xs'), + fontWeight: theme.font.medium, + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + borderRadius: theme.rounded.md, + border: `1px solid ${theme.color.border}`, + background: theme.color.card, + color: theme.color.cardForeground, + cursor: 'pointer', + _hover: { backgroundColor: theme.color.muted }, + }, + hoverAction: { + flexShrink: 0, + ...theme.text('xs'), + fontWeight: theme.font.medium, + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + borderRadius: theme.rounded.md, + border: `1px solid ${theme.color.border}`, + background: theme.color.card, + color: theme.color.cardForeground, + cursor: 'pointer', + transition: 'opacity 120ms ease', + _hover: { backgroundColor: theme.color.muted }, + }, + add: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(3), + width: '100%', + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + ...theme.text('sm'), + color: theme.color.mutedForeground, + _hover: { backgroundColor: theme.color.muted }, + }, + addIcon: { + display: 'grid', + placeItems: 'center', + flexShrink: 0, + width: theme.spacing(9), + height: theme.spacing(9), + borderRadius: theme.rounded.full, + backgroundColor: theme.color.muted, + color: theme.color.mutedForeground, + }, + footer: { + display: 'flex', + flexDirection: 'column', + borderTop: `1px solid ${theme.color.border}`, + padding: theme.spacing(1.5), + }, + signOutAll: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(3), + width: '100%', + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + ...theme.text('sm'), + color: theme.color.mutedForeground, + _hover: { backgroundColor: theme.color.muted }, + }, + branding: { + marginTop: theme.spacing(1), + padding: theme.spacing(2), + borderTop: `1px solid ${theme.color.border}`, + textAlign: 'center', + ...theme.text('xs'), + color: theme.color.mutedForeground, + }, + avatar: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + overflow: 'hidden', + fontWeight: theme.font.medium, + textTransform: 'uppercase', + lineHeight: 1, + '& > img': { width: '100%', height: '100%', objectFit: 'cover' }, + }, + }, + variants: { + shape: { + square: { + avatar: { + borderRadius: theme.rounded.md, + backgroundColor: theme.color.primary, + color: theme.color.primaryForeground, + }, + }, + circle: { + avatar: { + borderRadius: theme.rounded.full, + backgroundColor: theme.color.muted, + color: theme.color.mutedForeground, + }, + }, + }, + size: { + sm: { avatar: { width: theme.spacing(5), height: theme.spacing(5), ...theme.text('xs') } }, + md: { avatar: { width: theme.spacing(9), height: theme.spacing(9), ...theme.text('sm') } }, + }, + }, + defaultVariants: { shape: 'circle', size: 'md' }, +})); + +declare module '../registry' { + interface MosaicSlotRegistry { + 'user-button-trigger': true; + 'user-button-trigger-name': true; + 'user-button-trigger-badge': true; + 'user-button-popup': true; + 'user-button-header': true; + 'user-button-header-name': true; + 'user-button-header-actions': true; + 'user-button-action': true; + 'user-button-group': true; + 'user-button-group-label': true; + 'user-button-item': true; + 'user-button-select': true; + 'user-button-name': true; + 'user-button-secondary': true; + 'user-button-upgrade': true; + 'user-button-suggested-badge': true; + 'user-button-inline-button': true; + 'user-button-hover-action': true; + 'user-button-add': true; + 'user-button-add-icon': true; + 'user-button-footer': true; + 'user-button-sign-out-all': true; + 'user-button-branding': true; + 'user-button-avatar': true; + } +} + +// ─── Context ──────────────────────────────────────────────────────────────── + +const UserButtonContext = React.createContext(null); + +function useUserButtonContext(): UserButtonContextValue { + const ctx = React.useContext(UserButtonContext); + if (!ctx) { + throw new Error('UserButton compound components must be used within '); + } + return ctx; +} + +function activeMembership(data: UserButtonData): UserButtonMembership | undefined { + if (data.activeOrganizationId === null) { + return undefined; + } + return data.memberships.find(m => m.organizationId === data.activeOrganizationId); +} + +function membershipSubtitle(org: UserButtonMembership): string { + const parts: string[] = []; + if (org.membersCount !== undefined) { + parts.push(`${org.membersCount} members`); + } + if (org.planLabel) { + parts.push(org.planLabel); + } + return parts.join(' · '); +} + +// ─── Presentational leaves ──────────────────────────────────────────────────── + +type AvatarShape = 'square' | 'circle'; + +function Avatar({ + name, + imageUrl, + shape, + size, +}: { + name: string; + imageUrl?: string; + shape: AvatarShape; + size: 'sm' | 'md'; +}) { + const { avatar } = useRecipe(userButtonRecipe, { variants: { shape, size } }); + return ( + + {imageUrl ? ( + + ) : ( + name.trim().charAt(0) + )} + + ); +} + +interface RowProps { + name: string; + secondary?: string; + shape: AvatarShape; + imageUrl?: string; + onSelect?: () => void; + active?: boolean; + badge?: ReactNode; + trailing?: ReactNode; + hoverAction?: ReactNode; +} + +function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, trailing, hoverAction }: RowProps) { + const theme = useMosaicTheme(); + const { item, select, name: nameSlot, secondary: secondarySlot } = useRecipe(userButtonRecipe); + const inner = ( + <> + + + + {name} + {badge} + + {secondary ? {secondary} : null} + + + ); + return ( +
+ {onSelect ? ( + + ) : ( +
+ {inner} +
+ )} + {active ? ( + + ) : null} + {trailing} + {hoverAction} +
+ ); +} + +function SuggestedBadge() { + const { suggestedBadge } = useRecipe(userButtonRecipe); + return Suggested; +} + +function InlineButton({ label, onClick }: { label: string; onClick: () => void }) { + const { inlineButton } = useRecipe(userButtonRecipe); + return ( + + ); +} + +function HoverAction({ onClick }: { onClick: () => void }) { + const { hoverAction } = useRecipe(userButtonRecipe); + return ( + + ); +} + +function AddRow({ label, onClick }: { label: string; onClick: () => void }) { + const theme = useMosaicTheme(); + const { add, addIcon } = useRecipe(userButtonRecipe); + return ( + + ); +} + +// ─── Sections ───────────────────────────────────────────────────────────────── + +interface HeaderAction { + icon: IconName; + label: string; + onClick: () => void; +} + +function Header() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { header, headerName, headerActions, action, secondary, upgrade } = useRecipe(userButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeSession.name; + const image = isOrg ? org.imageUrl : data.activeSession.imageUrl; + + const actions: HeaderAction[] = []; + if (isOrg) { + if (data.onManageOrganization) { + actions.push({ icon: 'cog', label: 'Settings', onClick: data.onManageOrganization }); + } + if (data.onManageMembers) { + actions.push({ icon: 'users', label: 'Members', onClick: data.onManageMembers }); + } + } else { + if (data.onManageAccount) { + actions.push({ icon: 'cog', label: 'Manage account', onClick: data.onManageAccount }); + } + const signOut = data.onSignOutSession; + if (signOut) { + actions.push({ icon: 'log-out', label: 'Sign out', onClick: () => signOut(data.activeSession.sessionId) }); + } + } + + const showUpgrade = isOrg && org.upgradeable === true && data.onUpgrade !== undefined; + const subtitle = isOrg ? membershipSubtitle(org) : data.activeSession.email; + + return ( +
+
+ + + {label} + + {subtitle} + {showUpgrade ? ( + <> + {subtitle ? ' · ' : null} + + + ) : null} + + +
+ {actions.length > 0 ? ( +
+ {actions.map(a => ( + + ))} +
+ ) : null} +
+ ); +} + +function WorkspaceList() { + const data = useUserButtonContext(); + const { group } = useRecipe(userButtonRecipe); + const selectOrg = data.onSelectOrganization; + const acceptSuggestion = data.onAcceptSuggestion; + const acceptInvitation = data.onAcceptInvitation; + const signOutSession = data.onSignOutSession; + + return ( +
+ signOutSession(data.activeSession.sessionId)} /> : undefined + } + /> + {data.memberships.map(m => ( + selectOrg(m.organizationId) : undefined} + active={m.organizationId === data.activeOrganizationId} + /> + ))} + {data.suggestions.map(s => ( + } + trailing={ + acceptSuggestion ? ( + acceptSuggestion(s.id)} + /> + ) : undefined + } + /> + ))} + {data.invitations.map(i => ( + acceptInvitation(i.id)} + /> + ) : undefined + } + /> + ))} + {data.onCreateOrganization ? ( + + ) : null} +
+ ); +} + +function SessionsSection() { + const data = useUserButtonContext(); + const { group, groupLabel } = useRecipe(userButtonRecipe); + const switchSession = data.onSwitchSession; + + if (data.additionalSessions.length === 0 && !data.onAddAccount) { + return null; + } + + return ( +
+ {data.additionalSessions.length > 0 ?
Additional accounts
: null} + {data.additionalSessions.map(a => ( + switchSession(a.sessionId) : undefined} + /> + ))} + {data.onAddAccount ? ( + + ) : null} +
+ ); +} + +function Footer() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { footer, signOutAll, branding } = useRecipe(userButtonRecipe); + return ( +
+ {data.onSignOutAll ? ( + + ) : null} +
Secured by Clerk
+
+ ); +} + +// ─── Public parts ─────────────────────────────────────────────────────────── + +export interface UserButtonRootProps extends UserButtonData, UserButtonCallbacks { + children: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: PopoverProps['placement']; + sideOffset?: number; +} + +/** + * Owns the account/organization data + callbacks and forwards the popover's open state straight to + * the headless `Popover.Root` — it does not keep a second controllable-state copy. Leaves consume + * the data through context. + */ +export function UserButtonRoot(props: UserButtonRootProps) { + const { children, open, defaultOpen, onOpenChange, placement, sideOffset, ...data } = props; + return ( + + {children} + + ); +} + +/** The sidebar trigger: active workspace avatar + name (+ plan badge for orgs) and a selector icon. */ +export function UserButtonTrigger() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { trigger, triggerName, triggerBadge } = useRecipe(userButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeSession.name; + const image = isOrg ? org.imageUrl : data.activeSession.imageUrl; + + return ( + ( + + )} + /> + ); +} + +/** The popover surface: header, workspace list, additional accounts, and footer. */ +export function UserButtonPopup() { + const data = useUserButtonContext(); + const { popup } = useRecipe(userButtonRecipe); + return ( + + + +
+ {data.hasOrganizations ? : null} + +