Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/account-button-switcher.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, React.ComponentType>> = {
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')),
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines +3 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve __source when constructing the story module.

The story exports its raw source, but this manually built module drops it, preventing AccountButton code footers from rendering.

Proposed fix
 import {
+  __source as accountButtonSource,
   Default as AccountButtonDefault,
   meta as accountButtonMeta,
   MultipleAccounts as AccountButtonMultipleAccounts,
   Personal as AccountButtonPersonal,
 } from '../stories/account-button.stories';

 const accountButtonModule: StoryModule = {
   meta: accountButtonMeta,
+  __source: accountButtonSource,
   Default: AccountButtonDefault,

As per path instructions, Components-layer stories export __source to enable code footers.

Also applies to: 130-135

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/lib/registry.ts` around lines 3 - 8, Update the
manually constructed AccountButton story module to preserve the story’s raw
__source export. Import __source from account-button.stories and include it in
the module object alongside AccountButtonDefault, AccountButtonMultipleAccounts,
AccountButtonPersonal, and accountButtonMeta; apply the same change to the
corresponding second construction.

Source: Path instructions

import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -159,6 +172,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta };
const useDataTableModule: StoryModule = { meta: useDataTableMeta };

export const registry: StoryModule[] = [
// Account
accountButtonModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
Expand Down
115 changes: 115 additions & 0 deletions packages/swingset/src/stories/account-button.mdx
Original file line number Diff line number Diff line change
@@ -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

<Story
name='Default'
storyModule={AccountButtonStories}
/>
Comment on lines +3 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Use the Archetype C documentation structure.

Lead with one short paragraph describing owned state and wired lower-layer pieces, followed by one <Story> whose composition array names each direct dependency. The current multiple-story layout follows neither requirement.

As per path instructions, Archetype C MDX must use a single ownership/composition paragraph followed by one Story with a dependency composition array.

Also applies to: 68-89

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/account-button.mdx` around lines 3 - 21,
Restructure the AccountButton MDX documentation into Archetype C format: replace
the current descriptive sections with one short paragraph covering owned state
and the wired lower-layer pieces, then provide exactly one Story. Add a
composition array to that Story listing each direct dependency, including the
headless popover primitive and slot recipes, and remove the current multi-story
layout.

Source: Path instructions


## 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';

<AccountButton
status='ready'
activeAccount={{ sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' }}
activeOrganizationId='org_clerk_app'
hasOrganizations
memberships={[
{ kind: 'membership', organizationId: 'org_clerk_app', name: 'Clerk app', membersCount: 24, planLabel: 'Pro plan', upgradeable: true },
{ kind: 'membership', organizationId: 'org_clerk_cloud', name: 'Clerk Cloud' },
]}
suggestions={[{ kind: 'suggestion', id: 'sug_labs', organizationId: 'org_clerk_labs', name: 'Clerk Labs', status: 'pending' }]}
invitations={[]}
additionalAccounts={[{ sessionId: 'sess_2', userId: 'user_2', name: 'Preston Booth', email: 'acme@clerk.dev' }]}
onSelectOrganization={id => 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';

<AccountButtonRoot {...data} {...callbacks}>
<AccountButtonTrigger />
<AccountButtonPopup />
</AccountButtonRoot>
```

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.

<Story
name='Personal'
storyModule={AccountButtonStories}
/>

### 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.

<Story
name='MultipleAccounts'
storyModule={AccountButtonStories}
/>

## 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 |
113 changes: 113 additions & 0 deletions packages/swingset/src/stories/account-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Story>` 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',
};
Comment on lines +10 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wire the stories into the playground knobs.

Set meta.styles to accountButtonRecipe, add the local knobsAsProps cast, and spread the resulting props into each AccountButton. The current stories discard every playground value.

As per path instructions, story functions must cast Record<string, unknown> through knobsAsProps, and Mosaic recipe metadata must be assigned to meta.styles.

Also applies to: 36-112

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/account-button.stories.tsx` around lines 10 -
14, Update the AccountButton story metadata to assign accountButtonRecipe to
meta.styles. In each story function, cast the knobs Record<string, unknown>
through knobsAsProps and spread the resulting props into AccountButton so
playground values are applied.

Source: Path instructions


// 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<AccountButtonProps>;

const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' };

export function Default(_args: Record<string, unknown>) {
return (
<AccountButton
{...handlers}
status='ready'
activeAccount={preston}
activeOrganizationId='org_clerk_app'
hasOrganizations
memberships={[
{
kind: 'membership',
organizationId: 'org_clerk_app',
name: 'Clerk app',
membersCount: 24,
planLabel: 'Pro plan',
upgradeable: true,
},
{ kind: 'membership', organizationId: 'org_clerk_cloud', name: 'Clerk Cloud' },
]}
suggestions={[
{ kind: 'suggestion', id: 'sug_labs', organizationId: 'org_clerk_labs', name: 'Clerk Labs', status: 'pending' },
]}
invitations={[]}
additionalAccounts={[{ sessionId: 'sess_2', userId: 'user_2', name: 'Preston Booth', email: 'acme@clerk.dev' }]}
/>
);
}

export function Personal(_args: Record<string, unknown>) {
return (
<AccountButton
{...handlers}
status='ready'
activeAccount={{
sessionId: 'sess_cam',
userId: 'user_cam',
name: 'Cameron Walker',
email: 'cameron.walker@gmail.com',
}}
activeOrganizationId={null}
hasOrganizations={false}
memberships={[]}
suggestions={[]}
invitations={[]}
additionalAccounts={[
{ sessionId: 'sess_js', userId: 'user_js', name: 'Jeremy Sallee', email: 'jsallee@gmail.com' },
]}
/>
);
}

export function MultipleAccounts(_args: Record<string, unknown>) {
return (
<AccountButton
{...handlers}
status='ready'
activeAccount={preston}
activeOrganizationId='org_clerk_app'
hasOrganizations
memberships={[
{
kind: 'membership',
organizationId: 'org_clerk_app',
name: 'Clerk app',
membersCount: 24,
planLabel: 'Pro plan',
upgradeable: true,
},
{ kind: 'membership', organizationId: 'org_clerk_cloud', name: 'Clerk Cloud' },
]}
suggestions={[]}
invitations={[]}
additionalAccounts={[
{ sessionId: 'sess_cam', userId: 'user_cam', name: 'Cameron Walker', email: 'cameron.walker@gmail.com' },
]}
/>
);
}
Loading
Loading