diff --git a/.changeset/mosaic-radius-scale.md b/.changeset/mosaic-radius-scale.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-radius-scale.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/popover-freeze-on-close.md b/.changeset/popover-freeze-on-close.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/popover-freeze-on-close.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-button-switcher.md b/.changeset/user-button-switcher.md new file mode 100644 index 00000000000..0c8ed4b756d --- /dev/null +++ b/.changeset/user-button-switcher.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add the `UserButton` Mosaic component: an account and organization switcher that combines multi-session account switching with organization selection, suggestions, and invitations behind a single popover. Exposes the all-in-one `UserButton` plus the composable `UserButtonRoot`, `UserButtonTrigger`, and `UserButtonPopup` parts. `mode` narrows what the surface carries: `combined` (the default) lists organizations and accounts together, `orgs` is an organization switcher with no account rows, and `user` is an account switcher that never shows an organization. It owns no slots of its own: it is composed from `Popover`, `Card`, `Item`, `Avatar` and `Menu`, so `appearance.elements` overrides for those components theme it too. diff --git a/packages/headless/src/primitives/menu/README.md b/packages/headless/src/primitives/menu/README.md index ee2c9dda87e..4ac10b6dac2 100644 --- a/packages/headless/src/primitives/menu/README.md +++ b/packages/headless/src/primitives/menu/README.md @@ -139,7 +139,7 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall - Nested menus open on hover (75ms delay) with a `safePolygon` safe zone. - Only one sibling submenu can be open at a time. - Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event. -- `Escape` closes the innermost menu first, bubbling up through the tree. +- `Escape` closes one level: the innermost open menu, leaving its parent — a parent menu, or a `Popover` the menu is rendered inside — open. Pressing it again closes the next level up. An outside press is the opposite: it dismisses the whole stack at once. ## Important Notes diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx index f8ac956d40c..8c2725193d2 100644 --- a/packages/headless/src/primitives/menu/menu-root.tsx +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -45,7 +45,10 @@ function MenuInner(props: MenuProps) { const tree = useFloatingTree(); const nodeId = useFloatingNodeId(); const parentId = useFloatingParentNodeId(); - const isNested = parentId != null; + // A submenu, not merely a menu inside some other floating element. A menu rendered in a popover + // has a parent node id too, and treating that as nesting makes it hover-open, side-placed, and + // unclickable by mouse. + const isNested = parentId != null && parentContext != null; const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); @@ -98,7 +101,9 @@ function MenuInner(props: MenuProps) { ignoreMouse: isNested, }); const role = useRole(floatingContext, { role: 'menu' }); - const dismiss = useDismiss(floatingContext, { bubbles: true }); + // Escape must not bubble: it closes this menu and leaves whatever it sits inside — a parent menu, + // or a popover — open. An outside press is the opposite, and dismisses the whole stack. + const dismiss = useDismiss(floatingContext, { bubbles: { escapeKey: false, outsidePress: true } }); const listNavigation = useListNavigation(floatingContext, { listRef: elementsRef, activeIndex, diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx index dbc19745c69..dfa2fb58c19 100644 --- a/packages/headless/src/primitives/menu/menu.test.tsx +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; +import { Popover } from '../popover'; import { Menu } from './index'; afterEach(() => cleanup()); @@ -614,6 +615,80 @@ describe('Menu', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + + it('Escape closes only the submenu', async () => { + const user = userEvent.setup(); + render( + + Actions + + + + Share + + + Email + + + + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Escape}'); + + expect(screen.getByText('Share')).toHaveAttribute('data-closed', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-open', ''); + }); + }); + + describe('inside a popover', () => { + function renderMenuInPopover() { + return render( + + Open popover + + + + Actions + + + Cut + + + + + + , + ); + } + + it('Escape closes only the menu', async () => { + const user = userEvent.setup(); + renderMenuInPopover(); + + await user.click(screen.getByText('Actions')); + await user.keyboard('{Escape}'); + + expect(screen.getByText('Actions')).toHaveAttribute('data-closed', ''); + expect(screen.getByText('Open popover')).toHaveAttribute('data-open', ''); + }); + + it('Escape closes the popover once the menu is closed', async () => { + const user = userEvent.setup(); + renderMenuInPopover(); + + await user.click(screen.getByText('Actions')); + await user.keyboard('{Escape}'); + await user.keyboard('{Escape}'); + + expect(screen.getByText('Open popover')).toHaveAttribute('data-closed', ''); + }); }); describe('positioner', () => { diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md index 58ceb1d53aa..5e75920e4b1 100644 --- a/packages/headless/src/primitives/popover/README.md +++ b/packages/headless/src/primitives/popover/README.md @@ -63,14 +63,15 @@ const [open, setOpen] = useState(false); ### `Popover.Root` -| Prop | Type | Default | Description | -| -------------- | ------------------------- | ---------- | ---------------------------------- | -| `open` | `boolean` | — | Controlled open state | -| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | -| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | -| `placement` | `Placement` | `"bottom"` | Floating UI placement | -| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | -| `modal` | `boolean` | `false` | Traps focus within the popover | +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ---------- | ----------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | +| `alignOffset` | `number` | `0` | Nudge along the alignment axis (px) | +| `modal` | `boolean` | `false` | Traps focus within the popover | ### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close` @@ -104,6 +105,7 @@ Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popu - **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent. - **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap. - **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically. +- **Popup contents freeze while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`@clerk/headless/utils`) and hold their last frame instead of re-rendering under the animation. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land. Freezing wraps the children in a `display: contents` element and detaches refs inside them until the popup reopens. ## ARIA diff --git a/packages/headless/src/primitives/popover/popover-popup.tsx b/packages/headless/src/primitives/popover/popover-popup.tsx index 8f8d5b5a13b..64e2bcbd1da 100644 --- a/packages/headless/src/primitives/popover/popover-popup.tsx +++ b/packages/headless/src/primitives/popover/popover-popup.tsx @@ -2,17 +2,22 @@ import React from 'react'; -import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverPopupProps = ComponentProps<'div'>; export const PopoverPopup = React.forwardRef(function PopoverPopup(props, ref) { - const { render, ...otherProps } = props; - const { popupRef, transitionProps } = usePopoverContext(); + const { render, children, ...otherProps } = props; + const { open, popupRef, transitionProps } = usePopoverContext(); const defaultProps = { ...transitionProps, + // The popup outlives `open` by the length of its exit animation. Whatever closed it has + // usually changed the data behind it (switching account, picking an item), so the contents + // hold their last frame on the way out instead of swapping under the animation. The popup + // element itself stays live, so `data-closed` / `data-ending-style` still land. + children: {children}, }; return useRender({ diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx index 3edf3efabb4..0f502c5d711 100644 --- a/packages/headless/src/primitives/popover/popover-root.tsx +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -30,13 +30,14 @@ export interface PopoverProps { onOpenChange?: (open: boolean) => void; placement?: Placement; sideOffset?: number; + alignOffset?: number; modal?: boolean; children: ReactNode; } function PopoverInner(props: PopoverProps) { const nodeId = useFloatingNodeId(); - const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + const { placement: placementProp = 'bottom', sideOffset = 4, alignOffset = 0, modal = false, children } = props; const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); @@ -61,7 +62,7 @@ function PopoverInner(props: PopoverProps) { onOpenChange: setOpen, placement: placementProp, middleware: [ - offset(sideOffset), + offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }), flip({ crossAxis: placementProp.includes('-'), fallbackAxisSideDirection: 'end', diff --git a/packages/headless/src/utils/freeze.test.tsx b/packages/headless/src/utils/freeze.test.tsx new file mode 100644 index 00000000000..b0fb5adf2cd --- /dev/null +++ b/packages/headless/src/utils/freeze.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Freeze } from './freeze'; + +afterEach(() => { + cleanup(); +}); + +describe('Freeze', () => { + it('renders children while not frozen', () => { + render(Acme); + + expect(screen.getByText('Acme')).toBeInTheDocument(); + }); + + it('holds the committed DOM when children change while frozen', () => { + const { rerender } = render(Acme); + + rerender(Globex); + + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.queryByText('Globex')).toBeNull(); + }); + + it('keeps the held DOM visible', () => { + const { rerender } = render(Acme); + + rerender(Globex); + + expect(screen.getByText('Acme')).toBeVisible(); + }); + + it('keeps the held DOM visible across further updates while frozen', () => { + const { rerender } = render(Acme); + + rerender(Globex); + rerender(Initech); + + expect(screen.getByText('Acme')).toBeVisible(); + }); + + it('commits the pending children once unfrozen', () => { + const { rerender } = render(Acme); + + rerender(Globex); + rerender(Globex); + + expect(screen.getByText('Globex')).toBeInTheDocument(); + expect(screen.queryByText('Acme')).toBeNull(); + }); + + it('holds state updates raised from inside the frozen subtree', () => { + function Counter({ count }: { count: number }) { + return count: {count}; + } + + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(screen.getByText('count: 0')).toBeInTheDocument(); + }); +}); diff --git a/packages/headless/src/utils/freeze.tsx b/packages/headless/src/utils/freeze.tsx new file mode 100644 index 00000000000..bdb5469ebf6 --- /dev/null +++ b/packages/headless/src/utils/freeze.tsx @@ -0,0 +1,64 @@ +'use client'; + +import * as React from 'react'; + +/** + * Never settles. Throwing it suspends the enclosing boundary indefinitely: React keeps + * rendering the subtree but holds the commit, so the DOM keeps painting its last frame. + */ +const never = new Promise(() => {}); + +function Suspend(): null { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol, not an error. `React.use()` would say this more plainly but needs React 19.2; this package supports React 18. + throw never; +} + +export interface FreezeProps { + /** While `true`, the DOM below holds whatever it last committed. */ + frozen: boolean; + children?: React.ReactNode; +} + +/** + * Holds its subtree's DOM at the last committed frame while `frozen`. Renders keep + * happening, they just don't reach the DOM; the pending one commits when `frozen` flips + * back to `false`. + * + * Use it to stop content from visibly changing under an exit animation — a popover that + * closes because the thing it was showing changed would otherwise swap its contents on the + * way out. + */ +export function Freeze({ frozen, children }: FreezeProps) { + const contentRef = React.useRef(null); + + // Hold onto the node ourselves rather than reading a plain ref: hiding a boundary's children + // detaches their refs, so by the time the effect below runs a normal ref reads `null`. + const setContent = React.useCallback((node: HTMLDivElement | null) => { + if (node) { + contentRef.current = node; + } + }, []); + + // React hides a suspended boundary's host children with `display: none !important`, which is + // the opposite of what this is for. Undo it on the commit that applies it: insertion effects + // run after the boundary's mutation and before paint, so the held frame never blinks out. + // `display: contents` is also what the wrapper renders with, so React puts it back on unfreeze + // and the wrapper stays out of the layout it is spliced into. + React.useInsertionEffect(() => { + if (frozen) { + contentRef.current?.style.setProperty('display', 'contents'); + } + }, [frozen]); + + return ( + + {frozen ? : null} +
+ {children} +
+
+ ); +} diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index f2a2d4c17c4..87856e7a9c2 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,4 +1,5 @@ export { cssVars } from './css-vars'; +export { Freeze, type FreezeProps } from './freeze'; export { resetLayoutStyles } from './reset-layout-styles'; export { type ComponentProps, diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d54119bbc35..273efb6e7af 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { + user: { + 'user-button': dynamic(() => import('../stories/user-button.mdx')), + }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3b1e386414c..755bb25f6ff 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,12 @@ import { } from '../stories/text.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { + Combined as UserButtonCombined, + meta as userButtonMeta, + Organizations as UserButtonOrganizations, + User as UserButtonUser, +} from '../stories/user-button.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -176,6 +182,13 @@ const itemModule: StoryModule = { Group: ItemGroup, }; +const userButtonModule: StoryModule = { + meta: userButtonMeta, + Combined: UserButtonCombined, + Organizations: UserButtonOrganizations, + User: UserButtonUser, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -216,6 +229,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta }; const useDataTableModule: StoryModule = { meta: useDataTableMeta }; export const registry: StoryModule[] = [ + // User + userButtonModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 2aea12d0cac..e8984ede7b5 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -59,12 +59,12 @@ import { Item } from '@clerk/ui/mosaic/components/item'; ; ``` -Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'`. An action row that has no secondary text uses `Item.Label` in place of `Item.Title`: +Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an `Icon`. An action row that has no secondary text uses `Item.Label` in place of `Item.Title`: ```tsx }> - + Sign out of all accounts @@ -74,22 +74,24 @@ Media sizes itself from the row, so give it a child that fills its column — an ## Parts -| 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. 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 | @@ -111,4 +113,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 4c63b08424d..e8d5dad5fb3 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -1,8 +1,8 @@ /** @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 * as React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -16,68 +16,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 ( @@ -198,23 +136,7 @@ export function Group() { size='sm' shape='square' > - - - - - + @@ -234,7 +156,10 @@ export function Group() { Clerk - + - + Add account @@ -322,7 +250,10 @@ export function Group() { )} > - + Sign out of all accounts diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index 08dc13b078a..9489b27e933 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 ` +); + +interface WorkspaceRowProps { + name: string; + imageUrl?: string; + shape: 'circle' | 'square'; + active?: boolean; + onSelect?: () => void; + trailing?: ReactNode; +} + +/** One selectable workspace: personal account, organization, suggestion, or invitation. */ +function WorkspaceRow({ name, imageUrl, shape, active, onSelect, trailing }: WorkspaceRowProps) { + // Selecting what is already selected does nothing, so the active row is not a button at all. + const select = active ? undefined : onSelect; + + return ( + + + + + + {name} + + {trailing ? {trailing} : null} + {active ? ( + + + + ) : null} + + ); +} + +interface ActionRowProps { + icon: IconName; + label: string; + onClick: () => void; +} + +/** A bare action at the foot of a group ("Add account", "Sign out of all accounts"). */ +function ActionRow({ icon, label, onClick }: ActionRowProps) { + return ( + + + + + + {label} + + + ); +} + +// ─── Sections ─────────────────────────────────────────────────────────────── + +interface HeaderAction { + label: string; + /** An icon renders a square, icon-only button that labels itself through `aria-label`. */ + icon?: IconName; + onClick: () => void; +} + +/** The active workspace: who you are signed in as, and what you can do about it. */ +function Header() { + const data = useUserButtonContext(); + const { name, imageUrl, shape, organization } = headerWorkspace(data); + const subtitle = organization ? membershipSubtitle(organization) : data.activeSession.email; + // Inviting belongs to whichever organization is active, even where the account is what heads the + // surface. The gear manages whatever the header names. Signing out is a row in the list below. + const invitable = showsOrganizations(data) ? activeMembership(data) : undefined; + + const actions: HeaderAction[] = []; + if (invitable && data.onInviteMembers) { + actions.push({ label: 'Invite', onClick: data.onInviteMembers }); + } + 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'; +} + +/** + * An account, identified by its email. Every account renders this way — the active one heading + * its workspaces, the others under the separator — so a row is never mistaken for a workspace. + */ +function AccountRow({ email, actions }: { email: string; actions: AccountAction[] }) { + return ( + + + + {email} + + + {actions.length > 0 ? ( + + + + + {actions.map(a => ( + + ))} + + + + ) : null} + + ); +} + +/** The active account's own row: what its workspaces sit under, plus its account-wide actions. */ +function ActiveAccountRow() { + const data = useUserButtonContext(); + const signOutSession = data.onSignOutSession; + + 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(data.activeSession.sessionId), + }); + } + + return ( + + ); +} + +/** The workspaces one account belongs to. Picking one makes it, and the account, active. */ +function MembershipRows({ sessionId, memberships }: { sessionId: string; memberships: UserButtonMembership[] }) { + const data = useUserButtonContext(); + const selectWorkspace = data.onSelectWorkspace; + const isActiveAccount = sessionId === data.activeSession.sessionId; + + return ( + <> + {memberships.map(m => ( + selectWorkspace(sessionId, m.organizationId) : undefined} + active={isActiveAccount && m.organizationId === data.activeOrganizationId} + /> + ))} + + ); +} + +/** 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; + + return ( + <> + {data.suggestions.map(s => ( + acceptSuggestion(s.id)} + > + Join + + ) : undefined + } + /> + ))} + {data.invitations.map(i => ( + acceptInvitation(i.id)} + > + Accept + + ) : undefined + } + /> + ))} + + ); +} + +/** + * With organizations the list is grouped by account, so another account reads as a heading over its + * own workspaces. Without them there is nothing to head, so it is a plain row you click to switch. + */ +function AdditionalAccount({ account }: { account: UserButtonAccount }) { + const data = useUserButtonContext(); + const { session } = account; + const switchSession = data.onSwitchSession; + const signOutSession = data.onSignOutSession; + + if (showsOrganizations(data)) { + const actions: AccountAction[] = []; + if (switchSession) { + actions.push({ label: 'Switch to this account', onClick: () => switchSession(session.sessionId) }); + } + if (signOutSession) { + actions.push({ label: 'Sign out', color: 'negative', onClick: () => signOutSession(session.sessionId) }); + } + return ( + <> + + + + ); + } + + return ( + switchSession(session.sessionId) : undefined} + > + + + + + {session.name} + + + ); +} + +/** Everything the surface can switch to: the active account's workspaces, then the other accounts. */ +function SwitcherList() { + const data = useUserButtonContext(); + const workspaces = showsOrganizations(data); + // An org-only surface carries no account rows at all, not even the one it belongs to. + const accounts = data.mode === 'orgs' ? [] : data.additionalAccounts; + + if (!workspaces && accounts.length === 0) { + return null; + } + + // One group rather than one per account: every row is a peer you can switch to, so they share + // the list's spacing and its scroll. + return ( + <> + + + {workspaces ? ( + <> + {data.mode === 'orgs' ? null : } + + + + ) : null} + {accounts.map(a => ( + + ))} + + + ); +} + +/** 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 actions: ActionRowProps[] = []; + if (data.mode === 'orgs') { + if (data.onCreateOrganization) { + actions.push({ icon: 'plus', label: 'Create organization', onClick: data.onCreateOrganization }); + } + } else { + if (data.onAddAccount) { + actions.push({ icon: 'plus', label: 'Add account', onClick: data.onAddAccount }); + } + if (data.onSignOutAll) { + actions.push({ icon: 'log-out', label: 'Sign out of all accounts', onClick: data.onSignOutAll }); + } + } + + return ( + <> + {actions.length > 0 ? ( + <> + + + {actions.map(action => ( + + ))} + + + ) : null} +
+ Secured by +
+ + ); +} + +// ─── Public parts ─────────────────────────────────────────────────────────── + +export interface UserButtonRootProps extends UserButtonData, UserButtonCallbacks { + children: ReactNode; + /** @default 'combined' */ + mode?: UserButtonMode; + 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) { + const { children, mode = 'combined', open, defaultOpen, onOpenChange, placement, sideOffset, ...data } = props; + return ( + + {children} + + ); +} + +/** The trigger: the active workspace's avatar, and nothing else. */ +export function UserButtonTrigger() { + const data = useUserButtonContext(); + const { name, imageUrl, shape } = triggerWorkspace(data); + + return ( + + + + ); +} + +/** The popover surface: header, workspace list, additional accounts, and footer. */ +export function UserButtonPopup() { + return ( + + {/* The card lays its children out with a row gap; the rows read as one continuous list. */} + +
+ +