-
Notifications
You must be signed in to change notification settings - Fork 462
feat(ui): add AccountButton view component #9184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b6f96ae
8f6daac
d6d839c
dfae67d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 AgentsSource: 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 | | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 As per path instructions, story functions must cast Also applies to: 36-112 🤖 Prompt for AI AgentsSource: 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' }, | ||
| ]} | ||
| /> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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
__sourcewhen 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
__sourceto enable code footers.Also applies to: 130-135
🤖 Prompt for AI Agents
Source: Path instructions