Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/afraid-bobcats-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Improve accessibility of `<UserButton />` and `<OrganizationSwitcher />` by using `aria-*` attributes (where appropriate) and roles like `menu` and `menuitem`.
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useId } from 'react';

import { withOrganizationsEnabledGuard } from '../../common';
import { withCoreUserGuard } from '../../contexts';
import { Flow } from '../../customizables';
Expand All @@ -12,19 +14,23 @@ const _OrganizationSwitcher = withFloatingTree(() => {
offset: 8,
});

const switcherButtonMenuId = useId();

return (
<Flow.Root flow='organizationSwitcher'>
<OrganizationSwitcherTrigger
ref={reference}
onClick={toggle}
isOpen={isOpen}
aria-controls={switcherButtonMenuId}
/>
<Popover
nodeId={nodeId}
context={context}
isOpen={isOpen}
>
<OrganizationSwitcherPopover
id={switcherButtonMenuId}
close={toggle}
ref={floating}
style={{ ...styles }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
<PopoverCard.Root
elementDescriptor={descriptors.organizationSwitcherPopoverCard}
ref={ref}
role='dialog'
aria-label={`${currentOrg?.name} is active`}
{...rest}
>
<PopoverCard.Main elementDescriptor={descriptors.organizationSwitcherPopoverMain}>
Expand All @@ -145,7 +147,7 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
user={user}
sx={theme => t => ({ padding: `0 ${theme.space.$6}`, marginBottom: t.space.$2 })}
/>
<Actions>
<Actions role='menu'>
{manageOrganizationButton}
{__unstable_manageBillingUrl && billingOrganizationButton}
</Actions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
colorScheme='neutral'
sx={[t => ({ minHeight: 0, padding: `0 ${t.space.$2} 0 0`, position: 'relative' }), sx]}
ref={ref}
aria-label={`${props.isOpen ? 'Close' : 'Open'} organization switcher`}
aria-expanded={props.isOpen}
aria-haspopup='dialog'
Comment thread
LekoArts marked this conversation as resolved.
{...rest}
>
{organization && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => {
return (
<>
<UserInvitationSuggestionList />
<SecondaryActions elementDescriptor={descriptors.organizationSwitcherPopoverActions}>
<SecondaryActions
elementDescriptor={descriptors.organizationSwitcherPopoverActions}
role='menu'
>
<UserMembershipList {...{ onPersonalWorkspaceClick, onOrganizationClick }} />
<CreateOrganizationButton {...{ onCreateOrganizationClick }} />
</SecondaryActions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const SwitcherInvitationActions = (props: PropsOfComponent<typeof Flex> & { show
sx={t => ({
borderTop: showBorder ? `${t.borders.$normal} ${t.colors.$blackAlpha200}` : 'none',
})}
role='menu'
{...restProps}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ export const UserMembershipList = (props: UserMembershipListProps) => {
overflowY: 'auto',
...common.unstyledScrollbar(t),
})}
role='group'
aria-label={hidePersonal ? 'List of all organization memberships' : 'List of all accounts'}
>
{currentOrg && !hidePersonal && (
<PreviewButton
elementDescriptor={descriptors.organizationSwitcherPreviewButton}
icon={SwitchArrows}
sx={{ borderRadius: 0 }}
onClick={onPersonalWorkspaceClick}
role='menuitem'
>
<PersonalWorkspacePreview
user={userWithoutIdentifiers}
Expand All @@ -59,6 +62,7 @@ export const UserMembershipList = (props: UserMembershipListProps) => {
icon={SwitchArrows}
sx={{ borderRadius: 0 }}
onClick={() => onOrganizationClick(organization)}
role='menuitem'
>
<OrganizationPreview
elementId='organizationSwitcher'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MembershipRole } from '@clerk/types';
import { describe } from '@jest/globals';

import { render, runFakeTimers, waitFor } from '../../../../testUtils';
import { act, render, runFakeTimers, waitFor } from '../../../../testUtils';
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
import { OrganizationSwitcher } from '../OrganizationSwitcher';
import { createFakeUserOrganizationInvitation, createFakeUserOrganizationSuggestion } from './utlis';
Expand All @@ -14,7 +14,7 @@ describe('OrganizationSwitcher', () => {
f.withOrganizations();
f.withUser({ email_addresses: ['test@clerk.dev'] });
});
const { queryByRole } = render(<OrganizationSwitcher />, { wrapper });
const { queryByRole } = await act(() => render(<OrganizationSwitcher />, { wrapper }));
expect(queryByRole('button')).toBeDefined();
});

Expand All @@ -25,7 +25,7 @@ describe('OrganizationSwitcher', () => {
f.withUser({ email_addresses: ['test@clerk.dev'] });
});
props.setProps({ hidePersonal: false });
const { getByText } = render(<OrganizationSwitcher />, { wrapper });
const { getByText } = await act(() => render(<OrganizationSwitcher />, { wrapper }));
expect(getByText('Personal account')).toBeDefined();
});

Expand Down Expand Up @@ -168,7 +168,7 @@ describe('OrganizationSwitcher', () => {
props.setProps({ hidePersonal: true });
const { getByRole, userEvent } = render(<OrganizationSwitcher />, { wrapper });
await userEvent.click(getByRole('button'));
await userEvent.click(getByRole('button', { name: 'Manage Organization' }));
await userEvent.click(getByRole('menuitem', { name: 'Manage Organization' }));
expect(fixtures.clerk.openOrganizationProfile).toHaveBeenCalled();
});

Expand All @@ -183,8 +183,8 @@ describe('OrganizationSwitcher', () => {
});
props.setProps({ hidePersonal: true });
const { getByRole, userEvent } = render(<OrganizationSwitcher />, { wrapper });
await userEvent.click(getByRole('button'));
await userEvent.click(getByRole('button', { name: 'Create Organization' }));
await userEvent.click(getByRole('button', { name: 'Open organization switcher' }));
await userEvent.click(getByRole('menuitem', { name: 'Create Organization' }));
expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalled();
});

Expand All @@ -198,7 +198,7 @@ describe('OrganizationSwitcher', () => {
});
});
props.setProps({ hidePersonal: true });
const { queryByRole } = render(<OrganizationSwitcher />, { wrapper });
const { queryByRole } = await act(() => render(<OrganizationSwitcher />, { wrapper }));
expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument();
});

Expand Down
6 changes: 6 additions & 0 deletions packages/clerk-js/src/ui/components/UserButton/UserButton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useId } from 'react';

import { getFullName, getIdentifier } from '../../../utils/user';
import { useCoreUser, useUserButtonContext, withCoreUserGuard } from '../../contexts';
import { descriptors, Flex, Flow, Text } from '../../customizables';
Expand All @@ -14,6 +16,8 @@ const _UserButton = withFloatingTree(() => {
offset: 8,
});

const userButtonMenuId = useId();

return (
<Flow.Root flow='userButton'>
<Flex
Expand All @@ -27,13 +31,15 @@ const _UserButton = withFloatingTree(() => {
ref={reference}
onClick={toggle}
isOpen={isOpen}
aria-controls={userButtonMenuId}
/>
<Popover
nodeId={nodeId}
context={context}
isOpen={isOpen}
>
<UserButtonPopover
id={userButtonMenuId}
close={toggle}
ref={floating}
style={{ ...styles }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo

const sessionActions = authConfig.singleSessionMode ? null : otherSessions.length > 0 ? (
<>
<SecondaryActions>
<SecondaryActions role='menu'>
{otherSessions.map(session => (
<PreviewButton
key={session.id}
icon={SwitchArrows}
sx={t => ({ height: t.sizes.$14, borderRadius: 0 })}
onClick={handleSessionClicked(session)}
role='menuitem'
>
<UserPreview
user={session.user}
Expand All @@ -52,7 +53,7 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
))}
{addAccountButton}
</SecondaryActions>
<Actions>
<Actions role='menu'>
<Action
icon={SignOutDouble}
label={localizationKeys('userButton.action__signOutAll')}
Expand All @@ -61,14 +62,16 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
</Actions>
</>
) : (
<SecondaryActions>{addAccountButton}</SecondaryActions>
<SecondaryActions role='menu'>{addAccountButton}</SecondaryActions>
);

return (
<RootBox elementDescriptor={descriptors.userButtonPopoverRootBox}>
<PopoverCard.Root
elementDescriptor={descriptors.userButtonPopoverCard}
ref={ref}
role='dialog'
Comment thread
LekoArts marked this conversation as resolved.
aria-label='User button popover'
{...rest}
>
<PopoverCard.Main elementDescriptor={descriptors.userButtonPopoverMain}>
Expand All @@ -77,7 +80,10 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
user={user}
sx={theme => ({ padding: `0 ${theme.space.$6}`, marginBottom: theme.space.$2 })}
/>
<Actions elementDescriptor={descriptors.userButtonPopoverActions}>
<Actions
role='menu'
elementDescriptor={descriptors.userButtonPopoverActions}
>
<Action
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const UserButtonTrigger = withAvatarShimmer(
variant='roundWrapper'
sx={[theme => ({ borderRadius: theme.radii.$circle }), sx]}
ref={ref}
aria-label={`${props.isOpen ? 'Close' : 'Open'} user button`}
aria-expanded={props.isOpen}
aria-haspopup='dialog'
{...rest}
>
<UserAvatar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('UserButton', () => {
});
});
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'FL' }));
await userEvent.click(getByRole('button', { name: 'Open user button' }));
expect(getByText('Manage account')).not.toBeNull();
});

Expand All @@ -45,7 +45,7 @@ describe('UserButton', () => {
});
});
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'FL' }));
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Manage account'));
expect(fixtures.clerk.openUserProfile).toHaveBeenCalled();
});
Expand All @@ -60,7 +60,7 @@ describe('UserButton', () => {
});
});
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'FL' }));
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Sign out'));
expect(fixtures.clerk.signOut).toHaveBeenCalled();
});
Expand Down Expand Up @@ -96,7 +96,7 @@ describe('UserButton', () => {
it('renders all sessions', async () => {
const { wrapper } = await createFixtures(initConfig);
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'FL' }));
await userEvent.click(getByRole('button', { name: 'Open user button' }));
expect(getByText('First1 Last1')).toBeDefined();
expect(getByText('First2 Last2')).toBeDefined();
expect(getByText('First3 Last3')).toBeDefined();
Expand All @@ -106,7 +106,7 @@ describe('UserButton', () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'FL' }));
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('First3 Last3'));
expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
expect.objectContaining({ session: expect.objectContaining({ user: expect.objectContaining({ id: '3' }) }) }),
Expand All @@ -117,7 +117,7 @@ describe('UserButton', () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
fixtures.clerk.signOut.mockReturnValueOnce(Promise.resolve());
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'FL' }));
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Sign out'));
expect(fixtures.clerk.signOut).toHaveBeenCalledWith(expect.any(Function), { sessionId: '0' });
});
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const Action = (props: ActionProps) => {
]}
isDisabled={card.isLoading}
onClick={onClick}
role='menuitem'
{...rest}
>
<Flex
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/PoweredByClerk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const LogoMarkIconLink = () => {
'&:hover': { color: 'inherit' },
}}
isExternal
aria-label='Clerk logo'
>
<Icon
icon={LogoMark}
Expand Down