Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/user-button-a11y.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/ui': patch
'@clerk/shared': patch
'@clerk/localizations': patch
---

Fix UserButton popover accessibility: use `role="dialog"` with grouped actions instead of `role="menu"` with `menuitem` children, fix focus management via floating-ui's interaction system, and add identity-first trigger labels
7 changes: 5 additions & 2 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,11 +1131,14 @@ export const enUS: LocalizationResource = {
},
userButton: {
action__addAccount: 'Add account',
action__closeUserMenu: 'Close user menu',
action__closeUserMenu: '{{name}} - Close account panel',
action__manageAccount: 'Manage account',
action__openUserMenu: 'Open user menu',
action__openUserMenu: '{{name}} - Open account panel',
action__signOut: 'Sign out',
action__signOutAll: 'Sign out of all accounts',
label__userButtonPopover: 'Account panel',
label__accountActions: 'Account actions',
label__activeSessions: 'Active sessions',
},
userProfile: {
apiKeysPage: {
Expand Down
12 changes: 7 additions & 5 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,8 @@ type DeepLocalizationWithoutObjects<T> = {
* as a starting point.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Needs to be an interface for typedoc to link correctly
export interface LocalizationResource extends DeepPartial<
DeepLocalizationWithoutObjects<__internal_LocalizationResource>
> {}
export interface LocalizationResource
extends DeepPartial<DeepLocalizationWithoutObjects<__internal_LocalizationResource>> {}

export type __internal_LocalizationResource = {
locale: string;
Expand Down Expand Up @@ -995,8 +994,11 @@ export type __internal_LocalizationResource = {
action__signOut: LocalizationValue;
action__signOutAll: LocalizationValue;
action__addAccount: LocalizationValue;
action__openUserMenu: LocalizationValue;
action__closeUserMenu: LocalizationValue;
action__openUserMenu: LocalizationValue<'name'>;
action__closeUserMenu: LocalizationValue<'name'>;
label__userButtonPopover?: LocalizationValue;
label__accountActions?: LocalizationValue;
label__activeSessions?: LocalizationValue;
};
organizationSwitcher: {
personalWorkspace: LocalizationValue;
Expand Down
27 changes: 20 additions & 7 deletions packages/ui/src/components/UserButton/SessionActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { UserPreview } from '@/ui/elements/UserPreview';
import { USER_BUTTON_ITEM_ID } from '../../constants';
import { useUserButtonContext } from '../../contexts';
import type { LocalizationKey } from '../../customizables';
import { descriptors, Flex, localizationKeys } from '../../customizables';
import { descriptors, Flex, localizationKeys, useLocalizations } from '../../customizables';
import { Add, CogFilled, SignOut, SwitchArrowRight } from '../../icons';
import type { ThemableCssProp } from '../../styledSystem';
import type { DefaultItemIds, MenuItem } from '../../utils/createCustomMenuItems';
Expand All @@ -27,6 +27,7 @@ export const SingleSessionActions = (props: SingleSessionActionsProps) => {
const { handleManageAccountClicked, handleSignOutSessionClicked, handleUserProfileActionClicked, session } = props;

const { menutItems } = useUserButtonContext();
const { t } = useLocalizations();

const commonActionSx: ThemableCssProp = t => ({
borderTopWidth: t.borderWidths.$normal,
Expand Down Expand Up @@ -54,7 +55,8 @@ export const SingleSessionActions = (props: SingleSessionActionsProps) => {

return (
<Actions
role='menu'
role='group'
aria-label={t(localizationKeys('userButton.label__accountActions'))}
elementDescriptor={descriptors.userButtonPopoverActions}
elementId={descriptors.userButtonPopoverActions.setId('singleSession')}
sx={t => ({
Expand Down Expand Up @@ -102,6 +104,7 @@ export const SingleSessionActions = (props: SingleSessionActionsProps) => {
? handleSignOutSessionClicked(session)
: () => handleActionClick(item)
}
role={undefined}
sx={commonActionSx}
iconSx={t => ({
width: t.sizes.$4,
Expand Down Expand Up @@ -138,6 +141,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
} = props;

const { menutItems } = useUserButtonContext();
const { t } = useLocalizations();

const handleActionClick = async (route: MenuItem) => {
if (route?.path) {
Expand All @@ -164,7 +168,8 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
<>
{hasOnlyDefaultItems ? (
<SmallActions
role='menu'
role='group'
aria-label={t(localizationKeys('userButton.label__accountActions'))}
elementDescriptor={descriptors.userButtonPopoverActions}
elementId={descriptors.userButtonPopoverActions.setId('multiSession')}
>
Expand All @@ -183,6 +188,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
label={localizationKeys('userButton.action__manageAccount')}
onClick={handleManageAccountClicked}
focusRing
role={undefined}
/>
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
Expand All @@ -195,12 +201,14 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
label={localizationKeys('userButton.action__signOut')}
onClick={handleSignOutSessionClicked(session)}
focusRing
role={undefined}
/>
</Flex>
</SmallActions>
) : (
<SmallActions
role='menu'
role='group'
aria-label={t(localizationKeys('userButton.label__accountActions'))}
elementDescriptor={descriptors.userButtonPopoverActions}
elementId={descriptors.userButtonPopoverActions.setId('multiSession')}
sx={t => ({
Expand Down Expand Up @@ -246,6 +254,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
? handleSignOutSessionClicked(session)
: () => handleActionClick(item)
}
role={undefined}
sx={t => ({
border: 0,
padding: `${t.space.$2} ${t.space.$5}`,
Expand All @@ -267,7 +276,8 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
)}

<Actions
role='menu'
role='group'
aria-label={t(localizationKeys('userButton.label__activeSessions'))}
sx={t => ({
borderTopStyle: t.borderStyles.$solid,
borderTopWidth: t.borderWidths.$normal,
Expand All @@ -279,7 +289,6 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
key={session.id}
icon={SwitchArrowRight}
onClick={handleSessionClicked(session)}
role='menuitem'
>
<UserPreview user={session.user} />
</PreviewButton>
Expand All @@ -294,6 +303,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
icon={Add}
label={localizationKeys('userButton.action__addAccount')}
onClick={handleAddAccountClicked}
role={undefined}
iconSx={t => ({
width: t.sizes.$9,
height: t.sizes.$6,
Expand Down Expand Up @@ -336,9 +346,11 @@ export const SignOutAllActions = (props: SignOutAllActionsProps) => {
sx,
actionSx,
} = props;
const { t } = useLocalizations();
return (
<Actions
role='menu'
role='group'
aria-label={t(label || localizationKeys('userButton.action__signOutAll'))}
sx={[
t => ({
padding: t.space.$2,
Expand All @@ -358,6 +370,7 @@ export const SignOutAllActions = (props: SignOutAllActionsProps) => {
onClick={handleSignOutAllClicked}
variant='ghost'
colorScheme='neutral'
role={undefined}
sx={[
t => ({
backgroundColor: t.colors.$transparent,
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/UserButton/UserButtonPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { RootBox } from '@/ui/elements/RootBox';
import { UserPreview } from '@/ui/elements/UserPreview';

import { useEnvironment, useUserButtonContext } from '../../contexts';
import { descriptors } from '../../customizables';
import { descriptors, localizationKeys, useLocalizations } from '../../customizables';
import type { PropsOfComponent } from '../../styledSystem';
import { MultiSessionActions, SignOutAllActions, SingleSessionActions } from './SessionActions';
import { useMultisessionActions } from './useMultisessionActions';
Expand All @@ -22,6 +22,7 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
const { __experimental_asStandalone } = userButtonContext;
const { authConfig } = useEnvironment();
const { user } = useUser();
const { t } = useLocalizations();
const {
handleAddAccountClicked,
handleManageAccountClicked,
Expand All @@ -38,7 +39,7 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
elementDescriptor={descriptors.userButtonPopoverCard}
ref={ref}
role='dialog'
aria-label='User button popover'
aria-label={t(localizationKeys('userButton.label__userButtonPopover'))}
shouldEntryAnimate={!__experimental_asStandalone}
{...rest}
>
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/components/UserButton/UserButtonTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useUser } from '@clerk/shared/react';
import { forwardRef } from 'react';

Expand All @@ -19,14 +20,19 @@ export const UserButtonTrigger = withAvatarShimmer(
const { user } = useUser();
const { showName } = useUserButtonContext();
const { t } = useLocalizations();
const userName = (user && (getFullName(user) || getIdentifier(user))) || '';

return (
<Button
elementDescriptor={descriptors.userButtonTrigger}
variant='roundWrapper'
sx={[t => ({ borderRadius: showName ? t.radii.$md : t.radii.$circle, color: t.colors.$colorForeground }), sx]}
ref={ref}
aria-label={`${props.isOpen ? t(localizationKeys('userButton.action__closeUserMenu')) : t(localizationKeys('userButton.action__openUserMenu'))}`}
aria-label={t(
localizationKeys(props.isOpen ? 'userButton.action__closeUserMenu' : 'userButton.action__openUserMenu', {
name: userName,
}),
)}
aria-expanded={props.isOpen}
aria-haspopup='dialog'
{...rest}
Expand Down
Loading
Loading