`, or a custom element via `render`. |
-| `Item.Media` | `cl-item-media` | Square leading column: icon, image, or avatar. Sized by the root's `size`. |
-| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. |
-| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. |
-| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. |
-| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Dimmed until hovered. |
-| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
-| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). |
-| `Item.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. |
+| Part | Class | Description |
+| ------------------ | --------------------- | -------------------------------------------------------------------------------- |
+| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. |
+| `Item.Media` | `cl-item-media` | Square leading column: icon, image, or avatar. Sized by the root's `size`. |
+| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. |
+| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. |
+| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. |
+| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Inherits the row's text color. |
+| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
+| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). |
+| `Item.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. |
Every part accepts a `render` prop for element polymorphism and forwards a ref.
## Styling
+The row carries the text color and, through `--_cl-icon-color`, the strength of any `Icon` inside it: faded at rest, full-strength while hovered. Only interactive rows promote — a static row isn't pointing at anything, so its icon and `Item.Label` hold their resting color. A `Button` in `Item.Actions` sets its own icon color and is unaffected.
+
The root reflects its state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms:
| Prop | Attribute | Values | Default |
@@ -138,4 +140,4 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c
}
```
-`Item.Media` is a square that centers its child. Because the column is sized by the row, give it a child that fills it — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'` — rather than a fixed pixel size that won't track `size`. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`).
+`Item.Media` is a square that centers its child. Because the column is sized by the row, give it a child that fills it — an `Avatar.Root` with `size='fit'`, or an `Icon` — rather than a fixed pixel size that won't track `size`. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`).
diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx
index cbf66563c04..e07f9a8ee9f 100644
--- a/packages/swingset/src/stories/item.stories.tsx
+++ b/packages/swingset/src/stories/item.stories.tsx
@@ -1,6 +1,7 @@
/** @jsxImportSource @emotion/react */
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
import { Button } from '@clerk/ui/mosaic/components/button';
+import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Item } from '@clerk/ui/mosaic/components/item';
import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area';
import { radiusVars } from '@clerk/ui/mosaic/styles';
@@ -19,68 +20,6 @@ export const meta: StoryMeta = {
source: 'packages/ui/src/mosaic/components/item/item.tsx',
};
-function CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
-
-
- );
-}
-
-function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
-
-
-
- );
-}
-
-function SignOutIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
-
-
- );
-}
-
export function Default() {
return (
@@ -201,23 +140,7 @@ export function Group() {
size='sm'
shape='square'
>
-
-
-
-
-
+
@@ -237,7 +160,10 @@ export function Group() {
Clerk
-
+
-
+
Add account
@@ -325,7 +254,10 @@ export function Group() {
)}
>
-
+
Sign out of all accounts
@@ -363,7 +295,7 @@ export function Scrolling() {
{organizations.map(name => (
diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx
index 9ce80bcbbb2..e1b8e2aecad 100644
--- a/packages/swingset/src/stories/popover.component.mdx
+++ b/packages/swingset/src/stories/popover.component.mdx
@@ -145,16 +145,37 @@ centering on it.
Cross-axis flipping is only enabled for aligned placements, so `bottom-start` may become
`bottom-end` near a viewport edge while a plain `bottom` will not.
+`alignOffset` nudges the popup along that alignment axis, the way `sideOffset` does along the side.
+Use it to cancel padding inside the popup so its content, rather than its edge, lines up with the
+trigger — a negative value pulls a `-start` placement further left.
+
+```tsx
+
+ Open
+
+
+ Pulled 8px left of the trigger's start edge.
+
+
+ ;
+```
+
+It is a preference like placement is: `shift` still claws the popup back when the nudge would push
+it out of view.
+
## Parts
-| Part | Slot | Description |
-| --------------------- | --------------- | ----------------------------------------------------------------------- |
-| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `modal`. |
-| `Popover.Trigger` | — | Anchor element; renders a `` and accepts a `render` prop. |
-| `Popover.Popup` | `popover-popup` | The floating box; owns `size`, viewport clamps, and the enter/exit run. |
-| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
-| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
-| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |
+| Part | Slot | Description |
+| --------------------- | ----------------- | ----------------------------------------------------------------------------------- |
+| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `alignOffset`, `modal`. |
+| `Popover.Trigger` | `popover-trigger` | Anchor element; renders a `` and accepts a `render` prop. |
+| `Popover.Popup` | `popover-popup` | The floating box; owns `size`, viewport clamps, and the enter/exit run. |
+| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
+| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
+| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |
`Popover.Popup` renders the portal and the floating positioner internally. The positioner carries
the `popover-positioner` slot and is the `role="dialog"` element, which is why the popup's
diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx
index 4199aed5e9e..7c30369feb1 100644
--- a/packages/swingset/src/stories/scroll-area.stories.tsx
+++ b/packages/swingset/src/stories/scroll-area.stories.tsx
@@ -68,7 +68,7 @@ export function Default() {
{accounts.map(({ email, organizations }, index) => (
@@ -100,7 +100,7 @@ export function NotScrollable() {
{accounts[0].organizations.map(name => (
@@ -145,7 +145,7 @@ export function Gutter() {
{names.map(name => (
@@ -213,7 +213,7 @@ export function HoverReveal() {
{...root}
className={`${root.className} border-border w-full border`}
css={{ '--cl-scrollbar-thumb-idle': 'oklch(from var(--cl-scrollbar-thumb) l c h / 0)' }}
- style={{ height: 260, borderRadius: radiusVars['--cl-radius-inner'] }}
+ style={{ height: 260, borderRadius: radiusVars['--cl-radius-sm'] }}
>
{manyRows.map(name => (
@@ -248,7 +248,7 @@ export function ThemedScrollbar() {
'--cl-scrollbar-thumb-hover': 'oklch(0.65 0.24 15)',
'--cl-scrollbar-thumb-active': 'oklch(0.55 0.25 295)',
}}
- style={{ height: 260, borderRadius: radiusVars['--cl-radius-inner'] }}
+ style={{ height: 260, borderRadius: radiusVars['--cl-radius-sm'] }}
>
{manyRows.map(name => (
@@ -306,7 +306,7 @@ export function ShadowIndicators() {
+
+## Usage
+
+`UserButtonView` renders trigger and popup from one prop-driven call:
+
+```tsx
+import { UserButtonView } from '@clerk/ui/mosaic/user-button/user-button.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
+`UserButtonRoot`; the leaves read them from context:
+
+```tsx
+import {
+ UserButtonRoot,
+ UserButtonTrigger,
+ UserButtonPopup,
+} from '@clerk/ui/mosaic/user-button/user-button.view';
+
+
+
+
+
+```
+
+Exports are flat (not `UserButton.Trigger`) so each part declares its own `'use client'` boundary.
+
+## Trigger
+
+The active workspace's avatar and what it is called: the org and its plan wherever one heads the
+trigger, the account otherwise.
+
+`renderTriggerLabel={false}` leaves the avatar alone.
+
+
+
+`renderPlanBadge={false}` keeps the name. The badge is part of the label, so it needs both.
+
+
+
+## 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 org; `modePriority` flips it. |
+| `orgs` | Organizations only; no account rows. | The org. |
+| `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.
+**Invite** belongs to the active org wherever there is one; the gear manages whatever leads.
+
+`combined` carries both switchers, so `modePriority` picks which one leads. Everything is listed
+either way; only the trigger and the header move.
+
+
+
+Off-mode data is not an error. Both examples below take the same props as the one above and differ
+only by `mode`.
+
+### Organizations
+
+The org heads it, with **Invite** and the gear (**Manage organization**). With no account menu to
+hold it, **Create organization** moves to the foot. The extra account is in the props, deliberately
+unrendered.
+
+
+
+### User
+
+The account heads it, with **Sign out** in **Invite**'s slot and the gear (**Manage account**). The
+header is the active account, so the list holds only the accounts to switch to — no heading over
+them, and **Add account** moves to the foot the way **Create organization** does. An org is active;
+this mode ignores it, down to the trigger.
+
+
+
+## Menu items
+
+`customMenuItems` adds the app's own rows to the foot of the popup, ahead of 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.
+
+`menuItemOrder` names the foot's rows by id, and whatever it leaves out follows what it named. The
+ids it knows are `createOrganization`, `addAccount`, `signOutAll`, and each custom item's `id`. Those
+are the only actions the foot lists as rows; the rest live in the header or behind a `⋯`, where
+there is no list for an order to run in.
+
+Which of the three the foot carries depends on the surface, not just the mode: `createOrganization`
+is a row only on an org-only surface, and `addAccount` only where no Accounts heading holds it — an
+account with nobody to switch to. So an id the surface has not got is ignored rather than an error,
+and one order can cover every mode.
+
+
+
+## Parts
+
+| Part | Description |
+| ------------------- | ---------------------------------------------------------------------------- |
+| `UserButtonRoot` | Owns the data, callbacks, and `mode`; forwards open state to `Popover.Root`. |
+| `UserButtonTrigger` | The trigger: the active workspace's avatar, and what it is called. |
+| `UserButtonPopup` | The popover surface: header, workspace list, additional accounts, footer. |
diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx
new file mode 100644
index 00000000000..6573e08b297
--- /dev/null
+++ b/packages/swingset/src/stories/user-button.stories.tsx
@@ -0,0 +1,352 @@
+/** @jsxImportSource @emotion/react */
+import { Icon } from '@clerk/ui/mosaic/components/icon';
+import {
+ userButtonBusyKeys,
+ type UserButtonInvitation,
+ type UserButtonMembership,
+ type UserButtonProps,
+ type UserButtonSession,
+ type UserButtonSuggestion,
+ UserButtonView,
+} from '@clerk/ui/mosaic/user-button/user-button.view';
+import { useState } from 'react';
+
+import type { StoryMeta } from '@/lib/types';
+
+// 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',
+ title: 'UserButton',
+ source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
+};
+
+// Accounts wear their own photo. Only the flagship workspace carries the Clerk mark; the rest wear
+// the generated mark Clerk gives an organization that has not uploaded a logo.
+const clerkLogo = 'https://avatars.githubusercontent.com/u/49538330?v=4';
+const defaultOrgLogo =
+ 'https://img.clerk.com/eyJ0eXBlIjoiZGVmYXVsdCIsImlpZCI6Imluc18xbHlXRFppb2JyNjAwQUtVZVFEb1NsckVtb00iLCJyaWQiOiJvcmdfMnp6WVh1TURBRTBYWFh5Q1lHN3dyQXRFd0VpIiwiaW5pdGlhbHMiOiJQIn0?width=48';
+
+const colin: UserButtonSession = {
+ sessionId: 'sess_colin',
+ name: 'Colin',
+ identifier: 'colin@clerk.dev',
+ imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4',
+};
+
+const braden: UserButtonSession = {
+ sessionId: 'sess_braden',
+ name: 'Braden',
+ identifier: 'braden@clerk.dev',
+ imageUrl: 'https://avatars.githubusercontent.com/u/64913815?v=4',
+};
+
+/**
+ * One signed-in account and everything that belongs to it. The prototype holds all of it the way a
+ * backend would; only the active account's half of it ever reaches the component.
+ */
+interface Account {
+ session: UserButtonSession;
+ memberships: UserButtonMembership[];
+ activeOrganizationId: string | null;
+ suggestions: UserButtonSuggestion[];
+ invitations: UserButtonInvitation[];
+}
+
+const clerkCloud: UserButtonMembership = {
+ kind: 'membership',
+ organizationId: 'org_clerk_cloud',
+ name: 'Clerk Cloud',
+ membersCount: 6,
+ imageUrl: defaultOrgLogo,
+};
+
+// Two accounts with different workspaces, so switching account changes the list under it too.
+const initialAccounts: Account[] = [
+ {
+ session: colin,
+ activeOrganizationId: 'org_clerk_app',
+ memberships: [
+ {
+ kind: 'membership',
+ organizationId: 'org_clerk_app',
+ name: 'Clerk app',
+ membersCount: 24,
+ planLabel: 'Pro plan',
+ imageUrl: clerkLogo,
+ },
+ clerkCloud,
+ ],
+ suggestions: [
+ {
+ kind: 'suggestion',
+ id: 'sug_labs',
+ organizationId: 'org_clerk_labs',
+ name: 'Clerk Labs',
+ status: 'pending',
+ imageUrl: defaultOrgLogo,
+ },
+ ],
+ invitations: [],
+ },
+ {
+ session: braden,
+ // An organization only Braden is in, so switching to him changes the header, the trigger, and
+ // the list under it all at once.
+ activeOrganizationId: 'org_clerk_marketing',
+ memberships: [
+ {
+ kind: 'membership',
+ organizationId: 'org_clerk_marketing',
+ name: 'Clerk Marketing',
+ membersCount: 9,
+ imageUrl: defaultOrgLogo,
+ },
+ clerkCloud,
+ ],
+ suggestions: [],
+ invitations: [
+ {
+ kind: 'invitation',
+ id: 'inv_app',
+ organizationId: 'org_clerk_app',
+ organizationName: 'Clerk app',
+ status: 'pending',
+ imageUrl: clerkLogo,
+ },
+ ],
+ },
+];
+
+/** Joining is what turns a suggestion or an invitation into a workspace you can switch to. */
+function join(account: Account, organizationId: string, name: string, imageUrl?: string): Account {
+ return {
+ ...account,
+ activeOrganizationId: organizationId,
+ memberships: [...account.memberships, { kind: 'membership', organizationId, name, imageUrl }],
+ suggestions: account.suggestions.filter(s => s.organizationId !== organizationId),
+ invitations: account.invitations.filter(i => i.organizationId !== organizationId),
+ };
+}
+
+// Long enough to read the spinner without making the prototype feel broken.
+const LATENCY_MS = 800;
+
+/**
+ * The examples are prototypes, not screenshots: every row is wired to state, so picking a workspace
+ * or an account really switches to it, and Join turns a suggestion into a workspace. None of it is
+ * instant — each action is a network round trip against Clerk, so the prototype fakes one: the
+ * clicked row spins, the rest stand down, and the surface stays open so you land back on the result.
+ * Only picking a workspace closes it, because that is the one action the surface exists to perform.
+ * The actions that would navigate somewhere in a real app (Manage, Invite, Create organization, Add
+ * account) have nowhere to go here, so they only close the popover.
+ */
+function usePrototype(): Omit {
+ const [open, setOpen] = useState(false);
+ const [accounts, setAccounts] = useState(initialAccounts);
+ const [activeSessionId, setActiveSessionId] = useState(colin.sessionId);
+ const [pendingKey, setPendingKey] = useState(null);
+
+ const account = accounts.find(a => a.session.sessionId === activeSessionId) ?? accounts[0];
+ const close = () => setOpen(false);
+
+ // One action at a time, the same re-entry guard the connected component holds while a request
+ // is in flight. `pendingKey` is what the view reads to spin one row and stand the others down.
+ const run = (key: string, commit: () => void, closeOnSuccess = false) => {
+ if (pendingKey) {
+ return;
+ }
+ setPendingKey(key);
+ setTimeout(() => {
+ commit();
+ setPendingKey(null);
+ if (closeOnSuccess) {
+ close();
+ }
+ }, LATENCY_MS);
+ };
+
+ const updateActive = (change: (account: Account) => Account) =>
+ setAccounts(current => current.map(a => (a.session.sessionId === activeSessionId ? change(a) : a)));
+
+ const signOutSession = (sessionId: string) => {
+ const remaining = accounts.filter(a => a.session.sessionId !== sessionId);
+ const [next] = remaining;
+ // A prototype with nobody signed in has nothing left to show, so the last account stays put.
+ if (!next) {
+ return;
+ }
+ setAccounts(remaining);
+ if (sessionId === activeSessionId) {
+ setActiveSessionId(next.session.sessionId);
+ }
+ };
+
+ return {
+ open,
+ onOpenChange: setOpen,
+ pendingKey,
+ activeSession: account.session,
+ // The join is the backend's, not the component's: it is handed the active organization whole.
+ activeOrganization: account.memberships.find(m => m.organizationId === account.activeOrganizationId) ?? null,
+ hasOrganizations: account.memberships.length > 0,
+ memberships: account.memberships,
+ suggestions: account.suggestions,
+ invitations: account.invitations,
+ additionalSessions: accounts.filter(a => a.session.sessionId !== activeSessionId).map(a => a.session),
+ // Selecting an organization only ever acts on the active account, and is the one action that
+ // closes the surface behind it.
+ onSelectOrganization: organizationId =>
+ run(
+ userButtonBusyKeys.selectOrganization(organizationId),
+ () => updateActive(a => ({ ...a, activeOrganizationId: organizationId })),
+ true,
+ ),
+ // Joining switches to what you just joined, and staying open is what makes that visible.
+ onAcceptSuggestion: id =>
+ run(userButtonBusyKeys.acceptSuggestion(id), () =>
+ updateActive(a => {
+ const suggestion = a.suggestions.find(s => s.id === id);
+ return suggestion ? join(a, suggestion.organizationId, suggestion.name, suggestion.imageUrl) : a;
+ }),
+ ),
+ onAcceptInvitation: id =>
+ run(userButtonBusyKeys.acceptInvitation(id), () =>
+ updateActive(a => {
+ const invitation = a.invitations.find(i => i.id === id);
+ return invitation ? join(a, invitation.organizationId, invitation.organizationName, invitation.imageUrl) : a;
+ }),
+ ),
+ onSwitchSession: sessionId => run(userButtonBusyKeys.switchSession(sessionId), () => setActiveSessionId(sessionId)),
+ onSignOutSession: sessionId => run(userButtonBusyKeys.signOutSession(sessionId), () => signOutSession(sessionId)),
+ // Nothing is left to render once every account is gone, so this one closes too.
+ onSignOutAll: () => run(userButtonBusyKeys.signOutAll(), close),
+ onManageOrganization: close,
+ onInviteMembers: close,
+ onManageAccount: close,
+ onCreateOrganization: close,
+ onAddAccount: close,
+ };
+}
+
+export function Combined(_args: Record) {
+ const prototype = usePrototype();
+
+ return (
+
+ );
+}
+
+export function UserPriority(_args: Record) {
+ const prototype = usePrototype();
+
+ return (
+
+ );
+}
+
+export function AvatarOnly(_args: Record) {
+ const prototype = usePrototype();
+
+ return (
+
+ );
+}
+
+export function WithoutPlanBadge(_args: Record) {
+ const prototype = usePrototype();
+
+ return (
+
+ );
+}
+
+export function Organizations(_args: Record) {
+ const prototype = usePrototype();
+
+ // Fed the same data as the others, including the additional account it deliberately never shows.
+ return (
+
+ );
+}
+
+export function User(_args: Record) {
+ const prototype = usePrototype();
+
+ // Fed the same data too: an active organization and its workspaces, none of which this mode shows.
+ return (
+
+ );
+}
+
+export function CustomMenuItems(_args: Record) {
+ const prototype = usePrototype();
+
+ // The app's own rows join the foot, 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', 'addAccount', 'signOutAll', 'settings']}
+ />
+ );
+}
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts
index a25c291fc48..20383bc0a89 100644
--- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts
+++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts
@@ -19,8 +19,10 @@ export const styles = stylex.create({
verticalAlign: 'middle',
},
- // image fills the clipped box
+ // image fills the clipped box. It carries the root's radius rather than leaning on the clip
+ // alone, so a part that paints its own fill rounds off cleanly instead of showing a corner.
image: {
+ borderRadius: 'inherit',
aspectRatio: '1 / 1',
display: 'block',
objectFit: 'cover',
@@ -31,6 +33,7 @@ export const styles = stylex.create({
// fallback fills the box, centering its content and inheriting the sized font
fallback: {
+ borderRadius: 'inherit',
alignItems: 'center',
backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`,
color: colorVars['--cl-color-neutral'],
@@ -41,10 +44,10 @@ export const styles = stylex.create({
},
});
-// shape — square shares the control radius with Button; circle rounds fully
+// shape — square shares its radius with Button; circle rounds fully
export const shapes = stylex.create({
circle: { borderRadius: radiusVars['--cl-radius-full'] },
- square: { borderRadius: radiusVars['--cl-radius-control'] },
+ square: { borderRadius: radiusVars['--cl-radius-md'] },
});
// size — square box; fallback text scales with the box via inherited font-size
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx
index 8005d8747f2..f315d73ea2d 100644
--- a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx
+++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx
@@ -5,14 +5,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { Avatar } from './avatar';
// jsdom never fires load/error on images, so drive `new window.Image()` manually.
-// Each instance resolves to the outcome keyed by its `src`.
+// Each instance resolves to the outcome keyed by its `src`. A `cached` src reports `complete`
+// the moment it is assigned, the way a browser does for an image it already holds.
type Outcome = 'load' | 'error';
let outcomes: Record = {};
+let cached = new Set();
class MockImage {
+ complete = false;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(value: string) {
+ if (cached.has(value)) {
+ this.complete = true;
+ return;
+ }
queueMicrotask(() => {
if (outcomes[value] === 'error') {
this.onerror?.();
@@ -27,6 +34,7 @@ vi.stubGlobal('Image', MockImage);
afterEach(() => {
outcomes = {};
+ cached = new Set();
});
describe('Mosaic Avatar', () => {
@@ -87,6 +95,77 @@ describe('Mosaic Avatar', () => {
expect(screen.queryByText('CN')).not.toBeInTheDocument();
});
+ it('renders the image undraggable, and lets a consumer opt back in', async () => {
+ outcomes['https://example.com/a.png'] = 'load';
+ const { rerender } = render(
+
+
+ ,
+ );
+ expect(await screen.findByRole('img', { name: 'Alex' })).toHaveAttribute('draggable', 'false');
+
+ rerender(
+
+
+ ,
+ );
+ expect(await screen.findByRole('img', { name: 'Alex' })).toHaveAttribute('draggable', 'true');
+ });
+
+ // The rows this sits in remount whenever they change shape, and the header swaps between
+ // organizations that are already on screen. Either one re-resolves an image the browser already
+ // holds, so anything short of resolving before the first paint reads as the avatar disappearing.
+ it('shows a cached image without a pass through the fallback', () => {
+ cached.add('https://example.com/cached.png');
+ render(
+
+
+ CN
+ ,
+ );
+
+ expect(screen.getByRole('img', { name: 'Alex' })).toBeInTheDocument();
+ expect(screen.queryByText('CN')).not.toBeInTheDocument();
+ });
+
+ it('swaps straight to a cached image rather than falling back between the two', async () => {
+ outcomes['https://example.com/a.png'] = 'load';
+ cached.add('https://example.com/b.png');
+ const { rerender } = render(
+
+
+ CN
+ ,
+ );
+ await screen.findByRole('img', { name: 'Alex' });
+
+ rerender(
+
+
+ CN
+ ,
+ );
+
+ expect(screen.getByRole('img', { name: 'Alex' })).toHaveAttribute('src', 'https://example.com/b.png');
+ expect(screen.queryByText('CN')).not.toBeInTheDocument();
+ });
+
it('keeps the fallback when the image errors', async () => {
outcomes['https://example.com/bad.png'] = 'error';
render(
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx
index 89776eb127d..9f6a0313ece 100644
--- a/packages/ui/src/mosaic/components/avatar/avatar.tsx
+++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx
@@ -61,18 +61,28 @@ const AvatarImage = React.forwardRef(functio
const { status, onStatusChange } = useAvatarContext('Avatar.Image');
// Preload `src` and report status to the root, so the fallback shows until the image resolves.
- React.useEffect(() => {
+ // A layout effect, because it also has to catch the case below before anything is painted.
+ React.useLayoutEffect(() => {
if (!src) {
onStatusChange('error');
return;
}
- let active = true;
const image = new window.Image();
+ image.src = src;
+
+ // An image the browser already holds is complete the moment it is asked for. Resolving it here
+ // rather than off an event keeps a remount (a row changing shape) or a swap between two avatars
+ // already on screen from dropping to the initials and back for a frame.
+ if (image.complete) {
+ onStatusChange('loaded');
+ return;
+ }
+
+ let active = true;
onStatusChange('loading');
image.onload = () => active && onStatusChange('loaded');
image.onerror = () => active && onStatusChange('error');
- image.src = src;
return () => {
active = false;
@@ -88,6 +98,9 @@ const AvatarImage = React.forwardRef(functio
ref={ref}
src={src}
alt={alt}
+ // An avatar is an identity mark, not content to pull out of the page — dragging one
+ // only ever produces a stray ghost image mid-interaction.
+ draggable={false}
{...mergeStyleProps(themeProps('avatar-image'), stylex.props(reset.base, styles.image), className, style)}
{...rest}
/>
diff --git a/packages/ui/src/mosaic/components/badge/badge.styles.ts b/packages/ui/src/mosaic/components/badge/badge.styles.ts
index 8382323a36e..6e2580d412d 100644
--- a/packages/ui/src/mosaic/components/badge/badge.styles.ts
+++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts
@@ -3,7 +3,13 @@ import * as stylex from '@stylexjs/stylex';
import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
// warning/negative/positive tint a faded fill and use the saturated token as text;
-// primary/neutral fill with the solid token and use its `-foreground` for text.
+// primary fills with the solid token and uses its `-foreground` for text.
+//
+// Neutral has no faded surface to tint — `--cl-color-neutral-faded` is a text gray, and its
+// `-foreground` is a text color rather than an on-fill one, so it is unreadable against the solid
+// 900. It rides the same black/white scrim the button's neutral fill does, which composites against
+// any backdrop. Must be a local binding — StyleX inlines it; an imported one fails to compile.
+const neutralScrim = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 6%, transparent)`;
export const styles = stylex.create({
base: {
borderRadius: radiusVars['--cl-radius-full'],
@@ -27,7 +33,7 @@ export const colors = stylex.create({
color: colorVars['--cl-color-primary-foreground'],
},
neutral: {
- backgroundColor: colorVars['--cl-color-neutral'],
+ backgroundColor: neutralScrim,
color: colorVars['--cl-color-neutral-foreground'],
},
warning: {
diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts
index 7e11e708c1f..26a394da7b3 100644
--- a/packages/ui/src/mosaic/components/button/button.styles.ts
+++ b/packages/ui/src/mosaic/components/button/button.styles.ts
@@ -79,7 +79,7 @@ export const styles = stylex.create({
':enabled:hover': durationVars['--cl-duration-instant'],
},
borderColor: 'transparent',
- borderRadius: radiusVars['--cl-radius-control'],
+ borderRadius: radiusVars['--cl-radius-md'],
borderStyle: 'solid',
borderWidth: '1px',
// one ring for every color and variant — it reads as focus, not as the button's color
@@ -127,7 +127,7 @@ export const styles = stylex.create({
// shape — icon buttons zero their inline padding; width tracks the height. Longhands because
// StyleX ranks a longhand above a shorthand, so `paddingInline` would lose to what `sizes` sets.
shapeSquare: {
- borderRadius: radiusVars['--cl-radius-control'],
+ borderRadius: radiusVars['--cl-radius-md'],
paddingInlineEnd: 0,
paddingInlineStart: 0,
},
diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts
index b646125bf40..fd92807a731 100644
--- a/packages/ui/src/mosaic/components/card/card.styles.ts
+++ b/packages/ui/src/mosaic/components/card/card.styles.ts
@@ -39,7 +39,7 @@ export const styles = stylex.create({
export const elevations = stylex.create({
card: {
- borderRadius: radiusVars['--cl-radius-container'],
+ borderRadius: radiusVars['--cl-radius-xl'],
overflow: 'hidden',
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent),
@@ -47,13 +47,13 @@ export const elevations = stylex.create({
0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`,
},
flush: {
- borderRadius: radiusVars['--cl-radius-container'],
+ borderRadius: radiusVars['--cl-radius-xl'],
overflow: 'visible',
backgroundColor: 'transparent',
boxShadow: 'none',
},
overlay: {
- borderRadius: radiusVars['--cl-radius-container'],
+ borderRadius: radiusVars['--cl-radius-xl'],
overflow: 'hidden',
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent),
diff --git a/packages/ui/src/mosaic/components/clerk-logo/clerk-logo.tsx b/packages/ui/src/mosaic/components/clerk-logo/clerk-logo.tsx
new file mode 100644
index 00000000000..8333a808824
--- /dev/null
+++ b/packages/ui/src/mosaic/components/clerk-logo/clerk-logo.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+
+export function ClerkLogo(props: React.ComponentPropsWithoutRef<'svg'>) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/components/clerk-logo/index.ts b/packages/ui/src/mosaic/components/clerk-logo/index.ts
new file mode 100644
index 00000000000..de551e2977a
--- /dev/null
+++ b/packages/ui/src/mosaic/components/clerk-logo/index.ts
@@ -0,0 +1 @@
+export { ClerkLogo } from './clerk-logo';
diff --git a/packages/ui/src/mosaic/components/input/input.styles.ts b/packages/ui/src/mosaic/components/input/input.styles.ts
index e0d926cab7e..0b73eff3283 100644
--- a/packages/ui/src/mosaic/components/input/input.styles.ts
+++ b/packages/ui/src/mosaic/components/input/input.styles.ts
@@ -67,7 +67,7 @@ export const styles = stylex.create({
export const sizes = stylex.create({
sm: {
- borderRadius: radiusVars['--cl-radius-control'],
+ borderRadius: radiusVars['--cl-radius-md'],
paddingInline: space['3'],
fontSize: {
default: typeScaleVars['--cl-text-xs-size'],
@@ -77,7 +77,7 @@ export const sizes = stylex.create({
height: space['7'],
},
md: {
- borderRadius: radiusVars['--cl-radius-control'],
+ borderRadius: radiusVars['--cl-radius-md'],
paddingInline: space['3'],
fontSize: {
default: typeScaleVars['--cl-text-sm-size'],
@@ -87,7 +87,7 @@ export const sizes = stylex.create({
height: space['8'],
},
lg: {
- borderRadius: radiusVars['--cl-radius-element'],
+ borderRadius: radiusVars['--cl-radius-lg'],
paddingInline: space['3'],
fontSize: {
default: typeScaleVars['--cl-text-base-size'],
diff --git a/packages/ui/src/mosaic/components/item/item.markers.stylex.ts b/packages/ui/src/mosaic/components/item/item.markers.stylex.ts
deleted file mode 100644
index 03e38ee908f..00000000000
--- a/packages/ui/src/mosaic/components/item/item.markers.stylex.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as stylex from '@stylexjs/stylex';
-
-/**
- * Scopes a part's ancestor-state selectors to a Mosaic `Item.Root`, so an unrelated hovered
- * ancestor can't drive them. `Item.Root` applies it; `Item.Label` matches on it to follow the
- * row's hover rather than its own.
- *
- * Its own module because `@stylexjs/enforce-extension` requires the define-primitives to live in
- * a `.stylex.ts` file.
- */
-export const itemScope = stylex.defineMarker();
diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts
index e07aa324b30..a5a9486748d 100644
--- a/packages/ui/src/mosaic/components/item/item.styles.ts
+++ b/packages/ui/src/mosaic/components/item/item.styles.ts
@@ -1,18 +1,23 @@
import * as stylex from '@stylexjs/stylex';
import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
-import { itemScope } from './item.markers.stylex';
export const item = stylex.create({
base: {
- borderRadius: radiusVars['--cl-radius-element'],
+ // The icon in `Item.Media` rides the row's text strength rather than its own, the way
+ // `Button` does it. `Icon` reads the var (`icon.styles.ts`) — StyleX can't emit a descendant
+ // rule, so the value crosses the element boundary as a custom property. It is restated in
+ // `interactive` rather than gaining a hover branch here: StyleX resolves a property to the
+ // last style that declares it, so the two can't merge.
+ '--_cl-icon-color': colorVars['--cl-color-neutral-faded'],
+ borderRadius: radiusVars['--cl-radius-lg'],
outline: {
default: 'none',
':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`,
},
paddingInline: space['2'],
alignItems: 'center',
- color: colorVars['--cl-color-card-foreground'],
+ color: colorVars['--cl-color-neutral-faded'],
display: 'flex',
fontFamily: fontFamilyVars['--cl-font-family-sans'],
fontSize: typeScaleVars['--cl-text-sm-size'],
@@ -22,8 +27,16 @@ export const item = stylex.create({
width: '100%',
},
- // interactive rows (rendered as a link/button via `render`) gain hover + cursor
+ // interactive rows (rendered as a link/button via `render`) gain hover + cursor. Only these
+ // promote on hover: a static row is not pointing at anything, so its icon and label hold.
interactive: {
+ '--_cl-icon-color': {
+ default: colorVars['--cl-color-neutral-faded'],
+ '@media (hover: hover)': {
+ default: null,
+ ':hover': colorVars['--cl-color-neutral'],
+ },
+ },
backgroundColor: {
default: null,
':active': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`,
@@ -31,7 +44,20 @@ export const item = stylex.create({
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
+ color: {
+ default: colorVars['--cl-color-neutral-faded'],
+ '@media (hover: hover)': {
+ default: null,
+ ':hover': colorVars['--cl-color-neutral'],
+ },
+ },
cursor: 'pointer',
+ // A row that is standing down while another action runs keeps its place and its look, but
+ // stops answering the pointer — one declaration takes the cursor and the hover states with it.
+ pointerEvents: {
+ default: null,
+ ':disabled': 'none',
+ },
},
xs: {
@@ -87,12 +113,6 @@ export const description = stylex.create({
export const label = stylex.create({
base: {
- // keyed to the row's hover, not the text's, so pointing anywhere in the row promotes the label
- color: {
- default: null,
- [stylex.when.ancestor(':not(:hover)', itemScope)]: colorVars['--cl-color-neutral-faded'],
- [stylex.when.ancestor(':hover', itemScope)]: colorVars['--cl-color-neutral'],
- },
fontSize: typeScaleVars['--cl-text-xs-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx
index b783cf65bd6..510f8b653cc 100644
--- a/packages/ui/src/mosaic/components/item/item.tsx
+++ b/packages/ui/src/mosaic/components/item/item.tsx
@@ -6,7 +6,6 @@ import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../reset.styles';
import { truncationStyles } from '../typography.styles';
-import { itemScope } from './item.markers.stylex';
import * as slots from './item.styles';
/** The row's height and gap, and the width of the media column inside it. */
@@ -51,7 +50,7 @@ const Root = React.forwardRef(function MosaicItem(
props: {
...mergeStyleProps(
themeProps('item', { interactive, size }),
- stylex.props(reset.base, itemScope, slots.item.base, slots.item[size], interactive && slots.item.interactive),
+ stylex.props(reset.base, slots.item.base, slots.item[size], interactive && slots.item.interactive),
className,
style,
),
diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts
index 2175010ebd3..a10eb794ee5 100644
--- a/packages/ui/src/mosaic/components/menu/menu.styles.ts
+++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts
@@ -11,7 +11,7 @@ export const styles = stylex.create({
},
popup: {
- borderRadius: radiusVars['--cl-radius-element'],
+ borderRadius: radiusVars['--cl-radius-lg'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
diff --git a/packages/ui/src/mosaic/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts
index c5983722aea..2805d5384cb 100644
--- a/packages/ui/src/mosaic/components/popover/popover.styles.ts
+++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts
@@ -4,11 +4,11 @@ import { durationVars, easingVars } from '../../tokens.stylex';
export const styles = stylex.create({
// Floating wrapper. Positioning styles are applied inline by the headless
- // positioner; this only owns stacking and clears the focus outline the
- // FloatingFocusManager places here.
+ // positioner; this only clears the focus outline the FloatingFocusManager places
+ // here. No z-index: portalled siblings stack by DOM order, so a menu opened from
+ // inside a popover paints above it.
positioner: {
outline: 'none',
- zIndex: 50,
},
// The floating box, deliberately chrome-free: background, border, radius,
diff --git a/packages/ui/src/mosaic/components/popover/popover.test.tsx b/packages/ui/src/mosaic/components/popover/popover.test.tsx
index fff4fbe55b3..68abbd3cd22 100644
--- a/packages/ui/src/mosaic/components/popover/popover.test.tsx
+++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx
@@ -52,7 +52,7 @@ describe('Mosaic Popover', () => {
expect(screen.getByRole('link', { name: 'Open' })).toHaveAttribute('aria-haspopup', 'dialog');
});
- it('carries the mosaic slot classes on the positioner and popup', () => {
+ it('carries the mosaic slot classes on the trigger, positioner and popup', () => {
render(
Open
@@ -60,10 +60,24 @@ describe('Mosaic Popover', () => {
,
);
+ expect(document.querySelector('.cl-popover-trigger')).toBeInTheDocument();
expect(document.querySelector('.cl-popover-positioner')).toBeInTheDocument();
expect(document.querySelector('.cl-popover-popup')).toBeInTheDocument();
});
+ it('keeps a consumer className on the trigger alongside the slot class', () => {
+ render(
+
+ Open
+ Body
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ expect(trigger).toHaveClass('cl-popover-trigger');
+ expect(trigger).toHaveClass('mine');
+ });
+
it('defaults the popup to the md size and reflects it as data-size', () => {
render(
@@ -99,7 +113,7 @@ describe('Mosaic Popover', () => {
,
);
- const popup = screen.getByText('Body');
+ const popup = document.querySelector('.cl-popover-popup');
expect(popup).toHaveClass('cl-popover-popup', 'my-popup');
expect(popup).toHaveStyle({ marginTop: '8px' });
});
@@ -221,6 +235,6 @@ describe('Mosaic Popover', () => {
,
);
- expect(ref.current).toBe(screen.getByText('Body'));
+ expect(ref.current).toBe(document.querySelector('.cl-popover-popup'));
});
});
diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx
index ccd9079e41f..20139f5d0ce 100644
--- a/packages/ui/src/mosaic/components/popover/popover.tsx
+++ b/packages/ui/src/mosaic/components/popover/popover.tsx
@@ -25,10 +25,14 @@ export type PopoverTitleProps = MosaicComponentProps<'h2'>;
export type PopoverDescriptionProps = MosaicComponentProps<'p'>;
/** The anchor. Renders a ``; `render` swaps in another element. */
-const Trigger = React.forwardRef(function PopoverTrigger(props, ref) {
+const Trigger = React.forwardRef(function PopoverTrigger(
+ { className, style, ...props },
+ ref,
+) {
return (
);
diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx
index 057dbe0237a..d3a0e6ddda6 100644
--- a/packages/ui/src/mosaic/icons/registry.tsx
+++ b/packages/ui/src/mosaic/icons/registry.tsx
@@ -73,6 +73,20 @@ const Ellipsis = glyph(
/>,
);
+const ChevronUp = glyph(
+ ,
+);
+
+const ChevronUpDown = glyph(
+ ,
+);
+
const Plus = glyph(
,
);
+const Cog = glyph(
+ ,
+);
+
+const Users = glyph(
+ ,
+);
+
/** Runtime name → glyph map. `Icon`'s `name` prop is typed from these keys. */
export const iconRegistry = {
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-down': ChevronDown,
+ 'chevron-up': ChevronUp,
+ 'chevron-up-down': ChevronUpDown,
check: Check,
close: Close,
ellipsis: Ellipsis,
plus: Plus,
'log-out': LogOut,
+ cog: Cog,
+ users: Users,
} satisfies Record;
export type IconName = keyof typeof iconRegistry;
diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts
index 8f24cdf0f59..306cb14c2eb 100644
--- a/packages/ui/src/mosaic/tokens.stylex.ts
+++ b/packages/ui/src/mosaic/tokens.stylex.ts
@@ -59,18 +59,16 @@ export const colorVars = stylex.defineVars(colorDefaults);
// =============================================================================
// Radius Tokens
// =============================================================================
-// Named by what a surface is, not by size, so the steps nest: `inner` for a mark
-// sitting inside a control, `control` for the control itself (button, avatar
-// square), `container` for anything wrapping controls. `control` is 6px — a step
-// the 4/8/12 progression doesn't land on, which is why it's its own token rather
-// than a reuse of `inner` or `element`.
+// A plain size scale, so nesting a smaller step inside a larger one reads off the
+// name. `md` is 6px rather than a 4/8/12 step: it is the control radius (button,
+// avatar square), and neither neighbour sits right on a control.
const radiusDefaults = {
'--cl-radius-none': '0rem',
- '--cl-radius-inner': '0.25rem',
- '--cl-radius-control': '0.375rem',
- '--cl-radius-element': '0.5rem',
- '--cl-radius-container': '0.75rem',
+ '--cl-radius-sm': '0.25rem',
+ '--cl-radius-md': '0.375rem',
+ '--cl-radius-lg': '0.5rem',
+ '--cl-radius-xl': '0.75rem',
'--cl-radius-full': 'calc(infinity * 1px)',
} as const;
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx
new file mode 100644
index 00000000000..22d64271e67
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx
@@ -0,0 +1,755 @@
+import * as stylex from '@stylexjs/stylex';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { scrollAreaViewport } from '../../components/scroll-area';
+import { MosaicProvider } from '../../MosaicProvider';
+import type { UserButtonProps } from '../user-button.view';
+import { userButtonBusyKeys, UserButtonView } from '../user-button.view';
+
+// The connected UserButton only ever renders `combined` — the container does not expose `mode` — so
+// this is the only place the three surfaces can be told apart. One describe per mode covers what
+// each carries and withholds; the describes after them cover what the modes share.
+
+const alice = { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' };
+const bob = { sessionId: 'sess_2', name: 'Bob Jones', identifier: 'bob@example.com' };
+
+const foundry = {
+ kind: 'membership',
+ organizationId: 'org_1',
+ name: 'Foundry',
+ membersCount: 24,
+ planLabel: 'Pro',
+} as const;
+
+const otherCo = { kind: 'membership', organizationId: 'org_2', name: 'Other Co' } as const;
+
+const gamma = {
+ kind: 'invitation',
+ id: 'inv_1',
+ organizationId: 'org_3',
+ organizationName: 'Gamma',
+ status: 'pending',
+} as const;
+
+const beta = { kind: 'suggestion', id: 'sug_1', organizationId: 'org_4', name: 'Beta', status: 'pending' } as const;
+
+/**
+ * Every callback the connected container passes, so a test opts a surface *out* of an affordance
+ * rather than having to opt into it. `combined` is the container's own default.
+ */
+function renderView(props: Partial = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+const popup = () => screen.getByRole('dialog', { name: 'Account' });
+
+// The `cl-` slot classes are Mosaic's public theming hooks, so they are a stable handle on the
+// popup's sections rather than an implementation detail.
+const groups = () => Array.from(popup().querySelectorAll('.cl-item-group'));
+const titles = (group: HTMLElement | undefined) =>
+ Array.from(group?.querySelectorAll('.cl-item-title') ?? []).map(node => node.textContent ?? '');
+const row = (group: HTMLElement | undefined, title: string) =>
+ Array.from(group?.querySelectorAll('.cl-item') ?? []).find(
+ node => node.querySelector('.cl-item-title')?.textContent === title,
+ );
+
+const scrollClasses = stylex.props(...scrollAreaViewport('auto')).className?.split(' ') ?? [];
+
+/** The workspace list: the one group in the popup that scrolls. */
+const workspaceList = () => groups().find(group => scrollClasses.every(name => group.classList.contains(name)));
+
+/** The accounts group: the one whose rows are titled by identifier rather than by workspace name. */
+const accountsList = () =>
+ groups().find(group => group !== workspaceList() && titles(group).some(title => title.includes('@')));
+
+// `user` is an account switcher that never shows an organization, even when one is active.
+describe('UserButtonView, user mode', () => {
+ function renderUserMode(props: Partial = {}) {
+ return renderView({
+ mode: 'user',
+ // All of this is the organization side, and is here to prove the surface ignores it.
+ hasOrganizations: true,
+ activeOrganization: foundry,
+ memberships: [foundry, otherCo],
+ invitations: [gamma],
+ ...props,
+ });
+ }
+
+ it('names the account in the header, never the organization that is active', () => {
+ renderUserMode();
+
+ const header = groups()[0];
+ expect(within(header).getByText('Alice Smith')).toBeInTheDocument();
+ expect(within(header).getByText('alice@example.com')).toBeInTheDocument();
+ expect(screen.queryByText('Foundry')).toBeNull();
+ });
+
+ // A nameless account is titled by its identifier, so repeating it underneath says nothing.
+ it('drops the identifier line when it would only repeat the title', () => {
+ renderUserMode({ activeSession: { ...alice, name: 'alice@example.com' } });
+
+ const header = groups()[0];
+ expect(within(header).getAllByText('alice@example.com')).toHaveLength(1);
+ });
+
+ it('lists no workspaces at all', () => {
+ renderUserMode();
+
+ expect(workspaceList()).toBeUndefined();
+ expect(screen.queryByText('Personal account')).toBeNull();
+ expect(screen.queryByText('Gamma')).toBeNull();
+ });
+
+ // Every other surface hangs "Sign out" off the account's own row; this one has no such row, so it
+ // takes the labelled slot beside the gear. Inviting belongs to an organization, which is not what
+ // this surface is about.
+ it('signs out of the account from the header, beside the gear', async () => {
+ const onSignOutSession = vi.fn();
+ renderUserMode({ onSignOutSession });
+
+ expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
+
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1');
+ });
+
+ it('spins the header sign-out while it is in flight', () => {
+ renderUserMode({ pendingKey: userButtonBusyKeys.signOutSession('sess_1') });
+
+ const button = screen.getByRole('button', { name: 'Sign out' });
+ expect(button).toBeDisabled();
+ expect(button.querySelector('.cl-spinner')).not.toBeNull();
+ });
+
+ // With nothing above them to be told apart from, the account rows stand alone: no heading, and
+ // the account you are already on is not somewhere else to go.
+ it('lists only the accounts to switch to, with no heading above them', () => {
+ renderUserMode();
+
+ expect(titles(accountsList())).toEqual(['bob@example.com']);
+ expect(screen.queryByText('Accounts')).toBeNull();
+ });
+
+ it('takes "Add account" at the foot rather than into an account menu', () => {
+ renderUserMode();
+
+ expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Account actions' })).toBeNull();
+ });
+});
+
+// `orgs` is an organization switcher with no account rows at all.
+describe('UserButtonView, orgs mode', () => {
+ function renderOrgsMode(props: Partial = {}) {
+ return renderView({
+ mode: 'orgs',
+ hasOrganizations: true,
+ activeOrganization: foundry,
+ memberships: [foundry, otherCo],
+ ...props,
+ });
+ }
+
+ it('heads the surface with the active organization and what can be done to it', () => {
+ renderOrgsMode();
+
+ const header = groups()[0];
+ expect(within(header).getByText('Foundry')).toBeInTheDocument();
+ expect(within(header).getByText('24 members · Pro')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument();
+ });
+
+ // The account is all there is to head it with, and it is not an organization, so there is nobody
+ // to invite and the gear manages the account instead.
+ it('falls back to the account in the header where no organization is active', () => {
+ renderOrgsMode({ activeOrganization: null });
+
+ expect(within(groups()[0]).getByText('Alice Smith')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
+ });
+
+ it('lists the workspaces, the personal one among them', () => {
+ renderOrgsMode();
+
+ expect(titles(workspaceList())).toEqual(['Personal account', 'Foundry', 'Other Co']);
+ });
+
+ it('carries no account rows, not even the one it belongs to', () => {
+ renderOrgsMode();
+
+ expect(accountsList()).toBeUndefined();
+ expect(screen.queryByText('Accounts')).toBeNull();
+ expect(screen.queryByRole('button', { name: 'bob@example.com' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Actions for alice@example.com' })).toBeNull();
+ });
+
+ // No account menu to carry "Create organization", so it lands at the foot, in the slot the
+ // account-wide actions occupy elsewhere. Those actions themselves have no place here.
+ it('takes "Create organization" at the foot, in place of the account actions', () => {
+ renderOrgsMode();
+
+ expect(screen.getByRole('button', { name: 'Create organization' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull();
+ });
+});
+
+// `combined` is both switchers at once, so it is the only surface that has to decide which one
+// leads and where the account-wide actions live.
+describe('UserButtonView, combined mode', () => {
+ function renderCombined(props: Partial = {}) {
+ return renderView({
+ hasOrganizations: true,
+ activeOrganization: foundry,
+ memberships: [foundry, otherCo],
+ ...props,
+ });
+ }
+
+ it('heads the surface with the active organization by default', () => {
+ renderCombined();
+
+ // The subtitle is the header's alone; the row below it carries only a title.
+ expect(screen.getByText('24 members · Pro')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument();
+ });
+
+ it('heads the surface with the account where the user takes priority', () => {
+ renderCombined({ modePriority: 'user' });
+
+ expect(screen.queryByText('24 members · Pro')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ });
+
+ // The row heads the workspaces that belong to the account, the way the "Accounts" heading heads
+ // the other accounts, and carries the account-wide actions the header has no room for.
+ it('heads the workspace list with the active account and its own actions', async () => {
+ const onSignOutSession = vi.fn();
+ const act = userEvent.setup();
+ renderCombined({ onSignOutSession });
+
+ await act.click(screen.getByRole('button', { name: 'Actions for alice@example.com' }));
+
+ expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Create organization' })).toBeInTheDocument();
+ await act.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1');
+ });
+
+ it('keeps the header sign-out off, since the account row carries it', () => {
+ renderCombined();
+
+ expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull();
+ });
+
+ // Under a heading the group reads as the full set of accounts, so the one you are on is listed
+ // and checked rather than left out.
+ it('heads the other accounts under "Accounts", listing the one it is on', () => {
+ renderCombined();
+
+ expect(titles(accountsList())).toEqual(['alice@example.com', 'bob@example.com']);
+ expect(screen.getByText('Accounts')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'alice@example.com' })).toBeNull();
+ });
+
+ it('names the account it is on as the current one', () => {
+ renderCombined();
+
+ expect(row(accountsList(), 'alice@example.com')).toHaveAttribute('aria-current', 'true');
+ expect(row(accountsList(), 'bob@example.com')).not.toHaveAttribute('aria-current');
+ });
+
+ it('keeps "Add account" in the Accounts heading rather than at the foot', async () => {
+ renderCombined();
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Account actions' }));
+
+ expect(await screen.findByRole('menuitem', { name: 'Add account' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull();
+ });
+
+ // One account means no heading to hang it off, so "Add account" falls back to the foot, the same
+ // slot the account-only surface carries it in.
+ it('takes "Add account" at the foot where there is no heading to carry it', () => {
+ renderCombined({ additionalSessions: [] });
+
+ expect(accountsList()).toBeUndefined();
+ expect(screen.queryByText('Accounts')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument();
+ });
+
+ // The account row is not the workspace list's to withhold: an account with no organizations still
+ // needs somewhere to manage and sign out of the one it is signed in as.
+ it('keeps the account row with no organizations to head', () => {
+ renderCombined({ hasOrganizations: false, activeOrganization: null, memberships: [] });
+
+ expect(screen.getByRole('button', { name: 'Actions for alice@example.com' })).toBeInTheDocument();
+ // The section is there, holding the account row alone.
+ expect(workspaceList()).toBeDefined();
+ expect(titles(workspaceList())).toEqual([]);
+ expect(screen.queryByText('Personal account')).toBeNull();
+ });
+});
+
+describe('UserButtonView, the workspace list', () => {
+ function renderList(props: Partial = {}) {
+ return renderView({
+ hasOrganizations: true,
+ activeOrganization: foundry,
+ memberships: [foundry],
+ ...props,
+ });
+ }
+
+ // The order the existing OrganizationSwitcher lists these in: what is on offer leads, since it is
+ // the one row that goes away if it is not acted on, and invitations lead the suggestions since
+ // accepting one joins where a suggestion only files a request.
+ it('leads with the invitations, then the suggestions, then the workspaces held', () => {
+ renderList({ invitations: [gamma], suggestions: [beta] });
+
+ expect(titles(workspaceList())).toEqual(['Gamma', 'Beta', 'Personal account', 'Foundry']);
+ });
+
+ // The one surface in the popup that scrolls, so it takes the shared scroll area rather than a
+ // bare `overflow-y` of its own. `auto` rather than `stable`: a reserved gutter insets the rows
+ // whether or not the list overflows, leaving short lists with their avatars and icons off the
+ // edge the header and footer align to. Every list assertion here is found through these classes.
+ it('scrolls through the shared scroll area, at an automatic gutter', () => {
+ renderList();
+
+ expect(scrollClasses.length).toBeGreaterThan(0);
+ expect(workspaceList()).toBeDefined();
+ });
+
+ // The check beside the active row is decorative, so on its own it leaves that row reading to a
+ // screen reader exactly like the ones there is still somewhere to switch to.
+ it('names the active workspace as the current one', () => {
+ renderList({ memberships: [foundry, otherCo] });
+
+ expect(row(workspaceList(), 'Foundry')).toHaveAttribute('aria-current', 'true');
+ expect(row(workspaceList(), 'Other Co')).not.toHaveAttribute('aria-current');
+ expect(row(workspaceList(), 'Personal account')).not.toHaveAttribute('aria-current');
+ });
+
+ it('names the personal workspace as the current one where no organization is active', () => {
+ renderList({ activeOrganization: null });
+
+ expect(row(workspaceList(), 'Personal account')).toHaveAttribute('aria-current', 'true');
+ expect(row(workspaceList(), 'Foundry')).not.toHaveAttribute('aria-current');
+ });
+
+ it('hands the paging sentinel to the in-view ref only while more pages remain', () => {
+ const ref = vi.fn();
+ const { unmount } = renderList({ paging: { ref, hasMore: false } });
+ expect(ref).not.toHaveBeenCalled();
+ unmount();
+
+ renderList({ paging: { ref, hasMore: true } });
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLElement));
+ });
+
+ // Switching into an organization is not a one-way door: the account's own workspace is listed
+ // alongside the organizations, so there is always a way back out of one.
+ describe('the personal workspace', () => {
+ it('switches back out of the active organization', async () => {
+ const onSelectOrganization = vi.fn();
+ renderList({ onSelectOrganization });
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Personal account' }));
+
+ expect(onSelectOrganization).toHaveBeenCalledWith(null);
+ });
+
+ // Named for what it is among organizations rather than for the account, the way the existing
+ // OrganizationSwitcher names it. The trigger and header still name the account itself.
+ it('names it "Personal account" rather than repeating the account', () => {
+ renderList();
+
+ expect(titles(workspaceList())).toContain('Personal account');
+ expect(titles(workspaceList())).not.toContain('Alice Smith');
+ });
+
+ // The same contract every workspace row follows: what is already selected is not a button.
+ it('checks it, and offers no switch, where it is what is active', () => {
+ renderList({ activeOrganization: null });
+
+ expect(screen.getByText('Personal account')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull();
+ });
+
+ it('spins it while the switch is in flight', () => {
+ renderList({ pendingKey: userButtonBusyKeys.selectOrganization(null) });
+
+ const row = screen.getByRole('button', { name: 'Personal account' });
+ expect(row).toBeDisabled();
+ expect(row.querySelector('.cl-spinner')).not.toBeNull();
+ });
+
+ // An instance that requires an organization has no personal workspace to return to, so the row
+ // would stand there and do nothing. It is withheld rather than stood down: this is not a moment
+ // where the switch is unavailable, it is a surface where the workspace does not exist.
+ it('stays out of a surface that has no personal workspace', () => {
+ renderList({ hidePersonal: true, memberships: [foundry, otherCo] });
+
+ expect(screen.queryByText('Personal account')).toBeNull();
+ // The organizations are still listed; it is only the way back out of them that is gone.
+ expect(titles(workspaceList())).toEqual(['Foundry', 'Other Co']);
+ });
+ });
+
+ describe('the rows on offer', () => {
+ it('offers to accept an invitation and to join a suggestion', () => {
+ renderList({ invitations: [gamma], suggestions: [beta] });
+
+ expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Join' })).toBeInTheDocument();
+ });
+
+ // A pending row has to be reachable before there is a membership, or an account holding nothing
+ // but an invitation would open onto a surface with no way to accept it.
+ it('lists them with no memberships to list them beside', () => {
+ renderList({ hasOrganizations: false, activeOrganization: null, memberships: [], invitations: [gamma] });
+
+ expect(titles(workspaceList())).toEqual(['Gamma', 'Personal account']);
+ expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument();
+ });
+
+ // Every other affordance on the surface swaps its icon for a spinner, but these carry a label
+ // rather than an icon, so the spinner goes inside the button instead of taking its place.
+ it('spins inside the join button rather than replacing it', () => {
+ renderList({ suggestions: [beta], pendingKey: userButtonBusyKeys.acceptSuggestion('sug_1') });
+
+ const join = screen.getByRole('button', { name: 'Join' });
+ expect(join).toHaveAttribute('aria-busy', 'true');
+ expect(within(join).getByRole('progressbar')).toBeInTheDocument();
+ // The row itself stays as it was: the button is what reports the action, not the trailing edge.
+ expect(row(workspaceList(), 'Beta')?.querySelector('.cl-spinner')).toBe(within(join).getByRole('progressbar'));
+ });
+
+ it('spins inside the accept button too', () => {
+ renderList({ invitations: [gamma], pendingKey: userButtonBusyKeys.acceptInvitation('inv_1') });
+
+ const accept = screen.getByRole('button', { name: 'Accept' });
+ expect(accept).toHaveAttribute('aria-busy', 'true');
+ expect(within(accept).getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ // An accepted suggestion is waiting on an admin, so it reports rather than re-offers.
+ it('reports an accepted suggestion instead of offering to join it again', () => {
+ renderList({ suggestions: [{ ...beta, status: 'accepted' }] });
+
+ expect(screen.queryByRole('button', { name: 'Join' })).toBeNull();
+ expect(screen.getByText('Requested')).toBeInTheDocument();
+ });
+
+ // Accepting an invitation joins the organization, so an accepted one is a workspace like any
+ // other: click the row to switch to it.
+ it('lists an accepted invitation as a workspace to switch to', async () => {
+ const onSelectOrganization = vi.fn();
+ renderList({ invitations: [{ ...gamma, status: 'accepted' }], onSelectOrganization });
+
+ expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull();
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Gamma' }));
+
+ expect(onSelectOrganization).toHaveBeenCalledWith('org_3');
+ });
+
+ // The membership list catches up a moment after the accept, and for that moment the
+ // organization is in both lists.
+ it('drops an accepted invitation the membership list has caught up with', () => {
+ renderList({
+ invitations: [{ ...gamma, status: 'accepted' }],
+ memberships: [foundry, { kind: 'membership', organizationId: 'org_3', name: 'Gamma' }],
+ });
+
+ expect(titles(workspaceList())).toEqual(['Personal account', 'Foundry', 'Gamma']);
+ });
+
+ it('drops one for the organization that is already active', () => {
+ renderList({
+ invitations: [{ ...gamma, status: 'accepted' }],
+ activeOrganization: { kind: 'membership', organizationId: 'org_3', name: 'Gamma' },
+ });
+
+ // The header names it; the list does not offer to switch to what is already active.
+ expect(screen.queryByRole('button', { name: 'Gamma' })).toBeNull();
+ });
+ });
+
+ // Memberships, invitations and suggestions are three requests that land at three different
+ // moments; showing each as it arrives walks the list in in stages.
+ describe('while its first page is in flight', () => {
+ it('withholds every row behind one placeholder', () => {
+ renderList({ organizationsLoading: true, invitations: [gamma] });
+
+ expect(screen.getByText('Loading organizations…')).toBeInTheDocument();
+ expect(workspaceList()).toBeDefined();
+ expect(titles(workspaceList())).toEqual([]);
+ });
+
+ it('leaves the account row above it alone, since it does not wait on the list', () => {
+ renderList({ organizationsLoading: true });
+
+ expect(screen.getByRole('button', { name: 'Actions for alice@example.com' })).toBeInTheDocument();
+ });
+
+ it('drops the placeholder once the list has landed', () => {
+ renderList();
+
+ expect(screen.queryByText('Loading organizations…')).toBeNull();
+ expect(titles(workspaceList())).toEqual(['Personal account', 'Foundry']);
+ });
+
+ // `hasOrganizations` is answered before the lists are fetched, so an account with none never
+ // opens a section that then disappears under it.
+ it('stays out of a surface with nothing to list', () => {
+ renderList({ hasOrganizations: false, activeOrganization: null, memberships: [], organizationsLoading: true });
+
+ expect(screen.queryByText('Loading organizations…')).toBeNull();
+ });
+ });
+});
+
+// The foot is the one flat list of actions the surface has; everything else lives in the header or
+// behind a `⋯`. So it is where an app's own actions land, and the only list an order can run in.
+describe('UserButtonView, the foot', () => {
+ const terms = { id: 'terms', label: 'Terms of service' };
+ const support = { id: 'support', label: 'Support', href: '/support' };
+
+ const action = () => ({ ...terms, onClick: vi.fn() });
+
+ /** The foot's rows, in the order it lists them. It is the last group in the popup. */
+ const footLabels = () =>
+ Array.from(groups().at(-1)?.querySelectorAll('.cl-item-label') ?? []).map(node => node.textContent ?? '');
+
+ // The app's own actions lead, the way the existing UserButton lists them above "Add account".
+ it('leads with the custom rows', () => {
+ renderView({ customMenuItems: [action(), support] });
+
+ expect(footLabels()).toEqual(['Terms of service', 'Support', 'Sign out of all accounts']);
+ });
+
+ it('runs a custom action on press', async () => {
+ const item = action();
+ renderView({ customMenuItems: [item] });
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Terms of service' }));
+
+ expect(item.onClick).toHaveBeenCalled();
+ });
+
+ it('renders a custom link as one, pointed where it was told', () => {
+ renderView({ customMenuItems: [support] });
+
+ expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support');
+ });
+
+ it('renders the icon a custom row brings', () => {
+ renderView({ customMenuItems: [{ ...action(), icon: }] });
+
+ expect(screen.getByTestId('glyph')).toBeInTheDocument();
+ });
+
+ it('orders the rows by the ids it is given', () => {
+ renderView({ customMenuItems: [action(), support], menuItemOrder: ['signOutAll', 'support'] });
+
+ expect(footLabels()).toEqual(['Sign out of all accounts', 'Support', 'Terms of service']);
+ });
+
+ // Only some of the built-in actions are rows at all, and which of those a surface carries depends
+ // on its mode, so naming one it has not got is ordinary rather than a mistake.
+ it('drops an id no row answers to', () => {
+ renderView({ customMenuItems: [action()], menuItemOrder: ['manageAccount', 'signOutAll', 'nonsense'] });
+
+ expect(footLabels()).toEqual(['Sign out of all accounts', 'Terms of service']);
+ });
+
+ // "Add account" is a row only where no Accounts heading carries it, so this is the one surface
+ // whose foot has both account-wide actions to order.
+ it('orders "Add account" where the foot is what carries it', () => {
+ renderView({ additionalSessions: [], customMenuItems: [action()], menuItemOrder: ['addAccount', 'terms'] });
+
+ expect(footLabels()).toEqual(['Add account', 'Terms of service', 'Sign out of all accounts']);
+ });
+
+ it('carries the custom rows on an org-only surface too', () => {
+ renderView({ mode: 'orgs', hasOrganizations: true, activeOrganization: foundry, customMenuItems: [action()] });
+
+ expect(footLabels()).toEqual(['Terms of service', 'Create organization']);
+ });
+
+ it('holds a custom action in place, disabled, while another action runs', () => {
+ renderView({ customMenuItems: [action()], pendingKey: userButtonBusyKeys.switchSession('sess_9') });
+
+ expect(screen.getByRole('button', { name: 'Terms of service' })).toBeDisabled();
+ });
+
+ // A link is the browser's navigation rather than one of the surface's one-shot actions, so it has
+ // nothing to wait behind.
+ it('leaves a custom link followable while another action runs', () => {
+ renderView({ customMenuItems: [support], pendingKey: userButtonBusyKeys.switchSession('sess_9') });
+
+ expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support');
+ });
+});
+
+// Rows carry avatars, and an avatar's load state dies with the element it hangs off. Swapping a
+// row's host element out while it waits would remount it, dropping the avatar back to its initials
+// for the length of the action — so a row that stands down stays the button it was.
+describe('UserButtonView, one action at a time', () => {
+ function surface(pendingKey: string | null) {
+ return (
+
+
+
+ );
+ }
+
+ it.each([
+ ['a workspace row', 'Other Co'],
+ ['the personal row', 'Personal account'],
+ ['an account row', 'bob@example.com'],
+ ['an action row', 'Sign out of all accounts'],
+ // The `⋯` stands down the same way. Withholding what it opens would unmount the trigger, so
+ // the row would drop its trailing edge for the length of the action and get it back after.
+ ['the account menu', 'Actions for alice@example.com'],
+ ])('holds %s in place, disabled, while another action runs', (_name, label) => {
+ const { rerender } = render(surface(null));
+ const row = screen.getByRole('button', { name: label });
+
+ rerender(surface(userButtonBusyKeys.switchSession('sess_9')));
+
+ const stoodDown = screen.getByRole('button', { name: label });
+ expect(stoodDown).toBe(row);
+ expect(stoodDown).toBeDisabled();
+ });
+});
+
+describe('UserButtonTrigger', () => {
+ // Closed, so the only "Foundry" or "Alice Smith" on screen is the trigger's own label.
+ function renderTrigger(props: Partial = {}) {
+ return renderView({
+ defaultOpen: false,
+ hasOrganizations: true,
+ memberships: [foundry],
+ activeOrganization: foundry,
+ ...props,
+ });
+ }
+
+ it('names the active organization and its plan', () => {
+ renderTrigger({ mode: 'orgs' });
+
+ expect(screen.getByText('Foundry')).toBeInTheDocument();
+ expect(screen.getByText('Pro')).toBeInTheDocument();
+ });
+
+ it('names the account where no organization heads the trigger', () => {
+ renderTrigger({ mode: 'user' });
+
+ expect(screen.getByText('Alice Smith')).toBeInTheDocument();
+ expect(screen.queryByText('Pro')).toBeNull();
+ });
+
+ it('names the active organization in combined mode', () => {
+ renderTrigger({ mode: 'combined' });
+
+ expect(screen.getByText('Foundry')).toBeInTheDocument();
+ expect(screen.getByText('Pro')).toBeInTheDocument();
+ });
+
+ it('names the account in combined mode where the user takes priority', () => {
+ renderTrigger({ mode: 'combined', modePriority: 'user' });
+
+ expect(screen.getByText('Alice Smith')).toBeInTheDocument();
+ expect(screen.queryByText('Pro')).toBeNull();
+ });
+
+ it('renders the avatar alone when the label is off', () => {
+ renderTrigger({ mode: 'orgs', renderTriggerLabel: false });
+
+ expect(screen.queryByText('Foundry')).toBeNull();
+ expect(screen.queryByText('Pro')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Open account menu for Foundry' })).toBeInTheDocument();
+ });
+
+ it('keeps the name when only the plan badge is off', () => {
+ renderTrigger({ mode: 'orgs', renderPlanBadge: false });
+
+ expect(screen.getByText('Foundry')).toBeInTheDocument();
+ expect(screen.queryByText('Pro')).toBeNull();
+ });
+
+ // The corner follows the workspace mark, so a labelled trigger is a pill for a person and a
+ // squared-off block for an organization rather than a pill either way.
+ it('takes its corner from the workspace it names, labelled or not', () => {
+ const corner = (props: Partial) => {
+ const { unmount } = renderTrigger(props);
+ const className = screen.getByRole('button', { name: /Open account menu/ }).className;
+ unmount();
+ return className;
+ };
+
+ expect(corner({ mode: 'orgs' })).not.toEqual(corner({ mode: 'user' }));
+ expect(corner({ mode: 'orgs', renderTriggerLabel: false })).not.toEqual(
+ corner({ mode: 'user', renderTriggerLabel: false }),
+ );
+ });
+
+ // The active organization arrives on its own, ahead of the list it belongs to.
+ it('names the active organization before its membership list has loaded', () => {
+ renderTrigger({ mode: 'orgs', memberships: [], hasOrganizations: false, organizationsLoading: true });
+
+ expect(screen.getByText('Foundry')).toBeInTheDocument();
+ expect(screen.queryByText('Alice Smith')).toBeNull();
+ });
+});
diff --git a/packages/ui/src/mosaic/user-button/user-button.menu.ts b/packages/ui/src/mosaic/user-button/user-button.menu.ts
new file mode 100644
index 00000000000..686b7151b6e
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.menu.ts
@@ -0,0 +1,23 @@
+/**
+ * The foot's rows, in the order it shows them.
+ *
+ * Custom rows lead by default, the way the existing UserButton lists them above "Add account".
+ * `order` names rows by id to place them anywhere instead, and whatever it leaves out keeps its
+ * default place behind what it named. An id matching no row is dropped rather than held open: only
+ * some of the built-in actions are rows at all, and which of those a surface carries depends on its
+ * mode, so naming one it has not got is ordinary rather than a mistake.
+ */
+export function arrangeMenuRows(
+ order: readonly string[] | undefined,
+ custom: readonly T[],
+ builtIn: readonly T[],
+): T[] {
+ // A custom row taking a built-in's id shadows it, rather than both answering to the same id.
+ const rows = [...custom, ...builtIn].filter((row, index, all) => all.findIndex(r => r.id === row.id) === index);
+ if (!order?.length) {
+ return rows;
+ }
+
+ const named = [...new Set(order)].flatMap(id => rows.filter(row => row.id === id));
+ return [...named, ...rows.filter(row => !named.includes(row))];
+}
diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts
new file mode 100644
index 00000000000..69f3080f616
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts
@@ -0,0 +1,96 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, fontWeightVars, radiusVars, scrollFadeVars, space, typeScaleVars } from '../tokens.stylex';
+
+const pulse = stylex.keyframes({
+ '50%': { opacity: 0.5 },
+});
+
+export const styles = stylex.create({
+ // The avatar is the trigger, so the button paints nothing of its own.
+ trigger: {
+ padding: 0,
+ borderStyle: 'none',
+ outline: {
+ default: 'none',
+ ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`,
+ },
+ backgroundColor: 'transparent',
+ cursor: 'pointer',
+ display: 'inline-flex',
+ outlineOffset: '2px',
+ },
+
+ // A labelled trigger sits in a host app's chrome, so it stays flush like the avatar-only form
+ // and only spaces the avatar from its text.
+ triggerLabelled: {
+ gap: space['2'],
+ alignItems: 'center',
+ },
+
+ // Sized to the avatar it stands in for; it takes that avatar's corner from `triggerShapes`, since
+ // this block is the only part of the placeholder that paints. StyleX rather than the shared
+ // `Skeleton`, which is still Emotion-backed and would pull that runtime into the Mosaic build.
+ triggerSkeleton: {
+ animationDuration: '2s',
+ animationIterationCount: 'infinite',
+ animationName: {
+ default: pulse,
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)',
+ backgroundColor: colorVars['--cl-color-border'],
+ blockSize: '1.75rem',
+ inlineSize: '1.75rem',
+ },
+
+ // Matches `Item.Title`, so the trigger names a workspace the same way its row does. Capped,
+ // because the trigger sits in a host app's chrome and a long workspace name would push it apart.
+ triggerName: {
+ color: colorVars['--cl-color-neutral'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ maxWidth: '12rem',
+ },
+
+ // The workspace list scrolls; the header and footer stay put. The scroll area carries the
+ // overflow itself, along with the edge fades and the scrollbar, so only the cap and the scroll
+ // padding are ours. The padding answers those fades: tabbing to a row below the fold would
+ // otherwise land it flush against the edge the mask fades out, on the one row you just moved to.
+ scroll: {
+ scrollPaddingBlockEnd: scrollFadeVars['--cl-scroll-fade-size'],
+ scrollPaddingBlockStart: scrollFadeVars['--cl-scroll-fade-size'],
+ maxHeight: '18rem',
+ },
+
+ // The trailing column is as wide as the `⋯` menu button that owns it, so whatever stands in
+ // that button's place — the active check, a spinner — lands on the same centre line and the
+ // right edge of every row holds still as rows change state.
+ trailing: {
+ justifyContent: 'center',
+ width: space['7'],
+ },
+
+ branding: {
+ gap: space['2'],
+ paddingBlock: space['3'],
+ alignItems: 'center',
+ borderBlockStartColor: colorVars['--cl-color-border-faded'],
+ borderBlockStartStyle: 'solid',
+ borderBlockStartWidth: '1px',
+ color: colorVars['--cl-color-neutral-faded'],
+ display: 'flex',
+ fontSize: typeScaleVars['--cl-text-xs-size'],
+ justifyContent: 'center',
+ lineHeight: typeScaleVars['--cl-text-xs-leading'],
+ textAlign: 'center',
+ },
+});
+
+// The trigger takes the corner of the workspace mark it carries: round for a person, squared for
+// an organization. Rounding it fully would draw a circle around a square mark, labelled or not.
+export const triggerShapes = stylex.create({
+ circle: { borderRadius: radiusVars['--cl-radius-full'] },
+ square: { borderRadius: radiusVars['--cl-radius-md'] },
+});
diff --git a/packages/ui/src/mosaic/user-button/user-button.types.ts b/packages/ui/src/mosaic/user-button/user-button.types.ts
new file mode 100644
index 00000000000..5c05c3ed4e6
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.types.ts
@@ -0,0 +1,184 @@
+import type { ReactNode } from 'react';
+
+// ─── Data contract ──────────────────────────────────────────────────────────
+// Session-backed, discriminated resource rows. 1:1 with `useUserButtonController()`'s output, so the
+// controller and the view agree on a shape neither one owns.
+
+export interface UserButtonSession {
+ sessionId: string;
+ name: string;
+ /** Whatever the account is addressed by: username, email, phone, or wallet. */
+ identifier: string;
+ imageUrl?: string;
+}
+
+export interface UserButtonMembership {
+ kind: 'membership';
+ organizationId: string;
+ name: string;
+ imageUrl?: string;
+ membersCount?: number;
+ planLabel?: string;
+}
+
+export interface UserButtonSuggestion {
+ kind: 'suggestion';
+ id: string;
+ organizationId: string;
+ name: string;
+ imageUrl?: string;
+ /** `accepted` is awaiting approval, so it lists but cannot be joined again. */
+ status: 'pending' | 'accepted';
+}
+
+export interface UserButtonInvitation {
+ kind: 'invitation';
+ id: string;
+ organizationId: string;
+ organizationName: string;
+ imageUrl?: string;
+ /** `accepted` is already a workspace, so it lists as one rather than offering to be accepted. */
+ status: 'pending' | 'accepted';
+}
+
+/** Feeds one more page into a list as its foot scrolls into view. */
+export interface UserButtonPaging {
+ ref: (element: HTMLElement | null) => void;
+ hasMore: boolean;
+}
+
+export interface UserButtonData {
+ activeSession: UserButtonSession;
+ /**
+ * The active organization, described whole rather than found in `memberships`, so the surface
+ * names it while the list it belongs to is still loading. `null` => the personal workspace.
+ */
+ activeOrganization: UserButtonMembership | null;
+ /**
+ * Explicit; do not derive from `memberships.length`. Answered before the lists are fetched, so
+ * the surface knows whether to carry a workspace section at all without waiting on them.
+ */
+ hasOrganizations: boolean;
+ /**
+ * The account has no workspace of its own to return to, so the organizations are all there is.
+ * Withholds the personal row rather than standing it down: this is not a switch that is
+ * momentarily unavailable, it is a workspace that does not exist here.
+ */
+ hidePersonal?: boolean;
+ /**
+ * A first page is still in flight, so the workspace rows stand in as one placeholder rather than
+ * appearing a list at a time.
+ */
+ organizationsLoading?: boolean;
+ memberships: UserButtonMembership[];
+ suggestions: UserButtonSuggestion[];
+ invitations: UserButtonInvitation[];
+ paging?: UserButtonPaging;
+ /**
+ * The other signed-in accounts. Only sessions: an account's organizations are scoped to the
+ * session that fetches them, so they are unknowable until it is the active one.
+ */
+ additionalSessions: UserButtonSession[];
+}
+
+/** All optional. An unhandled action hides (or de-activates) the affordance it drives. */
+export interface UserButtonCallbacks {
+ /**
+ * Acts on the active account; another account's organizations are unreachable until you switch.
+ * `null` selects the personal workspace, which is how an account leaves an organization.
+ */
+ onSelectOrganization?: (organizationId: string | null) => void;
+ onAcceptSuggestion?: (suggestionId: string) => void;
+ onAcceptInvitation?: (invitationId: string) => void;
+ onSwitchSession?: (sessionId: string) => void;
+ onSignOutSession?: (sessionId: string) => void;
+ onSignOutAll?: () => void;
+ onManageOrganization?: () => void;
+ onInviteMembers?: () => void;
+ onManageAccount?: () => void;
+ onCreateOrganization?: () => void;
+ onAddAccount?: () => void;
+}
+
+/**
+ * Which switchers the surface carries. `combined` is both; `orgs` is an organization switcher with
+ * no account rows; `user` is an account switcher that never shows an organization, even when one
+ * is active.
+ */
+export type UserButtonMode = 'combined' | 'orgs' | 'user';
+
+/**
+ * Which of the two switchers a `combined` surface leads with: the one named in the trigger and
+ * headed in the popup. Both are still listed either way. The single-purpose modes have only one
+ * thing to lead with, so they ignore it.
+ */
+export type UserButtonModePriority = 'organizations' | 'user';
+
+/** Which switchers the surface carries, and which one it leads with. */
+export interface UserButtonModeProps {
+ /**
+ * Which switchers the popup carries: both, organizations alone, or accounts alone.
+ *
+ * @default 'combined'
+ */
+ mode?: UserButtonMode;
+ /**
+ * Which switcher a `combined` surface leads with in the trigger and the popup's header. The other
+ * one is still listed. Ignored by the single-purpose modes, which have only one thing to lead with.
+ *
+ * @default 'organizations'
+ */
+ modePriority?: UserButtonModePriority;
+}
+
+export interface UserButtonBusyState {
+ /**
+ * Key of the single in-flight action (see `userButtonBusyKeys`), or `null`/absent when idle. The
+ * affordance that owns it spins; every other one is disabled so a second action cannot start.
+ */
+ pendingKey?: string | null;
+}
+
+// ─── Menu items ─────────────────────────────────────────────────────────────
+
+/**
+ * A built-in action the foot of the popup lists as a row of its own, named by the id `menuItemOrder`
+ * knows it by. The surface's other actions live in its header or behind a `⋯`, where there is no
+ * list for an order to run in.
+ */
+export type UserButtonMenuItemId = 'createOrganization' | 'addAccount' | 'signOutAll';
+
+interface UserButtonMenuItemBase {
+ /** Identifies the row, for ordering. */
+ id: string;
+ /** Names the row. */
+ label: string;
+ icon?: ReactNode;
+}
+
+/** An action of your own at the foot of the popup. */
+export interface UserButtonMenuAction extends UserButtonMenuItemBase {
+ onClick: () => void;
+ href?: never;
+}
+
+/** A row at the foot of the popup that leaves for somewhere else. */
+export interface UserButtonMenuLink extends UserButtonMenuItemBase {
+ /** Where the row goes. */
+ href: string;
+ onClick?: never;
+}
+
+export type UserButtonMenuItem = UserButtonMenuAction | UserButtonMenuLink;
+
+/** The app's own actions at the foot of the popup, and the order the foot's rows run in. */
+export interface UserButtonMenuProps {
+ /** Actions and links of your own, added to the foot of the popup ahead of Clerk's own rows. */
+ customMenuItems?: UserButtonMenuItem[];
+ /**
+ * The order the foot's rows run in, by id: a built-in row's id, or a custom item's `id`. Anything
+ * left out follows the rows named here. An id the surface does not carry as a row is ignored,
+ * since which rows the foot has depends on its mode.
+ */
+ menuItemOrder?: (UserButtonMenuItemId | (string & {}))[];
+}
diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx
new file mode 100644
index 00000000000..5b3d984319a
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx
@@ -0,0 +1,1056 @@
+'use client';
+
+import type { PopoverProps } from '@clerk/headless/popover';
+import * as stylex from '@stylexjs/stylex';
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+
+import type { AvatarProps } from '../components/avatar';
+import { Avatar } from '../components/avatar';
+import { Badge } from '../components/badge';
+import { Button, SubmitButton } from '../components/button';
+import { Card } from '../components/card';
+import { ClerkLogo } from '../components/clerk-logo';
+import { Icon } from '../components/icon';
+import { Item } from '../components/item';
+import { Menu } from '../components/menu';
+import { Popover } from '../components/popover';
+import { scrollAreaViewport } from '../components/scroll-area';
+import { Spinner } from '../components/spinner';
+import { truncationStyles } from '../components/typography.styles';
+import type { IconName } from '../icons/registry';
+import { fontWeightVars } from '../tokens.stylex';
+import { arrangeMenuRows } from './user-button.menu';
+import { styles, triggerShapes } from './user-button.styles';
+import type {
+ UserButtonBusyState,
+ UserButtonCallbacks,
+ UserButtonData,
+ UserButtonMembership,
+ UserButtonMenuItemId,
+ UserButtonMenuProps,
+ UserButtonMode,
+ UserButtonModePriority,
+ UserButtonModeProps,
+ UserButtonSession,
+} from './user-button.types';
+
+// The data contract, the mode flags, and the menu item shapes live in `user-button.types`; they are
+// what the controller and the view agree on, so neither file owns them.
+export type * from './user-button.types';
+
+/**
+ * Stable keys naming which affordance owns the single in-flight action. Shared by the connected
+ * container (which sets `pendingKey`) and the view (which matches against it).
+ */
+export const userButtonBusyKeys = {
+ selectOrganization: (organizationId: string | null) => `select-org:${organizationId ?? 'personal'}`,
+ switchSession: (sessionId: string) => `switch:${sessionId}`,
+ signOutSession: (sessionId: string) => `sign-out:${sessionId}`,
+ signOutAll: () => 'sign-out-all',
+ acceptSuggestion: (suggestionId: string) => `accept-suggestion:${suggestionId}`,
+ acceptInvitation: (invitationId: string) => `accept-invitation:${invitationId}`,
+} as const;
+
+type UserButtonContextValue = UserButtonData &
+ UserButtonCallbacks &
+ UserButtonBusyState &
+ UserButtonMenuProps & { mode: UserButtonMode; modePriority: UserButtonModePriority };
+
+// ─── Context ────────────────────────────────────────────────────────────────
+
+const UserButtonContext = React.createContext(null);
+
+function useUserButtonContext(): UserButtonContextValue {
+ const value = React.useContext(UserButtonContext);
+ if (!value) {
+ throw new Error('UserButton parts must be rendered inside ');
+ }
+ return value;
+}
+
+/** Splits the one in-flight action into the affordance that owns it and every one that must wait. */
+function useBusy(key: string): { busy: boolean; disabled: boolean } {
+ const { pendingKey } = useUserButtonContext();
+ return { busy: pendingKey === key, disabled: Boolean(pendingKey) && pendingKey !== key };
+}
+
+interface ActiveWorkspace {
+ name: string;
+ imageUrl?: string;
+ shape: 'circle' | 'square';
+ /** Absent when the personal account is what's active. */
+ organization?: UserButtonMembership;
+}
+
+function workspace(organization: UserButtonMembership | undefined, session: UserButtonSession): ActiveWorkspace {
+ if (organization) {
+ return { name: organization.name, imageUrl: organization.imageUrl, shape: 'square', organization };
+ }
+ return { name: session.name, imageUrl: session.imageUrl, shape: 'circle' };
+}
+
+/**
+ * Whether the surface leads with an organization, which its mode settles on its own — before any
+ * data loads, so the placeholder can take the same corner the real trigger will.
+ */
+function leadsWithOrganization(mode: UserButtonMode, modePriority: UserButtonModePriority): boolean {
+ return mode === 'combined' ? modePriority === 'organizations' : mode === 'orgs';
+}
+
+/**
+ * What the surface leads with: named in the trigger and headed in the popup, so the two always
+ * agree. Only an organization-led surface with an organization actually active resolves to one.
+ */
+function leadWorkspace(data: UserButtonContextValue): ActiveWorkspace {
+ const leadsWithOrg = leadsWithOrganization(data.mode, data.modePriority);
+ return workspace(leadsWithOrg ? (data.activeOrganization ?? undefined) : undefined, data.activeSession);
+}
+
+/**
+ * `user` mode never lists organizations, and neither does an account with nothing to list. A
+ * pending invitation or suggestion counts: it has to be reachable before there is a membership.
+ * Loading does not count — `hasOrganizations` is answered before the lists are fetched, so an
+ * account with none never opens a section that then disappears under it.
+ */
+function showsOrganizations(data: UserButtonContextValue): boolean {
+ if (data.mode === 'user') {
+ return false;
+ }
+ return data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0;
+}
+
+/**
+ * The Accounts group only exists to switch between accounts, so it needs another one to switch to.
+ * An org-only surface carries no account rows at all, not even the one it belongs to.
+ */
+function showsAccounts(data: UserButtonContextValue): boolean {
+ return data.mode !== 'orgs' && data.additionalSessions.length > 0;
+}
+
+/**
+ * The heading tells the account rows apart from the workspaces above them, and carries "Add
+ * account". An account-only surface lists nothing to tell them apart from, so the rows stand alone
+ * and the foot takes the action.
+ */
+function showsAccountsHeading(data: UserButtonContextValue): boolean {
+ return showsAccounts(data) && data.mode !== 'user';
+}
+
+function membershipSubtitle(membership: UserButtonMembership): string {
+ const parts: string[] = [];
+ if (membership.membersCount !== undefined) {
+ parts.push(`${membership.membersCount} ${membership.membersCount === 1 ? 'member' : 'members'}`);
+ }
+ if (membership.planLabel) {
+ parts.push(membership.planLabel);
+ }
+ return parts.join(' · ');
+}
+
+function initials(name: string): string {
+ const [first = '', second = ''] = name.trim().split(/\s+/);
+ return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase() || '?';
+}
+
+// ─── Presentational leaves ──────────────────────────────────────────────────
+
+interface WorkspaceAvatarProps {
+ name: string;
+ imageUrl?: string;
+ shape: 'circle' | 'square';
+ size: AvatarProps['size'];
+}
+
+function WorkspaceAvatar({ name, imageUrl, shape, size }: WorkspaceAvatarProps) {
+ return (
+ // Decorative: every avatar here sits next to the same name in text, or in a labelled button, so
+ // exposing the fallback initials would only pad the accessible name ("O Other").
+
+ {imageUrl ? (
+
+ ) : null}
+ {initials(name)}
+
+ );
+}
+
+/**
+ * Renders `` so a whole row is one click target. Rows with their own controls skip it.
+ *
+ * A row that is waiting on an action stays a button and takes `disabled`, rather than dropping to a
+ * static row: swapping the host element out remounts the row, and the avatar it carries comes back
+ * as initials while it re-resolves an image the browser already has.
+ */
+const asButton =
+ (disabled = false) =>
+ ({ children, ...props }: React.HTMLAttributes) => (
+
+ {children}
+
+ );
+
+/** A row's trailing column, sized and centred so every state lands on the `⋯` button's centre line. */
+function Trailing({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+interface WorkspaceRowProps {
+ name: string;
+ imageUrl?: string;
+ shape: 'circle' | 'square';
+ active?: boolean;
+ onSelect?: () => void;
+ trailing?: ReactNode;
+ busy?: boolean;
+ disabled?: boolean;
+}
+
+/** One selectable workspace: personal account, organization, suggestion, or invitation. */
+function WorkspaceRow({ name, imageUrl, shape, active, onSelect, trailing, busy, disabled }: WorkspaceRowProps) {
+ // Selecting what is already selected does nothing, so the active row is not a button at all. A
+ // row that is merely waiting stays one, disabled.
+ const select = active ? undefined : onSelect;
+ const waiting = Boolean(busy || disabled);
+
+ return (
+
+
+
+
+
+ {name}
+
+ {busy ? (
+
+
+
+ ) : trailing ? (
+ {trailing}
+ ) : active ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+const asAnchor =
+ (href: string) =>
+ ({ children, ...props }: React.HTMLAttributes) => (
+
+ {children}
+
+ );
+
+interface ActionRowProps {
+ /** Identifies the row, for ordering. */
+ id: UserButtonMenuItemId | (string & {});
+ icon?: ReactNode;
+ label: string;
+ /** Where the row goes, for a row that leaves rather than acting. */
+ href?: string;
+ onClick?: () => void;
+ /** Key from `userButtonBusyKeys` when the action is one-shot; omitted for navigations. */
+ busyKey?: string;
+}
+
+/** A bare action at the foot of a group ("Add account", "Sign out of all accounts"). */
+function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) {
+ const { busy, disabled } = useBusy(busyKey ?? '');
+
+ return (
+
+ {busy ? : icon}
+
+ {label}
+
+
+ );
+}
+
+// ─── Sections ───────────────────────────────────────────────────────────────
+
+interface HeaderAction {
+ label: string;
+ /** An icon renders a square, icon-only button that labels itself through `aria-label`. */
+ icon?: IconName;
+ onClick: () => void;
+ /** Key from `userButtonBusyKeys` when the action is one-shot; omitted for navigations. */
+ busyKey?: string;
+}
+
+// Hooks cannot run inside a `.map`, so each button is its own component to read its own busy state.
+function HeaderActionButton({ label, icon, onClick, busyKey }: HeaderAction) {
+ const { busy, disabled } = useBusy(busyKey ?? '');
+ // On an icon button the spinner takes the icon's place; on a labelled one it leads the label, so
+ // the button keeps its width while the action runs.
+ const spinner = busy ? : null;
+
+ return (
+
+ {icon ? (
+ (spinner ?? (
+
+ ))
+ ) : (
+ <>
+ {spinner}
+ {label}
+ >
+ )}
+
+ );
+}
+
+/** The active workspace: who you are signed in as, and what you can do about it. */
+function Header() {
+ const data = useUserButtonContext();
+ const signOutSession = data.onSignOutSession;
+ const { sessionId, identifier } = data.activeSession;
+ const { name, imageUrl, shape, organization } = leadWorkspace(data);
+ // An account with no name is titled by its identifier, and repeating it underneath says nothing.
+ const accountSubtitle = identifier === name ? '' : identifier;
+ const subtitle = organization ? membershipSubtitle(organization) : accountSubtitle;
+ // Inviting belongs to whichever organization is active, even where the account is what heads the
+ // surface. The gear manages whatever the header names.
+ const invitable = showsOrganizations(data) ? data.activeOrganization : null;
+
+ const actions: HeaderAction[] = [];
+ if (invitable && data.onInviteMembers) {
+ actions.push({ label: 'Invite', onClick: data.onInviteMembers });
+ }
+ // Every other surface hangs "Sign out" off the account's own row. An account-only one has no such
+ // row, so it takes the labelled slot **Invite** occupies elsewhere, left of the gear.
+ if (data.mode === 'user' && signOutSession) {
+ actions.push({
+ label: 'Sign out',
+ onClick: () => signOutSession(sessionId),
+ busyKey: userButtonBusyKeys.signOutSession(sessionId),
+ });
+ }
+ if (organization) {
+ if (data.onManageOrganization) {
+ actions.push({ label: 'Manage organization', icon: 'cog', onClick: data.onManageOrganization });
+ }
+ } else if (data.onManageAccount) {
+ actions.push({ label: 'Manage account', icon: 'cog', onClick: data.onManageAccount });
+ }
+
+ return (
+
+
+
+
+
+
+ {name}
+ {subtitle ? {subtitle} : null}
+
+
+ {actions.map(a => (
+
+ ))}
+
+
+
+ );
+}
+
+interface AccountAction {
+ label: string;
+ onClick: () => void;
+ color?: 'negative';
+}
+
+/** The `⋯` that hangs off a row's trailing edge. Renders nothing when it would be empty. */
+function ActionMenu({ label, actions, disabled }: { label: string; actions: AccountAction[]; disabled?: boolean }) {
+ if (actions.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {actions.map(a => (
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * The active account, named by its identifier. It heads the workspaces that belong to it and
+ * carries the account-wide actions, the way the "Accounts" row heads the other accounts.
+ */
+function ActiveAccountRow() {
+ const data = useUserButtonContext();
+ const signOutSession = data.onSignOutSession;
+ const { identifier, sessionId } = data.activeSession;
+ // Its actions live in a menu that closes on click, so the row itself carries their spinner.
+ const { busy, disabled } = useBusy(userButtonBusyKeys.signOutSession(sessionId));
+
+ const actions: AccountAction[] = [];
+ if (data.onCreateOrganization) {
+ actions.push({ label: 'Create organization', onClick: data.onCreateOrganization });
+ }
+ if (data.onManageAccount) {
+ actions.push({ label: 'Manage account', onClick: data.onManageAccount });
+ }
+ if (signOutSession) {
+ actions.push({
+ label: 'Sign out',
+ color: 'negative',
+ onClick: () => signOutSession(sessionId),
+ });
+ }
+
+ return (
+
+
+
+ {identifier}
+
+
+ {busy ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
+interface MembershipRowProps {
+ membership: UserButtonMembership;
+ active: boolean;
+ onSelect?: () => void;
+}
+
+// Hooks cannot run inside a `.map`, so each row is its own component to read its own busy state.
+function MembershipRow({ membership, active, onSelect }: MembershipRowProps) {
+ const { busy, disabled } = useBusy(userButtonBusyKeys.selectOrganization(membership.organizationId));
+
+ return (
+
+ );
+}
+
+/**
+ * The account's own workspace, which is what "no active organization" is. Listed alongside the
+ * organizations so switching into one is not a one-way door: `null` is how you leave.
+ *
+ * Named for what it is among organizations rather than for the account, the way the existing
+ * OrganizationSwitcher names it. The trigger and header name the account itself, since that is
+ * what they are about.
+ */
+function PersonalRow() {
+ const data = useUserButtonContext();
+ const selectOrganization = data.onSelectOrganization;
+ const { imageUrl, shape } = workspace(undefined, data.activeSession);
+ const { busy, disabled } = useBusy(userButtonBusyKeys.selectOrganization(null));
+
+ if (data.hidePersonal) {
+ return null;
+ }
+
+ return (
+ selectOrganization(null) : undefined}
+ busy={busy}
+ disabled={disabled}
+ />
+ );
+}
+
+/** The organizations the active account belongs to. Its own workspace is the row above. */
+function MembershipRows() {
+ const data = useUserButtonContext();
+ const selectOrganization = data.onSelectOrganization;
+
+ return (
+ <>
+ {data.memberships.map(m => (
+ selectOrganization(m.organizationId) : undefined}
+ active={m.organizationId === data.activeOrganization?.organizationId}
+ />
+ ))}
+ >
+ );
+}
+
+interface PendingRowProps {
+ busyKey: string;
+ name: string;
+ imageUrl?: string;
+ actionLabel: string;
+ onAccept?: () => void;
+ /** Replaces the accept button when there is nothing left to do but wait. */
+ note?: string;
+}
+
+/** A workspace on offer: joined from its own trailing button rather than by clicking the row. */
+function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: PendingRowProps) {
+ const { busy, disabled } = useBusy(busyKey);
+
+ return (
+ {note}
+ ) : onAccept ? (
+ // Every other affordance here swaps its icon for a spinner, but this one is a labelled
+ // button, so the spinner goes inside it rather than taking the row's trailing edge — the
+ // press and the thing that reports it stay the same element. `pendingKey` is already
+ // spin-delayed by the container, so this asks for no second delay of its own.
+
+ {actionLabel}
+
+ ) : undefined
+ }
+ />
+ );
+}
+
+/** What the active account has been asked to join but has not joined yet. */
+function PendingRows() {
+ const data = useUserButtonContext();
+ const acceptSuggestion = data.onAcceptSuggestion;
+ const acceptInvitation = data.onAcceptInvitation;
+ const selectOrganization = data.onSelectOrganization;
+
+ // Accepting an invitation joins the organization, so an accepted one is a workspace the surface
+ // may already be showing. It stays listed only for as long as the membership list has yet to
+ // catch up with it, which is what keeps it reachable in the meantime.
+ const listed = new Set(data.memberships.map(m => m.organizationId));
+ if (data.activeOrganization) {
+ listed.add(data.activeOrganization.organizationId);
+ }
+ const invitations = data.invitations.filter(i => i.status === 'pending' || !listed.has(i.organizationId));
+
+ return (
+ <>
+ {/* Invitations first: one is addressed to this account and joins on accept, where a
+ suggestion only files a request. Same order as the existing OrganizationSwitcher. */}
+ {invitations.map(i =>
+ // Already joined, so it is a workspace like any other: click the row to switch to it.
+ i.status === 'accepted' ? (
+ selectOrganization(i.organizationId) : undefined}
+ />
+ ) : (
+ acceptInvitation(i.id) : undefined}
+ />
+ ),
+ )}
+ {data.suggestions.map(s => (
+ acceptSuggestion(s.id) : undefined}
+ />
+ ))}
+ >
+ );
+}
+
+/**
+ * A signed-in account: a plain row you click to switch to, checked where it is already the active
+ * one. Its workspaces cannot be listed here — they are scoped to the session that fetches them —
+ * so switching is all it offers.
+ */
+function AccountRow({ session, active }: { session: UserButtonSession; active?: boolean }) {
+ const data = useUserButtonContext();
+ const { busy, disabled } = useBusy(userButtonBusyKeys.switchSession(session.sessionId));
+ // The account you are already on is not something to switch to, so its row is not a button.
+ const switchSession = active ? undefined : data.onSwitchSession;
+
+ return (
+ switchSession(session.sessionId) : undefined}
+ >
+
+
+
+
+ {/* Named by its identifier, like the active account's row, so the two read as the same kind. */}
+ {session.identifier}
+
+ {busy ? (
+
+
+
+ ) : active ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/** Holds the workspace list's place until its first page lands. */
+function WorkspaceListLoadingRow() {
+ return (
+
+
+
+
+
+ Loading organizations…
+
+
+ );
+}
+
+/** The active account and everything it can switch to. This is the group that scrolls. */
+function WorkspaceSection() {
+ const data = useUserButtonContext();
+ // The account row carries the account's own actions, so it is not the workspace list's to
+ // withhold: an account with no organizations still needs somewhere to manage and sign out of it.
+ // The other two surfaces name the account in their header instead, or are not about it at all.
+ const accountRow = data.mode === 'combined' ? : null;
+ const listsOrganizations = showsOrganizations(data);
+
+ if (!accountRow && !listsOrganizations) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {/* `auto` rather than `stable`: a reserved gutter insets the rows whether or not the list
+ overflows, so short lists would sit their avatars and icons off the edge the header and
+ footer align to. */}
+
+ {accountRow}
+ {/* Memberships, invitations and suggestions are three separate requests landing at three
+ different moments. Rendering each as it arrives walks the list in in stages, so the
+ placeholder stands in for all of them until the last one is in. */}
+ {listsOrganizations &&
+ (data.organizationsLoading ? (
+
+ ) : (
+ <>
+ {/* What is on offer leads the list: an invitation or suggestion is the one row here
+ that goes away if it is not acted on, and the workspaces held are not going
+ anywhere. This is the order the existing OrganizationSwitcher lists them in. */}
+
+
+
+ {data.paging?.hasMore ?
: null}
+ >
+ ))}
+
+ >
+ );
+}
+
+/** The heading the account rows sit under, and the account-wide actions it carries. */
+function AccountsHeading() {
+ const data = useUserButtonContext();
+
+ const actions: AccountAction[] = [];
+ if (data.onAddAccount) {
+ actions.push({ label: 'Add account', onClick: data.onAddAccount });
+ }
+
+ return (
+
+
+ Accounts
+
+
+
+ );
+}
+
+/** The signed-in accounts, under their own heading, so they never read as workspaces. */
+function AccountsSection() {
+ const data = useUserButtonContext();
+
+ if (!showsAccounts(data)) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ {showsAccountsHeading(data) ? (
+ <>
+
+ {/* Under a heading the group reads as the full set of accounts, so the one you are on
+ is listed and checked. Without one it is a list of somewhere else to go. */}
+
+ >
+ ) : null}
+ {data.additionalSessions.map(s => (
+
+ ))}
+
+ >
+ );
+}
+
+/** The actions that close out the surface, plus the Clerk attribution. */
+function Footer() {
+ const data = useUserButtonContext();
+
+ // An org-only surface has no account menu to carry "Create organization", so it lands here
+ // instead, in the slot the account-wide actions occupy everywhere else.
+ const builtIn: ActionRowProps[] = [];
+ if (data.mode === 'orgs') {
+ if (data.onCreateOrganization) {
+ builtIn.push({
+ id: 'createOrganization',
+ icon: (
+
+ ),
+ label: 'Create organization',
+ onClick: data.onCreateOrganization,
+ });
+ }
+ } else {
+ // "Add account" lives in the Accounts heading wherever there is one; without it the foot
+ // carries it, the same slot "Create organization" takes on an org-only surface.
+ if (data.onAddAccount && !showsAccountsHeading(data)) {
+ builtIn.push({
+ id: 'addAccount',
+ icon: (
+
+ ),
+ label: 'Add account',
+ onClick: data.onAddAccount,
+ });
+ }
+ if (data.onSignOutAll) {
+ builtIn.push({
+ id: 'signOutAll',
+ icon: (
+
+ ),
+ label: 'Sign out of all accounts',
+ onClick: data.onSignOutAll,
+ busyKey: userButtonBusyKeys.signOutAll(),
+ });
+ }
+ }
+
+ const actions = arrangeMenuRows(data.menuItemOrder, data.customMenuItems ?? [], builtIn);
+
+ return (
+ <>
+ {actions.length > 0 ? (
+ <>
+
+
+ {actions.map(action => (
+
+ ))}
+
+ >
+ ) : null}
+
+ Secured by
+
+ >
+ );
+}
+
+// ─── Public parts ───────────────────────────────────────────────────────────
+
+export interface UserButtonRootProps
+ extends UserButtonData, UserButtonCallbacks, UserButtonBusyState, UserButtonMenuProps, UserButtonModeProps {
+ children: ReactNode;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ placement?: PopoverProps['placement'];
+ sideOffset?: number;
+}
+
+/**
+ * Owns the account/organization data + callbacks and forwards the popover's open state straight to
+ * the headless `Popover.Root` — it does not keep a second controllable-state copy. Leaves consume
+ * the data through context.
+ */
+export function UserButtonRoot(props: UserButtonRootProps): ReactElement {
+ const {
+ children,
+ mode = 'combined',
+ modePriority = 'organizations',
+ open,
+ defaultOpen,
+ onOpenChange,
+ placement,
+ sideOffset,
+ ...data
+ } = props;
+ return (
+
+ {children}
+
+ );
+}
+
+export interface UserButtonTriggerProps {
+ /**
+ * Names the active workspace beside its avatar — the organization wherever one heads the
+ * trigger, the account otherwise. Turn it off for the avatar alone.
+ *
+ * @default true
+ */
+ renderTriggerLabel?: boolean;
+ /**
+ * Carries the active organization's plan beside its name. Part of the label, so it needs
+ * `renderTriggerLabel`: a plan badge with nothing to qualify says nothing.
+ *
+ * @default true
+ */
+ renderPlanBadge?: boolean;
+}
+
+/** The trigger: the active workspace's avatar, and what it is called. */
+export function UserButtonTrigger({
+ renderTriggerLabel = true,
+ renderPlanBadge = true,
+}: UserButtonTriggerProps = {}): ReactElement {
+ const data = useUserButtonContext();
+ const { name, imageUrl, shape, organization } = leadWorkspace(data);
+ const planLabel = renderPlanBadge ? organization?.planLabel : undefined;
+
+ return (
+
+
+ {renderTriggerLabel ? (
+ <>
+ {name}
+ {planLabel ? {planLabel} : null}
+ >
+ ) : null}
+
+ );
+}
+
+/**
+ * Holds the trigger's space while the controller loads, so nothing shifts when the real avatar
+ * lands. That includes its corner, which the mode settles without waiting on data: an
+ * organization-led surface squares off, an account-led one stays round. Non-interactive.
+ */
+export function UserButtonTriggerSkeleton({
+ mode = 'combined',
+ modePriority = 'organizations',
+}: UserButtonModeProps = {}): ReactElement {
+ const shape = leadsWithOrganization(mode, modePriority) ? 'square' : 'circle';
+
+ return (
+
+ );
+}
+
+/** The popover surface: header, workspace list, additional accounts, and footer. */
+export function UserButtonPopup(): ReactElement {
+ return (
+
+ {/* The card lays its children out with a row gap; the rows read as one continuous list. */}
+
+
+
+
+
+
+
+ );
+}
+
+export type UserButtonProps = Omit & UserButtonTriggerProps;
+
+/**
+ * Presentational all-in-one: renders the trigger + popup from a single prop-driven call. The
+ * connected, Clerk-backed `UserButton` lives in `user-button.tsx` and wraps this view.
+ */
+export function UserButtonView({ renderTriggerLabel, renderPlanBadge, ...root }: UserButtonProps): ReactElement {
+ return (
+
+
+
+
+ );
+}