diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md new file mode 100644 index 00000000000..973e148bd3d --- /dev/null +++ b/.changeset/mosaic-user-button-header.md @@ -0,0 +1,7 @@ +--- +'@clerk/mosaic': patch +--- + +Remove the `modePriority` prop from `UserButton`. The header now follows what it leads with: an active organization offers Settings and Invite, and an account offers Settings and Sign out. + +The rows in the `UserButton` popup are now themed through their own `.cl-user-button-item` slots (`-media`, `-content`, `-label`, `-description`, `-trailing`), plus `.cl-user-button-group` and `.cl-user-button-separator`, instead of the shared `.cl-item` slots. diff --git a/packages/mosaic/src/components/item/item.styles.ts b/packages/mosaic/src/components/item/item.styles.ts index 46aaf947b39..899ecdfb531 100644 --- a/packages/mosaic/src/components/item/item.styles.ts +++ b/packages/mosaic/src/components/item/item.styles.ts @@ -64,8 +64,8 @@ export const item = stylex.create({ }, xs: { - gap: space['2'], - height: space['9'], + gap: space['1.5'], + height: space['8'], }, md: { gap: space['3'], @@ -87,7 +87,7 @@ export const media = stylex.create({ justifyContent: 'center', }, - xs: { width: space['6'] }, + xs: { width: space['5'] }, md: { width: space['10'] }, lg: { width: space['10'] }, }); diff --git a/packages/mosaic/src/components/menu/menu.styles.ts b/packages/mosaic/src/components/menu/menu.styles.ts index 9c8ca11b06f..5af8a02a728 100644 --- a/packages/mosaic/src/components/menu/menu.styles.ts +++ b/packages/mosaic/src/components/menu/menu.styles.ts @@ -84,9 +84,9 @@ export const item = stylex.create({ base: { borderRadius: '0.375rem', borderStyle: 'none', - gap: space['1'], + gap: space['0.5'], outline: 'none', - paddingInline: space['1'], + paddingInline: space['1.5'], alignItems: 'center', backgroundColor: { default: 'transparent', @@ -139,7 +139,7 @@ export const media = stylex.create({ justifyContent: 'center', }, xs: { width: space['4'] }, - sm: { width: space['6'] }, + sm: { width: space['5'] }, }); // The item lays its children out in one flat row, so the label is what has to take the space diff --git a/packages/mosaic/src/components/menu/menu.test.tsx b/packages/mosaic/src/components/menu/menu.test.tsx index eb0060c0c2a..e7711c47dbd 100644 --- a/packages/mosaic/src/components/menu/menu.test.tsx +++ b/packages/mosaic/src/components/menu/menu.test.tsx @@ -53,6 +53,35 @@ describe('Mosaic Menu', () => { expect(trigger).toHaveAttribute('aria-haspopup', 'menu'); }); + it('keeps a disabled default trigger focusable and closed when focusableWhenDisabled is set', async () => { + render( + + + + + Add workspace + + + , + ); + const trigger = screen.getByRole('button', { name: 'Actions' }); + + expect(trigger).toBeEnabled(); + expect(trigger).toHaveAttribute('aria-disabled', 'true'); + + const act = userEvent.setup(); + await act.tab(); + expect(trigger).toHaveFocus(); + + await act.keyboard('{Enter}'); + await act.click(trigger); + expect(screen.queryByRole('menu')).toBeNull(); + }); + it('renders a consumer trigger passed via render', () => { render( diff --git a/packages/mosaic/src/components/menu/menu.tsx b/packages/mosaic/src/components/menu/menu.tsx index 70026fce4ce..2332b06668b 100644 --- a/packages/mosaic/src/components/menu/menu.tsx +++ b/packages/mosaic/src/components/menu/menu.tsx @@ -22,14 +22,22 @@ import * as slots from './menu.styles'; export type { MenuProps }; -export type MenuTriggerProps = MosaicComponentProps<'button'>; +export type MenuTriggerProps = MosaicComponentProps<'button'> & { + /** + * Keeps a disabled default trigger in the tab order, marked `aria-disabled`. Applies to the + * default `Button` only; a `render` trigger handles its own disabled state. + * + * @default false + */ + focusableWhenDisabled?: boolean; +}; /** * Opens the menu. Renders a ghost `Button` holding an ellipsis glyph by default; * pass `children` for a labelled trigger, or `render` to supply your own element. */ export const MenuTrigger = React.forwardRef(function MosaicMenuTrigger( - { render, xstyle, children, ...rest }, + { render, xstyle, children, focusableWhenDisabled, ...rest }, ref, ) { const trigger: MenuTriggerProps['render'] = @@ -39,6 +47,7 @@ export const MenuTrigger = React.forwardRef variant='ghost' size='sm' shape={children ? 'default' : 'square'} + focusableWhenDisabled={focusableWhenDisabled} {...props} /> )); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx index 6dbc4ea32fe..a8a4f556214 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx @@ -533,7 +533,7 @@ describe('UserButton (connected)', () => { renderUserButton(); const act = await open(); - await accountAction(act, 'Create organization'); + await act.click(screen.getByRole('button', { name: 'Create organization' })); expect(openCreateOrganization).toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); @@ -544,21 +544,20 @@ describe('UserButton (connected)', () => { renderUserButton({ createOrganizationUrl: '/new-org' }); const act = await open(); - await accountAction(act, 'Create organization'); + await act.click(screen.getByRole('button', { name: 'Create organization' })); expect(navigate).toHaveBeenCalledWith('/new-org'); expect(openCreateOrganization).not.toHaveBeenCalled(); await waitFor(() => expect(popup()).toBeNull()); }); - it('leaves create-organization out of the account menu for a user who cannot open one', async () => { + it('leaves "Create organization" out of the list for a user who cannot open one', async () => { user = { ...(user as FakeUser), createOrganizationEnabled: false }; renderUserButton(); - const act = await open(); - await act.click(accountMenu()); + await open(); - expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull(); + expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); }); it('spins the clicked affordance and stands every other one down while an action is in flight', async () => { @@ -576,7 +575,7 @@ describe('UserButton (connected)', () => { // keeps its place in the tab order. Dropping it to a static row would remount it, and with it // the avatar it carries. expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'Switch account' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true'); expect(popup()).toBeInTheDocument(); deferred.resolve(); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts b/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts index 9dfe95e4a52..675b81c8d85 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts @@ -1,18 +1,14 @@ import { describe, expect, it } from 'vitest'; import { resolveUserButtonLayout } from '../user-button.layout'; -import type { UserButtonData, UserButtonMode, UserButtonModePriority } from '../user-button.types'; +import type { UserButtonData, UserButtonMode } from '../user-button.types'; const alice = { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }; const bob = { sessionId: 'sess_2', name: 'Bob Jones', identifier: 'bob@example.com' }; const foundry = { kind: 'membership', organizationId: 'org_1', name: 'Foundry' } as const; -function resolve( - mode: UserButtonMode, - data: Partial = {}, - modePriority: UserButtonModePriority = 'organization', -) { - return resolveUserButtonLayout(mode, modePriority, { +function resolve(mode: UserButtonMode, data: Partial = {}) { + return resolveUserButtonLayout(mode, { activeSession: alice, activeOrganization: foundry, hasOrganizations: true, @@ -28,7 +24,7 @@ describe('resolveUserButtonLayout, where each action lands', () => { it('spreads them across all four slots in combined mode', () => { expect(resolve('combined').actions).toEqual({ header: ['inviteMembers', 'manageLead'], - organizationsHeading: ['createOrganization', 'manageAccount', 'signOut'], + organizationsHeading: ['manageAccount', 'signOut'], organizationsFooter: ['createOrganization'], footer: ['switchAccount', 'signOutAll'], }); @@ -54,18 +50,44 @@ describe('resolveUserButtonLayout, where each action lands', () => { }); describe('resolveUserButtonLayout, what the data settles', () => { - it('offers no invitation where no organization is active', () => { - expect(resolve('combined', { activeOrganization: null }).actions.header).toEqual(['manageLead']); + it('leads with the account where no organization is active', () => { + const layout = resolve('combined', { activeOrganization: null }); + + expect(layout.lead).toBe('user'); + expect(layout.actions.header).toEqual(['signOut', 'manageLead']); + }); + + it('leads with no selection where no organization is active and personal is hidden', () => { + const layout = resolve('organization', { activeOrganization: null, hidePersonal: true }); + + expect(layout.lead).toBe('none'); + expect(layout.actions.header).toEqual(['manageLead']); }); // With no second account the flyout would open onto one row, so the foot offers that row instead. - // "All accounts" is that one account too, and the account's own row already signs out of it. - it.each(['combined', 'user'])( - 'leaves the foot "Add account" alone in %s mode where there is one account', - mode => { - expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount']); - }, - ); + // "All accounts" is that one account too, so the foot signs out of just it, in the singular. + it('collapses the foot to "Add account" and "Sign out" in combined mode where there is one account', () => { + expect(resolve('combined', { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']); + }); + + it('signs a lone account out from the header in user mode, leaving the foot to add one', () => { + const layout = resolve('user', { additionalSessions: [] }); + + expect(layout.actions.header).toEqual(['signOut', 'manageLead']); + expect(layout.actions.footer).toEqual(['addAccount']); + }); +}); + +describe('resolveUserButtonLayout, how the header carries its actions', () => { + it('stacks them wherever a labelled action joins the gear', () => { + expect(resolve('combined').headerLayout).toBe('stacked'); + expect(resolve('organization').headerLayout).toBe('stacked'); + expect(resolve('user').headerLayout).toBe('stacked'); + }); + + it('runs the gear inline where it is the only action', () => { + expect(resolve('combined', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline'); + }); }); describe('resolveUserButtonLayout, which sections render', () => { @@ -94,12 +116,3 @@ describe('resolveUserButtonLayout, which sections render', () => { expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false }); }); }); - -describe('resolveUserButtonLayout, what the surface leads with', () => { - it('takes the priority only where there are two things to choose between', () => { - expect(resolve('combined', {}, 'user').leadWith).toBe('user'); - expect(resolve('combined', {}, 'organization').leadWith).toBe('organization'); - expect(resolve('organization', {}, 'user').leadWith).toBe('organization'); - expect(resolve('user', {}, 'organization').leadWith).toBe('user'); - }); -}); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx index 4c17845117f..3a8f4092bba 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx @@ -462,7 +462,12 @@ describe('useUserButtonModel', () => { render(); const rows = memberships(); - expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 }); + expect(rows[0]).toMatchObject({ + kind: 'membership', + organizationId: 'org_1', + name: 'Acme', + membersCount: 3, + }); const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); expect(suggestions[0]).toMatchObject({ diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx index c42c24c7ff8..b0ffeecc8e8 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx @@ -72,14 +72,21 @@ const popup = () => screen.getByRole('dialog', { name: 'Account' }); // The `cl-` slot classes are Mosaic's public theming hooks, so they are a stable handle on the // popup's sections rather than an implementation detail. -const groups = () => Array.from(popup().querySelectorAll('.cl-item-group')); +const groups = () => Array.from(popup().querySelectorAll('.cl-user-button-group')); +const header = () => { + const node = popup().querySelector('.cl-user-button-header'); + if (!node) { + throw new Error('The popup has no header'); + } + return node; +}; const labels = (group: HTMLElement | undefined) => - Array.from(group?.querySelectorAll(".cl-item-label[data-variant='default']") ?? []).map( + Array.from(group?.querySelectorAll(".cl-user-button-item-label[data-variant='default']") ?? []).map( node => node.textContent ?? '', ); const row = (group: HTMLElement | undefined, label: string) => - Array.from(group?.querySelectorAll('.cl-item') ?? []).find( - node => node.querySelector(".cl-item-label[data-variant='default']")?.textContent === label, + Array.from(group?.querySelectorAll('.cl-user-button-item') ?? []).find( + node => node.querySelector(".cl-user-button-item-label[data-variant='default']")?.textContent === label, ); const scrollClasses = stylex.props(...scrollAreaViewport('auto')).className?.split(' ') ?? []; @@ -109,17 +116,15 @@ describe('UserButtonView, user mode', () => { it('names the account in the header, never the organization that is active', () => { renderUserMode(); - const header = groups()[0]; - expect(within(header).getByText('Alice Smith')).toBeInTheDocument(); - expect(within(header).getByText('alice@example.com')).toBeInTheDocument(); + expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); + expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); expect(screen.queryByText('Foundry')).toBeNull(); }); it('drops the identifier line when it would only repeat the label', () => { renderUserMode({ activeSession: { ...alice, name: 'alice@example.com' } }); - const header = groups()[0]; - expect(within(header).getAllByText('alice@example.com')).toHaveLength(1); + expect(within(header()).getAllByText('alice@example.com')).toHaveLength(1); }); it('lists no workspaces at all, and offers no way to make one', () => { @@ -135,19 +140,20 @@ describe('UserButtonView, user mode', () => { const onSignOutSession = vi.fn(); renderUserMode({ onSignOutSession }); - expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1'); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); }); it('spins the header sign-out while it is in flight', () => { - renderUserMode({ pendingKey: userButtonBusyKeys.signOutSession('sess_1') }); + renderUserMode({ pendingKey: userButtonBusyKeys.signOutSession('sess_1', 'header') }); const button = screen.getByRole('button', { name: 'Sign out' }); - expect(button).toBeDisabled(); - expect(button.querySelector('.cl-spinner')).not.toBeNull(); + expect(button).toHaveAttribute('type', 'button'); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(within(button).getByRole('progressbar')).toBeInTheDocument(); }); it('opens the accounts from the foot rather than listing them inline', async () => { @@ -184,18 +190,18 @@ describe('UserButtonView, organization mode', () => { it('heads the surface with the active organization and what can be done to it', () => { renderOrganizationMode(); - const header = groups()[0]; - expect(within(header).getByText('Foundry')).toBeInTheDocument(); - expect(within(header).getByText('24 members · Pro')).toBeInTheDocument(); + expect(within(header()).getByText('Foundry')).toBeInTheDocument(); + expect(within(header()).getByText('Pro · 24 members')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); }); it('falls back to the account in the header where no organization is active', () => { renderOrganizationMode({ activeOrganization: null }); - expect(within(groups()[0]).getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); + expect(within(header()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(within(header()).getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); }); @@ -203,7 +209,7 @@ describe('UserButtonView, organization mode', () => { it('names no organization selected where personal is hidden and none is active', () => { renderOrganizationMode({ hidePersonal: true, activeOrganization: null }); - expect(within(groups()[0]).getByText('No organization selected')).toBeInTheDocument(); + expect(within(header()).getByText('No organization selected')).toBeInTheDocument(); expect(screen.queryByText('Alice Smith')).toBeNull(); expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); @@ -215,8 +221,8 @@ describe('UserButtonView, organization mode', () => { it('offers to invite while the membership list is still in flight', () => { renderOrganizationMode({ memberships: [], hasOrganizations: false, organizationsLoading: true }); - expect(within(groups()[0]).getByText('Foundry')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument(); + expect(within(header()).getByText('Foundry')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); }); @@ -260,16 +266,9 @@ describe('UserButtonView, combined mode', () => { renderCombined(); // The subtitle is the header's alone; the row below it carries only a label. - expect(screen.getByText('24 members · Pro')).toBeInTheDocument(); + expect(screen.getByText('Pro · 24 members')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument(); - }); - - it('heads the surface with the account where the user takes priority', () => { - renderCombined({ modePriority: 'user' }); - - expect(screen.queryByText('24 members · Pro')).toBeNull(); - expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); }); it('heads the workspace list with the active account and its own actions', async () => { @@ -280,10 +279,9 @@ describe('UserButtonView, combined mode', () => { await act.click(screen.getByRole('button', { name: 'Actions for alice@example.com' })); expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Create organization' })).toBeInTheDocument(); await act.click(screen.getByRole('menuitem', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1'); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'organizationsHeading'); }); it('trails the workspaces with "Create organization", below the last of them', async () => { @@ -291,7 +289,7 @@ describe('UserButtonView, combined mode', () => { renderCombined({ onCreateOrganization }); // Not `labels`: the row is an action rather than a workspace, so its label is the interactive one. - const rows = Array.from(workspaceList()?.querySelectorAll('.cl-item-label') ?? []); + const rows = Array.from(workspaceList()?.querySelectorAll('.cl-user-button-item-label') ?? []); expect(rows.at(-1)?.textContent).toBe('Create organization'); await userEvent.setup().click(screen.getByRole('button', { name: 'Create organization' })); @@ -360,10 +358,10 @@ describe('UserButtonView, the workspace list', () => { } // Accepting an invitation joins; a suggestion only files a request, so invitations lead. - it('leads with the invitations, then the suggestions, then the workspaces held', () => { + it('lists the workspaces held, then the invitations, then the suggestions', () => { renderList({ invitations: [gamma], suggestions: [beta] }); - expect(labels(workspaceList())).toEqual(['Gamma', 'Beta', 'Personal account', 'Foundry']); + expect(labels(workspaceList())).toEqual(['Personal account', 'Foundry', 'Gamma', 'Beta']); }); // `auto` rather than `stable`: a reserved gutter would inset short lists off the edge the header @@ -472,7 +470,7 @@ describe('UserButtonView, the workspace list', () => { it('lists them with no memberships to list them beside', () => { renderList({ hasOrganizations: false, activeOrganization: null, memberships: [], invitations: [gamma] }); - expect(labels(workspaceList())).toEqual(['Gamma', 'Personal account']); + expect(labels(workspaceList())).toEqual(['Personal account', 'Gamma']); expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument(); }); @@ -588,7 +586,7 @@ describe('UserButtonView, the foot', () => { /** The foot's rows, in the order it lists them. It is the last group in the popup. */ const footActions = () => - Array.from(groups().at(-1)?.querySelectorAll(".cl-item-label[data-variant='interactive']") ?? []).map( + Array.from(groups().at(-1)?.querySelectorAll(".cl-user-button-item-label[data-variant='interactive']") ?? []).map( node => node.textContent ?? '', ); @@ -679,11 +677,16 @@ describe('UserButtonView, the foot', () => { expect(within(popup()).queryByRole('link', { name: 'Clerk' })).toBeNull(); }); - // "All accounts" is one account, and the account's own row already signs out of it. - it('withholds "Sign out of all accounts" where there is no second account', () => { - renderView({ additionalSessions: [] }); + // "All accounts" is one account, so the foot keeps its sign-out row and signs out of just that one. + it('signs out of the one account at the foot where there is no second', async () => { + const onSignOutSession = vi.fn(); + renderView({ additionalSessions: [], onSignOutSession }); expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + const rows = within(groups().at(-1) ?? document.body); + await userEvent.click(rows.getByRole('button', { name: 'Sign out' })); + + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'footer'); }); }); @@ -738,14 +741,39 @@ describe('UserButtonView, one action at a time', () => { // The press leaves focus on the row, so the row is what gets re-read while it works. It takes // the same pairing as a pending `SubmitButton` — `aria-busy` beside an indicator carrying a name // of its own — since a row that only stands down `aria-disabled` reads as unavailable instead. - it('reports the switch on the row that owns it, the way a pending button does', () => { - render(surface(userButtonBusyKeys.selectOrganization('org_2'))); + it.each([ + ['a workspace row', 'Other Co', userButtonBusyKeys.selectOrganization('org_2')], + ['an action row', 'Sign out of all accounts', userButtonBusyKeys.signOutAll()], + ['the accounts flyout', 'Switch account', userButtonBusyKeys.switchSession('sess_2')], + [ + 'the account menu', + 'Actions for alice@example.com', + userButtonBusyKeys.signOutSession('sess_1', 'organizationsHeading'), + ], + ])('reports the action on %s that owns it, in place, the way a pending button does', (_name, label, key) => { + const { rerender } = render(surface(null)); + const row = screen.getByRole('button', { name: label }); - const row = screen.getByRole('button', { name: 'Other Co' }); + rerender(surface(key)); + + expect(screen.getByRole('button', { name: label })).toBe(row); expect(row).toHaveAttribute('aria-busy', 'true'); expect(within(row).getByRole('progressbar')).toHaveAccessibleName('pending'); }); + it('spins only the sign-out that was pressed where the surface offers it in several places', () => { + render( + surface(userButtonBusyKeys.signOutSession('sess_1', 'header'), { + activeOrganization: null, + additionalSessions: [], + }), + ); + + const spinners = popup().querySelectorAll('.cl-spinner'); + expect(spinners).toHaveLength(1); + expect(within(header()).getByRole('button', { name: 'Sign out' })).toContainElement(spinners[0]); + }); + // The rows waiting on it are not running anything, so they carry the indicator's opposite. it('leaves the rows standing down beside it with nothing to report', () => { render(surface(userButtonBusyKeys.selectOrganization('org_2'))); @@ -760,7 +788,7 @@ describe('UserButtonView, one action at a time', () => { it.each([ ['the account menu', 'Actions for alice@example.com'], ['the accounts flyout', 'Switch account'], - ])('holds %s in place, disabled, while another action runs', (_name, label) => { + ])('holds %s in place, aria-disabled and still focusable, while another action runs', (_name, label) => { const { rerender } = render(surface(null)); const row = screen.getByRole('button', { name: label }); @@ -768,14 +796,8 @@ describe('UserButtonView, one action at a time', () => { const stoodDown = screen.getByRole('button', { name: label }); expect(stoodDown).toBe(row); - expect(stoodDown).toBeDisabled(); - }); - - // The flyout closes on pick, so the row that opened it is what is left to report the switch. - it('reports a switch on the row that opened the flyout', () => { - render(surface(userButtonBusyKeys.switchSession('sess_2'))); - - expect(screen.getByRole('button', { name: 'Switch account' }).querySelector('.cl-spinner')).not.toBeNull(); + expect(stoodDown).toHaveAttribute('aria-disabled', 'true'); + expect(stoodDown).toBeEnabled(); }); // `aria-disabled` is advisory, so the row has to drop the press itself. @@ -843,13 +865,6 @@ describe('UserButtonTrigger', () => { expect(screen.getByText('Pro')).toBeInTheDocument(); }); - it('names the account in combined mode where the user takes priority', () => { - renderTrigger({ mode: 'combined', modePriority: 'user' }); - - expect(screen.getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.queryByText('Pro')).toBeNull(); - }); - it('renders the avatar alone when the label is off', () => { renderTrigger({ mode: 'organization', renderTriggerLabel: false }); @@ -911,3 +926,104 @@ describe('UserButtonTrigger', () => { expect(screen.queryByText('Alice Smith')).toBeNull(); }); }); + +describe('UserButtonView, the header', () => { + function renderHeader(props: Partial = {}) { + return renderView({ + hasOrganizations: true, + activeOrganization: foundry, + memberships: [foundry, otherCo], + ...props, + }); + } + + it('carries the slot classes for the trigger, the popover, and the header', () => { + renderHeader(); + + expect(screen.getByRole('button', { name: 'Open account menu for Foundry' }).className).toMatch( + /^cl-popover-trigger cl-user-button-trigger /, + ); + expect(popup().querySelector('.cl-user-button-popover')?.className).toMatch( + /^cl-popover-popup cl-user-button-popover /, + ); + expect(header()).toHaveAttribute('data-layout', 'stacked'); + expect(header().querySelector('.cl-user-button-header-title')?.textContent).toBe('Foundry'); + expect(header().querySelector('.cl-user-button-header-description')?.textContent).toBe('Pro · 24 members'); + expect(header().querySelector('.cl-user-button-header-actions')).not.toBeNull(); + }); + + it('stacks the actions under the workspace as labelled buttons', () => { + renderHeader(); + + expect(header()).toHaveAttribute('data-layout', 'stacked'); + const settings = screen.getByRole('button', { name: 'Settings' }); + expect(settings).toHaveAttribute('data-full-width'); + expect(settings).not.toHaveAttribute('data-shape', 'square'); + expect(screen.getByRole('button', { name: 'Invite' })).toHaveAttribute('data-full-width'); + expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); + }); + + it('gives each stacked label a box of its own, so a long one truncates', () => { + renderHeader(); + + const settings = screen.getByRole('button', { name: 'Settings' }); + expect(within(settings).getByText('Settings').tagName).toBe('SPAN'); + }); + + it('stacks the account actions the same way', async () => { + const onSignOutSession = vi.fn(); + const onManageAccount = vi.fn(); + renderHeader({ mode: 'user', onSignOutSession, onManageAccount }); + + expect(header()).toHaveAttribute('data-layout', 'stacked'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Settings' })); + expect(onManageAccount).toHaveBeenCalled(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); + }); + + it('runs the gear inline as an icon where it is the only action', () => { + renderHeader({ hidePersonal: true, activeOrganization: null }); + + expect(header()).toHaveAttribute('data-layout', 'inline'); + expect(screen.getByRole('button', { name: 'Manage account' })).toHaveAttribute('data-shape', 'square'); + expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + }); + + it('signs a lone account out from the header in user mode, leaving the foot to add one', async () => { + const onSignOutSession = vi.fn(); + renderHeader({ mode: 'user', additionalSessions: [], onSignOutSession }); + + expect(header()).toHaveAttribute('data-layout', 'stacked'); + expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1); + await userEvent.setup().click(within(header()).getByRole('button', { name: 'Sign out' })); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); + }); + + it('leads a combined surface with the account where no organization is active', async () => { + const onSignOutSession = vi.fn(); + const onManageAccount = vi.fn(); + renderHeader({ activeOrganization: null, onSignOutSession, onManageAccount }); + + expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); + expect(header()).toHaveAttribute('data-layout', 'stacked'); + await userEvent.setup().click(within(header()).getByRole('button', { name: 'Settings' })); + expect(onManageAccount).toHaveBeenCalled(); + await userEvent.setup().click(within(header()).getByRole('button', { name: 'Sign out' })); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); + }); + + it('falls back to the identifier where no organization is active', () => { + renderHeader({ activeOrganization: null }); + + expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); + }); + + it('keeps the identifier on a user surface, whatever organization is active', () => { + renderHeader({ mode: 'user' }); + + expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/user-button/user-button-header.styles.ts b/packages/mosaic/src/features/user-button/user-button-header.styles.ts new file mode 100644 index 00000000000..e460dc8deeb --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-header.styles.ts @@ -0,0 +1,49 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + padding: space['3'], + gap: space['3'], + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + }, + + content: { + display: 'flex', + flexBasis: 0, + flexDirection: 'column', + flexGrow: 1, + minWidth: 0, + }, + + title: { + color: colorVars['--cl-color-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + + description: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + + actions: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + }, + + actionsStacked: { + display: 'grid', + flexBasis: '100%', + gridAutoColumns: 'minmax(0, 1fr)', + gridAutoFlow: 'column', + }, +}); diff --git a/packages/mosaic/src/features/user-button/user-button-header.view.tsx b/packages/mosaic/src/features/user-button/user-button-header.view.tsx new file mode 100644 index 00000000000..81ca8a25cae --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-header.view.tsx @@ -0,0 +1,54 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement, ReactNode } from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { truncationStyles } from '../../utils/typography.styles'; +import type { UserButtonHeaderLayout } from './user-button.types'; +import { styles } from './user-button-header.styles'; + +export interface UserButtonHeaderProps { + layout: UserButtonHeaderLayout; + avatar: ReactNode; + title: string; + description?: string; + actions?: ReactNode; +} + +export function UserButtonHeader({ layout, avatar, title, description, actions }: UserButtonHeaderProps): ReactElement { + return ( +
+ {avatar} +
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ ); +} diff --git a/packages/mosaic/src/features/user-button/user-button-item.styles.ts b/packages/mosaic/src/features/user-button/user-button-item.styles.ts new file mode 100644 index 00000000000..d8960326b43 --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-item.styles.ts @@ -0,0 +1,117 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + // The icon in the media column rides the row's text strength rather than its own, the way + // `Button` does it. `Icon` reads the var (`icon.styles.ts`) — StyleX can't emit a descendant + // rule, so the value crosses the element boundary as a custom property. It is restated in + // `interactive` rather than gaining a hover branch here: StyleX resolves a property to the + // last style that declares it, so the two can't merge. + '--_cl-icon-color': colorVars['--cl-color-foreground-secondary'], + borderRadius: radiusVars['--cl-radius-lg'], + gap: space['1.5'], + paddingInline: space['1.5'], + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + display: 'flex', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textAlign: 'start', + height: space['8'], + width: '100%', + }, + + // A row rendered as a link or button gains hover and cursor. A row standing down while another + // action runs keeps its look rather than dimming, but the pointer still reaches it to show + // `not-allowed`, so every hover branch excludes it. Both spellings count: a row that has to stay + // focusable while it waits carries `aria-disabled` instead of the native attribute. + interactive: { + '--_cl-icon-color': { + default: colorVars['--cl-color-foreground-secondary'], + '@media (hover: hover)': { + default: null, + ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-foreground'], + }, + }, + backgroundColor: { + default: null, + ':active:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-200'], + '@media (hover: hover)': { + ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-100'], + }, + }, + color: { + default: colorVars['--cl-color-foreground-secondary'], + '@media (hover: hover)': { + default: null, + ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-foreground'], + }, + }, + cursor: { + default: 'pointer', + ':is(:disabled, [aria-disabled="true"])': 'not-allowed', + }, + }, + + media: { + alignItems: 'center', + aspectRatio: '1/1', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + width: space['5'], + }, + + content: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + minWidth: 0, + }, + + label: { + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + + labelDefault: { + color: colorVars['--cl-color-foreground'], + }, + + description: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + + // At least as wide as the `⋯` menu button that owns it, so whatever stands in that button's + // place — the active check, a spinner — lands on the same center line and the right edge of every + // row holds still as rows change state. A labelled button or a note grows it past that. + trailing: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + minWidth: space['7'], + }, + + group: { + padding: space['1.5'], + width: '100%', + }, + + separator: { + borderStyle: 'none', + backgroundColor: colorVars['--cl-color-border-subtle'], + flexShrink: 0, + height: '1px', + width: '100%', + }, +}); diff --git a/packages/mosaic/src/features/user-button/user-button-item.view.tsx b/packages/mosaic/src/features/user-button/user-button-item.view.tsx new file mode 100644 index 00000000000..572788c6264 --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-item.view.tsx @@ -0,0 +1,118 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { useRender } from '../../primitives/utils'; +import type { MosaicComponentProps, MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../utils/focus-outline.styles'; +import { reset } from '../../utils/reset.styles'; +import { truncationStyles } from '../../utils/typography.styles'; +import { styles } from './user-button-item.styles'; + +export const UserButtonItem = React.forwardRef>(function UserButtonItem( + { render, xstyle, ...rest }, + ref, +) { + const interactive = Boolean(render); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps( + themeProps('user-button-item', { interactive }), + stylex.props(reset.base, focusOutline.visible, styles.root, interactive && styles.interactive, xstyle), + rest, + ), + }); +}); + +export function UserButtonItemMedia({ xstyle, ...rest }: MosaicElementProps<'div'>) { + return ( +
+ ); +} + +export function UserButtonItemContent({ xstyle, ...rest }: MosaicElementProps<'div'>) { + return ( +
+ ); +} + +export type UserButtonItemLabelProps = MosaicElementProps<'div'> & { + /** + * `default` names the row's subject (a person, an organization) in its own color. `interactive` + * takes the row's color, so it brightens with the row on hover. Use it where the text is the row + * itself (`Add account`, `Sign out`). + * + * @default 'default' + */ + variant?: 'default' | 'interactive'; +}; + +export function UserButtonItemLabel({ variant = 'default', xstyle, ...rest }: UserButtonItemLabelProps) { + return ( +
+ ); +} + +export function UserButtonItemDescription({ xstyle, ...rest }: MosaicElementProps<'div'>) { + return ( +
+ ); +} + +export function UserButtonItemTrailing({ xstyle, ...rest }: MosaicElementProps<'div'>) { + return ( +
+ ); +} + +export function UserButtonGroup({ xstyle, ...rest }: MosaicElementProps<'div'>) { + return ( +
+ ); +} + +export function UserButtonSeparator({ xstyle, ...rest }: MosaicElementProps<'hr'>) { + return ( +
+ ); +} diff --git a/packages/mosaic/src/features/user-button/user-button.controller.tsx b/packages/mosaic/src/features/user-button/user-button.controller.tsx index 9af27e07bc0..51badbee97c 100644 --- a/packages/mosaic/src/features/user-button/user-button.controller.tsx +++ b/packages/mosaic/src/features/user-button/user-button.controller.tsx @@ -90,7 +90,7 @@ const userButtonMachine = createMachine({ }, }); -export type UserButtonControllerOptions = Pick & UserButtonMenuProps; +export type UserButtonControllerOptions = Pick & UserButtonMenuProps; export type UserButtonController = | { status: 'loading' } @@ -107,7 +107,7 @@ export function useUserButtonController( model: UserButtonModel, options: UserButtonControllerOptions = {}, ): UserButtonController { - const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options; + const { mode: requestedMode, customMenuItems, menuItemOrder } = options; const [{ context }, send] = useMachine(userButtonMachine); // Every action here is a network round trip, so we can start the @@ -197,7 +197,6 @@ export function useUserButtonController( status: 'ready', ...data, mode, - modePriority, customMenuItems: menuItems, menuItemOrder, open: context.open, diff --git a/packages/mosaic/src/features/user-button/user-button.layout.ts b/packages/mosaic/src/features/user-button/user-button.layout.ts index 106aa7a099a..101e755ecd7 100644 --- a/packages/mosaic/src/features/user-button/user-button.layout.ts +++ b/packages/mosaic/src/features/user-button/user-button.layout.ts @@ -1,4 +1,4 @@ -import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './user-button.types'; +import type { UserButtonData, UserButtonHeaderLayout, UserButtonMode } from './user-button.types'; /* * Which mode puts what where. The surface is three slots deep, in this order, and each mode fills @@ -6,12 +6,13 @@ import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './u * * combined organization user * ┌────────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────────┐ - * │ Foundry [Invite][⚙] │ │ Foundry [Invite][⚙] │ │ Alice [Sign out][⚙] │ header + * │ Foundry │ │ Foundry │ │ Alice │ header + * │ [⚙ Settings] [Invite] │ │ [⚙ Settings] [Invite] │ │ [⚙ Settings] [Sign out] │ * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ * │ alice@x.com [⋯] │ │ │ │ │ organizationsHeading - * │ Personal account │ │ Personal account │ │ │ ┐ + * │ Personal account │ │ Personal account │ │ │ ┐ * │ ✓ Foundry │ │ ✓ Foundry │ │ │ ┘ organization rows - * │ + Create organization │ │ + Create organization │ │ │ organizationsFooter + * │ + Create organization │ │ + Create organization │ │ │ organizationsFooter * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ * │ ⇄ Switch account › │ │ │ │ ⇄ Switch account › │ ┐ * │ ⤴ Sign out of all accounts │ │ │ │ ⤴ Sign out of all accounts │ ┘ footer @@ -20,6 +21,10 @@ import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './u * The organizations are listed on the surface, headed by the active account, since they are the * workspaces that account can switch between. The other signed-in accounts are not: they are one * row at the foot that opens a flyout of them, so the surface stays about the workspace it is on. + * + * The header is about whatever leads, not the mode: an organization is managed and invited to, an + * account is managed and signed out of. With no organization active, a combined surface leads with + * the account. */ /** The four places an action can land. Every mode has a header and a footer; the list's two vary. */ @@ -34,49 +39,59 @@ export type UserButtonAction = | 'manageAccount' | 'signOut' | 'signOutAll' - /** The flyout of signed-in accounts. Collapses to `addAccount` where there is only the one. */ + /** The flyout of signed-in accounts. */ | 'switchAccount'; -/** One mode's whole surface, top to bottom. */ +/** + * What the trigger names and the header leads with. `none` is an organization-led surface with no + * organization active and no personal workspace to fall back to. + */ +export type UserButtonLead = 'organization' | 'user' | 'none'; + +const headers = { + organization: ['inviteMembers', 'manageLead'], + user: ['signOut', 'manageLead'], + none: ['manageLead'], +} as const satisfies Record; + +/** One mode's whole surface below the header, top to bottom. */ interface ModeLayout { - header: readonly UserButtonAction[]; /** * The workspaces the active account switches between: its own, plus the organizations it is in. * `false` is a list the mode does not carry at all; `heading: false` runs the rows unheaded. * `footer` trails the rows, inside the list, since what it offers is one more workspace. */ organizations: { heading: readonly UserButtonAction[] | false; footer: readonly UserButtonAction[] } | false; - footer: readonly UserButtonAction[]; + /** + * With a second account the foot opens onto all of them and signs out of every one. With just the + * one there is nothing to switch between or to sign out of "all" of. + */ + footer: { multiSession: readonly UserButtonAction[]; singleSession: readonly UserButtonAction[] }; } const modes = { combined: { - header: ['inviteMembers', 'manageLead'], - organizations: { heading: ['createOrganization', 'manageAccount', 'signOut'], footer: ['createOrganization'] }, - footer: ['switchAccount', 'signOutAll'], + organizations: { heading: ['manageAccount', 'signOut'], footer: ['createOrganization'] }, + footer: { multiSession: ['switchAccount', 'signOutAll'], singleSession: ['addAccount', 'signOut'] }, }, // Not about the account, so it heads its workspaces with nothing and offers no other account. organization: { - header: ['inviteMembers', 'manageLead'], organizations: { heading: false, footer: ['createOrganization'] }, - footer: [], + footer: { multiSession: [], singleSession: [] }, }, - // No workspaces at all, so the header takes the account's own actions and the foot is the - // accounts flyout and what acts across every one of them. + // No workspaces at all. The header already signs the lone account out, so the foot only adds one. user: { - header: ['signOut', 'manageLead'], organizations: false, - footer: ['switchAccount', 'signOutAll'], + footer: { multiSession: ['switchAccount', 'signOutAll'], singleSession: ['addAccount'] }, }, } as const satisfies Record; /** - * Where each of the surface's actions landed, resolved once from `mode`, `modePriority` and the - * data, so no section has to read any of them again. + * Where each of the surface's actions landed, resolved once from `mode` and the data, so no section + * has to read either of them again. */ export interface UserButtonLayout { - /** Which workspace the trigger names and the header leads with. */ - leadWith: 'organization' | 'user'; + lead: UserButtonLead; /** The organization rows: their own workspace, the organizations, and what is on offer. */ showOrganizations: boolean; /** @@ -84,15 +99,22 @@ export interface UserButtonLayout { * still needs somewhere to manage and sign out of itself. */ showOrganizationsHeading: boolean; + headerLayout: UserButtonHeaderLayout; /** What each slot carries, in the order it renders. */ actions: Record; } -export function resolveUserButtonLayout( - mode: UserButtonMode, - modePriority: UserButtonModePriority, - data: UserButtonData, -): UserButtonLayout { +function resolveLead(mode: UserButtonMode, data: UserButtonData): UserButtonLead { + if (mode === 'user') { + return 'user'; + } + if (data.activeOrganization) { + return 'organization'; + } + return data.hidePersonal ? 'none' : 'user'; +} + +export function resolveUserButtonLayout(mode: UserButtonMode, data: UserButtonData): UserButtonLayout { const declared: ModeLayout = modes[mode]; const organizationsHeading = declared.organizations === false ? false : declared.organizations.heading; const organizationsFooter = declared.organizations === false ? [] : declared.organizations.footer; @@ -102,39 +124,19 @@ export function resolveUserButtonLayout( // Loading does not count, so an account with none never opens a list that then disappears. const hasOrganizations = data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0; - /** The action this surface actually carries in place of the one declared, or `null` for none. */ - const resolve = (action: UserButtonAction): UserButtonAction | null => { - switch (action) { - // Inviting belongs to whichever organization is active, even where the account is what heads - // the surface. - case 'inviteMembers': - return data.activeOrganization ? action : null; - // "All accounts" is one account. The account's own row already signs out of it, so the foot - // would be offering the same thing over again, in the plural. - case 'signOutAll': - return hasOtherSessions ? action : null; - // With no second account there is nothing to switch between, so the flyout collapses to the - // one row it would have opened onto. - case 'switchAccount': - return hasOtherSessions ? action : 'addAccount'; - default: - return action; - } - }; - - const slot = (actions: readonly UserButtonAction[]): UserButtonAction[] => - actions.map(resolve).filter((action): action is UserButtonAction => action !== null); + const lead = resolveLead(mode, data); + const header = [...headers[lead]]; return { - // Only a combined surface has two things to choose between; the other two are what they are. - leadWith: mode === 'combined' ? modePriority : mode, + lead, showOrganizations: declared.organizations !== false && hasOrganizations, showOrganizationsHeading: organizationsHeading !== false, + headerLayout: header.some(action => action !== 'manageLead') ? 'stacked' : 'inline', actions: { - header: slot(declared.header), - organizationsHeading: organizationsHeading === false ? [] : slot(organizationsHeading), - organizationsFooter: slot(organizationsFooter), - footer: slot(declared.footer), + header, + organizationsHeading: organizationsHeading === false ? [] : [...organizationsHeading], + organizationsFooter: [...organizationsFooter], + footer: [...(hasOtherSessions ? declared.footer.multiSession : declared.footer.singleSession)], }, }; } diff --git a/packages/mosaic/src/features/user-button/user-button.messages.ts b/packages/mosaic/src/features/user-button/user-button.messages.ts index 52d8e876d56..b06d7c0008f 100644 --- a/packages/mosaic/src/features/user-button/user-button.messages.ts +++ b/packages/mosaic/src/features/user-button/user-button.messages.ts @@ -25,6 +25,7 @@ export const userButtonMessages = { }, manage: { invite: 'Invite', + settings: 'Settings', account: 'Manage account', organization: 'Manage organization', createOrganization: 'Create organization', diff --git a/packages/mosaic/src/features/user-button/user-button.model.tsx b/packages/mosaic/src/features/user-button/user-button.model.tsx index 9631acb8b3d..0fdb7559cc2 100644 --- a/packages/mosaic/src/features/user-button/user-button.model.tsx +++ b/packages/mosaic/src/features/user-button/user-button.model.tsx @@ -123,6 +123,8 @@ function toMembership(organization: OrganizationResource): UserButtonMembership name: organization.name, imageUrl: organization.imageUrl || undefined, membersCount: organization.membersCount, + // TODO: set `planLabel` once the organization resource carries its billing plan; the view and + // types already render it, so today the badge only appears with Swingset's mock data. }; } diff --git a/packages/mosaic/src/features/user-button/user-button.styles.ts b/packages/mosaic/src/features/user-button/user-button.styles.ts index b8276826e11..e8d44765bdd 100644 --- a/packages/mosaic/src/features/user-button/user-button.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button.styles.ts @@ -6,6 +6,12 @@ export const styles = stylex.create({ accountIdentifier: { fontWeight: fontWeightVars['--cl-font-medium'], }, + rowAvatar: { + '--_cl-avatar-radius': radiusVars['--cl-radius-sm'], + fontSize: '0.5rem', + height: space['5'], + width: space['5'], + }, trigger: { padding: 0, borderRadius: radiusVars['--cl-radius-md'], @@ -45,7 +51,7 @@ export const styles = stylex.create({ borderRadius: radiusVars['--cl-radius-full'], }, - // Matches `Item.Label`, so the trigger names a workspace the same way its row does. Capped, + // Matches `UserButtonItemLabel`, so the trigger names a workspace the same way its row does. Capped, // because the trigger sits in a host app's chrome and a long workspace name would push it apart. triggerName: { color: colorVars['--cl-color-foreground'], @@ -66,12 +72,4 @@ export const styles = stylex.create({ scroll: { maxHeight: '18rem', }, - - // The trailing column is as wide as the `⋯` menu button that owns it, so whatever stands in - // that button's place — the active check, a spinner — lands on the same center line and the - // right edge of every row holds still as rows change state. - trailing: { - justifyContent: 'center', - width: space['7'], - }, }); diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx index 398a07da2b2..1b79df622d5 100644 --- a/packages/mosaic/src/features/user-button/user-button.tsx +++ b/packages/mosaic/src/features/user-button/user-button.tsx @@ -79,12 +79,10 @@ export type UserButtonProps = UserButtonModelOptions & * ``` * * @example - * `mode` narrows the menu to one switcher, and `modePriority` picks which one a combined menu leads - * with — in its header, and in the trigger beside the avatar. The other one is still listed. + * `mode` narrows the menu to one switcher. * ```tsx * * - * * ``` * * @example @@ -133,7 +131,6 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { renderTriggerLabel, renderTriggerBadge, mode, - modePriority, userProfileProps, organizationProfileProps, customMenuItems, @@ -170,7 +167,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { appearance: organizationProfileProps?.appearance, }, }); - const controller = useUserButtonController(model, { mode, modePriority, customMenuItems, menuItemOrder }); + const controller = useUserButtonController(model, { mode, customMenuItems, menuItemOrder }); if (controller.status === 'loading') { return ( diff --git a/packages/mosaic/src/features/user-button/user-button.types.ts b/packages/mosaic/src/features/user-button/user-button.types.ts index e7a69fbf3b6..1fbaee03411 100644 --- a/packages/mosaic/src/features/user-button/user-button.types.ts +++ b/packages/mosaic/src/features/user-button/user-button.types.ts @@ -1,5 +1,7 @@ import type { ReactNode } from 'react'; +import type { UserButtonSlot } from './user-button.layout'; + // ─── Data contract ────────────────────────────────────────────────────────── // Session-backed, discriminated resource rows. 1:1 with `useUserButtonModel()`'s output, so the // model and the view agree on a shape neither one owns. @@ -92,7 +94,7 @@ export interface UserButtonCallbacks { onAcceptSuggestion?: (suggestionId: string) => void; onAcceptInvitation?: (invitationId: string) => void; onSwitchSession?: (sessionId: string) => void; - onSignOutSession?: (sessionId: string) => void; + onSignOutSession?: (sessionId: string, from: UserButtonSlot) => void; onSignOutAll?: () => void; onManageOrganization?: () => void; onInviteMembers?: () => void; @@ -109,13 +111,12 @@ export interface UserButtonCallbacks { export type UserButtonMode = 'combined' | 'organization' | 'user'; /** - * Which of the two switchers a `combined` surface leads with: the one named in the trigger and - * headed in the popup. Both are still listed either way. The single-purpose modes have only one - * thing to lead with, so they ignore it. + * How the header carries its actions: `inline` trails the workspace with them, the gear as an icon; + * `stacked` runs them under it as full-width labelled buttons. */ -export type UserButtonModePriority = 'organization' | 'user'; +export type UserButtonHeaderLayout = 'inline' | 'stacked'; -/** Which switchers the surface carries, and which one it leads with. */ +/** Which switchers the surface carries. */ export interface UserButtonModeProps { /** * Which switchers the popup carries: both, organizations alone, or accounts alone. @@ -123,13 +124,6 @@ export interface UserButtonModeProps { * @default 'combined' */ mode?: UserButtonMode; - /** - * Which switcher a `combined` surface leads with in the trigger and the popup's header. The other - * one is still listed. Ignored by the single-purpose modes, which have only one thing to lead with. - * - * @default 'organization' - */ - modePriority?: UserButtonModePriority; } /** Whether the surface signs itself with Clerk's mark. */ @@ -160,9 +154,11 @@ export interface UserButtonBusyState { * * `switchAccount` and `addAccount` share a slot: the foot carries the flyout of signed-in accounts * where there is more than one, and the row it would have opened onto where there is not. Name both - * to place that slot whichever way it resolves. + * to place that slot whichever way it resolves. So do `signOutAll` and `signOut`: the foot signs out + * of every account where there is more than one, and, in `combined` mode, of the one account where + * there is not. */ -export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOutAll'; +export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOutAll' | 'signOut'; interface UserButtonMenuItemBase { /** Identifies the row, for ordering. */ diff --git a/packages/mosaic/src/features/user-button/user-button.view.tsx b/packages/mosaic/src/features/user-button/user-button.view.tsx index 52d5940fd86..697bbb329fb 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -10,7 +10,6 @@ import { Badge } from '../../components/badge'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import { Icon } from '../../components/icon'; -import { Item } from '../../components/item'; import { Menu } from '../../components/menu'; import { Popover } from '../../components/popover'; import { scrollAreaViewport } from '../../components/scroll-area'; @@ -20,11 +19,12 @@ import type { MosaicMessages } from '../../localization'; import { fill, plural, useLocale, useMessages } from '../../localization'; import { Button as HeadlessButton } from '../../primitives/button'; import type { PopoverProps } from '../../primitives/popover'; +import { themeProps } from '../../props'; import { applyOrder } from '../../utils/apply-order'; import { focusOutline } from '../../utils/focus-outline.styles'; import { rtl } from '../../utils/rtl.styles'; import { truncationStyles } from '../../utils/typography.styles'; -import type { UserButtonLayout } from './user-button.layout'; +import type { UserButtonAction, UserButtonLayout, UserButtonSlot } from './user-button.layout'; import { resolveUserButtonLayout } from './user-button.layout'; import { styles } from './user-button.styles'; import type { @@ -32,12 +32,24 @@ import type { UserButtonBusyState, UserButtonCallbacks, UserButtonData, + UserButtonHeaderLayout, UserButtonMembership, UserButtonMenuItemId, UserButtonMenuProps, UserButtonModeProps, UserButtonSession, } from './user-button.types'; +import { UserButtonHeader } from './user-button-header.view'; +import { + UserButtonGroup, + UserButtonItem, + UserButtonItemContent, + UserButtonItemDescription, + UserButtonItemLabel, + UserButtonItemMedia, + UserButtonItemTrailing, + UserButtonSeparator, +} from './user-button-item.view'; // The data contract, the mode flags, and the menu item shapes live in `user-button.types`; they are // what the model and the view agree on, so neither file owns them. @@ -50,7 +62,7 @@ export type * from './user-button.types'; export const userButtonBusyKeys = { selectOrganization: (organizationId: string | null) => `select-org:${organizationId ?? 'personal'}`, switchSession: (sessionId: string) => `switch:${sessionId}`, - signOutSession: (sessionId: string) => `sign-out:${sessionId}`, + signOutSession: (sessionId: string, from: UserButtonSlot) => `sign-out:${from}:${sessionId}`, signOutAll: () => 'sign-out-all', acceptSuggestion: (suggestionId: string) => `accept-suggestion:${suggestionId}`, acceptInvitation: (invitationId: string) => `accept-invitation:${invitationId}`, @@ -105,35 +117,37 @@ type ActiveWorkspace = type Messages = MosaicMessages['userButton']; function leadWorkspace( - { layout, activeOrganization, activeSession, hidePersonal }: UserButtonContextValue, + { layout, activeOrganization, activeSession }: UserButtonContextValue, m: Messages, ): ActiveWorkspace { - if (layout.leadWith === 'organization') { - if (activeOrganization) { - return { - kind: 'organization', - name: activeOrganization.name, - imageUrl: activeOrganization.imageUrl, - shape: 'square', - organization: activeOrganization, - }; - } - if (hidePersonal) { - return { kind: 'none', name: m.workspaces.notSelected, shape: 'square' }; - } + if (layout.lead === 'organization' && activeOrganization) { + return { + kind: 'organization', + name: activeOrganization.name, + imageUrl: activeOrganization.imageUrl, + shape: 'square', + organization: activeOrganization, + }; + } + if (layout.lead === 'none') { + return { kind: 'none', name: m.workspaces.notSelected, shape: 'square' }; } - return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' }; + return { + kind: 'user', + name: activeSession.name, + imageUrl: activeSession.imageUrl, + shape: 'circle', + }; +} + +function joinDetails(...parts: Array): string { + return parts.filter(Boolean).join(' · '); } function membershipSubtitle(membership: UserButtonMembership, m: Messages, locale: string): string { - const parts: string[] = []; - if (membership.membersCount !== undefined) { - parts.push(plural(m.workspaces.members, membership.membersCount, locale)); - } - if (membership.planLabel) { - parts.push(membership.planLabel); - } - return parts.join(' · '); + const members = + membership.membersCount === undefined ? undefined : plural(m.workspaces.members, membership.membersCount, locale); + return joinDetails(membership.planLabel, members); } function initials(name: string): string { @@ -148,9 +162,10 @@ interface RowAvatarProps { imageUrl?: string; shape: 'circle' | 'square'; size: AvatarProps['size']; + xstyle?: AvatarProps['xstyle']; } -function RowAvatar({ name, imageUrl, shape, size }: RowAvatarProps) { +function RowAvatar({ name, imageUrl, shape, size, xstyle }: RowAvatarProps) { return ( // Decorative: the same name is always in text alongside. Held at the root so the whole mark // stays out of the accessible name however the image resolves. @@ -158,6 +173,7 @@ function RowAvatar({ name, imageUrl, shape, size }: RowAvatarProps) { aria-hidden size={size} shape={shape} + xstyle={xstyle} > {imageUrl ? ( ( /> ); -/** A row's trailing column, sized and centered so every state lands on the `⋯` button's center line. */ -function Trailing({ children }: { children: ReactNode }) { - return {children}; +// Focus stays on the row for the length of the action, so the row is what gets re-read when it +// changes. A decorative spinner changes nothing there and the wait passes in silence, so the +// indicator is named in its own right — the pairing `SubmitButton` makes, and the reason its +// pending state is spoken where this one was not. +function PendingSpinner() { + const m = useMessages('userButton'); + return ( + + ); } interface SwitcherRowProps { @@ -226,15 +253,13 @@ function SwitcherRow({ busy, disabled, }: SwitcherRowProps) { - const m = useMessages('userButton'); // 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} - + + + {name} + {busy ? ( - - - + + + ) : trailing ? ( - {trailing} + {trailing} ) : active ? ( - + - + ) : null} - + ); } @@ -307,63 +324,86 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) { const { busy, disabled } = useBusy(busyKey); return ( - - {busy ? : icon} - - {label} - - + {busy ? : icon} + + {label} + + ); } // ─── Sections ─────────────────────────────────────────────────────────────── interface HeaderAction { + id: UserButtonAction; label: string; - /** An icon renders a square, icon-only button that labels itself through `aria-label`. */ - icon?: IconName; + icon: IconName; + /** Inline, the button is square and shows the icon alone, labelling itself through `aria-label`. */ + iconOnly?: boolean; 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) { +function HeaderActionButton({ + layout, + label, + icon, + iconOnly, + onClick, + busyKey, +}: HeaderAction & { layout: UserButtonHeaderLayout }) { + const m = useMessages('userButton'); 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; + const stacked = layout === 'stacked'; + const compact = !stacked && iconOnly; + const buttonProps = { + variant: 'outline', + color: 'neutral', + size: 'sm', + shape: compact ? 'square' : 'default', + fullWidth: stacked, + 'aria-label': compact ? label : undefined, + disabled, + onClick, + } as const; + const leading = + stacked || compact ? ( + + ) : null; + const text = compact ? null : label; + + if (busyKey === undefined) { + return ( + + ); + } return ( - + {leading} + {text} + ); } @@ -372,71 +412,84 @@ function Header() { const m = useMessages('userButton'); const locale = useLocale(); const data = useUserButtonContext(); + const layout = data.layout.headerLayout; const signOutSession = data.onSignOutSession; const { sessionId, identifier } = data.activeSession; const workspace = leadWorkspace(data, m); - const { name, imageUrl, shape } = workspace; + const { name } = workspace; const organization = workspace.kind === 'organization' ? workspace.organization : undefined; // An account with no name is titled by its identifier, and repeating it underneath says nothing. // No selection is not the account, so it carries no identifier line either. - const accountSubtitle = identifier === name ? '' : identifier; const subtitle = workspace.kind === 'organization' ? membershipSubtitle(workspace.organization, m, locale) - : workspace.kind === 'user' - ? accountSubtitle + : workspace.kind === 'user' && identifier !== name + ? identifier : ''; const actions: HeaderAction[] = []; for (const action of data.layout.actions.header) { if (action === 'inviteMembers' && data.onInviteMembers) { - actions.push({ label: m.manage.invite, onClick: data.onInviteMembers }); + actions.push({ id: action, label: m.manage.invite, icon: 'users-add-right', onClick: data.onInviteMembers }); } - // Every other mode hangs "Sign out" off the organization heading. An account-only one has no - // such heading, so it takes the labelled slot **Invite** occupies elsewhere, left of the gear. + // An account that leads takes "Sign out" in the labelled slot **Invite** holds for an organization. if (action === 'signOut' && signOutSession) { actions.push({ + id: action, label: m.accounts.signOut, - onClick: () => signOutSession(sessionId), - busyKey: userButtonBusyKeys.signOutSession(sessionId), + icon: 'sign-out', + onClick: () => signOutSession(sessionId, 'header'), + busyKey: userButtonBusyKeys.signOutSession(sessionId, 'header'), }); } // The gear manages whatever the header names, which is settled by the data rather than the mode. + // Inline it is the icon alone, named for what it manages; stacked it reads "Settings". if (action === 'manageLead') { const manage = organization ? { label: m.manage.organization, onClick: data.onManageOrganization } : { label: m.manage.account, onClick: data.onManageAccount }; if (manage.onClick) { - actions.push({ label: manage.label, icon: 'cog-6-teeth', onClick: manage.onClick }); + actions.push({ + id: action, + label: layout === 'stacked' ? m.manage.settings : manage.label, + icon: 'cog-6-teeth', + iconOnly: true, + onClick: manage.onClick, + }); } } } + // Inline, the gear trails the labelled actions; stacked, it leads them. + const ordered = + layout === 'stacked' + ? [...actions.filter(a => a.id === 'manageLead'), ...actions.filter(a => a.id !== 'manageLead')] + : actions; return ( - - - - - - - {name} - {subtitle ? {subtitle} : null} - - - {actions.map(a => ( - - ))} - - - + + } + title={name} + description={subtitle} + actions={ + ordered.length > 0 + ? ordered.map(a => ( + + )) + : undefined + } + /> ); } @@ -447,17 +500,38 @@ interface RowAction { } /** The `⋯` that hangs off a row's trailing edge. Renders nothing when it would be empty. */ -function ActionMenu({ label, actions, disabled }: { label: string; actions: RowAction[]; disabled?: boolean }) { +interface ActionMenuProps { + label: string; + actions: RowAction[]; + busy?: boolean; + disabled?: boolean; +} + +function ActionMenu({ label, actions, busy = false, disabled }: ActionMenuProps) { + const m = useMessages('userButton'); if (actions.length === 0) { return null; } return ( - + ( + + )} /> {actions.map(a => ( @@ -472,7 +546,7 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA ))} - + ); } @@ -487,7 +561,7 @@ function OrganizationsHeading() { 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 { busy, disabled } = useBusy(userButtonBusyKeys.signOutSession(sessionId, 'organizationsHeading')); const actions: RowAction[] = []; for (const action of data.layout.actions.organizationsHeading) { @@ -501,28 +575,23 @@ function OrganizationsHeading() { actions.push({ label: m.accounts.signOut, color: 'negative', - onClick: () => signOutSession(sessionId), + onClick: () => signOutSession(sessionId, 'organizationsHeading'), }); } } return ( - - - {identifier} - - {busy ? ( - - - - ) : ( - - )} - + + + {identifier} + + + ); } @@ -625,7 +694,7 @@ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: Pe labelId={labelId} trailing={ note ? ( - {note} + {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 @@ -738,6 +807,7 @@ function SessionMenuItem({ session, active }: { session: UserButtonSession; acti imageUrl={session.imageUrl} shape='circle' size='fit' + xstyle={styles.rowAvatar} /> {session.identifier} @@ -779,34 +849,29 @@ function SwitchAccountRow() { fallbackPlacements={['left-start', 'top-start', 'bottom-start']} > } - /> - } + aria-busy={busy || undefined} + render={} > - + {busy ? ( - + ) : ( )} - - - {m.accounts.switch} - - + + + {m.accounts.switch} + + - + {/* The account it is on leads, checked: the flyout is the full set of accounts rather than @@ -847,14 +912,14 @@ function OrganizationListLoadingRow() { return ( // Plain text rather than a live region: it mounts with its copy already in it, so there is no // change for one to report, and the popup it lands in is read on open either way. - - + + - - - {m.workspaces.loading} - - + + + {m.workspaces.loading} + + ); } @@ -895,11 +960,11 @@ function OrganizationSection() { 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. */} - + {showOrganizationsHeading ? : null} {/* 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 @@ -909,19 +974,16 @@ function OrganizationSection() { ) : ( <> - {/* 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} ))} {/* Trails the rows rather than sitting at the foot of the surface: what it offers is one more of the workspaces above it, not an action on the account. */} {actions} - + ); } @@ -936,6 +998,8 @@ interface FooterRow { function Footer() { const m = useMessages('userButton'); const data = useUserButtonContext(); + const signOutSession = data.onSignOutSession; + const { sessionId } = data.activeSession; const builtIn: FooterRow[] = []; for (const action of data.layout.actions.footer) { @@ -961,6 +1025,24 @@ function Footer() { ), }); } + if (action === 'signOut' && signOutSession) { + builtIn.push({ + id: 'signOut', + node: ( + + } + label={m.accounts.signOut} + onClick={() => signOutSession(sessionId, 'footer')} + busyKey={userButtonBusyKeys.signOutSession(sessionId, 'footer')} + /> + ), + }); + } if (action === 'signOutAll' && data.onSignOutAll) { builtIn.push({ id: 'signOutAll', @@ -968,7 +1050,7 @@ function Footer() { @@ -996,12 +1078,12 @@ function Footer() { return ( <> - - + + {rows.map(r => ( {r.node} ))} - + ); } @@ -1030,20 +1112,10 @@ export interface UserButtonRootProps * the data through context. */ export function UserButtonRoot(props: UserButtonRootProps): ReactElement { - const { - children, - mode = 'combined', - modePriority = 'organization', - open, - defaultOpen, - onOpenChange, - placement, - sideOffset, - ...data - } = props; + const { children, mode = 'combined', 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); + const layout = resolveUserButtonLayout(mode, data); return ( {renderTriggerLabel ? ( @@ -1125,7 +1198,10 @@ export function UserButtonPopup(): ReactElement { const { renderBranding } = useUserButtonContext(); return ( - +
diff --git a/packages/mosaic/src/icons/glyphs/sign-out.tsx b/packages/mosaic/src/icons/glyphs/sign-out.tsx new file mode 100644 index 00000000000..5e76a05bc67 --- /dev/null +++ b/packages/mosaic/src/icons/glyphs/sign-out.tsx @@ -0,0 +1,11 @@ +import { glyph } from '../glyph'; + +export const SignOut = glyph( + , +); diff --git a/packages/mosaic/src/icons/glyphs/switch-account.tsx b/packages/mosaic/src/icons/glyphs/switch-account.tsx new file mode 100644 index 00000000000..4d4c9c6bdd5 --- /dev/null +++ b/packages/mosaic/src/icons/glyphs/switch-account.tsx @@ -0,0 +1,34 @@ +import { glyph } from '../glyph'; + +export const SwitchAccount = glyph( + <> + + + + + , +); diff --git a/packages/mosaic/src/icons/glyphs/users-add-right.tsx b/packages/mosaic/src/icons/glyphs/users-add-right.tsx new file mode 100644 index 00000000000..7ec176b1683 --- /dev/null +++ b/packages/mosaic/src/icons/glyphs/users-add-right.tsx @@ -0,0 +1,25 @@ +import { glyph } from '../glyph'; + +export const UsersAddRight = glyph( + <> + + + + , +); diff --git a/packages/mosaic/src/icons/registry.tsx b/packages/mosaic/src/icons/registry.tsx index f1a0f803a80..5a7edf92941 100644 --- a/packages/mosaic/src/icons/registry.tsx +++ b/packages/mosaic/src/icons/registry.tsx @@ -78,12 +78,15 @@ import { Shield } from './glyphs/shield'; import { ShieldCheck } from './glyphs/shield-check'; import { ShieldClose } from './glyphs/shield-close'; import { Sidebar } from './glyphs/sidebar'; +import { SignOut } from './glyphs/sign-out'; import { Spinner } from './glyphs/spinner'; import { Support } from './glyphs/support'; +import { SwitchAccount } from './glyphs/switch-account'; import { Trash } from './glyphs/trash'; import { UserCircle } from './glyphs/user-circle'; import { UserCirclePlus } from './glyphs/user-circle-plus'; import { Users } from './glyphs/users'; +import { UsersAddRight } from './glyphs/users-add-right'; import { X } from './glyphs/x'; import { XCircle } from './glyphs/x-circle'; @@ -307,12 +310,15 @@ export const iconRegistry = { 'shield-check': ShieldCheck, 'shield-close': ShieldClose, sidebar: Sidebar, + 'sign-out': SignOut, spinner: Spinner, support: Support, + 'switch-account': SwitchAccount, trash: Trash, 'user-circle': UserCircle, 'user-circle-plus': UserCirclePlus, users: Users, + 'users-add-right': UsersAddRight, x: X, 'x-circle': XCircle, } satisfies Record; diff --git a/packages/swingset/src/app/(clerk)/live-user-button.tsx b/packages/swingset/src/app/(clerk)/live-user-button.tsx index 140e1c7af14..3ab5d39e44e 100644 --- a/packages/swingset/src/app/(clerk)/live-user-button.tsx +++ b/packages/swingset/src/app/(clerk)/live-user-button.tsx @@ -6,7 +6,7 @@ import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; export function LiveUserButton() { return ( - + ); } diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index 54f60b08a6e..5522789e328 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -6,7 +6,7 @@ The account & organization switcher behind the user avatar. The active organizat carrying a **gear** and **Invite**. The account's workspaces sit under it: every organization, plus **suggested** and **invited** ones it can Join. **Switch account** at the foot opens the signed-in accounts as a flyout — the active one checked — so an account never reads as a workspace. With -nobody to switch to, that row is **Add account** instead. The foot signs out of everything. `mode` narrows all of it, and `modePriority` picks what heads it; +nobody to switch to, that row is **Add account** instead. The foot signs out of everything. `mode` narrows all of it; see [Modes](#modes). Only the active account's organizations are listed. Not a design choice: org requests are scoped to @@ -37,16 +37,16 @@ that would navigate (Manage, Invite, Create organization, Add account) only clos import { UserButtonView } from '@clerk/mosaic/features/user-button/user-button.view'; setActive({ organization })} onSwitchSession={session => setActive({ session })} onSignOutAll={() => signOut()} @@ -73,20 +73,14 @@ Exports are flat (not `UserButton.Trigger`) so each part declares its own `'use ## Trigger -The active workspace's avatar and what it is called: the org and its plan wherever one heads the -trigger, no selection when personal is hidden and none is active, the account otherwise. +The active workspace's avatar and what it is called: the org and its plan where one is active, no +selection when personal is hidden and none is active, the account otherwise. -`renderTriggerLabel={false}` leaves the avatar alone. +`renderTriggerLabel={false}` leaves the avatar alone. A user's avatar on its own rounds the trigger +fully to match it; see [User only](#user-only). - -A user's avatar on its own rounds the trigger fully to match it. - - @@ -97,6 +91,13 @@ A user's avatar on its own rounds the trigger fully to match it. storyModule={UserButtonStories} /> +## Header + +The header names whatever leads and carries what you can do about it, stacked under it as +full-width labelled buttons. An organization is described by its plan and member count, and carries +**Settings** with **Invite**. An account is described by its identifier, and carries **Settings** +with **Sign out**. Where the gear is the only action, it trails the workspace inline as an icon. + ## Modes `mode` picks which switchers the surface carries — the only prop that changes the popup's shape. @@ -104,46 +105,74 @@ Everything else is data. | Mode | Popup | Leads with | | -------------------- | ------------------------------------ | --------------------------------- | -| `combined` (default) | Organizations and accounts together. | The org; `modePriority` flips it. | -| `organization` | Organizations only; no account rows. | The org. | +| `combined` (default) | Organizations and accounts together. | The active org, else the account. | +| `organization` | Organizations only; no account rows. | The active org, else the account. | | `user` | Accounts only; never shows an org. | The account. | What a mode leads with is named in the trigger and heads the popup — the two always agree. -**Invite** belongs to the active org wherever there is one; the gear manages whatever leads. -`combined` carries both switchers, so `modePriority` picks which one leads. Everything is listed -either way; only the trigger and the header move. +### Combined + +The active org heads it. The workspaces sit under the account's identifier, whose `⋯` manages or +signs out of the account, with **Create organization** at their foot. **Switch account** opens the +signed-in accounts, and the foot signs out of all of them. + +With one account signed in, the foot is **Add account** and **Sign out**. + + -Off-mode data is not an error. Both examples below take the same props as the one above and differ -only by `mode`. +With the personal workspace selected, the account leads, and the header carries **Sign out**. + + -### Organizations +### Org only -The org heads it, with **Invite** and the gear (**Manage organization**). **Create organization** trails -the workspaces the way it does on a combined surface, and nothing else is on offer, so the popup ends -there. The extra account is in the props, deliberately unrendered. +The org heads it, with **Settings** and **Invite**. **Create organization** trails the workspaces, and +nothing else is on offer, so the popup ends there. -### User + + +### User only -The account heads it, with **Sign out** in **Invite**'s slot and the gear (**Manage account**). No -workspaces are listed at all, so **Switch account** at the foot is the whole of it. An org is active; -this mode ignores it, down to the trigger. +The account heads it, with **Settings** and **Sign out**. No workspaces are listed, so **Switch +account** and **Sign out of all accounts** at the foot are the whole of it. + + +With one account signed in, the foot is **Add account** alone. + + + ## Loading Memberships, invitations and suggestions are three separate requests, and the popup opens before any @@ -162,9 +191,9 @@ Reopen the popup to watch it again. An instance in single-session mode holds one account at a time, so there is nothing to switch to and no second account to add. It hands the view an empty `additionalSessions` and leaves `onAddAccount` -and `onSignOutAll` off — the surface withholds **Switch account**, **Add account**, and **Sign out of -all accounts** together. The account's own `⋯` still carries **Sign out**, which is all "all -accounts" ever meant here. +and `onSignOutAll` off — the surface withholds **Switch account** and **Add account**. The foot keeps +its sign-out row, but as **Sign out** of the one account, since that is all "all accounts" ever +meant here. ; + +/** The multi-account frames: NestLabs Creative active, beside Acme, with Clerk on offer. */ +const nestLabsWithSuggestion: Workspaces = { + activeOrganizationId: nestLabs.organizationId, + memberships: [nestLabs, acme], + suggestions: [clerkSuggestion], + invitations: [], }; -// Two accounts with different workspaces, so switching account changes the list under it too. -const initialAccounts: Account[] = [ - { - session: colin, - activeOrganizationId: 'org_clerk_app', - memberships: [ - { - kind: 'membership', - organizationId: 'org_clerk_app', - name: 'Clerk app', - membersCount: 24, - planLabel: 'Pro plan', - imageUrl: clerkLogo, - }, - clerkCloud, - ], - suggestions: [ - { - kind: 'suggestion', - id: 'sug_labs', - organizationId: 'org_clerk_labs', - name: 'Clerk Labs', - status: 'pending', - imageUrl: defaultOrgLogo, - }, - ], - invitations: [], - }, - { - session: braden, - // An organization only Braden is in, so switching to him changes the header, the trigger, and - // the list under it all at once. - activeOrganizationId: 'org_clerk_marketing', - memberships: [ - { - kind: 'membership', - organizationId: 'org_clerk_marketing', - name: 'Clerk Marketing', - membersCount: 9, - imageUrl: defaultOrgLogo, - }, - clerkCloud, - ], - suggestions: [], - invitations: [ - { - kind: 'invitation', - id: 'inv_app', - organizationId: 'org_clerk_app', - organizationName: 'Clerk app', - status: 'pending', - imageUrl: clerkLogo, - }, - ], - }, +/** The single-account and organization-only frames: NestLabs Creative active, beside Clerk App and Acme. */ +const nestLabsWithClerkApp: Workspaces = { + activeOrganizationId: nestLabs.organizationId, + memberships: [nestLabs, clerkApp, acme], + suggestions: [], + invitations: [], +}; + +/** The personal-workspace frame: no organization active, Clerk App and Acme to switch to. */ +const personalWorkspace: Workspaces = { + activeOrganizationId: null, + memberships: [clerkApp, acme], + suggestions: [], + invitations: [], +}; + +// The other two sign-ins' workspaces, reached only by switching to them. +const otherAccounts: Account[] = [ + { session: cameron('sess_cameron_2'), ...nestLabsWithClerkApp }, + { session: cameron('sess_cameron_3'), ...personalWorkspace }, ]; /** Joining is what turns a suggestion or an invitation into a workspace you can switch to. */ @@ -146,19 +136,20 @@ const LATENCY_MS = 800; * account) have nowhere to go here, so they only close the popover. */ function usePrototype({ + workspaces = nestLabsWithSuggestion, + singleSession = false, hidePersonal = false, - startWithoutOrganization = false, }: { + workspaces?: Workspaces; + singleSession?: boolean; hidePersonal?: boolean; - startWithoutOrganization?: boolean; } = {}): Omit { const [open, setOpen] = useState(false); - const [accounts, setAccounts] = useState(() => - startWithoutOrganization - ? initialAccounts.map(a => (a.session.sessionId === colin.sessionId ? { ...a, activeOrganizationId: null } : a)) - : initialAccounts, - ); - const [activeSessionId, setActiveSessionId] = useState(colin.sessionId); + const [accounts, setAccounts] = useState(() => [ + { session: cameron('sess_cameron_1'), ...workspaces }, + ...(singleSession ? [] : otherAccounts), + ]); + const [activeSessionId, setActiveSessionId] = useState('sess_cameron_1'); const [pendingKey, setPendingKey] = useState(null); const account = accounts.find(a => a.session.sessionId === activeSessionId) ?? accounts[0]; @@ -233,7 +224,8 @@ function usePrototype({ }), ), onSwitchSession: sessionId => run(userButtonBusyKeys.switchSession(sessionId), () => setActiveSessionId(sessionId)), - onSignOutSession: sessionId => run(userButtonBusyKeys.signOutSession(sessionId), () => signOutSession(sessionId)), + onSignOutSession: (sessionId, from) => + run(userButtonBusyKeys.signOutSession(sessionId, from), () => signOutSession(sessionId)), // Nothing is left to render once every account is gone, so this one closes too. onSignOutAll: () => run(userButtonBusyKeys.signOutAll(), close), onManageOrganization: close, @@ -244,8 +236,10 @@ function usePrototype({ }; } +// ─── Mode: Combined ───────────────────────────────────────────────────────── + export function Combined(_args: Record) { - const prototype = usePrototype(); + const prototype = usePrototype({ hidePersonal: true }); return ( ) { ); } -export function UserPriority(_args: Record) { - const prototype = usePrototype(); +export function CombinedIconTrigger(_args: Record) { + const prototype = usePrototype({ hidePersonal: true }); return ( ); } -export function AvatarOnly(_args: Record) { - const prototype = usePrototype(); +export function CombinedSingleAccount(_args: Record) { + const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, singleSession: true, hidePersonal: true }); return ( ); } -export function UserAvatarOnly(_args: Record) { - const prototype = usePrototype(); +export function CombinedPersonalWorkspace(_args: Record) { + const prototype = usePrototype({ workspaces: personalWorkspace, singleSession: true }); return ( ); } -export function WithoutTriggerBadge(_args: Record) { - const prototype = usePrototype(); +// ─── Mode: Org only ───────────────────────────────────────────────────────── + +export function Organizations(_args: Record) { + const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, hidePersonal: true }); return ( ); } -export function Organizations(_args: Record) { - const prototype = usePrototype(); +export function OrganizationsIconTrigger(_args: Record) { + const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, hidePersonal: true }); - // Fed the same data as the others, including the additional account it deliberately never shows. return ( ); } +// ─── Mode: User only ──────────────────────────────────────────────────────── + export function User(_args: Record) { const prototype = usePrototype(); - // Fed the same data too: an active organization and its workspaces, none of which this mode shows. return ( ) { ); } +export function UserAvatarTrigger(_args: Record) { + const prototype = usePrototype(); + + return ( + + ); +} + +export function UserAvatarSingleSession(_args: Record) { + const prototype = usePrototype({ singleSession: true }); + + return ( + + ); +} + +// ─── Beyond the frames ────────────────────────────────────────────────────── + +export function WithoutTriggerBadge(_args: Record) { + const prototype = usePrototype({ hidePersonal: true }); + + return ( + + ); +} + export function NoOrganizationSelected(_args: Record) { - const prototype = usePrototype({ hidePersonal: true, startWithoutOrganization: true }); + const prototype = usePrototype({ workspaces: personalWorkspace, hidePersonal: true }); // Personal is withheld and nothing is active, so the lead is no selection — not the account. // Picking an organization leaves it. @@ -339,15 +372,14 @@ export function NoOrganizationSelected(_args: Record) { } export function SingleSession(_args: Record) { - const prototype = usePrototype(); + const prototype = usePrototype({ singleSession: true, hidePersonal: true }); // What an instance in single-session mode hands the view: one account, and neither of the two - // actions that only make sense with a second one. The account's own `⋯` still signs out of it. + // actions that only make sense with a second one. The foot signs out of just that account. return ( @@ -355,7 +387,7 @@ export function SingleSession(_args: Record) { } export function CustomMenuItems(_args: Record) { - const prototype = usePrototype(); + const prototype = usePrototype({ singleSession: true, hidePersonal: true }); // The app's own rows join the foot, and `menuItemOrder` puts them wherever it names them. Ids for // rows the surface does not carry are ignored, so one order can cover every mode. @@ -365,7 +397,6 @@ export function CustomMenuItems(_args: Record) { mode='combined' // One account resolves the accounts row to "Add account", which is the form `menuItemOrder` // names by either id. - additionalSessions={[]} customMenuItems={[ { id: 'settings', @@ -414,7 +445,7 @@ const ORGANIZATIONS_LATENCY_MS = 2500; * nothing to show it. The wait restarts on every open, so it can be watched more than once. */ export function LoadingOrganizations(_args: Record) { - const prototype = usePrototype(); + const prototype = usePrototype({ hidePersonal: true }); const [organizationsLoading, setOrganizationsLoading] = useState(false); useEffect(() => {