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/mosaic-user-button-trigger-label.md b/.changeset/mosaic-user-button-trigger-label.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-trigger-label.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/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 bf70205abb2..2afbed329be 100644 --- a/packages/headless/src/primitives/menu/menu-root.tsx +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -114,7 +114,9 @@ function MenuInner(props: MenuProps) { delete reference.role; return { ...baseRole, reference }; }, [baseRole, isNested]); - 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 0baf2917088..ec5349e579a 100644 --- a/packages/headless/src/primitives/menu/menu.test.tsx +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -682,6 +682,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 d9831ea4e16..212277c3e4c 100644 --- a/packages/headless/src/primitives/popover/popover-root.tsx +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -31,6 +31,7 @@ export interface PopoverProps { onOpenChange?: (open: boolean) => void; placement?: Placement; sideOffset?: number; + alignOffset?: number; modal?: boolean; /** * Where focus lands when the popup opens. @@ -47,7 +48,14 @@ export interface PopoverProps { function PopoverInner(props: PopoverProps) { const nodeId = useFloatingNodeId(); - const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, initialFocus = 'auto', children } = props; + const { + placement: placementProp = 'bottom', + sideOffset = 4, + alignOffset = 0, + modal = false, + initialFocus = 'auto', + children, + } = props; const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); @@ -71,7 +79,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 566a8adacfa..f54beed344e 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 { isKeyboardEvent, isKeyboardOpen } from './interaction-modality'; export { resetLayoutStyles } from './reset-layout-styles'; export { diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 9a19812fbf1..e2a82bd28f7 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 93db371c0e5..88c990e8977 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -115,6 +115,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'; @@ -187,6 +193,13 @@ const itemModule: StoryModule = { Scrolling: ItemScrolling, }; +const userButtonModule: StoryModule = { + meta: userButtonMeta, + Combined: UserButtonCombined, + Organizations: UserButtonOrganizations, + User: UserButtonUser, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -237,6 +250,8 @@ const scrollAreaModule: StoryModule = { 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 b7555624052..09ca1fd19c3 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -86,12 +86,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 @@ -101,22 +101,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 | @@ -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 ` + ); + +/** 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 ( + + ); +} + +/** 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. */} + +
+ + +