diff --git a/.changeset/mosaic-user-button-view.md b/.changeset/mosaic-user-button-view.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-view.md @@ -0,0 +1,2 @@ +--- +--- 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 fde2fb75410..e1b8e2aecad 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -168,14 +168,14 @@ it out of view. ## Parts -| Part | Slot | Description | -| --------------------- | --------------- | ----------------------------------------------------------------------------------- | -| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `alignOffset`, `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; + /** + * Draws the avatar's fallback initials, where what the row is titled by is not what it is called: + * an account row is titled by its identifier, but the mark stands for the person. + * + * @default name + */ + avatarName?: string; + imageUrl?: string; + shape: 'circle' | 'square'; + active?: boolean; + onSelect?: () => void; + trailing?: ReactNode; + busy?: boolean; + disabled?: boolean; +} + +/** One selectable row: personal account, organization, suggestion, invitation, or another account. */ +function WorkspaceRow({ + name, + avatarName = 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 = data.layout.placement.inviteMembers === 'header' ? data.activeOrganization : null; + + const actions: HeaderAction[] = []; + if (invitable && data.onInviteMembers) { + actions.push({ label: m.manage.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.layout.placement.signOut === 'header' && signOutSession) { + actions.push({ + label: m.accounts.signOut, + onClick: () => signOutSession(sessionId), + busyKey: userButtonBusyKeys.signOutSession(sessionId), + }); + } + if (organization) { + if (data.onManageOrganization) { + actions.push({ label: m.manage.organization, icon: 'cog', onClick: data.onManageOrganization }); + } + } else if (data.onManageAccount) { + actions.push({ label: m.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.layout.placement.createOrganization === 'account-row' && data.onCreateOrganization) { + actions.push({ label: m.manage.createOrganization, onClick: data.onCreateOrganization }); + } + if (data.onManageAccount) { + actions.push({ label: m.manage.account, onClick: data.onManageAccount }); + } + if (data.layout.placement.signOut === 'account-row' && signOutSession) { + actions.push({ + label: m.accounts.signOut, + 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 { 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 switchSession = data.onSwitchSession; + const { busy, disabled } = useBusy(userButtonBusyKeys.switchSession(session.sessionId)); + + return ( + switchSession(session.sessionId) : undefined} + busy={busy} + disabled={disabled} + /> + ); +} + +/** Holds the workspace list's place until its first page lands. */ +function WorkspaceListLoadingRow() { + return ( + // The rows land under a popup that is already open, so nothing else reports the wait ending. + + + + + + {m.workspaces.loading} + + + ); +} + +/** 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.layout.accountRow ? : null; + const listsOrganizations = data.layout.listsOrganizations; + + 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(); + // Everything it opens is a navigation, so it owns no action of its own to spin. It still stands + // down while one runs, the way the account row's `⋯` does. + const { disabled } = useBusy(); + + const actions: AccountAction[] = []; + if (data.layout.placement.addAccount === 'accounts-heading' && data.onAddAccount) { + actions.push({ label: m.accounts.add, onClick: data.onAddAccount }); + } + + return ( + + + {m.accounts.heading} + + + + ); +} + +/** The signed-in accounts, under their own heading, so they never read as workspaces. */ +function AccountsSection() { + const data = useUserButtonContext(); + + if (!data.layout.listsAccounts) { + return null; + } + + return ( + <> + + + {data.layout.accountsHeading ? ( + <> + + {/* 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(); + + const { placement } = data.layout; + // "Create organization" and "Add account" are the same slot, taken by whichever of the two the + // surface has nowhere else to put (see `user-button.layout`), so they share the icon as well. + const plus = ( + + ); + + const builtIn: ActionRowProps[] = []; + if (placement.createOrganization === 'footer' && data.onCreateOrganization) { + builtIn.push({ + id: 'createOrganization', + icon: plus, + label: m.manage.createOrganization, + onClick: data.onCreateOrganization, + }); + } + if (placement.addAccount === 'footer' && data.onAddAccount) { + builtIn.push({ id: 'addAccount', icon: plus, label: m.accounts.add, onClick: data.onAddAccount }); + } + if (placement.signOutAll === 'footer' && data.onSignOutAll) { + builtIn.push({ + id: 'signOutAll', + icon: ( + + ), + label: m.accounts.signOutAll, + onClick: data.onSignOutAll, + busyKey: userButtonBusyKeys.signOutAll(), + }); + } + + // Custom rows lead by default, the way the existing UserButton lists them above "Add account". + const actions = applyOrder( + data.menuItemOrder, + [...(data.customMenuItems ?? []), ...builtIn], + r => r.id, + ); + + return ( + <> + {actions.length > 0 ? ( + <> + + + {actions.map(action => ( + + ))} + + + ) : null} + {data.branded === false ? null : ( +
+ {m.branding.securedBy}{' '} + + + +
+ )} + + ); +} + +// ─── Public parts ─────────────────────────────────────────────────────────── + +export interface UserButtonRootProps + extends + UserButtonData, + UserButtonCallbacks, + UserButtonBusyState, + UserButtonBrandingProps, + 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 = 'organization', + open, + defaultOpen, + onOpenChange, + placement, + sideOffset, + ...data + } = props; + // Resolved here so the sections below never read `mode` again: which affordance lands in which + // slot is settled once, in one table, rather than re-derived by each part that renders one. + const layout = resolveUserButtonLayout(mode, modePriority, data); + + 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 + */ + renderTriggerBadge?: boolean; +} + +/** The trigger: the active workspace's avatar, and what it is called. */ +export function UserButtonTrigger({ + renderTriggerLabel = true, + renderTriggerBadge = true, +}: UserButtonTriggerProps = {}): ReactElement { + const data = useUserButtonContext(); + const { name, imageUrl, shape, organization } = leadWorkspace(data); + const planLabel = renderTriggerBadge ? organization?.planLabel : undefined; + + return ( + + + {renderTriggerLabel ? ( + <> + {name} + {planLabel ? {planLabel} : null} + + ) : null} + + ); +} + +/** 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. */} + +
+ + +