Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(clerk-js): Improve accessibility in UserButton and OrganizationSwitcher #1826

Merged
merged 9 commits into from
Oct 12, 2023
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'
LekoArts marked this conversation as resolved.
Show resolved Hide 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: ['[email protected]'] });
});
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: ['[email protected]'] });
});
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'
LekoArts marked this conversation as resolved.
Show resolved Hide 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