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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/mosaic-profile-component.md
Original file line number Diff line number Diff line change
@@ -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. `<UserProfile />` now shows only the account's pages, and `<OrganizationProfile />` only the organization's. To keep both in one profile, render `<Profile />`.

```tsx
<Profile />
<Profile mode='user' />
<Profile mode='organization' afterLeaveOrganizationUrl='/organizations' />
```

`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. `<UserButton />` and `<Switcher mode='user' />` open the account's pages, a lone `<OrganizationSwitcher />` opens the organization's pages, and only a combined `<Switcher />` opens one profile with both. `profile.combined: false` still splits a combined menu's profile in two.

| Before | After |
| --------------------------------------------------------- | --------------------------------------------- |
| `<UserProfile />` | `<Profile />` |
| `<UserProfile combined />` | `<Profile />` |
| `<UserProfile combined={false} />` | `<UserProfile />` or `<Profile mode='user' />` |
| `<UserProfile organizationProfileProps={...} />` | `<Profile organizationProfileProps={...} />` |

To find the code to update, run:

```sh
git grep -n '<UserProfile'
```

New exported types: `ProfileProps`, `CombinedProfileProps` and `ProfileMode`.
15 changes: 15 additions & 0 deletions .changeset/mosaic-switcher.md
Original file line number Diff line number Diff line change
@@ -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. `<UserButton />` is now `<Switcher mode='user' />` and `<OrganizationSwitcher />` is `<Switcher mode='organization' />`. 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 |
| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `<UserButton />` (accounts and organizations) | `<Switcher />` |
| `<UserButton combined={false} />` | `<UserButton />` or `<Switcher mode='user' />` |
| `<UserButton triggerVariant='condensed' />` | `<Switcher triggerVariant='condensed' />` |
| Organization props on `UserButton` (`hidePersonal`, `afterSelectOrganizationUrl`, `createOrganizationUrl`, ...) | The same props on `Switcher` |
| `UserButtonProps` (combined menu) | `CombinedSwitcherProps` |

Find usages with `git grep -n "UserButton"`.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { MosaicProvider, UserButton } from '@clerk/mosaic';
import { Switcher, MosaicProvider } from '@clerk/mosaic';

export default function Page() {
return (
Expand All @@ -12,7 +12,7 @@ export default function Page() {
},
}}
>
<UserButton customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]} />
<Switcher customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]} />
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use client';

import { MosaicProvider, UserButton } from '@clerk/mosaic';
import { Switcher, MosaicProvider } from '@clerk/mosaic';

export function MosaicUserButton() {
return (
<MosaicProvider experimental={{ newProfiles: true }}>
<UserButton />
<Switcher />
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use client';

import { MosaicProvider, UserProfile } from '@clerk/mosaic';
import { MosaicProvider, Profile } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<UserProfile customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]} />
<Profile customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]} />
</MosaicProvider>
);
}
2 changes: 1 addition & 1 deletion packages/mosaic/.claude/skills/verify-mosaic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ $ CS ab eval '(() => document.querySelector("[role=dialog]").dataset.variant)()'

Conventions:

- Routes are `/<group-slug>/<component-slug>`: `/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 `/<group-slug>/<component-slug>`: `/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-<slot>` class plus `data-<axis>` 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.
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/.claude/skills/verify-mosaic/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>` 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.
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ClerkProvider publishableKey={process.env.CLERK_PUBLISHABLE_KEY}>
<UserButton />
<Switcher />
{children}
</ClerkProvider>
);
Expand Down
13 changes: 7 additions & 6 deletions packages/mosaic/docs/documenting-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Read this when you add or change a Swingset page for a Mosaic component, block, hook or style: a `<name>.stories.tsx` or `<name>.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

Expand All @@ -19,7 +19,8 @@ Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `p

| Group | What lives here | Archetype |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `Switcher` | Composed flow UI (e.g. `Switcher`) | C |
| `Profile` | Composed flow UI (`Profile`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Organization Profile` | Composed flow UI (e.g. `OrganizationProfileGeneralPanel`) | C |
| `Misc` | Standalone composed components (e.g. `OrganizationSwitcher`, `Waitlist`) | C |
Expand All @@ -30,7 +31,7 @@ Worked examples: `button.stories.tsx` and `button.mdx` (archetype A, simple), `p
| `Hooks` | Headless hooks (`useForm`) | B (adapted) |
| `Localization` | The `localization` prop on `MosaicProvider`: catalogs, overrides, locale, and the message helpers | B (adapted) |

`User Button` / `User Profile` / `Organization Profile` / `Misc` / `Blocks` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`Switcher` / `Profile` / `User Profile` / `Organization Profile` / `Misc` / `Blocks` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export
Expand Down Expand Up @@ -204,14 +205,14 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

Every props table has a **Default** column, and the `Type` stays a plain union, never with the default inlined. The auto `<PropTable>` fills it from `meta.styles._defaultVariants`; hand-written tables (`Prop | Type | Default | Description`) fill it by hand. Name the default member (`'bottom-start'`), use `—` when there is none and append `(required)` for a required prop, and state a behavioral default in words (`inherits Root`).

## Archetype C — composed layer (`User Button`, `User Profile`, `Organization Profile`, `Misc`, `Blocks`)
## Archetype C — composed layer (`Switcher`, `Profile`, `User Profile`, `Organization Profile`, `Misc`, `Blocks`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

```mdx
import * as SwitcherStories from './user-button.stories';
import * as SwitcherStories from './switcher.stories';

# UserButton
# Switcher

<!-- One paragraph: what state this owns and which lower-layer pieces it wires together. -->

Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(<UserButton />);
await renderWithClerk(<Switcher />);
const user = userEvent.setup();

await user.click(screen.getByRole('button', { name: /Open account menu/ }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ describe('OrganizationProfile', () => {
expect(screen.queryByText('Loading profile')).toBeNull();
});

it("adds the provider's organization pages", async () => {
serve();
await renderWithClerk(<OrganizationProfile />, undefined, undefined, undefined, {
userProfileProps: { customPages: [{ path: 'usage', label: 'Usage', content: <p>Usage</p> }] },
organizationProfileProps: { customPages: [{ path: 'audit', label: 'Audit log', content: <p>Audit</p> }] },
});
expect(tabNames()).toEqual(['General', 'Members', 'Audit log']);
});

it('renders nothing when signed out', async () => {
const fapi = serve();
fapi.client = fapiClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,9 @@
import type { ReactNode } from 'react';

import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
import { organizationProfilePageIds } from './organization-profile.layout';
import { useOrganizationProfileModel } from './organization-profile.model';
import { Profile } from '../profile/profile';
import type { OrganizationProfileOptions } from './organization-profile.provider';
import { OrganizationProfileProvider } from './organization-profile.provider';
import type { OrganizationProfilePageId, OrganizationProfilePages } from './organization-profile.types';
import type { OrganizationProfileViewProps } from './organization-profile.view';
import { OrganizationProfileView } from './organization-profile.view';
import { OrganizationProfileApiKeysPanel } from './organization-profile-api-keys-panel';
import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel';
import { OrganizationProfileMembersPanel } from './organization-profile-members-panel/organization-profile-members-panel';

/** Everything `<OrganizationProfile />` takes: your own pages and their order, the page it opens on, and where leaving the organization lands. */
export interface OrganizationProfileProps
Expand Down Expand Up @@ -42,7 +33,7 @@ export interface OrganizationProfileProps
}

/**
* The active organization's profile: General, Members when the user may read or manage
* The active organization's profile, the same as `<Profile mode='organization' />`: General, Members when the user may read or manage
* memberships, and API keys when the instance enables them and the user may use them, with your own
* pages alongside. It reads the organization from Clerk, so it takes no data. It renders `fallback`
* until Clerk answers, and nothing at all when there is no active organization or nobody is signed in.
Expand Down Expand Up @@ -80,55 +71,11 @@ export interface OrganizationProfileProps
* <OrganizationProfile afterLeaveOrganizationUrl='/organizations' />
* ```
*/
export function OrganizationProfile(props: OrganizationProfileProps) {
const { organizationProfileProps } = useMosaicProfile();
const {
customPages = organizationProfileProps?.customPages,
pageOrder = organizationProfileProps?.pageOrder,
defaultPage,
afterLeaveOrganizationUrl = organizationProfileProps?.afterLeaveOrganizationUrl,
fallback,
apiKeysProps = organizationProfileProps?.apiKeysProps,
} = props;
const model = useOrganizationProfileModel();
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
const navigation = useProfilePagesController(
defaultPage ?? pageOrder?.[0] ?? 'general',
pages && organizationProfilePageIds({ pages, customPages, pageOrder }),
);

if (model.status === 'loading') {
return fallback ?? null;
}
if (!pages) {
return null;
}

export function OrganizationProfile(props: OrganizationProfileProps): ReactNode {
return (
<OrganizationProfileProvider afterLeaveOrganizationUrl={afterLeaveOrganizationUrl}>
<OrganizationProfileView
elevation='flush'
{...navigation}
customPages={customPages}
pageOrder={pageOrder}
pages={pages}
/>
</OrganizationProfileProvider>
<Profile
{...props}
mode='organization'
/>
);
}

/**
* The organization's built-in panels, for the pages the instance has turned on. `apiKeysProps.hide`
* leaves the API keys panel out; the rest of `apiKeysProps` goes to it.
*/
export function organizationProfilePanels(
available: readonly OrganizationProfilePageId[],
{ hide: hideAPIKeys = false, ...apiKeysProps }: APIKeysPageProps = {},
): OrganizationProfilePages {
return {
general: <OrganizationProfileGeneralPanel />,
members: available.includes('members') ? <OrganizationProfileMembersPanel /> : undefined,
apiKeys:
available.includes('apiKeys') && !hideAPIKeys ? <OrganizationProfileApiKeysPanel {...apiKeysProps} /> : undefined,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading