diff --git a/.changeset/mosaic-switcher-group-labels.md b/.changeset/mosaic-switcher-group-labels.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-switcher-group-labels.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 4c617211702..2a424929365 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -107,6 +107,11 @@ const reading = (...names: string[]) => .filter(node => !node.closest('.cl-organization-switcher-header')) .map(node => node.textContent); +const groupNames = () => + within(requiredPopup()) + .getAllByRole('group') + .map(group => group.getAttribute('aria-label')); + describe('OrganizationSwitcher', () => { it('renders nothing while Clerk is still loading', async () => { serveFapi(signedIn()); @@ -153,6 +158,20 @@ describe('OrganizationSwitcher', () => { expect(openOrganizationProfile).toHaveBeenCalled(); }); + it('groups the organizations apart from the actions on the active one, naming each', async () => { + await renderSwitcher({}, noOffers()); + await openWithList(); + + expect(groupNames()).toEqual(['Organizations', 'Organization actions']); + const organizations = screen.getByRole('group', { name: 'Organizations' }); + expect(within(organizations).getByText('Acme').closest('[aria-current="true"]')).not.toBeNull(); + expect(within(organizations).getByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(within(organizations).getByRole('button', { name: 'Add organization' })).toBeInTheDocument(); + const actions = screen.getByRole('group', { name: 'Organization actions' }); + expect(within(actions).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(within(actions).getByRole('button', { name: 'Invite members' })).toBeInTheDocument(); + }); + it('falls back to the account, with nothing to manage, where no organization is active', async () => { await renderSwitcher({}, signedIn({ client: fapiClient([personalSession]) })); await open(); @@ -172,6 +191,7 @@ describe('OrganizationSwitcher', () => { expect(within(requiredPopup()).queryByText('Alice Smith')).toBeNull(); expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull(); + expect(screen.queryByRole('group', { name: 'Organization actions' })).toBeNull(); }); it('carries no account rows, and trails the organizations with create-organization', async () => { diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts b/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts index 42dfee1fba7..39a7313b6d2 100644 --- a/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts +++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts @@ -15,6 +15,10 @@ export const organizationSwitcherMessages = { requested: 'Requested', pending: 'pending', }, + groups: { + organizations: 'Organizations', + organization: 'Organization actions', + }, manage: { invite: 'Invite members', settings: 'Settings', diff --git a/packages/mosaic/src/features/switcher/switcher-item.view.tsx b/packages/mosaic/src/features/switcher/switcher-item.view.tsx index b06e3fd3342..efda49b9162 100644 --- a/packages/mosaic/src/features/switcher/switcher-item.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher-item.view.tsx @@ -96,9 +96,17 @@ export function SwitcherItemTrailing({ xstyle, ...rest }: MosaicElementProps<'di ); } -export function SwitcherGroup({ xstyle, ...rest }: MosaicElementProps<'div'>) { +export type SwitcherGroupProps = MosaicElementProps<'div'> & { label: string }; + +export function SwitcherGroup({ label, xstyle, ...rest }: SwitcherGroupProps) { const slot = useSlot(); - return
; + return ( +
+ ); } export function SwitcherSeparator({ xstyle, ...rest }: MosaicElementProps<'hr'>) { diff --git a/packages/mosaic/src/features/switcher/switcher.view.tsx b/packages/mosaic/src/features/switcher/switcher.view.tsx index 5ae81dcbfba..79ec1069fb3 100644 --- a/packages/mosaic/src/features/switcher/switcher.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher.view.tsx @@ -761,7 +761,10 @@ function OrganizationSection() { {/* `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. */} - + {/* Memberships, invitations and suggestions are three separate requests landing at three different moments. Rendering each as it arrives walks the list in in stages, so the placeholder stands in for all of them until the last one is in. */} @@ -856,8 +859,10 @@ interface MenuRow { /** What can be done about the lead (manage it, and invite to an organization that leads), and the app's own rows. */ function ManageSection() { const m = useSwitcherMessages(); + const userButton = useMessages('userButton'); const data = useSwitcherContext(); - const targets = settingsTargets(data, leadSelection(data, m), m); + const lead = leadSelection(data, m); + const targets = settingsTargets(data, lead, m); const builtIn: MenuRow[] = []; for (const action of data.layout.actions.manage) { @@ -902,7 +907,7 @@ function ManageSection() { return ( <> - + {rows.map(r => ( {r.node} ))} @@ -965,7 +970,7 @@ function Footer() { return ( <> - {rows} + {rows} ); } diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 757efb0102f..c77517339b2 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -111,6 +111,11 @@ const reading = (...names: string[]) => .filter(node => !node.closest('.cl-user-button-header')) .map(node => node.textContent); +const groupNames = () => + within(requiredPopup()) + .getAllByRole('group') + .map(group => group.getAttribute('aria-label')); + const current = () => Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent); @@ -912,6 +917,14 @@ describe('UserButton', () => { expect(within(requiredPopup()).queryByText('Acme')).toBeNull(); }); + it('names its groups as the account actions and the session actions, with no organizations', async () => { + await renderUserButton({ mode: 'user' }); + await open(); + + expect(groupNames()).toEqual(['Account actions', 'Session actions']); + expect(screen.queryByRole('group', { name: 'Organizations' })).toBeNull(); + }); + it('drops the identifier line when it would only repeat the name', async () => { const nameless = fapiUser({ ...alice, first_name: null, last_name: null }); await renderUserButton( @@ -999,6 +1012,13 @@ describe('UserButton', () => { expect(openOrganizationProfile).toHaveBeenCalled(); }); + it('names its groups as the organizations, the actions on the active one, and the session actions', async () => { + await renderUserButton({}, noOffers()); + await openWithList(); + + expect(groupNames()).toEqual(['Organizations', 'Organization actions', 'Session actions']); + }); + it('keeps the identifier beneath the organization even when it is all that names the account', async () => { const nameless = fapiUser({ ...alice, first_name: null, last_name: null }); await renderUserButton({}, signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless })]) })); @@ -1205,6 +1225,14 @@ describe('UserButton', () => { expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support'); }); + it('sit in the group named for the actions on the active organization', async () => { + await renderUserButton({ customMenuItems: [terms] }); + await open(); + + const actions = screen.getByRole('group', { name: 'Organization actions' }); + expect(within(actions).getByRole('button', { name: 'Terms of service' })).toBeInTheDocument(); + }); + it('renders the icon a custom row brings', async () => { await renderUserButton({ customMenuItems: [{ ...terms, icon: }] }); await open(); 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 d3353eff40a..730568fcb75 100644 --- a/packages/mosaic/src/features/user-button/user-button.messages.ts +++ b/packages/mosaic/src/features/user-button/user-button.messages.ts @@ -16,7 +16,13 @@ export const userButtonMessages = { /** Names the pending indicator on a row and inside the accept and join buttons, read beside the label. */ pending: 'pending', }, + groups: { + organizations: 'Organizations', + organization: 'Organization actions', + account: 'Account actions', + }, accounts: { + label: 'Session actions', switch: 'Switch account', add: 'Add account', signOut: 'Sign out',