diff --git a/.changeset/user-button-a11y.md b/.changeset/user-button-a11y.md new file mode 100644 index 00000000000..39b9a4cb99c --- /dev/null +++ b/.changeset/user-button-a11y.md @@ -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 diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 3b5b3e1bc61..c42355f4fab 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -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: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 222509565bb..aa96f8e0166 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -62,9 +62,8 @@ type DeepLocalizationWithoutObjects = { * 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> {} export type __internal_LocalizationResource = { locale: string; @@ -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; diff --git a/packages/ui/src/components/UserButton/SessionActions.tsx b/packages/ui/src/components/UserButton/SessionActions.tsx index 2947759741e..a1cc4aed4b4 100644 --- a/packages/ui/src/components/UserButton/SessionActions.tsx +++ b/packages/ui/src/components/UserButton/SessionActions.tsx @@ -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'; @@ -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, @@ -54,7 +55,8 @@ export const SingleSessionActions = (props: SingleSessionActionsProps) => { return ( ({ @@ -102,6 +104,7 @@ export const SingleSessionActions = (props: SingleSessionActionsProps) => { ? handleSignOutSessionClicked(session) : () => handleActionClick(item) } + role={undefined} sx={commonActionSx} iconSx={t => ({ width: t.sizes.$4, @@ -138,6 +141,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { } = props; const { menutItems } = useUserButtonContext(); + const { t } = useLocalizations(); const handleActionClick = async (route: MenuItem) => { if (route?.path) { @@ -164,7 +168,8 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { <> {hasOnlyDefaultItems ? ( @@ -183,6 +188,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { label={localizationKeys('userButton.action__manageAccount')} onClick={handleManageAccountClicked} focusRing + role={undefined} /> { label={localizationKeys('userButton.action__signOut')} onClick={handleSignOutSessionClicked(session)} focusRing + role={undefined} /> ) : ( ({ @@ -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}`, @@ -267,7 +276,8 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { )} ({ borderTopStyle: t.borderStyles.$solid, borderTopWidth: t.borderWidths.$normal, @@ -279,7 +289,6 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { key={session.id} icon={SwitchArrowRight} onClick={handleSessionClicked(session)} - role='menuitem' > @@ -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, @@ -336,9 +346,11 @@ export const SignOutAllActions = (props: SignOutAllActionsProps) => { sx, actionSx, } = props; + const { t } = useLocalizations(); return ( ({ padding: t.space.$2, @@ -358,6 +370,7 @@ export const SignOutAllActions = (props: SignOutAllActionsProps) => { onClick={handleSignOutAllClicked} variant='ghost' colorScheme='neutral' + role={undefined} sx={[ t => ({ backgroundColor: t.colors.$transparent, diff --git a/packages/ui/src/components/UserButton/UserButtonPopover.tsx b/packages/ui/src/components/UserButton/UserButtonPopover.tsx index 28aca51b525..6eab611bf1f 100644 --- a/packages/ui/src/components/UserButton/UserButtonPopover.tsx +++ b/packages/ui/src/components/UserButton/UserButtonPopover.tsx @@ -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'; @@ -22,6 +22,7 @@ export const UserButtonPopover = React.forwardRef diff --git a/packages/ui/src/components/UserButton/UserButtonTrigger.tsx b/packages/ui/src/components/UserButton/UserButtonTrigger.tsx index 64463e0b7a5..5a6b50b4e88 100644 --- a/packages/ui/src/components/UserButton/UserButtonTrigger.tsx +++ b/packages/ui/src/components/UserButton/UserButtonTrigger.tsx @@ -1,3 +1,4 @@ +import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; import { forwardRef } from 'react'; @@ -19,6 +20,7 @@ export const UserButtonTrigger = withAvatarShimmer( const { user } = useUser(); const { showName } = useUserButtonContext(); const { t } = useLocalizations(); + const userName = (user && (getFullName(user) || getIdentifier(user))) || ''; return (