-
Notifications
You must be signed in to change notification settings - Fork 460
feat(ui): add UserButton 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
Open
alexcarpenter
wants to merge
4
commits into
main
Choose a base branch
from
carp/account-button-switcher
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
44cf5f9
feat(ui): add UserButton account/org switcher
alexcarpenter 4ed5124
feat(ui): give UserButton its own User section above Organization
alexcarpenter 4c5ad93
refactor(ui): move UserButton into its own user-button/ folder
alexcarpenter ef35f2d
fix(ui): resolve UserButton lint errors
alexcarpenter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
| <Story | ||
| name='Default' | ||
| storyModule={UserButtonStories} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| The all-in-one `UserButton` renders the trigger and popup from a single prop-driven call: | ||
|
|
||
| ```tsx | ||
| import { UserButton } from '@clerk/ui/mosaic/user-button/user-button.view'; | ||
|
|
||
| <UserButton | ||
| status='ready' | ||
| activeSession={{ 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={[]} | ||
| additionalSessions={[{ sessionId: 'sess_2', userId: 'user_2', name: 'Preston Booth', email: 'acme@clerk.dev' }]} | ||
| onSelectOrganization={id => 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/user-button/user-button.view'; | ||
|
|
||
| <UserButtonRoot {...data} {...callbacks}> | ||
| <UserButtonTrigger /> | ||
| <UserButtonPopup /> | ||
| </UserButtonRoot> | ||
| ``` | ||
|
|
||
| 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. | ||
|
|
||
| <Story | ||
| name='Personal' | ||
| storyModule={UserButtonStories} | ||
| /> | ||
|
|
||
| ### 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='MultipleSessions' | ||
| storyModule={UserButtonStories} | ||
| /> | ||
|
|
||
| ## 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 | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /** @jsxImportSource @emotion/react */ | ||
| import { UserButton, type UserButtonProps } from '@clerk/ui/mosaic/user-button/user-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 './user-button.stories?raw'; | ||
|
|
||
| export const meta: StoryMeta = { | ||
| group: 'User', | ||
| title: 'UserButton', | ||
| source: 'packages/ui/src/mosaic/user-button/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<UserButtonProps>; | ||
|
|
||
| const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' }; | ||
|
|
||
| export function Default(_args: Record<string, unknown>) { | ||
| return ( | ||
| <UserButton | ||
| {...handlers} | ||
| status='ready' | ||
| activeSession={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={[]} | ||
| additionalSessions={[{ sessionId: 'sess_2', userId: 'user_2', name: 'Preston Booth', email: 'acme@clerk.dev' }]} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function Personal(_args: Record<string, unknown>) { | ||
| return ( | ||
| <UserButton | ||
| {...handlers} | ||
| status='ready' | ||
| activeSession={{ | ||
| sessionId: 'sess_cam', | ||
| userId: 'user_cam', | ||
| name: 'Cameron Walker', | ||
| email: 'cameron.walker@gmail.com', | ||
| }} | ||
| activeOrganizationId={null} | ||
| hasOrganizations={false} | ||
| memberships={[]} | ||
| suggestions={[]} | ||
| invitations={[]} | ||
| additionalSessions={[ | ||
| { sessionId: 'sess_js', userId: 'user_js', name: 'Jeremy Sallee', email: 'jsallee@gmail.com' }, | ||
| ]} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function MultipleSessions(_args: Record<string, unknown>) { | ||
| return ( | ||
| <UserButton | ||
| {...handlers} | ||
| status='ready' | ||
| activeSession={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={[]} | ||
| additionalSessions={[ | ||
| { sessionId: 'sess_cam', userId: 'user_cam', name: 'Cameron Walker', email: 'cameron.walker@gmail.com' }, | ||
| ]} | ||
| /> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import type { PopoverPortalProps, PopoverProps } from '@clerk/headless/popover'; | ||
| import { Popover as HeadlessPopover } from '@clerk/headless/popover'; | ||
| import type { FunctionComponent } from 'react'; | ||
|
|
||
| import { withMosaicSlot } from './withMosaicSlot'; | ||
|
|
||
| /** | ||
| * The headless popover parts bridged into mosaic. Each styleable part is wrapped | ||
| * with `withMosaicSlot`, which forwards its ref and accepts the per-slot props a | ||
| * recipe produces (`css`, `data-cl-slot`, state attrs) — the bridged type is | ||
| * inferred, so there is nothing to hand-annotate per part. | ||
| * | ||
| * The structural parts (`Root`, `Portal`) render no element of their own and | ||
| * pass through unchanged; they are cast to their public component types so the | ||
| * inferred `Popover` type stays portable (otherwise it references internal | ||
| * `@clerk/headless` declaration paths). | ||
| */ | ||
| export const Popover = { | ||
| Root: HeadlessPopover.Root as FunctionComponent<PopoverProps>, | ||
| Trigger: withMosaicSlot(HeadlessPopover.Trigger), | ||
| Portal: HeadlessPopover.Portal as FunctionComponent<PopoverPortalProps>, | ||
| Positioner: withMosaicSlot(HeadlessPopover.Positioner), | ||
| Popup: withMosaicSlot(HeadlessPopover.Popup), | ||
| Arrow: withMosaicSlot(HeadlessPopover.Arrow), | ||
| Title: withMosaicSlot(HeadlessPopover.Title), | ||
| Description: withMosaicSlot(HeadlessPopover.Description), | ||
| Close: withMosaicSlot(HeadlessPopover.Close), | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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
Wire the stories into the playground knobs.
Set
meta.stylestoaccountButtonRecipe, add the localknobsAsPropscast, and spread the resulting props into eachAccountButton. The current stories discard every playground value.As per path instructions, story functions must cast
Record<string, unknown>throughknobsAsProps, and Mosaic recipe metadata must be assigned tometa.styles.Also applies to: 36-112
🤖 Prompt for AI Agents
Source: Path instructions