diff --git a/.changeset/account-button-switcher.md b/.changeset/account-button-switcher.md new file mode 100644 index 00000000000..ee86de2c772 --- /dev/null +++ b/.changeset/account-button-switcher.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add the `AccountButton` 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 `AccountButton` plus the composable `AccountButtonRoot`, `AccountButtonTrigger`, and `AccountButtonPopup` 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 35bdb3e9da1..15c7434b05b 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { + account: { + 'account-button': dynamic(() => import('../stories/account-button.mdx')), + }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..83edf68d8a6 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,5 +1,11 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; +import { + Default as AccountButtonDefault, + meta as accountButtonMeta, + MultipleAccounts as AccountButtonMultipleAccounts, + Personal as AccountButtonPersonal, +} from '../stories/account-button.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { @@ -121,6 +127,13 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const accountButtonModule: StoryModule = { + meta: accountButtonMeta, + Default: AccountButtonDefault, + Personal: AccountButtonPersonal, + MultipleAccounts: AccountButtonMultipleAccounts, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -159,6 +172,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta }; const useDataTableModule: StoryModule = { meta: useDataTableMeta }; export const registry: StoryModule[] = [ + // Account + accountButtonModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, diff --git a/packages/swingset/src/stories/account-button.mdx b/packages/swingset/src/stories/account-button.mdx new file mode 100644 index 00000000000..625a07f8138 --- /dev/null +++ b/packages/swingset/src/stories/account-button.mdx @@ -0,0 +1,115 @@ +import * as AccountButtonStories from './account-button.stories'; + +# AccountButton + +The account & organization switcher that sits behind the user button. 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 `AccountButtonRoot` (mock data in the +examples). A `useAccountButtonController()` that reads live Clerk resources is a drop-in follow-up. + +## Example + + + +## Usage + +The all-in-one `AccountButton` renders the trigger and popup from a single prop-driven call: + +```tsx +import { AccountButton } from '@clerk/ui/mosaic/account/account-button.view'; + + setActive({ organization: id })} + onSelectPersonal={() => setActive({ organization: null })} + onSwitchAccount={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 `AccountButtonRoot` and are read from context by the leaves, so they take no props: + +```tsx +import { + AccountButtonRoot, + AccountButtonTrigger, + AccountButtonPopup, +} from '@clerk/ui/mosaic/account/account-button.view'; + + + + + +``` + +The exports are flat (not `AccountButton.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 | +| ---------------------- | ---------------------------------------------------------------------------- | +| `AccountButtonRoot` | Owns the data + callbacks and forwards popover open state to `Popover.Root`. | +| `AccountButtonTrigger` | The sidebar trigger: active workspace avatar, name, plan badge, selector. | +| `AccountButtonPopup` | The popover surface: header, workspace list, additional accounts, footer. | + +## Styling + +The component is themed with a Mosaic slot recipe (`accountButtonRecipe`). Override any slot through +`appearance.elements` — e.g. `{ 'account-button-popup': { borderRadius: 24 } }`. + +| Slot | Description | +| ------------------------------ | ------------------------------------------------------- | +| `account-button-trigger` | The trigger button | +| `account-button-popup` | The popover surface | +| `account-button-header` | Active workspace header | +| `account-button-action` | Header action buttons (Settings / Members) | +| `account-button-group` | A divided section (workspace list, additional accounts) | +| `account-button-item` | A selectable workspace / account row | +| `account-button-avatar` | Org (square) / account (circle) avatar | +| `account-button-inline-button` | Row actions (Join / Accept) | +| `account-button-hover-action` | The hover-revealed Sign out on the active account | +| `account-button-footer` | Sign out of all accounts + branding | diff --git a/packages/swingset/src/stories/account-button.stories.tsx b/packages/swingset/src/stories/account-button.stories.tsx new file mode 100644 index 00000000000..7362d9c9a05 --- /dev/null +++ b/packages/swingset/src/stories/account-button.stories.tsx @@ -0,0 +1,113 @@ +/** @jsxImportSource @emotion/react */ +import { AccountButton, type AccountButtonProps } from '@clerk/ui/mosaic/account/account-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 './account-button.stories?raw'; + +export const meta: StoryMeta = { + group: 'Account', + title: 'AccountButton', + source: 'packages/ui/src/mosaic/account/account-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: () => {}, + onSwitchAccount: () => {}, + 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 MultipleAccounts(_args: Record) { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/account/account-button.view.tsx b/packages/ui/src/mosaic/account/account-button.view.tsx new file mode 100644 index 00000000000..e5d83a9a8f9 --- /dev/null +++ b/packages/ui/src/mosaic/account/account-button.view.tsx @@ -0,0 +1,906 @@ +'use client'; + +import type { PopoverProps } from '@clerk/headless/popover'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { Icon } from '../components/icon'; +import type { IconName } from '../icons/registry'; +import { Popover } from '../primitives/popover'; +import { defineSlotRecipe, useRecipe } from '../slot-recipe'; + +// 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 +// `useAccountButtonController()` output so the controller is a drop-in follow-up. + +export interface AccountButtonAccount { + sessionId: string; + userId: string; + name: string; + email: string; + imageUrl?: string; +} + +export interface AccountButtonMembership { + kind: 'membership'; + organizationId: string; + name: string; + imageUrl?: string; + membersCount?: number; + planLabel?: string; + upgradeable?: boolean; + membershipRequestCount?: number; +} + +export interface AccountButtonSuggestion { + kind: 'suggestion'; + id: string; + organizationId: string; + name: string; + imageUrl?: string; + status: 'pending' | 'accepted'; +} + +export interface AccountButtonInvitation { + kind: 'invitation'; + id: string; + organizationId: string; + organizationName: string; + imageUrl?: string; +} + +export interface AccountButtonData { + status: 'loading' | 'ready'; + activeAccount: AccountButtonAccount; + /** `null` => the personal workspace is active. */ + activeOrganizationId: string | null; + /** Explicit; do not derive from `memberships.length`. */ + hasOrganizations: boolean; + memberships: AccountButtonMembership[]; + suggestions: AccountButtonSuggestion[]; + invitations: AccountButtonInvitation[]; + additionalAccounts: AccountButtonAccount[]; +} + +/** All optional. An unhandled action hides (or de-activates) the affordance it drives. */ +export interface AccountButtonCallbacks { + onSelectOrganization?: (organizationId: string) => void; + onSelectPersonal?: () => void; + onAcceptSuggestion?: (suggestionId: string) => void; + onAcceptInvitation?: (invitationId: string) => void; + onSwitchAccount?: (sessionId: string) => void; + onSignOutSession?: (sessionId: string) => void; + onSignOutAll?: () => void; + onManageOrganization?: () => void; + onManageMembers?: () => void; + onManageAccount?: () => void; + onCreateOrganization?: () => void; + onAddAccount?: () => void; + onUpgrade?: () => void; +} + +type AccountButtonContextValue = AccountButtonData & AccountButtonCallbacks; + +// ─── Recipe ─────────────────────────────────────────────────────────────────── + +export const accountButtonRecipe = defineSlotRecipe(theme => ({ + slots: { + trigger: { slot: 'account-button-trigger' }, + triggerName: { slot: 'account-button-trigger-name' }, + triggerBadge: { slot: 'account-button-trigger-badge' }, + popup: { slot: 'account-button-popup' }, + header: { slot: 'account-button-header' }, + headerName: { slot: 'account-button-header-name' }, + headerActions: { slot: 'account-button-header-actions' }, + action: { slot: 'account-button-action' }, + group: { slot: 'account-button-group' }, + groupLabel: { slot: 'account-button-group-label' }, + item: { slot: 'account-button-item' }, + select: { slot: 'account-button-select' }, + name: { slot: 'account-button-name' }, + secondary: { slot: 'account-button-secondary' }, + upgrade: { slot: 'account-button-upgrade' }, + suggestedBadge: { slot: 'account-button-suggested-badge' }, + inlineButton: { slot: 'account-button-inline-button' }, + hoverAction: { slot: 'account-button-hover-action' }, + add: { slot: 'account-button-add' }, + addIcon: { slot: 'account-button-add-icon' }, + footer: { slot: 'account-button-footer' }, + signOutAll: { slot: 'account-button-sign-out-all' }, + branding: { slot: 'account-button-branding' }, + avatar: { slot: 'account-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: 'start', + 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="account-button-hover-action"]': { opacity: 0, pointerEvents: 'none' }, + '&:hover [data-cl-slot="account-button-hover-action"], &:focus-within [data-cl-slot="account-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: 'start', + 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: 'start', + ...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: 'start', + ...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 { + 'account-button-trigger': true; + 'account-button-trigger-name': true; + 'account-button-trigger-badge': true; + 'account-button-popup': true; + 'account-button-header': true; + 'account-button-header-name': true; + 'account-button-header-actions': true; + 'account-button-action': true; + 'account-button-group': true; + 'account-button-group-label': true; + 'account-button-item': true; + 'account-button-select': true; + 'account-button-name': true; + 'account-button-secondary': true; + 'account-button-upgrade': true; + 'account-button-suggested-badge': true; + 'account-button-inline-button': true; + 'account-button-hover-action': true; + 'account-button-add': true; + 'account-button-add-icon': true; + 'account-button-footer': true; + 'account-button-sign-out-all': true; + 'account-button-branding': true; + 'account-button-avatar': true; + } +} + +// ─── Context ──────────────────────────────────────────────────────────────── + +const AccountButtonContext = React.createContext(null); + +function useAccountButtonContext(): AccountButtonContextValue { + const ctx = React.useContext(AccountButtonContext); + if (!ctx) { + throw new Error('AccountButton compound components must be used within '); + } + return ctx; +} + +function activeMembership(data: AccountButtonData): AccountButtonMembership | undefined { + if (data.activeOrganizationId === null) { + return undefined; + } + return data.memberships.find(m => m.organizationId === data.activeOrganizationId); +} + +function membershipSubtitle(org: AccountButtonMembership): 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(accountButtonRecipe, { 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 { item, select, name: nameSlot, secondary: secondarySlot } = useRecipe(accountButtonRecipe); + const inner = ( + <> + + + + {name} + {badge} + + {secondary ? {secondary} : null} + + + ); + return ( +
+ {onSelect ? ( + + ) : ( +
+ {inner} +
+ )} + {active ? ( + ({ color: t.color.cardForeground, flexShrink: 0 })} + /> + ) : null} + {trailing} + {hoverAction} +
+ ); +} + +function SuggestedBadge() { + const { suggestedBadge } = useRecipe(accountButtonRecipe); + return Suggested; +} + +function InlineButton({ label, onClick }: { label: string; onClick: () => void }) { + const { inlineButton } = useRecipe(accountButtonRecipe); + return ( + + ); +} + +function HoverAction({ onClick }: { onClick: () => void }) { + const { hoverAction } = useRecipe(accountButtonRecipe); + return ( + + ); +} + +function AddRow({ label, onClick }: { label: string; onClick: () => void }) { + const { add, addIcon } = useRecipe(accountButtonRecipe); + return ( + + ); +} + +// ─── Sections ───────────────────────────────────────────────────────────────── + +interface HeaderAction { + icon: IconName; + label: string; + onClick: () => void; +} + +function Header() { + const data = useAccountButtonContext(); + const { header, headerName, headerActions, action, secondary, upgrade } = useRecipe(accountButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeAccount.name; + const image = isOrg ? org.imageUrl : data.activeAccount.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: 'sign-out', label: 'Sign out', onClick: () => signOut(data.activeAccount.sessionId) }); + } + } + + const showUpgrade = isOrg && org.upgradeable === true && data.onUpgrade !== undefined; + const subtitle = isOrg ? membershipSubtitle(org) : data.activeAccount.email; + + return ( +
+
+ + + {label} + + {subtitle} + {showUpgrade ? ( + <> + {subtitle ? ' · ' : null} + + + ) : null} + + +
+ {actions.length > 0 ? ( +
+ {actions.map(a => ( + + ))} +
+ ) : null} +
+ ); +} + +function WorkspaceList() { + const data = useAccountButtonContext(); + const { group } = useRecipe(accountButtonRecipe); + const selectOrg = data.onSelectOrganization; + const acceptSuggestion = data.onAcceptSuggestion; + const acceptInvitation = data.onAcceptInvitation; + const signOutSession = data.onSignOutSession; + + return ( +
+ signOutSession(data.activeAccount.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 AccountsSection() { + const data = useAccountButtonContext(); + const { group, groupLabel } = useRecipe(accountButtonRecipe); + const switchAccount = data.onSwitchAccount; + + if (data.additionalAccounts.length === 0 && !data.onAddAccount) { + return null; + } + + return ( +
+ {data.additionalAccounts.length > 0 ?
Additional accounts
: null} + {data.additionalAccounts.map(a => ( + switchAccount(a.sessionId) : undefined} + /> + ))} + {data.onAddAccount ? ( + + ) : null} +
+ ); +} + +function Footer() { + const data = useAccountButtonContext(); + const { footer, signOutAll, branding } = useRecipe(accountButtonRecipe); + return ( +
+ {data.onSignOutAll ? ( + + ) : null} +
Secured by Clerk
+
+ ); +} + +// ─── Public parts ─────────────────────────────────────────────────────────── + +export interface AccountButtonRootProps extends AccountButtonData, AccountButtonCallbacks { + 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 AccountButtonRoot(props: AccountButtonRootProps) { + 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 AccountButtonTrigger() { + const data = useAccountButtonContext(); + const { trigger, triggerName, triggerBadge } = useRecipe(accountButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeAccount.name; + const image = isOrg ? org.imageUrl : data.activeAccount.imageUrl; + + return ( + ( + + )} + /> + ); +} + +/** The popover surface: header, workspace list, additional accounts, and footer. */ +export function AccountButtonPopup() { + const data = useAccountButtonContext(); + const { popup } = useRecipe(accountButtonRecipe); + return ( + + + +
+ {data.hasOrganizations ? : null} + +