diff --git a/.changeset/mosaic-profile-component.md b/.changeset/mosaic-profile-component.md
new file mode 100644
index 00000000000..ec3e0d7209f
--- /dev/null
+++ b/.changeset/mosaic-profile-component.md
@@ -0,0 +1,30 @@
+---
+'@clerk/mosaic': minor
+---
+
+**Breaking:** Add a `Profile` component that shows the account's pages, the active organization's pages, or both. `` now shows only the account's pages, and `` only the organization's. To keep both in one profile, render ``.
+
+```tsx
+
+
+
+```
+
+`mode` defaults to `'combined'`. Without a `mode`, `Profile` follows `MosaicProvider`'s `profile.combined`, so `false` shows only the account's pages.
+
+With `experimental.newProfiles`, the profile a menu opens now matches the menu. `` and `` open the account's pages, a lone `` opens the organization's pages, and only a combined `` opens one profile with both. `profile.combined: false` still splits a combined menu's profile in two.
+
+| Before | After |
+| --------------------------------------------------------- | --------------------------------------------- |
+| `` | `` |
+| `` | `` |
+| `` | `` or `` |
+| `` | `` |
+
+To find the code to update, run:
+
+```sh
+git grep -n '` is now `` and `` is ``. Each mode takes only its own props, so TypeScript flags organization options on `UserButton` and `triggerVariant` outside `combined`. A `mode` chosen at runtime takes only the props every mode shares.
+
+| Before | After |
+| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| `` (accounts and organizations) | `` |
+| `` | `` or `` |
+| `` | `` |
+| Organization props on `UserButton` (`hidePersonal`, `afterSelectOrganizationUrl`, `createOrganizationUrl`, ...) | The same props on `Switcher` |
+| `UserButtonProps` (combined menu) | `CombinedSwitcherProps` |
+
+Find usages with `git grep -n "UserButton"`.
diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx
index 766d3f60a90..7327a6332da 100644
--- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx
+++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { MosaicProvider, UserButton } from '@clerk/mosaic';
+import { Switcher, MosaicProvider } from '@clerk/mosaic';
export default function Page() {
return (
@@ -12,7 +12,7 @@ export default function Page() {
},
}}
>
-
+
);
}
diff --git a/integration/templates/next-app-router-mosaic/src/app/mosaic-user-button.tsx b/integration/templates/next-app-router-mosaic/src/app/mosaic-user-button.tsx
index 8815431f838..278d8a112e2 100644
--- a/integration/templates/next-app-router-mosaic/src/app/mosaic-user-button.tsx
+++ b/integration/templates/next-app-router-mosaic/src/app/mosaic-user-button.tsx
@@ -1,11 +1,11 @@
'use client';
-import { MosaicProvider, UserButton } from '@clerk/mosaic';
+import { Switcher, MosaicProvider } from '@clerk/mosaic';
export function MosaicUserButton() {
return (
-
+
);
}
diff --git a/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
index a23e6aeb5d6..17fd65685e1 100644
--- a/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
+++ b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
@@ -1,11 +1,11 @@
'use client';
-import { MosaicProvider, UserProfile } from '@clerk/mosaic';
+import { MosaicProvider, Profile } from '@clerk/mosaic';
export default function Page() {
return (
- custom-page-content
}]} />
+ custom-page-content }]} />
);
}
diff --git a/packages/mosaic/.claude/skills/verify-mosaic/SKILL.md b/packages/mosaic/.claude/skills/verify-mosaic/SKILL.md
index 4c3972ca719..b44f669e863 100644
--- a/packages/mosaic/.claude/skills/verify-mosaic/SKILL.md
+++ b/packages/mosaic/.claude/skills/verify-mosaic/SKILL.md
@@ -61,7 +61,7 @@ $ CS ab eval '(() => document.querySelector("[role=dialog]").dataset.variant)()'
Conventions:
-- Routes are `//`: `/components/button`, `/components/dialog`, `/user-button/user-button`, `/misc/organization-switcher`. The slug is the story's `meta.group` and `meta.title` run through `packages/swingset/src/lib/slug.ts`. A wrong path still returns 200 with `No docs found`, so check the level-1 heading. Read the paths of the expanded sidebar groups with `CS ab eval '(() => [...document.querySelectorAll("[data-sidebar] a")].map(a => a.getAttribute("href")))()'`, or click the link with `CS ab click 'a[href="/user-button/user-button"]'`.
+- Routes are `//`: `/components/button`, `/components/dialog`, `/switcher/switcher`, `/misc/organization-switcher`. The slug is the story's `meta.group` and `meta.title` run through `packages/swingset/src/lib/slug.ts`. A wrong path still returns 200 with `No docs found`, so check the level-1 heading. Read the paths of the expanded sidebar groups with `CS ab eval '(() => [...document.querySelectorAll("[data-sidebar] a")].map(a => a.getAttribute("href")))()'`, or click the link with `CS ab click 'a[href="/switcher/switcher"]'`.
- Prefer role and accessible name (`find role button --name …`). When `find` cannot resolve a name, as with the header switches, take `snapshot -i` and use the `@ref`. Refs change after every navigation and every re-render, so take a new snapshot first.
- Mosaic parts carry their identity in the DOM: a `.cl-` class plus `data-` attributes (`data-variant`, `data-size`, `data-color`, `data-open`). Assert on those, not on computed colours or class hashes. `data-style-src` lists the StyleX source lines that styled the element.
- Wrap every `eval` body in an IIFE. The page keeps `const`s between `eval` calls, and a second `const b` throws.
diff --git a/packages/mosaic/.claude/skills/verify-mosaic/notes.md b/packages/mosaic/.claude/skills/verify-mosaic/notes.md
index 099f82dd77d..dfde1c31405 100644
--- a/packages/mosaic/.claude/skills/verify-mosaic/notes.md
+++ b/packages/mosaic/.claude/skills/verify-mosaic/notes.md
@@ -26,7 +26,7 @@ Prove it looks right in every state you touched.
Prove it works the way a user would test it: do the task end to end and check the result on the page.
-- Pages are under the flow's group: `/user-button/...`, `/user-profile/...`, `/organization-profile/...`, `/misc/...`, `/blocks/...`. A section page such as `/user-profile/user-profile-email-section` has the default story first, then error and pending stories below it.
+- Pages are under the flow's group: `/switcher/...`, `/user-profile/...`, `/organization-profile/...`, `/misc/...`, `/blocks/...`. A section page such as `/user-profile/user-profile-email-section` has the default story first, then error and pending stories below it.
- The data is fixtures, and they keep state on the page. On the email section, `Add email` → type an address → `Continue` → type `424242` adds `Manage ` to the list.
- Drive the default story through the change, then the error story for the failure path. Prove the end state with a snapshot, not a screenshot alone.
- Turn on Chaos to check long and multi-script data.
diff --git a/packages/mosaic/README.md b/packages/mosaic/README.md
index 7475eb9e9e3..72a567c0f5f 100644
--- a/packages/mosaic/README.md
+++ b/packages/mosaic/README.md
@@ -58,13 +58,13 @@ Import the stylesheet and render Mosaic components beneath the SDK `ClerkProvide
```tsx
import { ClerkProvider } from '@clerk/react';
-import { UserButton } from '@clerk/mosaic';
+import { Switcher } from '@clerk/mosaic';
import '@clerk/mosaic/styles.css';
export function App({ children }) {
return (
-
+
{children}
);
diff --git a/packages/mosaic/docs/documenting-components.md b/packages/mosaic/docs/documenting-components.md
index a5fc421b76b..ff7869f205b 100644
--- a/packages/mosaic/docs/documenting-components.md
+++ b/packages/mosaic/docs/documenting-components.md
@@ -2,7 +2,7 @@
Read this when you add or change a Swingset page for a Mosaic component, block, hook or style: a `.stories.tsx` or `.mdx` in `packages/swingset/src/stories/`. It covers what to write once the page is wired; how the app works is in `packages/swingset/CLAUDE.md`, and the registry, `docModules` and redirect wiring is in `packages/swingset/README.md`.
-Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `popover.component.mdx` (A, compound), `accordion.mdx` (B), `user-button.mdx` (C).
+Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `popover.component.mdx` (A, compound), `accordion.mdx` (B), `switcher.mdx` (C).
## Rules
@@ -19,7 +19,8 @@ Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `p
| Group | What lives here | Archetype |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
-| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
+| `Switcher` | Composed flow UI (e.g. `Switcher`) | C |
+| `Profile` | Composed flow UI (`Profile`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Organization Profile` | Composed flow UI (e.g. `OrganizationProfileGeneralPanel`) | C |
| `Misc` | Standalone composed components (e.g. `OrganizationSwitcher`, `Waitlist`) | C |
@@ -30,7 +31,7 @@ Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `p
| `Hooks` | Headless hooks (`useForm`) | B (adapted) |
| `Localization` | The `localization` prop on `MosaicProvider`: catalogs, overrides, locale, and the message helpers | B (adapted) |
-`User Button` / `User Profile` / `Organization Profile` / `Misc` / `Blocks` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
+`Switcher` / `Profile` / `User Profile` / `Organization Profile` / `Misc` / `Blocks` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export
@@ -204,14 +205,14 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th
Every props table has a **Default** column, and the `Type` stays a plain union, never with the default inlined. The auto `` fills it from `meta.styles._defaultVariants`; hand-written tables (`Prop | Type | Default | Description`) fill it by hand. Name the default member (`'bottom-start'`), use `—` when there is none and append `(required)` for a required prop, and state a behavioral default in words (`inherits Root`).
-## Archetype C — composed layer (`User Button`, `User Profile`, `Organization Profile`, `Misc`, `Blocks`)
+## Archetype C — composed layer (`Switcher`, `Profile`, `User Profile`, `Organization Profile`, `Misc`, `Blocks`)
These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:
```mdx
-import * as SwitcherStories from './user-button.stories';
+import * as SwitcherStories from './switcher.stories';
-# UserButton
+# Switcher
diff --git a/packages/mosaic/docs/testing.md b/packages/mosaic/docs/testing.md
index 38860341941..8b3dcdbefa1 100644
--- a/packages/mosaic/docs/testing.md
+++ b/packages/mosaic/docs/testing.md
@@ -2,7 +2,7 @@
Read this when you write, move or review a test in `packages/mosaic` (`*.test.ts(x)`, `*.feature.test.tsx`, `*.browser.test.tsx`, `*.visual.test.tsx`, `*.test-d.ts`, or anything under `__tests__/`). It covers which tier a behavior belongs in, where test files go, the feature-test toolkit and screenshot tests; what a feature test should assert for each layer is in the Testing section of [Models](models.md#testing), [Controllers](controllers.md#testing) and [Views](views.md#testing).
-Worked example: `src/features/user-button/__tests__/user-button.feature.test.tsx`.
+Worked example: `src/features/switcher/__tests__/switcher.feature.test.tsx`.
## Rules
@@ -64,7 +64,7 @@ it('makes the selected organization active and closes', async () => {
client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' })]),
memberships: [fapiMembership(acme), fapiMembership(other)],
});
- await renderWithClerk();
+ await renderWithClerk();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Open account menu/ }));
diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx
index f52f2cadd02..171a662e087 100644
--- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx
@@ -66,6 +66,15 @@ describe('OrganizationProfile', () => {
expect(screen.queryByText('Loading profile')).toBeNull();
});
+ it("adds the provider's organization pages", async () => {
+ serve();
+ await renderWithClerk(, undefined, undefined, undefined, {
+ userProfileProps: { customPages: [{ path: 'usage', label: 'Usage', content:
, undefined, undefined, undefined, profile);
@@ -196,10 +196,18 @@ describe('UserProfile modal return', () => {
expect(tabs).toContain('Terms');
});
- it('stays account-only when the provider turns combined off', async () => {
- const tabs = await tabsAfterReturning({ combined: false });
-
- expect(tabs).not.toContain('General');
+ it.each([
+ { name: 'a user profile', param: 'user-profile%2Faccount', profile: {}, expected: ['Account', 'Security'] },
+ {
+ name: 'a combined profile',
+ param: 'profile%2Faccount',
+ profile: { combined: false },
+ expected: ['Account', 'Security', 'General'],
+ },
+ ])('reopens $name whatever the provider says', async ({ param, profile, expected }) => {
+ const tabs = await tabsAfterReturning(profile, param);
+
+ expect(tabs).toEqual(expected);
});
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 498e24fc37c..3f20842acdb 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -227,7 +227,7 @@ function unavailable() {
}
export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAccountsModel {
- const { additionalOAuthScopes, mode } = useUserProfileOptions();
+ const { additionalOAuthScopes, mode, combined } = useUserProfileOptions();
const clerk = useClerk();
const { isLoaded, user } = useUser();
const environment = useMosaicEnvironment();
@@ -283,7 +283,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco
};
const getRedirectUrl = async () => (transport ? String(await transport.getRedirectUrl()) : window.location.href);
- const withModalState = (url: string) => withUserProfileModalReturn(url, mode);
+ const withModalState = (url: string) => withUserProfileModalReturn(url, { mode, combined });
const completeVerification = async (response: ExternalAccountResource): Promise => {
const url = response.verification?.externalVerificationRedirectURL;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
index fd4419a5ac8..e66eceb1f85 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
@@ -36,7 +36,7 @@ function startEmailVerification(
email: EmailAddressResource,
linkRedirectUrl: string | undefined,
router: MosaicRouter,
- mode: UserProfileOptions['mode'],
+ profile: Pick,
): UserProfileEmailVerification {
if (email.matchesSsoConnection) {
return {
@@ -50,7 +50,7 @@ function startEmailVerification(
save(async () => {
const prepared = await email.prepareVerification({
strategy: 'enterprise_sso',
- redirectUrl: withUserProfileModalReturn(window.location.href, mode),
+ redirectUrl: withUserProfileModalReturn(window.location.href, profile),
});
const url = prepared.verification.externalVerificationRedirectURL;
if (!url) {
@@ -75,10 +75,10 @@ function toEmailVerifier(
email: EmailAddressResource,
linkRedirectUrl: string | undefined,
router: MosaicRouter,
- mode: UserProfileOptions['mode'],
+ profile: Pick,
): UserProfileEmailVerifier {
return {
- start: () => startEmailVerification(email, linkRedirectUrl, router, mode),
+ start: () => startEmailVerification(email, linkRedirectUrl, router, profile),
verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS),
};
}
@@ -86,7 +86,7 @@ function toEmailVerifier(
export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel {
const model = useUserProfileUserModel();
const router = useMosaicRouter();
- const { mode } = useUserProfileOptions();
+ const { mode, combined } = useUserProfileOptions();
const [removeEmail, removeReverification] = useReverificationFlow((user: UserResource, id: string) =>
byId(user.emailAddresses, id, 'email address').destroy(),
);
@@ -115,7 +115,8 @@ export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel
const verifiesByLink = Boolean(attributes.email_address?.verifications.includes('email_link'));
const linkRedirectUrl = verifiesByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined;
- const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router, mode);
+ const verifierFor = (email: EmailAddressResource) =>
+ toEmailVerifier(email, linkRedirectUrl, router, { mode, combined });
return {
status: 'ready',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
index 0c43e042e4e..b2c45aff52c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
@@ -83,7 +83,7 @@ export type UserProfileEnterpriseAccountsModel =
};
export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAccountsModel {
- const { mode } = useUserProfileOptions();
+ const { mode, combined } = useUserProfileOptions();
const clerk = useClerk();
const router = useMosaicRouter();
const [createAccount, connectReverification] = useReverificationFlow(
@@ -137,7 +137,7 @@ export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAc
if (!projection.connections.some(connection => connection.id === connectionId)) {
throw unavailable();
}
- const redirectUrl = withUserProfileModalReturn(window.location.href, mode);
+ const redirectUrl = withUserProfileModalReturn(window.location.href, { mode, combined });
const account = await createAccount(current, { enterpriseConnectionId: connectionId, redirectUrl });
currentUser();
const redirect = account.verification?.externalVerificationRedirectURL;
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
index c9d35d442de..d869390ea4f 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
@@ -1,7 +1,7 @@
import { useUser } from '@clerk/shared/react';
import React from 'react';
-import { UserProfileSurface } from './user-profile';
+import { UserProfileSurface } from '../profile/profile';
import type { UserProfileModalContentProps } from './user-profile.modal.types';
export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement {
@@ -14,10 +14,11 @@ export default function UserProfileModalContent({ options, close }: UserProfileM
}
}, [signedOut, close]);
- const { page, ...props } = options ?? {};
+ const { page, combined = true, ...props } = options ?? {};
return (
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
index d0fd10c14f4..64b62722a62 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
@@ -6,14 +6,18 @@ import { userProfileModal } from './user-profile.modal';
import type { UserProfileOptions } from './user-profile.provider';
const MODAL_PARAM = '__clerk_mosaic_modal';
-const ACCOUNT_PAGE = 'user-profile/account';
+const USER_ACCOUNT_PAGE = 'user-profile/account';
+const COMBINED_ACCOUNT_PAGE = 'profile/account';
-export function withUserProfileModalReturn(url: string, mode: UserProfileOptions['mode']): string {
+export function withUserProfileModalReturn(
+ url: string,
+ { mode, combined = false }: Pick,
+): string {
if (mode !== 'modal') {
return url;
}
const next = new URL(url);
- next.searchParams.set(MODAL_PARAM, ACCOUNT_PAGE);
+ next.searchParams.set(MODAL_PARAM, combined ? COMBINED_ACCOUNT_PAGE : USER_ACCOUNT_PAGE);
return next.toString();
}
@@ -24,12 +28,13 @@ export function UserProfileModalReturn(): null {
React.useEffect(() => {
queueMicrotask(() => {
const url = new URL(window.location.href);
- if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) {
+ const page = url.searchParams.get(MODAL_PARAM);
+ if (page !== USER_ACCOUNT_PAGE && page !== COMBINED_ACCOUNT_PAGE) {
return;
}
url.searchParams.delete(MODAL_PARAM);
window.history.replaceState(withoutNextAppRouterMarker(window.history.state), '', url);
- open({ ...userProfileModalOptions(profile), page: 'account' });
+ open({ ...userProfileModalOptions(profile), combined: page === COMBINED_ACCOUNT_PAGE, page: 'account' });
});
}, [open, profile]);
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts
index c1c8d1e51b0..84d29e4f5e7 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts
@@ -1,11 +1,12 @@
import type { ModalContentProps } from '../../modals/modal.types';
-import type { UserProfileProps } from './user-profile';
+import type { CombinedProfileProps } from '../profile/profile';
export interface UserProfileModalOptions extends Pick<
- UserProfileProps,
- 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'combined' | 'organizationProfileProps'
+ CombinedProfileProps,
+ 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'organizationProfileProps'
> {
- page?: UserProfileProps['defaultPage'];
+ combined?: boolean;
+ page?: CombinedProfileProps['defaultPage'];
}
export type UserProfileModalContentProps = ModalContentProps;
diff --git a/packages/mosaic/src/features/user-profile/user-profile.provider.tsx b/packages/mosaic/src/features/user-profile/user-profile.provider.tsx
index 5c2d61616fe..add24253b09 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.provider.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.provider.tsx
@@ -11,6 +11,7 @@ export interface UserProfileOptions {
*/
additionalOAuthScopes?: AdditionalOAuthScopes;
mode?: 'modal' | 'mounted';
+ combined?: boolean;
}
export interface UserProfileProviderProps extends UserProfileOptions {
@@ -19,8 +20,12 @@ export interface UserProfileProviderProps extends UserProfileOptions {
const UserProfileContext = createContext({});
-export function UserProfileProvider({ additionalOAuthScopes, mode, children }: UserProfileProviderProps) {
- return {children};
+export function UserProfileProvider({ additionalOAuthScopes, mode, combined, children }: UserProfileProviderProps) {
+ return (
+
+ {children}
+
+ );
}
export function useUserProfileOptions(): UserProfileOptions {
diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx
index 754efaa863e..74699d4c9ce 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.tsx
@@ -3,22 +3,9 @@
import type { ReactNode } from 'react';
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
-import type { OrganizationProfileProps } from '../organization-profile/organization-profile';
-import { organizationProfilePanels } from '../organization-profile/organization-profile';
-import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model';
-import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider';
-import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
-import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
-import { useUserProfilePages } from '../switcher/switcher.pages';
-import { ORGANIZATION_PAGE_PREFIX, userProfilePageIds } from './user-profile.layout';
+import { Profile } from '../profile/profile';
import type { UserProfileOptions } from './user-profile.provider';
-import { UserProfileProvider } from './user-profile.provider';
import type { UserProfileViewProps } from './user-profile.view';
-import { UserProfileView } from './user-profile.view';
-import { UserProfileApiKeysPanel } from './user-profile-api-keys-panel';
-import { UserProfileProfilePanel } from './user-profile-profile-panel';
-import { UserProfileSecurityPanel } from './user-profile-security-panel';
-import { useUserProfileUserModel } from './user-profile-user.model';
/** Everything `` takes: your own pages and their order, the page it opens on, and the OAuth scopes it asks for. */
export interface UserProfileProps
@@ -40,27 +27,12 @@ export interface UserProfileProps
* keys a page lists, and `showDescription` adds an optional description when creating a key.
*/
apiKeysProps?: APIKeysPageProps;
- /**
- * Whether the surface adds the active organization's pages after the account's. `false` shows the
- * account's alone, even when an organization is active.
- *
- * @default `MosaicProvider`'s `profile.combined`, which is `true` unless set
- */
- combined?: boolean;
- /**
- * Options for the organization's pages when `combined`: your own pages and their order, the API
- * keys page, and where leaving the organization lands. `pageOrder` names the organization's pages
- * without the `organization/` prefix, as in `['members', 'general']`.
- */
- organizationProfileProps?: Pick<
- OrganizationProfileProps,
- 'customPages' | 'pageOrder' | 'apiKeysProps' | 'afterLeaveOrganizationUrl'
- >;
}
/**
* The signed-in user's profile: Account, Security, and API keys when the instance enables them,
- * with your own pages alongside, and the active organization's pages after them. It reads the user from Clerk, so it takes no data. It renders
+ * with your own pages alongside. It is ``, so it never lists the active
+ * organization's pages. It reads the user from Clerk, so it takes no data. It renders
* `fallback` until Clerk answers, and nothing at all when nobody is signed in.
*
* @example
@@ -76,29 +48,12 @@ export interface UserProfileProps
* ```
*
* @example
- * `defaultPage` picks the page the profile opens on without moving it in the navigation. An
- * organization page's id is prefixed with `organization/`.
+ * `defaultPage` picks the page the profile opens on without moving it in the navigation.
* ```tsx
*
* ```
*
* @example
- * `combined={false}` leaves the active organization's pages out.
- * ```tsx
- *
- * ```
- *
- * @example
- * `organizationProfileProps` adds your own pages to the organization's group.
- * ```tsx
- * , content: }],
- * }}
- * />
- * ```
- *
- * @example
* `customPages` adds your own pages; `pageOrder` places them among the built-in ones.
* ```tsx
*
* ```
*/
-export function UserProfile(props: UserProfileProps) {
- const { combined, userProfileProps, organizationProfileProps: providerOrganization } = useMosaicProfile();
- const {
- combined: ownCombined = combined,
- customPages = userProfileProps?.customPages,
- pageOrder = userProfileProps?.pageOrder,
- additionalOAuthScopes = userProfileProps?.additionalOAuthScopes,
- apiKeysProps = userProfileProps?.apiKeysProps,
- organizationProfileProps: {
- customPages: organizationCustomPages = providerOrganization?.customPages,
- pageOrder: organizationPageOrder = providerOrganization?.pageOrder,
- apiKeysProps: organizationApiKeysProps = providerOrganization?.apiKeysProps,
- afterLeaveOrganizationUrl = providerOrganization?.afterLeaveOrganizationUrl,
- } = {},
- ...rest
- } = props;
-
- return (
-
- );
-}
-
-export function UserProfileSurface({
- presentation,
- combined = true,
- organizationProfileProps,
- customPages,
- pageOrder,
- defaultPage,
- additionalOAuthScopes,
- fallback,
- apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {},
-}: UserProfileProps & { presentation: NonNullable }) {
- const model = useUserProfileUserModel();
- const builtInPages = useUserProfilePages();
-
- if (model.status === 'loading') {
- return fallback ?? null;
- }
- if (model.status === 'hidden') {
- return null;
- }
-
- const viewProps: AccountViewProps = {
- elevation: 'flush',
- initialPage: defaultPage ?? pageOrder?.[0] ?? 'account',
- customPages,
- pageOrder,
- pages: {
- account: ,
- security: ,
- apiKeys:
- builtInPages.includes('apiKeys') && !hideAPIKeys ? : undefined,
- },
- };
-
- return (
-
- {combined ? (
-
- ) : (
-
- )}
-
- );
-}
-
-type AccountViewProps = Omit & {
- initialPage: UserProfileViewProps['activePage'];
-};
-
-function AccountUserProfileView({ initialPage, ...viewProps }: AccountViewProps) {
- const navigation = useProfilePagesController(initialPage, userProfilePageIds(viewProps));
-
+export function UserProfile(props: UserProfileProps): ReactNode {
return (
-
);
}
-
-function CombinedUserProfileView({
- initialPage,
- organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {},
- ...viewProps
-}: AccountViewProps & Pick) {
- const model = useOrganizationProfileModel();
- const organization =
- model.status === 'ready'
- ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder }
- : undefined;
- const pageIds = model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization });
- const navigation = useProfilePagesController(
- initialPage,
- pageIds,
- initialPage.startsWith(ORGANIZATION_PAGE_PREFIX)
- ? pageIds?.find(id => id.startsWith(ORGANIZATION_PAGE_PREFIX))
- : undefined,
- );
-
- return (
-
-
-
- );
-}
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index a64f3111ea2..82546b92007 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -1,13 +1,20 @@
'use client';
-export type { UserButtonProps } from './features/user-button/user-button';
+export type {
+ CombinedSwitcherProps,
+ OrganizationSwitcherProps,
+ SwitcherProps,
+ UserButtonProps,
+} from './features/switcher/switcher';
+export { Switcher } from './features/switcher/switcher';
export { UserButton } from './features/user-button/user-button';
export type { UserAvatarProps } from './features/user-avatar/user-avatar';
export { UserAvatar } from './features/user-avatar/user-avatar';
-export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher';
export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher';
export type { OrganizationProfileProps } from './features/organization-profile/organization-profile';
export { OrganizationProfile } from './features/organization-profile/organization-profile';
+export type { CombinedProfileProps, ProfileMode, ProfileProps } from './features/profile/profile';
+export { Profile } from './features/profile/profile';
export type { UserProfileProps } from './features/user-profile/user-profile';
export { UserProfile } from './features/user-profile/user-profile';
export type { MosaicProfile } from './features/profile-pages/use-mosaic-profile';
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index c5b03f9c371..c65e4f48847 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -185,6 +185,7 @@ export type {
export { CreateOrganization } from '../features/create-organization/create-organization';
export { OrganizationList } from '../features/organization-list/organization-list';
export { OrganizationSwitcher } from '../features/organization-switcher/organization-switcher';
+export { Switcher } from '../features/switcher/switcher';
export { TaskChooseOrganization } from '../features/task-choose-organization/task-choose-organization';
export { TaskSetupMFA } from '../features/task-setup-mfa/task-setup-mfa';
export { UserButton } from '../features/user-button/user-button';
diff --git a/packages/swingset/src/app/(clerk)/layout.tsx b/packages/swingset/src/app/(clerk)/layout.tsx
index 45f75b113bd..34f8651f19c 100644
--- a/packages/swingset/src/app/(clerk)/layout.tsx
+++ b/packages/swingset/src/app/(clerk)/layout.tsx
@@ -7,7 +7,7 @@ import { Separator } from '@/components/ui/separator';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { LiveSidebar } from './live-sidebar';
-import { LiveUserButton } from './live-user-button';
+import { LiveSwitcher } from './live-switcher';
function LiveChrome({ children, userButton }: { children: ReactNode; userButton?: ReactNode }) {
return (
@@ -70,7 +70,7 @@ export default function ClerkLayout({ children }: { children: ReactNode }) {
signUpFallbackRedirectUrl='/live'
afterSignOutUrl='/live'
>
- }>{children}
+ }>{children}
);
}
diff --git a/packages/swingset/src/app/(clerk)/live-switcher.tsx b/packages/swingset/src/app/(clerk)/live-switcher.tsx
new file mode 100644
index 00000000000..321b34b2f68
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live-switcher.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { Switcher } from '@clerk/mosaic/features/switcher/switcher';
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
+
+export function LiveSwitcher() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(clerk)/live-user-button.tsx b/packages/swingset/src/app/(clerk)/live-user-button.tsx
deleted file mode 100644
index ca8deb98b9c..00000000000
--- a/packages/swingset/src/app/(clerk)/live-user-button.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-'use client';
-
-import { UserButton } from '@clerk/mosaic/features/user-button/user-button';
-import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
-
-export function LiveUserButton() {
- return (
-
-
-
- );
-}
diff --git a/packages/swingset/src/app/(clerk)/live/profile/profile/page.tsx b/packages/swingset/src/app/(clerk)/live/profile/profile/page.tsx
new file mode 100644
index 00000000000..1c4d6dc5259
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live/profile/profile/page.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { Profile } from '@clerk/mosaic/features/profile/profile';
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
+import { useUser } from '@clerk/nextjs';
+import Link from 'next/link';
+
+import { LivePageHeader } from '../../../live-page-header';
+
+export default function ProfileLivePage() {
+ const { isLoaded, isSignedIn } = useUser();
+
+ return (
+
+
+
+ The connected profile: the signed-in account's pages, then the active organization's.
+
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to use the live harness.
+
+ ) : null}
+ Loading…} />
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/switcher/switcher/page.tsx
similarity index 69%
rename from packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx
rename to packages/swingset/src/app/(clerk)/live/switcher/switcher/page.tsx
index b3321f42f96..ede2ebc54f9 100644
--- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx
+++ b/packages/swingset/src/app/(clerk)/live/switcher/switcher/page.tsx
@@ -1,20 +1,21 @@
'use client';
-import { UserButton } from '@clerk/mosaic/features/user-button/user-button';
+import { Switcher } from '@clerk/mosaic/features/switcher/switcher';
import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
import { useUser } from '@clerk/nextjs';
import Link from 'next/link';
-export default function UserButtonLivePage() {
+export default function SwitcherLivePage() {
const { isLoaded, isSignedIn } = useUser();
return (
-
User button
+
Switcher
- The connected user button, with the signed-in account, its organizations, and the profiles it opens.
+ The connected switcher in each mode, with the signed-in account, its organizations, and the profiles it
+ opens.
diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx
index cb93cf6dee3..c68a00d505e 100644
--- a/packages/swingset/src/components/Composition.tsx
+++ b/packages/swingset/src/components/Composition.tsx
@@ -7,14 +7,15 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
- /** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
+ /** Which Mosaic layer the piece lives in (e.g. `Switcher`, `Components`, `Primitives`). */
layer: string;
}
// Mosaic layers, high → low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = [
- 'User Button',
+ 'Switcher',
+ 'Profile',
'User Profile',
'Organization Profile',
'Misc',
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 674dd120af6..5e0233b0099 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -9,8 +9,11 @@ import { PlaygroundProvider } from './PlaygroundContext';
// 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> = {
- 'user-button': {
- 'user-button': dynamic(() => import('../stories/user-button.mdx')),
+ switcher: {
+ switcher: dynamic(() => import('../stories/switcher.mdx')),
+ },
+ profile: {
+ profile: dynamic(() => import('../stories/profile.mdx')),
},
'user-profile': {
'user-profile': dynamic(() => import('../stories/user-profile.mdx')),
@@ -53,6 +56,7 @@ const docModules: Record> = {
},
misc: {
'mosaic-provider': dynamic(() => import('../stories/mosaic-provider.mdx')),
+ 'user-button': dynamic(() => import('../stories/user-button.mdx')),
'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')),
'create-organization': dynamic(() => import('../stories/create-organization.mdx')),
'organization-list': dynamic(() => import('../stories/organization-list.mdx')),
diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts
index f551d0e8951..96e9c72eaff 100644
--- a/packages/swingset/src/lib/live-navigation.ts
+++ b/packages/swingset/src/lib/live-navigation.ts
@@ -15,6 +15,7 @@ type LiveSidebarGroup = {
};
const livePages = new Set([
+ '/profile/profile',
'/user-profile/user-profile',
'/user-profile/user-profile-profile-panel',
'/user-profile/user-profile-security-panel',
@@ -48,7 +49,7 @@ const livePages = new Set([
'/misc/sign-in-with-metamask-button',
'/misc/sign-up-button',
'/misc/sign-out-button',
- '/user-button/user-button',
+ '/switcher/switcher',
]);
const inventoriedGroups = new Set(['User Profile', 'Organization Profile']);
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 2c05aa64f75..c23edf58155 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -260,6 +260,7 @@ import {
meta as profileComponentMeta,
Transitions as ProfileTransitions,
} from '../stories/profile.component.stories';
+import { Default as ProfileFeatureDefault, meta as profileFeatureMeta } from '../stories/profile.stories';
import {
Default as ProviderButtonDefault,
Many as ProviderButtonMany,
@@ -398,6 +399,7 @@ import {
States as SwitchStates,
WithField as SwitchWithField,
} from '../stories/switch.stories';
+import { Combined as SwitcherCombined, meta as switcherMeta } from '../stories/switcher.stories';
import { meta as tablePrimitiveMeta } from '../stories/table.primitive.stories';
import {
Default as TableDefault,
@@ -458,11 +460,7 @@ import {
Shapes as UserAvatarShapes,
Sizes as UserAvatarSizes,
} from '../stories/user-avatar.stories';
-import {
- Combined as UserButtonCombined,
- meta as userButtonMeta,
- User as UserButtonUser,
-} from '../stories/user-button.stories';
+import { Default as UserButtonDefault, meta as userButtonMeta } from '../stories/user-button.stories';
import {
Default as UserProfileDefault,
meta as userProfileMeta,
@@ -811,10 +809,14 @@ const itemModule: StoryModule = {
Group: ItemGroup,
};
+const switcherModule: StoryModule = {
+ meta: switcherMeta,
+ Combined: SwitcherCombined,
+};
+
const userButtonModule: StoryModule = {
meta: userButtonMeta,
- Combined: UserButtonCombined,
- User: UserButtonUser,
+ Default: UserButtonDefault,
};
const taskSetupMfaModule: StoryModule = {
@@ -1074,6 +1076,11 @@ const userProfileApiKeysPanelModule: StoryModule = {
Default: UserProfileApiKeysPanelDefault,
Empty: UserProfileApiKeysPanelEmpty,
};
+const profileFeatureModule: StoryModule = {
+ meta: profileFeatureMeta,
+ Default: ProfileFeatureDefault,
+};
+
const userProfileModule: StoryModule = {
meta: userProfileMeta,
Default: UserProfileDefault,
@@ -1277,8 +1284,10 @@ const organizationProfileMembersPanelModule: StoryModule = {
};
export const registry: StoryModule[] = [
- // User Button
- userButtonModule,
+ // Switcher
+ switcherModule,
+ // Profile
+ profileFeatureModule,
// User Profile
userProfileModule,
// User Profile · Panels
@@ -1312,6 +1321,7 @@ export const registry: StoryModule[] = [
organizationProfileDangerSectionModule,
// Misc
{ meta: mosaicProviderMeta },
+ userButtonModule,
organizationSwitcherModule,
createOrganizationModule,
organizationListModule,
diff --git a/packages/swingset/src/stories/mosaic-provider.mdx b/packages/swingset/src/stories/mosaic-provider.mdx
index 2e0ef5bbb75..1b6ac9d5f35 100644
--- a/packages/swingset/src/stories/mosaic-provider.mdx
+++ b/packages/swingset/src/stories/mosaic-provider.mdx
@@ -9,10 +9,10 @@ components open.
Wrap the part of your app that uses Mosaic. Every prop is optional.
```tsx
-import { MosaicProvider, UserButton } from '@clerk/mosaic';
+import { Switcher, MosaicProvider } from '@clerk/mosaic';
-
+ ;
```
@@ -29,13 +29,13 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';
## Experimental
-| Option | Type | Default | Description |
-| ------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| `newProfiles` | `boolean` | `false` | `UserButton` and `OrganizationSwitcher` open Mosaic's own `UserProfile` and `OrganizationProfile` instead of Clerk's modals. |
+| Option | Type | Default | Description |
+| ------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `newProfiles` | `boolean` | `false` | `Switcher`, `UserButton` and `OrganizationSwitcher` open Mosaic's own `UserProfile` and `OrganizationProfile` instead of Clerk's modals. |
## Profile
-`profile` configures the profile that `UserButton`, `OrganizationSwitcher`, `UserProfile` and
+`profile` configures the profile that `Switcher`, `UserButton`, `OrganizationSwitcher`, `UserProfile` and
`OrganizationProfile` share. `UserProfile` and `OrganizationProfile` use it for any prop they don't set
themselves.
@@ -50,19 +50,21 @@ themselves.
}}
>
-
+
```
-`combined` on `MosaicProvider` decides what the profile shows, and `combined` on `UserButton` decides what
-its menu lists.
+The button you pick (`Switcher`, `UserButton` or `OrganizationSwitcher`) decides what its menu lists and
+which profile it opens. `combined` on `MosaicProvider` decides whether a combined `Switcher` opens one
+profile or two, and what a `` without a `mode` shows.
-`profile.combined` takes effect only with `experimental.newProfiles`. Without it, `UserButton` and
-`OrganizationSwitcher` open Clerk's modals.
+For `Switcher`, `UserButton` and `OrganizationSwitcher`, `profile.combined` takes effect only with
+`experimental.newProfiles`. Without it, they open Clerk's modals. A `` without a `mode` follows
+`profile.combined` either way.
| Option | Type | Default | Description |
| -------------------------- | -------------------------- | ------- | --------------------------------------------------------------------------------- |
-| `combined` | `boolean` | `true` | Whether one profile carries the account's pages and the active organization's. |
+| `combined` | `boolean` | `true` | Whether a combined menu, or a `` without a `mode`, shows both groups. |
| `userProfileUrl` | `string` | — | Your own user profile page. Setting it routes there instead of opening the modal. |
| `organizationProfileUrl` | `string` | — | Your own organization profile page. Setting it routes there instead of the modal. |
| `userProfileProps` | `ProfileUserProps` | — | Options for the user profile. |
diff --git a/packages/swingset/src/stories/organization-switcher.mdx b/packages/swingset/src/stories/organization-switcher.mdx
index 452d2872665..ce7bd6a2f19 100644
--- a/packages/swingset/src/stories/organization-switcher.mdx
+++ b/packages/swingset/src/stories/organization-switcher.mdx
@@ -5,12 +5,13 @@ import * as OrganizationSwitcherStories from './organization-switcher.stories';
The organization switcher on its own. The active organization heads the surface. The account's
organizations sit under it: every one it belongs to, plus **suggested** and **invited** ones it can
Join, with **Add organization** at their foot. **Settings** and **Invite members** follow them. There are no account rows;
-for those, use `UserButton`.
+for those, use `UserButton` or `Switcher`. It is ``, and
+takes the same props minus the account options and `triggerVariant`.
It renders nothing while Clerk loads, when nobody is signed in, and when the instance has organizations
turned off.
-It shares its view with `UserButton`, so the parts, loading state, and behavior are the same. Only the
+It shares its view with [`Switcher`](/switcher/switcher), so the parts, loading state, and behavior are the same. Only the
surface differs: its own `.cl-organization-switcher-*` classes and its own `organizationSwitcher`
localization namespace.
@@ -35,7 +36,7 @@ The avatar and name of the active organization. Where none is active, the accoun
`showTriggerLabel={false}` leaves the avatar alone.
-`triggerBadge` also supports supplying an async function to determine the badge shown alongside the trigger. The function receives an object with `kind` and `id`, and returns an object with `id` and `label`, or `null` to hide the badge. See the [UserButton trigger example](./user-button#trigger).
+`triggerBadge` also supports supplying an async function to determine the badge shown alongside the trigger. The function receives an object with `kind` and `id`, and returns an object with `id` and `label`, or `null` to hide the badge. See the [Switcher trigger example](/switcher/switcher#trigger).
diff --git a/packages/swingset/src/stories/profile.stories.tsx b/packages/swingset/src/stories/profile.stories.tsx
new file mode 100644
index 00000000000..5b32aaeaab5
--- /dev/null
+++ b/packages/swingset/src/stories/profile.stories.tsx
@@ -0,0 +1,30 @@
+import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view';
+
+import type { StoryMeta } from '@/lib/types';
+
+import { useOrganizationProfileFixture } from './fixtures/organization-profile';
+import { useUserProfileFixture } from './fixtures/user-profile';
+
+export { default as __source } from './profile.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Profile',
+ status: 'wip',
+ title: 'Profile',
+ layout: 'wide',
+ source: 'packages/mosaic/src/features/profile/profile.tsx',
+};
+
+export function Default() {
+ const { activePage, setActivePage, pages } = useUserProfileFixture();
+ const organization = useOrganizationProfileFixture();
+ return (
+
+ );
+}
diff --git a/packages/swingset/src/stories/switcher.mdx b/packages/swingset/src/stories/switcher.mdx
new file mode 100644
index 00000000000..8fee7bbe1f3
--- /dev/null
+++ b/packages/swingset/src/stories/switcher.mdx
@@ -0,0 +1,306 @@
+import * as SwitcherStories from './switcher.stories';
+
+# Switcher
+
+The account & organization switcher behind the user avatar. The active organization heads the surface,
+badged with the account. The account's organizations sit under it: every one it belongs to, plus
+**suggested** and **invited** ones it can Join. **Settings** and **Invite members** follow them, and
+**Switch account** at the foot opens the signed-in
+accounts as a flyout — the active one checked — so an account never reads as an organization. The
+flyout also adds an account and signs out of all of them. With nobody to switch to, that row is
+**Add account** instead. The foot signs out of the active account. `mode` narrows all of it;
+see [Modes](#modes). [`UserButton`](/misc/user-button) and
+[`OrganizationSwitcher`](/misc/organization-switcher) are the narrowed modes under their own names.
+
+Only the active account's organizations are listed. Not a design choice: org requests are scoped to
+the session that makes them, so another account's organizations are unknowable until it _is_ active.
+Hence `additionalSessions` carries sessions alone, and `onSelectOrganization` takes no session.
+
+Composed from `Popover`, `Card`, `Item`, `Avatar`, and `Menu`, so behavior, focus, and ARIA come from
+them rather than being restyled. `SwitcherView` is presentational; the connected `Switcher` wraps
+it with `useSwitcherController()` and needs no props.
+
+## Example
+
+A working prototype over mock accounts — try it. Nothing is instant: the clicked row spins, the rest
+stand down, and the surface stays open so you land on the result. Only picking an organization closes it.
+`pendingKey` drives that, and the connected `Switcher` sets it from the action in flight. Actions
+that would navigate (Settings, Invite members, Add organization, Add account) only close the popover here.
+
+
+
+## Usage
+
+`SwitcherView` renders trigger and popup from one prop-driven call:
+
+```tsx
+import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view';
+
+ setActive({ organization })}
+ onSwitchSession={session => setActive({ session })}
+ onSignOutAll={() => signOut()}
+/>
+```
+
+To drop the popup into your own trigger, compose the parts. Data and callbacks live on
+`SwitcherRoot`; the leaves read them from context:
+
+```tsx
+import {
+ SwitcherRoot,
+ SwitcherTrigger,
+ SwitcherPopup,
+} from '@clerk/mosaic/features/switcher/switcher.view';
+
+
+
+
+
+```
+
+Exports are flat (not `Switcher.Trigger`) so each part declares its own `'use client'` boundary.
+
+## Trigger
+
+The avatar and name of whatever leads the popup. With an organization active in `combined`, the
+trigger leads with the organization, badged with the account.
+
+`triggerVariant='condensed'` makes the trigger smaller and drops the account's badge. It applies to
+`combined` only; the other modes are always condensed.
+
+
+
+`showTriggerLabel={false}` leaves the avatar alone. A user's avatar on its own rounds the trigger
+fully to match it; see [UserButton](/misc/user-button).
+
+
+
+`triggerBadge={false}` keeps the name. The badge is part of the label, so it needs both.
+
+
+
+`triggerBadge` also supports supplying an async function to determine the badge shown alongside the trigger. The function receives an object with `kind` and `id`, and returns an object with `id` and `label`, or `null` to hide the badge. The returned `label` is used as the text content of the label while the returned `id` is embedded into a `data-badge-id` attribute that can then be used to target specific badge types (such as with CSS).
+
+```tsx
+ {
+ const plan = await (kind === "organization" ? getOrganizationPlan(id) : getUserPlan(id));
+ if (plan) {
+ return { id: plan.key, label: plan.name };
+ }
+
+ return null;
+ }}
+/>
+```
+
+
+
+## Header
+
+The header names whatever leads, and carries no actions of its own. An organization is described by
+its plan and member count. An account is described by its identifier, and so is an organization the
+account badges.
+
+What you can do about the lead sits in its own group, under the organizations: **Settings**, plus
+**Invite members** where an organization leads. With an organization active in `combined`,
+**Settings** opens a menu of **Manage profile** and **Manage organization**. Where only one of them is
+available, **Settings** opens it directly.
+
+## Modes
+
+`mode` picks which switchers the surface carries — the only prop that changes the popup's shape.
+Everything else is data. Each mode takes only the props that apply to it, so TypeScript flags
+`triggerVariant` outside `combined`, organization options on `user`, and account options on
+`organization`.
+
+| Mode | Popup | Leads with | Same as |
+| -------------------- | --------------------------------------- | ---------------------------------------- | -------------------------- |
+| `combined` (default) | Organizations and accounts together. | The active org, badged with the account. | |
+| `user` | Accounts only; never shows an org. | The account. | `` |
+| `organization` | Organizations and the personal account. | The active org, or the account. | `` |
+
+```tsx
+import { Switcher, OrganizationSwitcher, UserButton } from '@clerk/mosaic';
+
+
+ // or
+ // or
+```
+
+What a mode leads with is named in the trigger and heads the popup — the two always agree.
+
+### Combined
+
+The active org heads it, badged with the account. The organizations sit under it, with **Add
+organization** at their foot, then **Settings** and **Invite members**. **Switch account** opens the signed-in
+accounts, with **Add account** and **Sign out of all accounts**, and **Sign out** at the foot signs
+out of the active account.
+
+
+
+
+
+With one account signed in, the foot is **Add account** and **Sign out**.
+
+
+
+### User and organization only
+
+`mode='user'` and `mode='organization'` are documented on their own pages:
+[UserButton](/misc/user-button) and [OrganizationSwitcher](/misc/organization-switcher).
+
+## Loading
+
+Memberships, invitations and suggestions are three separate requests, and the popup opens before any
+of them land. `organizationsLoading` holds the organization list's place until the last one is in — one
+placeholder for all three, so the list does not walk itself in a row at a time. The header is
+already known, so it renders throughout.
+
+Reopen the popup to watch it again.
+
+
+
+## Single session
+
+An instance in single-session mode holds one account at a time, so there is nothing to switch to and
+no second account to add. It hands the view an empty `additionalSessions` and leaves `onAddAccount`
+and `onSignOutAll` off — the surface withholds **Switch account**, **Add account**, and **Sign out of
+all accounts**. The foot keeps **Sign out**.
+
+
+
+## Profile modal
+
+Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a
+dialog. With `experimental={{ newProfiles: true }}` on `MosaicProvider`, the exported Switcher opens
+Mosaic's `UserProfile` the same way, against the signed-in user; without it, it opens Clerk's profile.
+This example backs the modal with fixture data, so it runs without a Clerk session. The profile is
+combined: the active organization's pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls
+`open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or
+`open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a
+wait.
+
+
+
+## Menu items
+
+`customMenuItems` adds the app's own rows to the group with **Settings** and **Invite members**, after
+Clerk's own. A row with `onClick` is an action and closes the popup on the way out; a row with `href`
+is a link and navigates on its own, so it stays followable while another action is in flight. The
+`icon` is optional — a row without one still holds the column, so labels stay in line. A custom item
+whose `id` is `settings` or `inviteMembers` replaces that row, in its place.
+
+`menuItemOrder` names that group's rows by id, and whatever it leaves out follows what it named. The
+ids it knows are `settings`, `inviteMembers`, and each custom item's `id`. The organizations above
+and the account rows at the foot keep their own order. An id the group has not got is ignored rather
+than an error, so one order can cover every mode.
+
+
+
+## Parts
+
+| Part | Description |
+| ----------------- | -------------------------------------------------------------------------------- |
+| `SwitcherRoot` | Owns the data, callbacks, and `mode`; forwards open state to `Popover.Root`. |
+| `SwitcherTrigger` | The trigger: the active account or organization's avatar, and what it is called. |
+| `SwitcherPopup` | The popover surface: header, organization list, Settings group, footer. |
+
+## Styling
+
+| Slot | Attributes |
+| ------------------------------------ | --------------------------------------- |
+| `.cl-user-button-trigger` | |
+| `.cl-user-button-popover` | |
+| `.cl-user-button-header` | |
+| `.cl-user-button-header-content` | |
+| `.cl-user-button-header-title` | |
+| `.cl-user-button-header-description` | |
+| `.cl-user-button-avatar` | |
+| `.cl-user-button-avatar-badge` | |
+| `.cl-user-button-group` | |
+| `.cl-user-button-separator` | |
+| `.cl-user-button-item` | `data-interactive` |
+| `.cl-user-button-item-media` | |
+| `.cl-user-button-item-content` | |
+| `.cl-user-button-item-label` | `data-variant="default \| interactive"` |
+| `.cl-user-button-item-description` | |
+| `.cl-user-button-item-trailing` | |
+
+### Avatar
+
+`.cl-user-button-avatar` holds the account's badge on the organization's avatar. The cutout behind
+the badge follows its size, so resizing either keeps the gap even. Set `width` and `height` on
+`.cl-user-button-avatar` to resize it, and these variables on it to place the badge:
+
+| Variable | Default | Description |
+| -------------------------- | ----------------------------- | --------------------------------------------------------------- |
+| `--cl-avatar-badge-size` | `14px` trigger, `16px` header | The badge's width and height. |
+| `--cl-avatar-badge-gap` | `1px` | The space cut out around the badge. |
+| `--cl-avatar-badge-offset` | `2px` trigger, `4px` header | How far the badge sits past the corner of the avatar it badges. |
+
+```css
+.cl-user-button-avatar {
+ width: 48px;
+ height: 48px;
+ --cl-avatar-badge-size: 20px;
+ --cl-avatar-badge-gap: 2px;
+}
+```
+
+The trigger's avatar and the header's both read the sliders. A slider left at **default** sets
+nothing. Swingset injects its styles at a specificity a plain rule cannot beat, so this example marks
+the size `!important`; an app importing the layered stylesheet does not need to. Turn on **RTL** to
+see the badge move to the other corner.
+
+
diff --git a/packages/swingset/src/stories/switcher.stories.tsx b/packages/swingset/src/stories/switcher.stories.tsx
new file mode 100644
index 00000000000..ba1e5eceabd
--- /dev/null
+++ b/packages/swingset/src/stories/switcher.stories.tsx
@@ -0,0 +1,368 @@
+import { Button } from '@clerk/mosaic/components/button';
+import { Icon } from '@clerk/mosaic/components/icon';
+import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view';
+import { BadgedAvatar } from '@clerk/mosaic/features/switcher/switcher-avatar.view';
+import type {
+ UserProfileModalContentProps,
+ UserProfileModalOptions,
+} from '@clerk/mosaic/features/user-profile/user-profile.modal.types';
+import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view';
+import { Cog6Teeth } from '@clerk/mosaic/icons/glyphs/cog-6-teeth';
+import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface';
+import type { ModalSurfaces } from '@clerk/mosaic/modals/modal.types';
+import { ModalHost } from '@clerk/mosaic/modals/modal-host';
+import { useModal } from '@clerk/mosaic/modals/use-modal';
+import { useEffect, useState } from 'react';
+
+import type { StoryMeta } from '@/lib/types';
+
+import { useOrganizationProfileFixture } from './fixtures/organization-profile';
+import { nestLabsWithClerkApp, usePrototype } from './fixtures/switcher';
+import { useUserProfileFixture } from './fixtures/user-profile';
+
+// 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 './switcher.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Switcher',
+ title: 'Switcher',
+ label: 'Switcher',
+ status: 'stable',
+ source: 'packages/mosaic/src/features/switcher/switcher.tsx',
+};
+
+// ─── Mode: Combined ─────────────────────────────────────────────────────────
+
+export function Combined(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+
+ return (
+
+ );
+}
+
+export function CombinedCondensedTrigger(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+
+ return (
+
+ );
+}
+
+export function CombinedIconTrigger(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+
+ return (
+
+ );
+}
+
+export function CombinedSingleAccount(_args: Record) {
+ const prototype = usePrototype({ organizations: nestLabsWithClerkApp, singleSession: true, hidePersonal: true });
+
+ return (
+
+ );
+}
+
+// ─── Beyond the frames ──────────────────────────────────────────────────────
+
+export function WithoutTriggerBadge(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+
+ return (
+
+ );
+}
+
+export function CustomTriggerBadge(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+
+ return (
+ Promise.resolve({ id: 'enterprise', label: 'Enterprise plan' })}
+ />
+ );
+}
+
+const avatarControls = [
+ { key: 'size', label: 'Avatar size', min: 24, max: 96, initial: 42 },
+ { key: 'badgeSize', label: '--cl-avatar-badge-size', min: 8, max: 40, initial: 16 },
+ { key: 'badgeGap', label: '--cl-avatar-badge-gap', min: 0, max: 8, initial: 1 },
+ { key: 'badgeOffset', label: '--cl-avatar-badge-offset', min: -8, max: 16, initial: 4 },
+] as const;
+
+type AvatarControl = (typeof avatarControls)[number]['key'];
+
+export function CustomizedAvatar(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+ const [values, setValues] = useState>>({});
+ const { activeOrganization, activeSession } = prototype;
+
+ const declarations = [
+ values.size === undefined ? '' : `width: ${values.size}px !important; height: ${values.size}px !important;`,
+ values.badgeSize === undefined ? '' : `--cl-avatar-badge-size: ${values.badgeSize}px;`,
+ values.badgeGap === undefined ? '' : `--cl-avatar-badge-gap: ${values.badgeGap}px;`,
+ values.badgeOffset === undefined ? '' : `--cl-avatar-badge-offset: ${values.badgeOffset}px;`,
+ ].join(' ');
+
+ return (
+
+ );
+}
+
+export function SingleSession(_args: Record) {
+ const prototype = usePrototype({ singleSession: true, hidePersonal: true });
+
+ // What an instance in single-session mode hands the view: one account, and neither of the two
+ // actions that only make sense with a second one. The foot signs out of just that account.
+ return (
+
+ );
+}
+
+export function CustomMenuItems(_args: Record) {
+ const prototype = usePrototype({ singleSession: true, hidePersonal: true });
+
+ // The app's own rows join Settings, and `menuItemOrder` puts them wherever it names them. Ids for
+ // rows the surface does not carry are ignored, so one order can cover every mode.
+ return (
+
+ ),
+ onClick: () => {},
+ },
+ // `icon` takes any node, so an app brings its own glyph rather than picking from Mosaic's set.
+ {
+ id: 'docs',
+ label: 'Documentation',
+ icon: (
+
+ ),
+ href: 'https://clerk.com/docs',
+ },
+ ]}
+ menuItemOrder={['docs', 'settings', 'inviteMembers', 'app-settings']}
+ />
+ );
+}
+
+const integrationsPage = {
+ label: 'Integrations',
+ path: 'integrations',
+ content:
An app-provided page, passed to the profile modal as a custom page.
+ );
+}
+
+// Longer than the action latency above: this one is there to be caught, not to be got past.
+const ORGANIZATIONS_LATENCY_MS = 2500;
+
+/**
+ * The organization list's first page landing under a popup that is already open. The other examples
+ * are handed their organizations on the first render, so the placeholder they would show has
+ * nothing to show it. The wait restarts on every open, so it can be watched more than once.
+ */
+export function LoadingOrganizations(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+ const [organizationsLoading, setOrganizationsLoading] = useState(false);
+
+ useEffect(() => {
+ if (!prototype.open) {
+ return;
+ }
+ // eslint-disable-next-line react/set-state-in-effect -- Simulate a new organization request each time the popup opens
+ setOrganizationsLoading(true);
+ const timer = setTimeout(() => setOrganizationsLoading(false), ORGANIZATIONS_LATENCY_MS);
+ return () => clearTimeout(timer);
+ }, [prototype.open]);
+
+ return (
+
+ );
+}
diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx
index 104bec6830d..7135e25638d 100644
--- a/packages/swingset/src/stories/user-button.mdx
+++ b/packages/swingset/src/stories/user-button.mdx
@@ -2,310 +2,51 @@ import * as UserButtonStories from './user-button.stories';
# UserButton
-The account & organization switcher behind the user avatar. The active organization heads the surface,
-badged with the account. The account's organizations sit under it: every one it belongs to, plus
-**suggested** and **invited** ones it can Join. **Settings** and **Invite members** follow them, and
-**Switch account** at the foot opens the signed-in
-accounts as a flyout — the active one checked — so an account never reads as an organization. The
-flyout also adds an account and signs out of all of them. With nobody to switch to, that row is
-**Add account** instead. The foot signs out of the active account. `mode` narrows all of it;
-see [Modes](#modes). For organizations alone, use `OrganizationSwitcher`.
+The account menu on its own. The active account heads the surface. **Switch account** opens the
+signed-in accounts as a flyout, with the active one checked, and also adds an account and signs out
+of all of them. With nobody to switch to, that row is **Add account** instead. **Settings** opens the
+profile, and the foot signs out of the active account. It never lists an organization, even when one
+is active; for those, use `OrganizationSwitcher` or `Switcher`. It is
+``, and takes the same props minus the organization options and
+`triggerVariant`.
-Only the active account's organizations are listed. Not a design choice: org requests are scoped to
-the session that makes them, so another account's organizations are unknowable until it _is_ active.
-Hence `additionalSessions` carries sessions alone, and `onSelectOrganization` takes no session.
+It renders `fallback` while Clerk loads, and nothing when nobody is signed in.
-Composed from `Popover`, `Card`, `Item`, `Avatar`, and `Menu`, so behavior, focus, and ARIA come from
-them rather than being restyled. `SwitcherView` is presentational; the connected `UserButton` wraps
-it with `useSwitcherController()` and needs no props.
+It shares its view with [`Switcher`](/switcher/switcher), so the parts, loading
+state, behavior, `.cl-user-button-*` classes and `userButton` localization namespace are the same.
## Example
-A working prototype over mock accounts — try it. Nothing is instant: the clicked row spins, the rest
-stand down, and the surface stays open so you land on the result. Only picking an organization closes it.
-`pendingKey` drives that, and the connected `UserButton` sets it from the action in flight. Actions
-that would navigate (Settings, Invite members, Add organization, Add account) only close the popover here.
-
## Usage
-`SwitcherView` renders trigger and popup from one prop-driven call:
-
```tsx
-import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view';
+import { UserButton } from '@clerk/mosaic';
- setActive({ organization })}
- onSwitchSession={session => setActive({ session })}
- onSignOutAll={() => signOut()}
-/>
+;
```
-To drop the popup into your own trigger, compose the parts. Data and callbacks live on
-`SwitcherRoot`; the leaves read them from context:
-
-```tsx
-import {
- SwitcherRoot,
- SwitcherTrigger,
- SwitcherPopup,
-} from '@clerk/mosaic/features/switcher/switcher.view';
-
-
-
-
-
-```
-
-Exports are flat (not `UserButton.Trigger`) so each part declares its own `'use client'` boundary.
-
## Trigger
-The avatar and name of whatever leads the popup. With an organization active in `combined`, the
-trigger leads with the organization, badged with the account.
-
-`triggerVariant='condensed'` makes the trigger smaller and drops the account's badge. `user` mode is
-always condensed.
-
-
-
-`showTriggerLabel={false}` leaves the avatar alone. A user's avatar on its own rounds the trigger
-fully to match it; see [User only](#user-only).
-
-
-
-`triggerBadge={false}` keeps the name. The badge is part of the label, so it needs both.
-
-
-
-`triggerBadge` also supports supplying an async function to determine the badge shown alongside the trigger. The function receives an object with `kind` and `id`, and returns an object with `id` and `label`, or `null` to hide the badge. The returned `label` is used as the text content of the label while the returned `id` is embedded into a `data-badge-id` attribute that can then be used to target specific badge types (such as with CSS).
-
-```tsx
- {
- const plan = await (kind === "organization" ? getOrganizationPlan(id) : getUserPlan(id));
- if (plan) {
- return { id: plan.key, label: plan.name };
- }
-
- return null;
- }}
-/>
-```
-
-
-
-## Header
-
-The header names whatever leads, and carries no actions of its own. An organization is described by
-its plan and member count. An account is described by its identifier, and so is an organization the
-account badges.
-
-What you can do about the lead sits in its own group, under the organizations: **Settings**, plus
-**Invite members** where an organization leads. With an organization active in `combined`,
-**Settings** opens a menu of **Manage profile** and **Manage organization**. Where only one of them is
-available, **Settings** opens it directly.
-
-## Modes
-
-`mode` picks which switchers the surface carries — the only prop that changes the popup's shape.
-Everything else is data.
-
-| Mode | Popup | Leads with |
-| -------------------- | ------------------------------------ | ---------------------------------------- |
-| `combined` (default) | Organizations and accounts together. | The active org, badged with the account. |
-| `user` | Accounts only; never shows an org. | The account. |
-
-What a mode leads with is named in the trigger and heads the popup — the two always agree.
-
-### Combined
-
-The active org heads it, badged with the account. The organizations sit under it, with **Add
-organization** at their foot, then **Settings** and **Invite members**. **Switch account** opens the signed-in
-accounts, with **Add account** and **Sign out of all accounts**, and **Sign out** at the foot signs
-out of the active account.
-
-
-
-
-
-With one account signed in, the foot is **Add account** and **Sign out**.
-
-
-
-### User only
-
-The account heads it. No organizations are listed, so **Settings**, then **Switch account** and
-**Sign out** at the foot, are the whole of it.
-
-
-
-
-
-With one account signed in, the foot is **Add account** and **Sign out**.
-
-
-
-## Loading
-
-Memberships, invitations and suggestions are three separate requests, and the popup opens before any
-of them land. `organizationsLoading` holds the organization list's place until the last one is in — one
-placeholder for all three, so the list does not walk itself in a row at a time. The header is
-already known, so it renders throughout.
-
-Reopen the popup to watch it again.
-
-
-
-## Single session
-
-An instance in single-session mode holds one account at a time, so there is nothing to switch to and
-no second account to add. It hands the view an empty `additionalSessions` and leaves `onAddAccount`
-and `onSignOutAll` off — the surface withholds **Switch account**, **Add account**, and **Sign out of
-all accounts**. The foot keeps **Sign out**.
+The trigger is always condensed: the account's avatar and name. `showTriggerLabel={false}` leaves
+the avatar alone, and rounds the trigger fully to match it.
-## Profile modal
-
-Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a
-dialog. With `experimental={{ newProfiles: true }}` on `MosaicProvider`, the exported UserButton opens
-Mosaic's `UserProfile` the same way, against the signed-in user; without it, it opens Clerk's profile.
-This example backs the modal with fixture data, so it runs without a Clerk session. The profile is
-combined: the active organization's pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls
-`open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or
-`open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a
-wait.
+With a single session, the flyout gives way to **Add account**.
## Menu items
-`customMenuItems` adds the app's own rows to the group with **Settings** and **Invite members**, after
-Clerk's own. A row with `onClick` is an action and closes the popup on the way out; a row with `href`
-is a link and navigates on its own, so it stays followable while another action is in flight. The
-`icon` is optional — a row without one still holds the column, so labels stay in line. A custom item
-whose `id` is `settings` or `inviteMembers` replaces that row, in its place.
-
-`menuItemOrder` names that group's rows by id, and whatever it leaves out follows what it named. The
-ids it knows are `settings`, `inviteMembers`, and each custom item's `id`. The organizations above
-and the account rows at the foot keep their own order. An id the group has not got is ignored rather
-than an error, so one order can cover every mode.
-
-
-
-## Parts
-
-| Part | Description |
-| ----------------- | -------------------------------------------------------------------------------- |
-| `SwitcherRoot` | Owns the data, callbacks, and `mode`; forwards open state to `Popover.Root`. |
-| `SwitcherTrigger` | The trigger: the active account or organization's avatar, and what it is called. |
-| `SwitcherPopup` | The popover surface: header, organization list, Settings group, footer. |
-
-## Styling
-
-| Slot | Attributes |
-| ------------------------------------ | --------------------------------------- |
-| `.cl-user-button-trigger` | |
-| `.cl-user-button-popover` | |
-| `.cl-user-button-header` | |
-| `.cl-user-button-header-content` | |
-| `.cl-user-button-header-title` | |
-| `.cl-user-button-header-description` | |
-| `.cl-user-button-avatar` | |
-| `.cl-user-button-avatar-badge` | |
-| `.cl-user-button-group` | |
-| `.cl-user-button-separator` | |
-| `.cl-user-button-item` | `data-interactive` |
-| `.cl-user-button-item-media` | |
-| `.cl-user-button-item-content` | |
-| `.cl-user-button-item-label` | `data-variant="default \| interactive"` |
-| `.cl-user-button-item-description` | |
-| `.cl-user-button-item-trailing` | |
-
-### Avatar
-
-`.cl-user-button-avatar` holds the account's badge on the organization's avatar. The cutout behind
-the badge follows its size, so resizing either keeps the gap even. Set `width` and `height` on
-`.cl-user-button-avatar` to resize it, and these variables on it to place the badge:
-
-| Variable | Default | Description |
-| -------------------------- | ----------------------------- | --------------------------------------------------------------- |
-| `--cl-avatar-badge-size` | `14px` trigger, `16px` header | The badge's width and height. |
-| `--cl-avatar-badge-gap` | `1px` | The space cut out around the badge. |
-| `--cl-avatar-badge-offset` | `2px` trigger, `4px` header | How far the badge sits past the corner of the avatar it badges. |
-
-```css
-.cl-user-button-avatar {
- width: 48px;
- height: 48px;
- --cl-avatar-badge-size: 20px;
- --cl-avatar-badge-gap: 2px;
-}
-```
-
-The trigger's avatar and the header's both read the sliders. A slider left at **default** sets
-nothing. Swingset injects its styles at a specificity a plain rule cannot beat, so this example marks
-the size `!important`; an app importing the layered stylesheet does not need to. Turn on **RTL** to
-see the badge move to the other corner.
-
-
+`customMenuItems` and `menuItemOrder` work as they do on `Switcher`.
diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx
index 14e6e6b6de1..7727943c7c0 100644
--- a/packages/swingset/src/stories/user-button.stories.tsx
+++ b/packages/swingset/src/stories/user-button.stories.tsx
@@ -1,89 +1,20 @@
-import { Button } from '@clerk/mosaic/components/button';
-import { Icon } from '@clerk/mosaic/components/icon';
import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view';
-import { BadgedAvatar } from '@clerk/mosaic/features/switcher/switcher-avatar.view';
-import type {
- UserProfileModalContentProps,
- UserProfileModalOptions,
-} from '@clerk/mosaic/features/user-profile/user-profile.modal.types';
-import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view';
-import { Cog6Teeth } from '@clerk/mosaic/icons/glyphs/cog-6-teeth';
-import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface';
-import type { ModalSurfaces } from '@clerk/mosaic/modals/modal.types';
-import { ModalHost } from '@clerk/mosaic/modals/modal-host';
-import { useModal } from '@clerk/mosaic/modals/use-modal';
-import { useEffect, useState } from 'react';
import type { StoryMeta } from '@/lib/types';
-import { useOrganizationProfileFixture } from './fixtures/organization-profile';
-import { nestLabsWithClerkApp, usePrototype } from './fixtures/switcher';
-import { useUserProfileFixture } from './fixtures/user-profile';
+import { usePrototype } from './fixtures/switcher';
-// 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: 'User Button',
+ group: 'Misc',
title: 'UserButton',
label: 'User button',
status: 'stable',
- source: 'packages/mosaic/src/features/switcher/switcher.view.tsx',
+ source: 'packages/mosaic/src/features/user-button/user-button.tsx',
};
-// ─── Mode: Combined ─────────────────────────────────────────────────────────
-
-export function Combined(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
-
- return (
-
- );
-}
-
-export function CombinedCondensedTrigger(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
-
- return (
-
- );
-}
-
-export function CombinedIconTrigger(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
-
- return (
-
- );
-}
-
-export function CombinedSingleAccount(_args: Record) {
- const prototype = usePrototype({ organizations: nestLabsWithClerkApp, singleSession: true, hidePersonal: true });
-
- return (
-
- );
-}
-
-// ─── Mode: User only ────────────────────────────────────────────────────────
-
-export function User(_args: Record) {
+export function Default(_args: Record) {
const prototype = usePrototype();
return (
@@ -94,7 +25,7 @@ export function User(_args: Record) {
);
}
-export function UserAvatarTrigger(_args: Record) {
+export function IconTrigger(_args: Record) {
const prototype = usePrototype();
return (
@@ -106,7 +37,7 @@ export function UserAvatarTrigger(_args: Record) {
);
}
-export function UserAvatarSingleSession(_args: Record) {
+export function IconTriggerSingleSession(_args: Record) {
const prototype = usePrototype({ singleSession: true });
return (
@@ -117,289 +48,3 @@ export function UserAvatarSingleSession(_args: Record) {
/>
);
}
-
-// ─── Beyond the frames ──────────────────────────────────────────────────────
-
-export function WithoutTriggerBadge(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
-
- return (
-
- );
-}
-
-export function CustomTriggerBadge(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
-
- return (
- Promise.resolve({ id: 'enterprise', label: 'Enterprise plan' })}
- />
- );
-}
-
-const avatarControls = [
- { key: 'size', label: 'Avatar size', min: 24, max: 96, initial: 42 },
- { key: 'badgeSize', label: '--cl-avatar-badge-size', min: 8, max: 40, initial: 16 },
- { key: 'badgeGap', label: '--cl-avatar-badge-gap', min: 0, max: 8, initial: 1 },
- { key: 'badgeOffset', label: '--cl-avatar-badge-offset', min: -8, max: 16, initial: 4 },
-] as const;
-
-type AvatarControl = (typeof avatarControls)[number]['key'];
-
-export function CustomizedAvatar(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
- const [values, setValues] = useState>>({});
- const { activeOrganization, activeSession } = prototype;
-
- const declarations = [
- values.size === undefined ? '' : `width: ${values.size}px !important; height: ${values.size}px !important;`,
- values.badgeSize === undefined ? '' : `--cl-avatar-badge-size: ${values.badgeSize}px;`,
- values.badgeGap === undefined ? '' : `--cl-avatar-badge-gap: ${values.badgeGap}px;`,
- values.badgeOffset === undefined ? '' : `--cl-avatar-badge-offset: ${values.badgeOffset}px;`,
- ].join(' ');
-
- return (
-
- );
-}
-
-export function SingleSession(_args: Record) {
- const prototype = usePrototype({ singleSession: true, hidePersonal: true });
-
- // What an instance in single-session mode hands the view: one account, and neither of the two
- // actions that only make sense with a second one. The foot signs out of just that account.
- return (
-
- );
-}
-
-export function CustomMenuItems(_args: Record) {
- const prototype = usePrototype({ singleSession: true, hidePersonal: true });
-
- // The app's own rows join Settings, and `menuItemOrder` puts them wherever it names them. Ids for
- // rows the surface does not carry are ignored, so one order can cover every mode.
- return (
-
- ),
- onClick: () => {},
- },
- // `icon` takes any node, so an app brings its own glyph rather than picking from Mosaic's set.
- {
- id: 'docs',
- label: 'Documentation',
- icon: (
-
- ),
- href: 'https://clerk.com/docs',
- },
- ]}
- menuItemOrder={['docs', 'settings', 'inviteMembers', 'app-settings']}
- />
- );
-}
-
-const integrationsPage = {
- label: 'Integrations',
- path: 'integrations',
- content:
An app-provided page, passed to the profile modal as a custom page.
- );
-}
-
-// Longer than the action latency above: this one is there to be caught, not to be got past.
-const ORGANIZATIONS_LATENCY_MS = 2500;
-
-/**
- * The organization list's first page landing under a popup that is already open. The other examples
- * are handed their organizations on the first render, so the placeholder they would show has
- * nothing to show it. The wait restarts on every open, so it can be watched more than once.
- */
-export function LoadingOrganizations(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true });
- const [organizationsLoading, setOrganizationsLoading] = useState(false);
-
- useEffect(() => {
- if (!prototype.open) {
- return;
- }
- // eslint-disable-next-line react/set-state-in-effect -- Simulate a new organization request each time the popup opens
- setOrganizationsLoading(true);
- const timer = setTimeout(() => setOrganizationsLoading(false), ORGANIZATIONS_LATENCY_MS);
- return () => clearTimeout(timer);
- }, [prototype.open]);
-
- return (
-
- );
-}