From 9994c4521a19ead14fafc0717e70aa77549b7ea7 Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Tue, 10 Nov 2026 13:33:25 -0500
Subject: [PATCH 1/6] feat(mosaic): add Switcher and make UserButton list
accounts only
---
.changeset/mosaic-switcher.md | 15 +
.../src/app/custom/page.tsx | 4 +-
.../src/app/mosaic-user-button.tsx | 4 +-
.../.claude/skills/verify-mosaic/SKILL.md | 2 +-
.../.claude/skills/verify-mosaic/notes.md | 2 +-
packages/mosaic/README.md | 4 +-
.../mosaic/docs/documenting-components.md | 12 +-
packages/mosaic/docs/testing.md | 2 +-
.../organization-switcher.tsx | 13 +-
...switcher-combined-open-chromium-linux.png} | Bin
.../switcher-avatar.feature.test.tsx} | 6 +-
.../__tests__/switcher.feature.test.tsx | 1543 ++++++++++++++++
.../switcher.new-profiles.feature.test.tsx} | 14 +-
.../switcher.profile.feature.test.tsx | 19 +-
.../__tests__/switcher.visual.test.tsx} | 8 +-
.../src/features/switcher/switcher.model.tsx | 5 +-
.../src/features/switcher/switcher.test-d.tsx | 98 ++
.../mosaic/src/features/switcher/switcher.tsx | 120 +-
.../__tests__/user-button.feature.test.tsx | 1548 +----------------
.../src/features/user-button/user-button.tsx | 85 +-
packages/mosaic/src/index.ts | 9 +-
packages/mosaic/src/styles/index.ts | 1 +
packages/swingset/src/app/(clerk)/layout.tsx | 4 +-
.../src/app/(clerk)/live-switcher.tsx | 12 +
.../src/app/(clerk)/live-user-button.tsx | 12 -
.../switcher}/page.tsx | 14 +-
.../swingset/src/components/Composition.tsx | 4 +-
.../swingset/src/components/DocsViewer.tsx | 5 +-
packages/swingset/src/lib/live-navigation.ts | 2 +-
packages/swingset/src/lib/registry.ts | 20 +-
.../swingset/src/stories/mosaic-provider.mdx | 20 +-
.../src/stories/organization-switcher.mdx | 9 +-
packages/swingset/src/stories/switcher.mdx | 306 ++++
.../swingset/src/stories/switcher.stories.tsx | 368 ++++
packages/swingset/src/stories/user-button.mdx | 297 +---
.../src/stories/user-button.stories.tsx | 367 +---
36 files changed, 2616 insertions(+), 2338 deletions(-)
create mode 100644 .changeset/mosaic-switcher.md
rename packages/mosaic/src/features/{user-button/__tests__/__screenshots__/user-button.visual.test.tsx/user-button-combined-open-chromium-linux.png => switcher/__tests__/__screenshots__/switcher.visual.test.tsx/switcher-combined-open-chromium-linux.png} (100%)
rename packages/mosaic/src/features/{user-button/__tests__/user-button-avatar.feature.test.tsx => switcher/__tests__/switcher-avatar.feature.test.tsx} (97%)
create mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.feature.test.tsx
rename packages/mosaic/src/features/{user-button/__tests__/user-button.new-profiles.feature.test.tsx => switcher/__tests__/switcher.new-profiles.feature.test.tsx} (95%)
rename packages/mosaic/src/features/{user-button/__tests__/user-button.visual.test.tsx => switcher/__tests__/switcher.visual.test.tsx} (77%)
create mode 100644 packages/mosaic/src/features/switcher/switcher.test-d.tsx
create mode 100644 packages/swingset/src/app/(clerk)/live-switcher.tsx
delete mode 100644 packages/swingset/src/app/(clerk)/live-user-button.tsx
rename packages/swingset/src/app/(clerk)/live/{user-button/user-button => switcher/switcher}/page.tsx (69%)
create mode 100644 packages/swingset/src/stories/switcher.mdx
create mode 100644 packages/swingset/src/stories/switcher.stories.tsx
diff --git a/.changeset/mosaic-switcher.md b/.changeset/mosaic-switcher.md
new file mode 100644
index 00000000000..b3fe6cf83e0
--- /dev/null
+++ b/.changeset/mosaic-switcher.md
@@ -0,0 +1,15 @@
+---
+'@clerk/mosaic': minor
+---
+
+**Breaking:** Add `Switcher`, the account and organization menu, and make `UserButton` list accounts only. `Switcher` takes `mode`: `'combined'` (the default) lists accounts and organizations, `'user'` lists accounts only, and `'organization'` lists organizations. `` is now `` and `` is ``. Each mode takes only its own props, so TypeScript flags organization options on `UserButton` and `triggerVariant` outside `combined`.
+
+| Before | After |
+| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| `` (accounts and organizations) | `` |
+| `` | `` or `` |
+| `` | `` |
+| Organization props on `UserButton` (`hidePersonal`, `afterSelectOrganizationUrl`, `createOrganizationUrl`, ...) | The same props on `Switcher` |
+| `UserButtonMenuProps` | `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/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..8314f49e846 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,7 @@ 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 |
| `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 +30,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` / `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 +204,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`, `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..70e63af38ca 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
diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
index 0e4ff5630b4..a6c6a8b5f07 100644
--- a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
+++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
@@ -2,24 +2,15 @@
import type { ReactElement } from 'react';
-import type { SwitcherProps } from '../switcher/switcher';
+import type { OrganizationSwitcherProps } from '../switcher/switcher';
import { Switcher } from '../switcher/switcher';
-import type { OrganizationSwitcherModelOptions } from '../switcher/switcher.model';
-import type { SwitcherMenuProps } from '../switcher/switcher.types';
-import type { SwitcherTriggerProps } from '../switcher/switcher.view';
-
-/** Everything `` takes: trigger content, and the app's own menu rows. */
-export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
- SwitcherTriggerProps &
- SwitcherMenuProps &
- Pick;
/**
* The active organization's avatar, and the menu behind it: switch organization or back to the
* personal account, join a suggested or invited one, create one, invite members, and open the
* organization's settings. It reads the active session and organization from Clerk, so it takes no
* data. It renders `fallback` until Clerk answers, and nothing at all when nobody is signed in or the
- * instance has organizations turned off.
+ * instance has organizations turned off. It is ``.
*
* @example
* ```tsx
diff --git a/packages/mosaic/src/features/user-button/__tests__/__screenshots__/user-button.visual.test.tsx/user-button-combined-open-chromium-linux.png b/packages/mosaic/src/features/switcher/__tests__/__screenshots__/switcher.visual.test.tsx/switcher-combined-open-chromium-linux.png
similarity index 100%
rename from packages/mosaic/src/features/user-button/__tests__/__screenshots__/user-button.visual.test.tsx/user-button-combined-open-chromium-linux.png
rename to packages/mosaic/src/features/switcher/__tests__/__screenshots__/switcher.visual.test.tsx/switcher-combined-open-chromium-linux.png
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher-avatar.feature.test.tsx
similarity index 97%
rename from packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx
rename to packages/mosaic/src/features/switcher/__tests__/switcher-avatar.feature.test.tsx
index 04c3d97e258..031326d3780 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx
+++ b/packages/mosaic/src/features/switcher/__tests__/switcher-avatar.feature.test.tsx
@@ -13,7 +13,7 @@ import {
fapiUser,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
-import { UserButton } from '../user-button';
+import { Switcher } from '../switcher';
const GAP = 4;
@@ -27,7 +27,7 @@ const alice = fapiUser({
organization_memberships: memberships,
});
-describe('UserButton badged avatar customization', () => {
+describe('Switcher badged avatar customization', () => {
it('keeps the gap around a resized badge in the trigger', async () => {
customize(`.cl-user-button-avatar { --cl-avatar-badge-size: 24px; --cl-avatar-badge-gap: ${GAP}px; }`);
await renderSignedIn();
@@ -74,7 +74,7 @@ async function renderSignedIn() {
client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: acme.id })]),
memberships,
});
- await renderWithClerk();
+ await renderWithClerk();
}
async function triggerAvatar(): Promise {
diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.feature.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.feature.test.tsx
new file mode 100644
index 00000000000..f52ac6b816a
--- /dev/null
+++ b/packages/mosaic/src/features/switcher/__tests__/switcher.feature.test.tsx
@@ -0,0 +1,1543 @@
+import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types';
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
+
+import { deferred } from '../../../__tests__/async';
+import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEmailAddress,
+ fapiEnvironment,
+ fapiInvitation,
+ fapiMembership,
+ fapiOrganization,
+ fapiPhoneNumber,
+ fapiSession,
+ fapiSuggestion,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { openMenu } from '../../../__tests__/feature/menu';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { PortalProvider } from '../../../primitives/portal';
+import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
+import type { SwitcherProps } from '../switcher';
+import { Switcher } from '../switcher';
+
+const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
+const other = fapiOrganization({ id: 'org_9', name: 'Other' });
+const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });
+const gamma = fapiOrganization({ id: 'org_3', name: 'Gamma' });
+
+const aliceMemberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] }), fapiMembership(other)];
+
+const alice = fapiUser({
+ id: 'user_1',
+ first_name: 'Alice',
+ last_name: 'Smith',
+ username: 'alice',
+ email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })],
+ organization_memberships: aliceMemberships,
+});
+
+const bob = fapiUser({
+ id: 'user_2',
+ first_name: 'Bob',
+ last_name: 'Jones',
+ email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })],
+});
+
+const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' });
+const bobSession = fapiSession({ id: 'sess_2', user: bob });
+
+function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed {
+ return {
+ client: fapiClient([aliceSession, bobSession]),
+ memberships: aliceMemberships,
+ invitations: [fapiInvitation('inv_1', gamma)],
+ suggestions: [fapiSuggestion('sug_1', beta)],
+ ...overrides,
+ };
+}
+
+function tree(props: SwitcherProps = {}) {
+ return (
+
}],
- pageOrder: ['account', 'terms'],
- },
- });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
- const user = await open();
-
- await openSettings(user, 'Manage profile');
- await waitFor(() => expect(popup()).toBeNull());
-
- const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']);
- });
-
- it.each([
- { features: {}, pages: ['Terms', 'account', 'security'] },
- { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] },
- ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => {
- const { clerk } = await renderUserButton({}, signedIn({ environment: instanceWith(features) }), {
- userProfileProps: { customPages: [terms], pageOrder: ['terms'] },
- });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
- const user = await open();
-
- await openSettings(user, 'Manage profile');
- await waitFor(() => expect(popup()).toBeNull());
-
- const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages);
- });
-
- it.each([
- { features: {}, organization: {}, pages: ['Terms', 'general', 'members'] },
- {
- features: { billing: true, apiKeys: true },
- organization: {},
- pages: ['Terms', 'general', 'members', 'billing', 'apiKeys'],
- },
- { features: { sso: true }, organization: {}, pages: ['Terms', 'general', 'members'] },
- {
- features: { sso: true },
- organization: { self_serve_sso_enabled: true },
- pages: ['Terms', 'general', 'members', 'security'],
- },
- {
- features: { sso: true, directorySync: true },
- organization: {},
- pages: ['Terms', 'general', 'members'],
- },
- {
- features: { sso: true, directorySync: true },
- organization: { self_serve_directory_sync_enabled: true },
- pages: ['Terms', 'general', 'members', 'security'],
- },
- ])(
- 'orders the OrganizationProfile pages the instance and organization turn on: $features $organization',
- async ({ features, organization, pages }) => {
- const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization });
- const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })];
- const { clerk } = await renderUserButton(
- {},
- signedIn({
- environment: instanceWith(features),
- client: fapiClient([
- fapiSession({
- id: 'sess_1',
- user: { ...alice, organization_memberships: memberships },
- last_active_organization_id: 'org_1',
- }),
- ]),
- memberships,
- }),
- { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
- );
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
- const user = await open();
-
- await openSettings(user, 'Manage organization');
- await waitFor(() => expect(popup()).toBeNull());
-
- const customPages = openOrganizationProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages);
- },
- );
-
- it('opens the InviteMembers modal and closes', async () => {
- const { clerk, navigate } = await renderUserButton();
- const openInviteMembers = vi.spyOn(clerk, 'openInviteMembers').mockImplementation(() => {});
- const user = await open();
-
- await user.click(screen.getByRole('button', { name: 'Invite members' }));
-
- expect(openInviteMembers).toHaveBeenCalled();
- expect(navigate).not.toHaveBeenCalled();
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('offers no invite to a member who cannot manage memberships', async () => {
- const plain = [fapiMembership(acme), fapiMembership(other)];
- await renderUserButton(
- {},
- signedIn({
- client: fapiClient([fapiSession({ ...aliceSession, user: { ...alice, organization_memberships: plain } })]),
- memberships: plain,
- }),
- );
- await open();
-
- expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull();
- });
-
- it('opens the CreateOrganization modal and closes', async () => {
- const { clerk, navigate } = await renderUserButton();
- const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {});
- const user = await open();
-
- await user.click(await screen.findByRole('button', { name: 'Add organization' }));
-
- expect(openCreateOrganization).toHaveBeenCalled();
- expect(navigate).not.toHaveBeenCalled();
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('hands the CreateOrganization modal the portal root the app provides', async () => {
- const container = document.body.appendChild(document.createElement('div'));
- onTestFinished(() => container.remove());
- serveFapi(signedIn());
- const { clerk } = await renderWithClerk({tree()});
- const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {});
- const user = await open();
-
- await user.click(await screen.findByRole('button', { name: 'Add organization' }));
-
- expect(openCreateOrganization.mock.calls[0]?.[0]?.getContainer?.()).toBe(container);
- });
-
- it('navigates to create an organization when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({ createOrganizationUrl: '/new-org' });
- const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {});
- const user = await open();
-
- await user.click(await screen.findByRole('button', { name: 'Add organization' }));
-
- expect(navigate).toHaveBeenCalledWith('/new-org');
- expect(openCreateOrganization).not.toHaveBeenCalled();
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('navigates to manage the account when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({}, undefined, { userProfileUrl: '/account' });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
- const user = await open();
-
- await openSettings(user, 'Manage profile');
-
- expect(navigate).toHaveBeenCalledWith('/account');
- expect(openUserProfile).not.toHaveBeenCalled();
- });
-
- it('navigates to manage the organization when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({}, undefined, { organizationProfileUrl: '/org' });
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
- const user = await open();
-
- await openSettings(user, 'Manage organization');
-
- expect(navigate).toHaveBeenCalledWith('/org');
- expect(openOrganizationProfile).not.toHaveBeenCalled();
- });
-
- it('sends add-account to the sign-in page', async () => {
- const { navigate } = await renderUserButton({ signInUrl: '/sign-in' });
- const user = await open();
-
- const menu = await openAccounts(user);
- await user.click(within(menu).getByRole('menuitem', { name: 'Add account' }));
-
- await waitFor(() => expect(navigate).toHaveBeenCalledWith('/sign-in'));
- });
-
- it('sends a switched account with a pending task to finish it', async () => {
- const pendingBob = fapiSession({ ...bobSession, status: 'pending', tasks: [{ key: 'choose-organization' }] });
- const { navigate } = await renderUserButton(
- { signInUrl: '/sign-in' },
- signedIn({ client: fapiClient([aliceSession, pendingBob]) }),
- );
- const user = await open();
-
- const menu = await openAccounts(user);
- await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' }));
-
- await waitFor(() => expect(navigate).toHaveBeenCalledWith('/sign-in#/tasks/choose-organization'));
- });
-
- it('leaves create-organization out for a user who cannot create one', async () => {
- const restricted = fapiUser({ ...alice, create_organization_enabled: false });
- await renderUserButton(
- {},
- signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: restricted }), bobSession]) }),
- );
- await open();
-
- expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Add organization' })).toBeNull();
- });
- });
-
- describe('while an action is in flight', () => {
- it('spins the clicked row and stands every other one down', async () => {
- await renderUserButton();
- const user = await open();
- await screen.findByRole('button', { name: 'Other' });
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
-
- await user.click(screen.getByRole('button', { name: 'Other' }));
-
- expect(waiting()).toBe(true);
- expect(signOut()).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByRole('button', { name: 'Settings' })).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByRole('button', { name: 'Invite members' })).toHaveAttribute('aria-disabled', 'true');
- expect(popup()).toBeInTheDocument();
-
- touch.release();
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('holds the surface on the organization it started with while Clerk navigates after a switch', async () => {
- const { navigate } = await renderUserButton({
- afterSelectOrganizationUrl: '/org/:id',
- fallback: ,
- });
- let arrive: () => void = () => {};
- navigate.mockImplementationOnce(
- () =>
- new Promise(resolve => {
- arrive = resolve;
- }),
- );
- const user = await open();
-
- await user.click(await screen.findByRole('button', { name: 'Other' }));
- await waitFor(() => expect(navigate).toHaveBeenCalledWith('/org/org_9'));
-
- expect(screen.queryByTestId('fallback')).toBeNull();
- expect(current()).toEqual([expect.stringContaining('Acme')]);
-
- act(() => arrive());
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('stays open, clears busy state and logs the failure when the switch fails', async () => {
- const log = vi.spyOn(console, 'error').mockImplementation(() => {});
- await renderUserButton();
- const user = await open();
- await screen.findByRole('button', { name: 'Other' });
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
-
- await user.click(screen.getByRole('button', { name: 'Other' }));
- expect(waiting()).toBe(true);
-
- touch.fail('session_touch_failed');
-
- await waitFor(() => expect(waiting()).toBe(false));
- expect(popup()).toBeInTheDocument();
- expect(signOut()).toBeEnabled();
- expect(log).toHaveBeenCalledWith(
- '[Clerk] User button action failed',
- expect.objectContaining({ errors: [expect.objectContaining({ code: 'session_touch_failed' })] }),
- );
- });
-
- it('still shows the action when reopened before it settles, and starts no second one', async () => {
- await renderUserButton();
- await open();
- await screen.findByRole('button', { name: 'Other' });
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
- vi.useFakeTimers({ shouldAdvanceTime: true });
-
- try {
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- const menu = await openAccounts(user);
- await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' }));
- expect(waiting()).toBe(true);
- await waitFor(() => expect(touch.requests).toHaveLength(1));
-
- await user.click(trigger());
- await waitFor(() => expect(popup()).toBeNull());
-
- await act(() => vi.advanceTimersByTimeAsync(400));
- await user.click(trigger());
-
- expect(popup()).toBeInTheDocument();
- expect(waiting()).toBe(true);
-
- await user.click(screen.getByRole('button', { name: 'Other' }));
- expect(touch.requests).toHaveLength(1);
-
- touch.release();
- await waitFor(() => expect(waiting()).toBe(false));
- } finally {
- vi.useRealTimers();
- }
- expect(popup()).toBeInTheDocument();
- });
-
- it('closes on success even if the popover was dismissed and reopened meanwhile', async () => {
- await renderUserButton();
- const user = await open();
- await screen.findByRole('button', { name: 'Other' });
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
-
- await user.click(screen.getByRole('button', { name: 'Other' }));
- await user.click(trigger());
- await waitFor(() => expect(popup()).toBeNull());
-
- await user.click(trigger());
- expect(waiting()).toBe(true);
-
- touch.release();
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('reopens ready to use after an action that closed it', async () => {
- await renderUserButton();
- const user = await open();
-
- await user.click(await screen.findByRole('button', { name: 'Other' }));
- await waitFor(() => expect(popup()).toBeNull());
-
- await user.click(trigger());
-
- expect(waiting()).toBe(false);
- expect(signOut()).toBeEnabled();
- });
- });
-
- it('loads the next page of organizations when the end of the list scrolls into view', async () => {
- const many = Array.from({ length: 15 }, (_, i) => fapiOrganization({ id: `org_p${i + 1}`, name: `Org ${i + 1}` }));
- await renderUserButton({}, signedIn({ memberships: [fapiMembership(acme), ...many.map(o => fapiMembership(o))] }));
- await open();
-
- const last = await screen.findByRole('button', { name: 'Org 9' });
- last.scrollIntoView();
-
- expect(await screen.findByRole('button', { name: 'Org 15' })).toBeInTheDocument();
- });
-
- it('loads the next page of invitations when the end of the list scrolls into view', async () => {
- const many = Array.from({ length: 15 }, (_, i) =>
- fapiInvitation(`inv_p${i + 1}`, fapiOrganization({ id: `org_i${i + 1}`, name: `Invited ${i + 1}` })),
- );
- await renderUserButton({}, noOffers({ invitations: many }));
- await open();
-
- const last = await screen.findByText('Invited 10');
- last.scrollIntoView();
-
- expect(await screen.findByText('Invited 15')).toBeInTheDocument();
- });
-
- it('lists the active organization where its page puts it, and lists it once', async () => {
- const many = Array.from({ length: 15 }, (_, i) => fapiOrganization({ id: `org_p${i + 1}`, name: `Org ${i + 1}` }));
- await renderUserButton({}, signedIn({ memberships: [...many.map(o => fapiMembership(o)), fapiMembership(acme)] }));
- await open();
-
- const last = await screen.findByRole('button', { name: 'Org 9' });
- expect(reading('Acme', 'Personal account', 'Org 1')).toEqual(['Personal account', 'Org 1']);
-
- last.scrollIntoView();
- await screen.findByRole('button', { name: 'Org 15' });
-
- expect(reading('Acme', 'Personal account', 'Org 15')).toEqual(['Personal account', 'Org 15', 'Acme']);
- });
-
- describe('in user mode', () => {
- it('heads the surface with the account, never the active organization', async () => {
- await renderUserButton({ combined: false });
- await open();
-
- expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument();
- expect(within(requiredPopup()).getByText('alice')).toBeInTheDocument();
- expect(within(requiredPopup()).queryByText('Acme')).toBeNull();
- });
-
- it('drops the identifier line when it would only repeat the name', async () => {
- const nameless = fapiUser({ ...alice, first_name: null, last_name: null });
- await renderUserButton(
- { combined: false },
- signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless }), bobSession]) }),
- );
- await open();
-
- expect(within(requiredPopup()).getAllByText('alice')).toHaveLength(1);
- });
-
- it('lists no organizations and offers no way to make one', async () => {
- await renderUserButton({ combined: false });
- await open();
-
- expect(screen.queryByText('Personal account')).toBeNull();
- expect(screen.queryByText('Gamma')).toBeNull();
- expect(screen.queryByRole('button', { name: 'Add organization' })).toBeNull();
- });
-
- it('manages the account from its settings row, and signs out at the foot', async () => {
- const { clerk } = await renderUserButton({ combined: false });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
- const user = await open();
-
- expect(
- within(requiredPopup().querySelector('.cl-user-button-header') ?? requiredPopup()).queryAllByRole('button'),
- ).toEqual([]);
- expect(reading('Settings', 'Switch account', 'Sign out')).toEqual(['Settings', 'Switch account', 'Sign out']);
- expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull();
-
- await user.click(screen.getByRole('button', { name: 'Settings' }));
- expect(screen.queryByRole('menu')).toBeNull();
- expect(openUserProfile).toHaveBeenCalled();
- });
-
- it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => {
- await renderUserButton({ combined: false });
- const user = await open();
-
- const items = within(await openAccounts(user)).getAllByRole('menuitem');
-
- expect(items).toHaveLength(4);
- expect(items[0]).toHaveAccessibleName('alice');
- expect(items[1]).toHaveAccessibleName('bob@example.com');
- expect(items[2]).toHaveAccessibleName('Add account');
- expect(items[3]).toHaveAccessibleName('Sign out of all accounts');
- });
- });
-
- describe('in combined mode', () => {
- it('stands in for a mode it does not take', async () => {
- const props: UserButtonProps = {};
- Object.assign(props, { mode: 'organization' });
- await renderUserButton(props);
-
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- expect(document.querySelector('.cl-user-button-trigger')).not.toBeNull();
- });
-
- it('heads the surface with the active organization, badged with the account, managing both', async () => {
- const { clerk } = await renderUserButton();
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
-
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- const user = await open();
-
- const header = requiredPopup().querySelector('.cl-user-button-header');
- expect(header?.querySelector('.cl-user-button-header-title')).toHaveTextContent('Acme');
- expect(header?.querySelector('.cl-user-button-header-description')).toHaveTextContent('alice');
- expect(header?.querySelector('.cl-user-button-avatar > [data-shape]')).toHaveAttribute('data-shape', 'square');
- expect(header?.querySelector('.cl-user-button-avatar-badge > [data-shape]')).toHaveAttribute(
- 'data-shape',
- 'circle',
- );
- expect(within(header ?? requiredPopup()).queryAllByRole('button')).toEqual([]);
- expect(reading('Add organization', 'Settings', 'Invite members', 'Switch account', 'Sign out')).toEqual([
- 'Add organization',
- 'Settings',
- 'Invite members',
- 'Switch account',
- 'Sign out',
- ]);
- await openSettings(user, 'Manage organization');
- expect(openOrganizationProfile).toHaveBeenCalled();
- });
-
- it('keeps the identifier beneath the organization even when it is all that names the account', async () => {
- const nameless = fapiUser({ ...alice, first_name: null, last_name: null });
- await renderUserButton({}, signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless })]) }));
-
- expect(trigger()).toHaveAccessibleName('Open account menu for alice');
- await open();
-
- expect(requiredPopup().querySelector('.cl-user-button-header-title')).toHaveTextContent('Acme');
- expect(requiredPopup().querySelector('.cl-user-button-header-description')).toHaveTextContent('alice');
- });
-
- it('trails the foot with sign-out, behind the flyout of accounts', async () => {
- await renderUserButton();
- await open();
-
- expect(reading('Switch account', 'Add account', 'Sign out', 'Sign out of all accounts')).toEqual([
- 'Switch account',
- 'Sign out',
- ]);
- });
-
- it('trails the organizations with create-organization', async () => {
- await renderUserButton({}, noOffers());
- await openWithList();
-
- expect(reading('Personal account', 'Acme', 'Other', 'Add organization').at(-1)).toBe('Add organization');
- });
-
- it('marks the account it is already on in the flyout', async () => {
- await renderUserButton();
- const user = await open();
-
- const menu = await openAccounts(user);
-
- expect(within(menu).getByRole('menuitem', { name: 'alice' })).toHaveAttribute('aria-current', 'true');
- expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).not.toHaveAttribute('aria-current');
- expect(within(menu).getByRole('menuitem', { name: 'Add account' })).toBeInTheDocument();
- expect(within(menu).getByRole('menuitem', { name: 'Sign out of all accounts' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull();
- });
-
- it('takes add-account at the foot where there is no second account', async () => {
- await renderUserButton({}, alone());
- await open();
-
- expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull();
- expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull();
- expect(reading('Switch account', 'Add account', 'Sign out of all accounts')).toEqual(['Add account']);
- });
-
- it('heads an account with no organizations with the account alone', async () => {
- const loner = fapiUser({ ...alice, organization_memberships: [] });
- await renderUserButton(
- {},
- noOffers({ memberships: [], client: fapiClient([fapiSession({ id: 'sess_1', user: loner }), bobSession]) }),
- );
- await open();
-
- expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
- expect(screen.queryByText('Personal account')).toBeNull();
- });
- });
-
- describe('naming the account', () => {
- const phone = fapiPhoneNumber({ id: 'idn_phone', phone_number: '+15550100' });
- const wallet: Web3WalletJSON = {
- object: 'web3_wallet',
- id: 'idn_wallet',
- web3_wallet: '0xabc',
- verification: null,
- };
- const noEmail = { username: null, email_addresses: [], primary_email_address_id: null };
-
- const signedInAs = (overrides: Partial) =>
- signedIn({ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ ...alice, ...overrides }) })]) });
-
- it.each([
- ['first and last name', {}, 'Alice Smith'],
- ['username', { first_name: null, last_name: null }, 'alice'],
- ['email address', { first_name: null, last_name: null, username: null }, 'alice@example.com'],
- ])('names it by its %s', async (_source, overrides, name) => {
- await renderUserButton({ combined: false }, signedInAs(overrides));
-
- expect(trigger()).toHaveAccessibleName(`Open account menu for ${name}`);
- });
-
- it.each([
- ['username', {}, 'alice'],
- ['email address', { username: null }, 'alice@example.com'],
- ['phone number', { ...noEmail, phone_numbers: [phone], primary_phone_number_id: 'idn_phone' }, '+15550100'],
- ['web3 wallet', { ...noEmail, web3_wallets: [wallet], primary_web3_wallet_id: 'idn_wallet' }, '0xabc'],
- ])('identifies it by its %s', async (_source, overrides, identifier) => {
- await renderUserButton({}, signedInAs(overrides));
- await open();
-
- expect(within(requiredPopup()).getByText(identifier)).toBeInTheDocument();
- });
- });
-
- describe('the organization list', () => {
- it('lists the organizations held in the order they come, then the invitations, then the suggestions', async () => {
- await renderUserButton({}, signedIn({ memberships: [fapiMembership(other), fapiMembership(acme)] }));
- await openWithList();
-
- expect(reading('Gamma', 'Beta', 'Personal account', 'Acme', 'Other')).toEqual([
- 'Personal account',
- 'Other',
- 'Acme',
- 'Gamma',
- 'Beta',
- ]);
- });
-
- it('names the active organization as the current one', async () => {
- await renderUserButton();
- await openWithList();
-
- expect(current()).toEqual([expect.stringContaining('Acme')]);
- });
-
- it('checks the personal account, and offers no switch to it, where it is active', async () => {
- await renderUserButton({}, signedIn({ client: fapiClient([personalSession, bobSession]) }));
- await openWithList();
-
- expect(current()).toEqual([expect.stringContaining('Personal account')]);
- expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull();
- });
-
- it('describes each offer by the organization it acts on', async () => {
- await renderUserButton();
- await openWithList();
-
- expect(screen.getByRole('button', { name: 'Accept' })).toHaveAccessibleDescription('Gamma');
- expect(screen.getByRole('button', { name: 'Join' })).toHaveAccessibleDescription('Beta');
- });
-
- it('lists an invitation for an account that holds nothing else', async () => {
- const loner = fapiUser({ ...alice, organization_memberships: [] });
- await renderUserButton(
- {},
- signedIn({
- memberships: [],
- suggestions: [],
- client: fapiClient([fapiSession({ id: 'sess_1', user: loner })]),
- }),
- );
- await open();
-
- expect(await screen.findByRole('button', { name: 'Accept' })).toBeInTheDocument();
- expect(reading('Gamma', 'Personal account')).toEqual(['Personal account', 'Gamma']);
- });
-
- it('reports an accepted suggestion instead of offering to join it again', async () => {
- await renderUserButton({}, noOffers({ suggestions: [fapiSuggestion('sug_1', beta, { status: 'accepted' })] }));
- await openWithList();
-
- expect(screen.queryByRole('button', { name: 'Join' })).toBeNull();
- expect(reading('Beta', 'Requested')).toEqual(['Beta', 'Requested']);
- });
-
- it('lists an accepted invitation once, as an organization to switch to', async () => {
- await renderUserButton({}, noOffers({ invitations: [fapiInvitation('inv_1', gamma)] }));
- const user = await openWithList();
-
- await user.click(screen.getByRole('button', { name: 'Accept' }));
-
- expect(await screen.findByRole('button', { name: 'Gamma' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull();
- expect(reading('Gamma')).toEqual(['Gamma']);
- });
-
- it('holds every row behind one placeholder until the first page lands', async () => {
- serveFapi(signedIn());
- const invitations = holdRequests('get', '/v1/me/organization_invitations');
- await renderWithClerk(tree());
- await open();
-
- expect(await screen.findByText('Loading organizations…')).toBeInTheDocument();
- expect(reading('Personal account', 'Other')).toEqual([]);
- expect(requiredPopup().querySelector('.cl-user-button-header-title')).toHaveTextContent('Acme');
-
- invitations.release();
- await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull());
- expect(reading('Personal account', 'Other')).toEqual(['Personal account', 'Other']);
- });
- });
-
- describe('the custom rows', () => {
- const terms = { id: 'terms', label: 'Terms of service', onClick: () => {} };
- const support = { id: 'support', label: 'Support', href: '/support' };
- const group = (name: string) =>
- screen.getByRole(name === 'Support' ? 'link' : 'button', { name }).closest('.cl-user-button-group');
-
- it('follow Settings and Invite members in their group, above the foot', async () => {
- await renderUserButton({ customMenuItems: [terms, support] });
- await open();
-
- expect(
- reading('Settings', 'Invite members', 'Terms of service', 'Support', 'Switch account', 'Sign out'),
- ).toEqual(['Settings', 'Invite members', 'Terms of service', 'Support', 'Switch account', 'Sign out']);
- expect(group('Terms of service')).toBe(group('Settings'));
- expect(group('Switch account')).not.toBe(group('Settings'));
- expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support');
- });
-
- it('renders the icon a custom row brings', async () => {
- await renderUserButton({ customMenuItems: [{ ...terms, icon: }] });
- await open();
-
- expect(screen.getByTestId('glyph')).toBeInTheDocument();
- });
-
- it('orders the group by the ids it is given, leaving the foot as it is', async () => {
- await renderUserButton({
- customMenuItems: [terms, support],
- menuItemOrder: ['support', 'inviteMembers', 'signOut', 'nonsense'],
- });
- await open();
-
- expect(
- reading('Settings', 'Invite members', 'Terms of service', 'Support', 'Switch account', 'Sign out'),
- ).toEqual(['Support', 'Invite members', 'Settings', 'Terms of service', 'Switch account', 'Sign out']);
- });
-
- it('replaces the built-in row whose id one takes, in its place', async () => {
- const appSettings = { id: 'settings', label: 'App settings', onClick: () => {} };
- await renderUserButton({ customMenuItems: [terms, appSettings] });
- await open();
-
- expect(reading('Settings', 'App settings', 'Invite members', 'Terms of service')).toEqual([
- 'App settings',
- 'Invite members',
- 'Terms of service',
- ]);
- });
-
- it('keeps a row whose id names a built-in the mode does not show', async () => {
- const invite = { id: 'inviteMembers', label: 'Invite teammates', onClick: () => {} };
- await renderUserButton({ customMenuItems: [invite] }, signedIn({ client: fapiClient([personalSession]) }));
- await open();
-
- expect(reading('Settings', 'Invite teammates')).toEqual(['Settings', 'Invite teammates']);
- });
-
- it('places a replacing row by the id it took', async () => {
- const appSettings = { id: 'settings', label: 'App settings', onClick: () => {} };
- await renderUserButton({ customMenuItems: [terms, appSettings], menuItemOrder: ['terms', 'inviteMembers'] });
- await open();
-
- expect(reading('App settings', 'Invite members', 'Terms of service')).toEqual([
- 'Terms of service',
- 'Invite members',
- 'App settings',
- ]);
- });
-
- it('signs the popup with Clerk where the instance is branded', async () => {
- await renderUserButton({}, signedIn({ environment: fapiEnvironment({ display_config: { branded: true } }) }));
- await open();
-
- expect(within(requiredPopup()).getByRole('link', { name: 'Clerk' })).toBeInTheDocument();
- });
-
- it('carries no mark where the instance is unbranded', async () => {
- await renderUserButton();
- await open();
-
- expect(within(requiredPopup()).queryByRole('link', { name: 'Clerk' })).toBeNull();
- });
- });
-
- describe('while an action is in flight, beside the busy row', () => {
- it.each(['Other', 'Personal account', 'Sign out'])(
- 'holds "%s" in place, aria-disabled and still focusable',
- async label => {
- await renderUserButton({}, noOffers());
- const user = await openWithList();
- const before = screen.getByRole('button', { name: label });
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
-
- await openAccounts(user).then(menu =>
- user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })),
- );
-
- const after = screen.getByRole('button', { name: label });
- expect(after).toBe(before);
- expect(after).toHaveAttribute('aria-disabled', 'true');
- expect(after).toBeEnabled();
-
- touch.release();
- await waitFor(() => expect(waiting()).toBe(false));
- },
- );
-
- it('reports the switch on the row that owns it, and nothing on the rows beside it', async () => {
- await renderUserButton({}, noOffers());
- const user = await openWithList();
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
-
- await user.click(screen.getByRole('button', { name: 'Other' }));
-
- const busy = screen.getByRole('button', { name: 'Other' });
- expect(busy).toHaveAttribute('aria-busy', 'true');
- expect(within(busy).getByRole('progressbar')).toHaveAccessibleName('pending');
- const idle = screen.getByRole('button', { name: 'Personal account' });
- expect(idle).not.toHaveAttribute('aria-busy');
- expect(within(idle).queryByRole('progressbar')).toBeNull();
- expect(signOut()).toHaveAttribute('aria-disabled', 'true');
-
- touch.release();
- await waitFor(() => expect(popup()).toBeNull());
- });
-
- it('stands a custom action down but leaves a custom link followable', async () => {
- const onClick = vi.fn();
- await renderUserButton(
- {
- customMenuItems: [
- { id: 'terms', label: 'Terms of service', onClick },
- { id: 'support', label: 'Support', href: '/support' },
- ],
- },
- noOffers(),
- );
- const user = await openWithList();
- const touch = holdRequests('post', '/v1/client/sessions/:id/touch');
-
- await user.click(screen.getByRole('button', { name: 'Other' }));
-
- const action = screen.getByRole('button', { name: 'Terms of service' });
- expect(action).toHaveAttribute('aria-disabled', 'true');
- await user.click(action);
- expect(onClick).not.toHaveBeenCalled();
- expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support');
-
- touch.release();
- await waitFor(() => expect(popup()).toBeNull());
- });
- });
-
- describe('the trigger', () => {
- it.each([
- [{ combined: true }, 'Acme'],
- [{ combined: false }, 'Alice Smith'],
- ])('with %o names %s beside the avatar', async (props, name) => {
- await renderUserButton(props);
-
- expect(within(trigger()).getByText(name)).toBeInTheDocument();
- expect(popup()).toBeNull();
- });
-
- it('names the active organization alone, badged with the account, and opens as the account', async () => {
- await renderUserButton();
-
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- expect(trigger()).toHaveAttribute('data-variant', 'full');
- expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
- expect(within(trigger()).queryByText('alice')).toBeNull();
- expect(within(trigger()).queryByText('Alice Smith')).toBeNull();
- expect(trigger().querySelector('.cl-user-button-avatar > [data-shape]')).toHaveAttribute('data-shape', 'square');
- expect(trigger().querySelector('.cl-user-button-avatar-badge > [data-shape]')).toHaveAttribute(
- 'data-shape',
- 'circle',
- );
- });
-
- it('drops the account badge from a condensed trigger', async () => {
- await renderUserButton({ triggerVariant: 'condensed' });
-
- expect(trigger()).toHaveAttribute('data-variant', 'condensed');
- expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
- expect(trigger().querySelector('.cl-user-button-avatar-badge')).toBeNull();
- });
-
- it('leads a full trigger with the account alone when no organization is active', async () => {
- await renderUserButton({}, signedIn({ client: fapiClient([personalSession]) }));
-
- expect(trigger()).toHaveAttribute('data-variant', 'full');
- expect(within(trigger()).getByText('Alice Smith')).toBeInTheDocument();
- expect(trigger().querySelector('[data-shape]')).toHaveAttribute('data-shape', 'circle');
- expect(trigger().querySelector('.cl-user-button-avatar-badge')).toBeNull();
- });
-
- it('keeps the condensed trigger in user mode', async () => {
- await renderUserButton({ combined: false, triggerVariant: 'full' });
-
- expect(trigger()).toHaveAttribute('data-variant', 'condensed');
- });
-
- it('leads the avatar alone with the organization, unbadged', async () => {
- await renderUserButton({ showTriggerLabel: false });
-
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- expect(trigger().querySelector('[data-shape]')).toHaveAttribute('data-shape', 'square');
- expect(trigger().querySelector('.cl-user-button-avatar-badge')).toBeNull();
- });
-
- it('names no organization beneath the account in user mode', async () => {
- await renderUserButton({ combined: false });
-
- expect(within(trigger()).queryByText('Acme')).toBeNull();
- });
-
- it('still names the account in user mode where personal is hidden and none is active', async () => {
- await renderUserButton(
- { combined: false, hidePersonal: true },
- signedIn({ client: fapiClient([personalSession]) }),
- );
-
- expect(within(trigger()).getByText('Alice Smith')).toBeInTheDocument();
- });
-
- it('renders the avatar alone when the label is off', async () => {
- await renderUserButton({ combined: false, showTriggerLabel: false });
-
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- expect(within(trigger()).queryByText('Alice Smith')).toBeNull();
- });
-
- it('names the active organization before its membership list has loaded', async () => {
- serveFapi(signedIn());
- const memberships = holdRequests('get', '/v1/me/organization_memberships');
- await renderWithClerk(tree());
-
- expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
- await userEvent.setup().click(trigger());
- expect(within(requiredPopup()).getByRole('button', { name: 'Invite members' })).toBeInTheDocument();
-
- memberships.release();
- await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull());
- });
- });
-});
-
-describe('UserButton custom trigger badges', () => {
- it.each([
- { combined: true, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' },
- { combined: false, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' },
- {
- combined: true,
- seed: signedIn({ client: fapiClient([personalSession]) }),
- badge: 'user:user_1',
- name: 'Alice Smith',
- },
- ])('shows a badge for the displayed identity as $badge', async ({ combined, seed, badge, name }) => {
- await renderUserButton(
- { combined, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) },
- seed,
- );
-
- await waitFor(() => expect(within(trigger()).getByText(badge)).toBeInTheDocument());
- expect(within(trigger()).getByText(badge).closest('.cl-badge')).toHaveAttribute('data-badge-id', 'pro');
- expect(within(trigger()).getByText(name)).toBeInTheDocument();
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- await open();
- expect(within(requiredPopup()).queryByText(badge)).toBeNull();
- });
-
- it('hides the badge while pending and for null without changing the name', async () => {
- const badge = deferred<{ id: string; label: string } | null>();
- const triggerBadge = vi.fn(({ id }: { id: string }) => (id === 'org_1' ? badge.promise : Promise.resolve(null)));
- const { clerk } = await renderUserButton({ triggerBadge });
-
- expect(trigger()).toHaveTextContent('Acme');
- expect(trigger()).not.toHaveTextContent('Pro plan');
- await act(() => badge.resolve({ id: 'pro', label: 'Pro plan' }));
- await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
- expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
-
- await act(() => clerk.setActive({ organization: 'org_9' }));
- await waitFor(() => expect(triggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' }));
- expect(trigger()).not.toHaveTextContent('Pro plan');
- expect(within(trigger()).getByText('Other')).toBeInTheDocument();
- });
-
- it('discards the previous workspace badge after switching organizations', async () => {
- const first = deferred<{ id: string; label: string } | null>();
- const triggerBadge = vi.fn(async ({ id }: { id: string }) =>
- id === 'org_1' ? first.promise : { id: 'other', label: 'Other plan' },
- );
- const { clerk } = await renderUserButton({ triggerBadge });
- await waitFor(() => expect(triggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_1' }));
-
- await act(() => clerk.setActive({ organization: 'org_9' }));
- await waitFor(() => expect(within(trigger()).getByText('Other plan')).toBeInTheDocument());
- await act(() => first.resolve({ id: 'previous', label: 'Previous plan' }));
- expect(within(trigger()).getByText('Other')).toBeInTheDocument();
- expect(within(trigger()).getByText('Other plan').closest('.cl-badge')).toHaveAttribute('data-badge-id', 'other');
- expect(trigger()).not.toHaveTextContent('Previous plan');
- });
-
- it('keeps the badge mounted when opened with an inline callback', async () => {
- const calls = vi.fn();
- const inline = () => {
- calls();
- return Promise.resolve({ id: 'enterprise', label: 'Enterprise plan' });
- };
- const { rerender } = await renderUserButton({ triggerBadge: () => inline() });
- await waitFor(() => expect(within(trigger()).getByText('Enterprise plan')).toBeInTheDocument());
-
- let dropped = false;
- const observer = new MutationObserver(() => {
- dropped ||= !trigger().textContent?.includes('Enterprise plan');
- });
- observer.observe(trigger(), { childList: true, subtree: true, characterData: true });
- onTestFinished(() => observer.disconnect());
-
- await open();
- rerender(tree({ triggerBadge: () => inline() }));
- expect(within(trigger()).getByText('Enterprise plan')).toBeInTheDocument();
- await act(() => Promise.resolve());
-
- expect(dropped).toBe(false);
- expect(calls).toHaveBeenCalledTimes(1);
- });
-
- it('hides the badge for false or on failure', async () => {
- const triggerBadge = vi.fn(({ id }: { id: string }) =>
- id === 'org_1'
- ? Promise.resolve({ id: 'pro', label: 'Pro plan' })
- : Promise.reject(new Error('Plan unavailable')),
- );
- const { clerk, rerender } = await renderUserButton({ triggerBadge });
- await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
-
- rerender(tree({ triggerBadge: false }));
- expect(trigger()).not.toHaveTextContent('Pro plan');
- expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
+ it('lists accounts and no organizations, even when one is active', async () => {
+ const { trigger, user, popup } = await renderUserButton();
- rerender(tree({ triggerBadge }));
- await waitFor(() => expect(within(trigger()).getByText('Pro plan')).toBeInTheDocument());
+ expect(within(trigger).queryByText('Acme')).toBeNull();
+ for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) {
+ expect(within(popup).queryByText(name)).toBeNull();
+ }
+ expect(within(popup).queryByRole('button', { name: 'Invite members' })).toBeNull();
- await act(() => clerk.setActive({ organization: 'org_9' }));
- await waitFor(() => expect(triggerBadge).toHaveBeenCalledWith({ kind: 'organization', id: 'org_9' }));
- expect(trigger()).not.toHaveTextContent('Pro plan');
- expect(within(trigger()).getByText('Other')).toBeInTheDocument();
+ const menu = await openMenu(user, within(popup).getByRole('button', { name: 'Switch account' }));
+ expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument();
});
- it('does not request a badge when the trigger label is hidden', async () => {
- const triggerBadge = vi.fn(() => Promise.resolve({ id: 'pro', label: 'Pro plan' }));
- await renderUserButton({ showTriggerLabel: false, triggerBadge });
+ it('stays an account menu when handed a mode it does not take', async () => {
+ const props: UserButtonProps = {};
+ Object.assign(props, { mode: 'combined' });
+ const { trigger, popup } = await renderUserButton(props);
- expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
- expect(trigger()).not.toHaveTextContent('Acme');
- expect(trigger()).not.toHaveTextContent('Pro plan');
- expect(triggerBadge).not.toHaveBeenCalled();
+ expect(trigger).toHaveAttribute('data-variant', 'condensed');
+ expect(within(trigger).queryByText('Acme')).toBeNull();
+ expect(within(popup).queryByRole('button', { name: 'Invite members' })).toBeNull();
});
});
diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx
index a547c08fa09..72bd4c3dbf9 100644
--- a/packages/mosaic/src/features/user-button/user-button.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.tsx
@@ -2,97 +2,26 @@
import type { ReactElement } from 'react';
-import type { SwitcherProps } from '../switcher/switcher';
+import type { UserButtonProps } from '../switcher/switcher';
import { Switcher } from '../switcher/switcher';
-import type { SwitcherTriggerVariant } from '../switcher/switcher.view';
-
-/** What the menu lists, and how much its trigger carries. */
-export interface UserButtonMenuProps {
- /**
- * Whether the menu lists organizations beside accounts. `false` lists accounts alone, and never shows
- * an organization, even when one is active. The profile's shape is set by `MosaicProvider`'s `profile`.
- *
- * @default true
- */
- combined?: boolean;
- /**
- * How much the labelled trigger carries when `combined`: `full` badges the active organization with
- * the account at a larger size, `condensed` shows the lead's avatar alone. Otherwise it is always
- * `condensed`.
- *
- * @default 'full'
- */
- triggerVariant?: SwitcherTriggerVariant;
-}
-
-/** Everything `` takes: trigger content, and the app's own menu rows. */
-export type UserButtonProps = SwitcherProps & UserButtonMenuProps;
/**
- * The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account,
- * open the profile, and sign out. It reads the active session and organization from Clerk, so it takes
- * no data. It renders `fallback` until Clerk answers, and nothing at all when nobody is signed in.
- *
- * Each action is a request: the row you click spins, the others stand down, and the menu stays open on
- * the result. Only an action that navigates closes it.
+ * The signed-in user's avatar, and the account menu behind it: switch or add an account, open the
+ * profile, and sign out. It never lists an organization, even when one is active. It is
+ * ``; use `` for accounts and organizations in one menu.
*
* @example
* ```tsx
* import { UserButton } from '@clerk/mosaic';
*
- *
- * ```
- *
- * @example
- * `combined={false}` narrows the menu to the account switcher.
- * ```tsx
- *
- * ```
- *
- * @example
- * `triggerVariant` trades the badged trigger for the lead's avatar alone.
- * ```tsx
- *
- * ```
- *
- * @example
- * `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` template,
- * a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands. Set
- * `userProfileUrl` and `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to
- * pages of your own.
- * ```tsx
- *
- * ```
- *
- * @example
- * `fallback` holds the space while Clerk is still answering. Size it to the trigger to keep the row
- * it sits in from moving. Nothing stands in once the answer is that nobody is signed in.
- * ```tsx
- * } />
- * ```
- *
- * @example
- * `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an
- * `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`.
- * ```tsx
- * , href: 'https://example.com/docs' },
- * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() },
- * ]}
- * menuItemOrder={['docs', 'support', 'settings', 'inviteMembers']}
- * />
+ *
* ```
*/
-export function UserButton({ combined = true, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement {
+export function UserButton(props: UserButtonProps = {}): ReactElement {
return (
);
}
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index a64f3111ea2..ed6c2cfc002 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -1,10 +1,15 @@
'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';
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/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..dd6011cb889 100644
--- a/packages/swingset/src/components/Composition.tsx
+++ b/packages/swingset/src/components/Composition.tsx
@@ -7,14 +7,14 @@ 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',
'User Profile',
'Organization Profile',
'Misc',
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 674dd120af6..185eb4aba2e 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -9,8 +9,8 @@ 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')),
},
'user-profile': {
'user-profile': dynamic(() => import('../stories/user-profile.mdx')),
@@ -53,6 +53,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..a89ca7e92b6 100644
--- a/packages/swingset/src/lib/live-navigation.ts
+++ b/packages/swingset/src/lib/live-navigation.ts
@@ -48,7 +48,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..6bf90ed8e1a 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -398,6 +398,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 +459,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 +808,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 = {
@@ -1277,8 +1278,8 @@ const organizationProfileMembersPanelModule: StoryModule = {
};
export const registry: StoryModule[] = [
- // User Button
- userButtonModule,
+ // Switcher
+ switcherModule,
// User Profile
userProfileModule,
// User Profile · Panels
@@ -1312,6 +1313,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..2d62e1e6840 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,14 +50,14 @@ themselves.
}}
>
-
+
```
-`combined` on `MosaicProvider` decides what the profile shows, and `combined` on `UserButton` decides what
-its menu lists.
+`combined` on `MosaicProvider` decides what the profile shows, and the button you pick (`Switcher`,
+`UserButton` or `OrganizationSwitcher`) decides what its menu lists.
-`profile.combined` takes effect only with `experimental.newProfiles`. Without it, `UserButton` and
+`profile.combined` takes effect only with `experimental.newProfiles`. Without it, `Switcher`, `UserButton` and
`OrganizationSwitcher` open Clerk's modals.
| Option | Type | Default | Description |
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).
+
+## 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 (
-
- );
-}
From b6eb74ac6bb0e88810188f69af26e4d5274eeb9a Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Tue, 10 Nov 2026 13:47:47 -0500
Subject: [PATCH 2/6] test(mosaic): mark the rejected Switcher prop, not the
JSX call
---
.../mosaic/src/features/switcher/switcher.test-d.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/mosaic/src/features/switcher/switcher.test-d.tsx b/packages/mosaic/src/features/switcher/switcher.test-d.tsx
index 462683aff55..52d32c4922a 100644
--- a/packages/mosaic/src/features/switcher/switcher.test-d.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.test-d.tsx
@@ -31,24 +31,24 @@ describe('Switcher props', () => {
signInUrl='/sign-in'
/>
);
- // @ts-expect-error the trigger variant only applies to the combined menu
void (
);
- // @ts-expect-error a user menu lists no organizations
void (
);
- // @ts-expect-error a user menu lists no organizations
void (
);
@@ -62,17 +62,17 @@ describe('Switcher props', () => {
createOrganizationUrl='/orgs/new'
/>
);
- // @ts-expect-error the trigger variant only applies to the combined menu
void (
);
- // @ts-expect-error an organization menu lists no accounts
void (
);
From 4439b3a752829b83d8750daa86bc8e75db7266d8 Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Tue, 10 Nov 2026 13:59:29 -0500
Subject: [PATCH 3/6] docs(mosaic): address PR #10752 review feedback
---
.changeset/mosaic-switcher.md | 2 +-
packages/mosaic/docs/testing.md | 2 +-
.../__tests__/organization-switcher.feature.test.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.changeset/mosaic-switcher.md b/.changeset/mosaic-switcher.md
index b3fe6cf83e0..60ece1ef52a 100644
--- a/.changeset/mosaic-switcher.md
+++ b/.changeset/mosaic-switcher.md
@@ -10,6 +10,6 @@
| `` | `` or `` |
| `` | `` |
| Organization props on `UserButton` (`hidePersonal`, `afterSelectOrganizationUrl`, `createOrganizationUrl`, ...) | The same props on `Switcher` |
-| `UserButtonMenuProps` | `CombinedSwitcherProps` |
+| `UserButtonProps` (combined menu) | `CombinedSwitcherProps` |
Find usages with `git grep -n "UserButton"`.
diff --git a/packages/mosaic/docs/testing.md b/packages/mosaic/docs/testing.md
index 70e63af38ca..8b3dcdbefa1 100644
--- a/packages/mosaic/docs/testing.md
+++ b/packages/mosaic/docs/testing.md
@@ -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-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
index f8272ca6270..82529f9c1f1 100644
--- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
@@ -18,7 +18,7 @@ import {
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
-import type { OrganizationSwitcherProps } from '../organization-switcher';
+import type { OrganizationSwitcherProps } from '../../switcher/switcher';
import { OrganizationSwitcher } from '../organization-switcher';
beforeAll(async () => {
From 3d122eeedb031c4c18bab77cb9ccbd137bfc0f96 Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Tue, 10 Nov 2026 14:02:44 -0500
Subject: [PATCH 4/6] fix(mosaic): let Switcher take a mode chosen at runtime
---
.changeset/mosaic-switcher.md | 2 +-
.../src/features/switcher/switcher.test-d.tsx | 77 ++++++++++---------
.../mosaic/src/features/switcher/switcher.tsx | 15 ++--
3 files changed, 50 insertions(+), 44 deletions(-)
diff --git a/.changeset/mosaic-switcher.md b/.changeset/mosaic-switcher.md
index 60ece1ef52a..d3cef3a5ec5 100644
--- a/.changeset/mosaic-switcher.md
+++ b/.changeset/mosaic-switcher.md
@@ -2,7 +2,7 @@
'@clerk/mosaic': minor
---
-**Breaking:** Add `Switcher`, the account and organization menu, and make `UserButton` list accounts only. `Switcher` takes `mode`: `'combined'` (the default) lists accounts and organizations, `'user'` lists accounts only, and `'organization'` lists organizations. `` is now `` and `` is ``. Each mode takes only its own props, so TypeScript flags organization options on `UserButton` and `triggerVariant` outside `combined`.
+**Breaking:** Add `Switcher`, the account and organization menu, and make `UserButton` list accounts only. `Switcher` takes `mode`: `'combined'` (the default) lists accounts and organizations, `'user'` lists accounts only, and `'organization'` lists organizations. `` 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 |
| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
diff --git a/packages/mosaic/src/features/switcher/switcher.test-d.tsx b/packages/mosaic/src/features/switcher/switcher.test-d.tsx
index 52d32c4922a..9f385170008 100644
--- a/packages/mosaic/src/features/switcher/switcher.test-d.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.test-d.tsx
@@ -2,7 +2,9 @@ import { describe, test } from 'vitest';
import { OrganizationSwitcher } from '../organization-switcher/organization-switcher';
import { UserButton } from '../user-button/user-button';
+import type { SwitcherProps } from './switcher';
import { Switcher } from './switcher';
+import type { SwitcherMode } from './switcher.types';
describe('Switcher props', () => {
test('takes every option in combined mode, with or without the mode', () => {
@@ -31,27 +33,12 @@ describe('Switcher props', () => {
signInUrl='/sign-in'
/>
);
- void (
-
- );
- void (
-
- );
- void (
-
- );
+ // @ts-expect-error -- the trigger variant only applies to the combined menu
+ void ({ mode: 'user', triggerVariant: 'full' } satisfies SwitcherProps);
+ // @ts-expect-error -- a user menu lists no organizations
+ void ({ mode: 'user', hidePersonal: true } satisfies SwitcherProps);
+ // @ts-expect-error -- a user menu lists no organizations
+ void ({ mode: 'user', createOrganizationUrl: '/orgs/new' } satisfies SwitcherProps);
});
test('takes organization options in organization mode and rejects the rest', () => {
@@ -62,37 +49,55 @@ describe('Switcher props', () => {
createOrganizationUrl='/orgs/new'
/>
);
+ // @ts-expect-error -- the trigger variant only applies to the combined menu
+ void ({ mode: 'organization', triggerVariant: 'full' } satisfies SwitcherProps);
+ // @ts-expect-error -- an organization menu lists no accounts
+ void ({ mode: 'organization', afterSwitchSessionUrl: '/dashboard' } satisfies SwitcherProps);
+ });
+
+ test('requires a URL to navigate to the create-organization page', () => {
+ // @ts-expect-error -- navigation needs a URL
+ void ({ createOrganizationMode: 'navigation' } satisfies SwitcherProps);
+ // @ts-expect-error -- navigation needs a URL
+ void ({ mode: 'organization', createOrganizationMode: 'navigation' } satisfies SwitcherProps);
+ });
+
+ test('takes a mode chosen at runtime with the props every mode shares', () => {
+ const mode = pickMode();
void (
- );
- void (
-
);
+ // @ts-expect-error -- only a static combined mode takes the trigger variant
+ void ({ mode, triggerVariant: 'full' } satisfies SwitcherProps);
+ // @ts-expect-error -- only a static mode takes that mode's options
+ void ({ mode, hidePersonal: true } satisfies SwitcherProps);
+ // @ts-expect-error -- only a static mode takes that mode's options
+ void ({ mode, createOrganizationMode: 'navigation' } satisfies SwitcherProps);
});
});
describe('UserButton and OrganizationSwitcher props', () => {
test('match their Switcher mode and take no mode of their own', () => {
void ();
- // @ts-expect-error a user menu lists no organizations
+ // @ts-expect-error -- a user menu lists no organizations
void ();
- // @ts-expect-error the trigger variant only applies to the combined menu
+ // @ts-expect-error -- the trigger variant only applies to the combined menu
void ();
- // @ts-expect-error the mode is fixed
+ // @ts-expect-error -- the mode is fixed
void ();
void ();
- // @ts-expect-error an organization menu lists no accounts
+ // @ts-expect-error -- an organization menu lists no accounts
void ();
- // @ts-expect-error the mode is fixed
+ // @ts-expect-error -- the mode is fixed
void ();
});
});
+
+function pickMode(): SwitcherMode {
+ return 'user';
+}
diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx
index 8ddee652616..64f50e4950c 100644
--- a/packages/mosaic/src/features/switcher/switcher.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.tsx
@@ -11,7 +11,7 @@ import type {
} from './switcher.model';
import { useSwitcherModel } from './switcher.model';
import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages';
-import type { SwitcherMenuProps } from './switcher.types';
+import type { SwitcherMenuProps, SwitcherMode } from './switcher.types';
import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view';
import { SwitcherView } from './switcher.view';
@@ -47,13 +47,14 @@ export type OrganizationSwitcherProps = SwitcherBaseProps & OrganizationSwitcher
/**
* What the menu lists, and the options that apply to it. `combined` lists accounts and organizations,
* `user` lists accounts alone and never an organization, even when one is active, and `organization`
- * lists organizations and the personal account. The profile's shape is set by `MosaicProvider`'s
- * `profile`.
+ * lists organizations and the personal account. A mode chosen at runtime takes only the props every
+ * mode shares. The profile's shape is set by `MosaicProvider`'s `profile`.
*/
export type SwitcherProps =
| ({ mode?: 'combined' } & CombinedSwitcherProps)
- | ({ mode: 'user' } & UserButtonProps)
- | ({ mode: 'organization' } & OrganizationSwitcherProps);
+ | ({ mode: 'user'; triggerVariant?: never } & UserButtonProps)
+ | ({ mode: 'organization'; triggerVariant?: never } & OrganizationSwitcherProps)
+ | ({ mode: SwitcherMode } & SwitcherBaseProps & { [K in keyof SwitcherModelOptions | 'triggerVariant']?: never });
/**
* The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account,
@@ -123,13 +124,13 @@ export function Switcher(props: SwitcherProps = {}): ReactElement | null {
showTriggerLabel,
triggerBadge,
mode = 'combined',
+ triggerVariant: requestedTriggerVariant,
customMenuItems,
menuItemOrder,
fallback,
...options
} = props;
- const triggerVariant =
- props.mode === 'user' || props.mode === 'organization' ? 'condensed' : (props.triggerVariant ?? 'full');
+ const triggerVariant = mode === 'combined' ? (requestedTriggerVariant ?? 'full') : 'condensed';
const profile = useMosaicProfile();
const { userProfileProps, organizationProfileProps } = profile;
// The portals must outlive the popover, so custom pages are bridged here rather than inside it.
From 898a119492144c8c639710279c0cb78a7b85e7f1 Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Tue, 10 Nov 2026 17:40:03 -0500
Subject: [PATCH 5/6] feat(mosaic): add Profile and match the opened profile to
the menu
---
.changeset/mosaic-profile-component.md | 30 ++
.../src/app/user-profile/page.tsx | 4 +-
.../mosaic/docs/documenting-components.md | 5 +-
.../organization-profile.feature.test.tsx | 9 +
.../organization-profile.tsx | 67 +---
.../profile-pages/use-mosaic-profile.ts | 6 +-
.../__tests__/profile.feature.test.tsx | 261 +++++++++++++++
.../src/features/profile/profile.test-d.tsx | 90 ++++++
.../mosaic/src/features/profile/profile.tsx | 298 ++++++++++++++++++
.../switcher.new-profiles.feature.test.tsx | 9 +-
.../switcher.profile.feature.test.tsx | 113 ++++---
.../src/features/switcher/switcher.model.tsx | 25 +-
.../__tests__/user-profile.feature.test.tsx | 197 +-----------
...user-profile.modal-return.feature.test.tsx | 20 +-
...rofile-connected-accounts-section.model.ts | 4 +-
.../user-profile-email-section.model.ts | 13 +-
...ofile-enterprise-accounts-section.model.ts | 4 +-
.../user-profile.modal-content.tsx | 5 +-
.../user-profile/user-profile.modal-return.ts | 15 +-
.../user-profile/user-profile.modal.types.ts | 9 +-
.../user-profile/user-profile.provider.tsx | 9 +-
.../features/user-profile/user-profile.tsx | 184 +----------
packages/mosaic/src/index.ts | 2 +
.../app/(clerk)/live/profile/profile/page.tsx | 34 ++
.../swingset/src/components/Composition.tsx | 1 +
.../swingset/src/components/DocsViewer.tsx | 3 +
packages/swingset/src/lib/live-navigation.ts | 1 +
packages/swingset/src/lib/registry.ts | 8 +
.../swingset/src/stories/mosaic-provider.mdx | 7 +-
packages/swingset/src/stories/profile.mdx | 17 +
.../swingset/src/stories/profile.stories.tsx | 30 ++
31 files changed, 958 insertions(+), 522 deletions(-)
create mode 100644 .changeset/mosaic-profile-component.md
create mode 100644 packages/mosaic/src/features/profile/__tests__/profile.feature.test.tsx
create mode 100644 packages/mosaic/src/features/profile/profile.test-d.tsx
create mode 100644 packages/mosaic/src/features/profile/profile.tsx
create mode 100644 packages/swingset/src/app/(clerk)/live/profile/profile/page.tsx
create mode 100644 packages/swingset/src/stories/profile.mdx
create mode 100644 packages/swingset/src/stories/profile.stories.tsx
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 '
- custom-page-content
}]} />
+ custom-page-content }]} />
);
}
diff --git a/packages/mosaic/docs/documenting-components.md b/packages/mosaic/docs/documenting-components.md
index 8314f49e846..ff7869f205b 100644
--- a/packages/mosaic/docs/documenting-components.md
+++ b/packages/mosaic/docs/documenting-components.md
@@ -20,6 +20,7 @@ Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `p
| Group | What lives here | Archetype |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `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) |
-`Switcher` / `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,7 +205,7 @@ 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 (`Switcher`, `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:
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: