Improve accessibility of the `<AvatarSetting> component (#30907)

* Always use an accessible button with base avatar rendered inside it

* Rename avatarAltText to accessibleName

* Improve accessibility

* Fix tests
This commit is contained in:
R Midhun Suresh
2025-09-30 17:53:37 +00:00
committed by GitHub
parent b6046d2120
commit 2f8e2be09d
5 changed files with 40 additions and 42 deletions
@@ -262,7 +262,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
? undefined ? undefined
: (this.state.avatarFile ?? this.state.originalAvatarUrl ?? undefined) : (this.state.avatarFile ?? this.state.originalAvatarUrl ?? undefined)
} }
avatarAltText={_t("room_settings|general|avatar_field_label")} avatarAccessibleName={_t("room_settings|general|avatar_field_label")}
disabled={!this.state.canSetAvatar} disabled={!this.state.canSetAvatar}
onChange={this.onAvatarChanged} onChange={this.onAvatarChanged}
removeAvatar={canRemove ? this.removeAvatar : undefined} removeAvatar={canRemove ? this.removeAvatar : undefined}
+26 -30
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ReactNode, createRef, useCallback, useEffect, useState, useId } from "react"; import React, { type JSX, type ReactNode, createRef, useCallback, useEffect, useState } from "react";
import EditIcon from "@vector-im/compound-design-tokens/assets/web/icons/edit"; import EditIcon from "@vector-im/compound-design-tokens/assets/web/icons/edit";
import UploadIcon from "@vector-im/compound-design-tokens/assets/web/icons/share"; import UploadIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete"; import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
@@ -89,9 +89,9 @@ interface IProps {
removeAvatar?: () => void; removeAvatar?: () => void;
/** /**
* The alt text for the avatar * The accessible name for the avatar, eg: "Foo's Profile Picture"
*/ */
avatarAltText: string; avatarAccessibleName: string;
/** /**
* String to use for computing the colour of the placeholder avatar if no avatar is set * String to use for computing the colour of the placeholder avatar if no avatar is set
@@ -121,7 +121,7 @@ export function getFileChanged(e: React.ChangeEvent<HTMLInputElement>): File | n
*/ */
const AvatarSetting: React.FC<IProps> = ({ const AvatarSetting: React.FC<IProps> = ({
avatar, avatar,
avatarAltText, avatarAccessibleName,
onChange, onChange,
removeAvatar, removeAvatar,
disabled, disabled,
@@ -147,9 +147,6 @@ const AvatarSetting: React.FC<IProps> = ({
} }
}, [avatar]); }, [avatar]);
// Prevents ID collisions when this component is used more than once on the same page.
const a11yId = useId();
const onFileChanged = useCallback( const onFileChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const file = getFileChanged(e); const file = getFileChanged(e);
@@ -170,33 +167,26 @@ const AvatarSetting: React.FC<IProps> = ({
setMenuOpen(newOpen); setMenuOpen(newOpen);
}, []); }, []);
let avatarElement = ( const avatarElement = (
<AccessibleButton <AccessibleButton
element="div" element="div"
onClick={uploadAvatar} /**
* This button will open a menu. That is done by passing this element as trigger
* to the menu component, hence the empty onClick.
*/
onClick={() => {}}
className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay" className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay"
aria-labelledby={disabled ? undefined : a11yId}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
disabled={disabled} disabled={disabled}
> >
<BaseAvatar idName={placeholderId} name={placeholderName} size="90px" /> <BaseAvatar
idName={placeholderId}
name={placeholderName}
size="90px"
url={avatarURL}
altText={avatarAccessibleName}
/>
</AccessibleButton> </AccessibleButton>
); );
if (avatarURL) {
avatarElement = (
<AccessibleButton
element="img"
className="mx_AvatarSetting_avatarDisplay"
src={avatarURL}
alt={avatarAltText}
onClick={uploadAvatar}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
disabled={disabled}
/>
);
}
let uploadAvatarBtn: JSX.Element | undefined; let uploadAvatarBtn: JSX.Element | undefined;
if (!disabled) { if (!disabled) {
@@ -204,14 +194,20 @@ const AvatarSetting: React.FC<IProps> = ({
mx_AvatarSetting_uploadButton_active: menuOpen, mx_AvatarSetting_uploadButton_active: menuOpen,
}); });
uploadAvatarBtn = ( uploadAvatarBtn = (
<div className={uploadButtonClasses}> <div
<EditIcon width="20px" height="20px" /> className={uploadButtonClasses}
role="button"
aria-label={_t("settings|general|avatar_open_menu")}
tabIndex={0}
aria-haspopup="menu"
>
<EditIcon aria-hidden={true} width="20px" height="20px" />
</div> </div>
); );
} }
const content = ( const content = (
<div className="mx_AvatarSetting_avatar" role="group" aria-label={avatarAltText}> <div className="mx_AvatarSetting_avatar" role="group" aria-label={avatarAccessibleName}>
{avatarElement} {avatarElement}
{uploadAvatarBtn} {uploadAvatarBtn}
</div> </div>
@@ -203,7 +203,7 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
<div className="mx_UserProfileSettings_profile"> <div className="mx_UserProfileSettings_profile">
<AvatarSetting <AvatarSetting
avatar={avatarURL ?? undefined} avatar={avatarURL ?? undefined}
avatarAltText={_t("common|user_avatar")} avatarAccessibleName={_t("common|user_avatar")}
onChange={onAvatarChange} onChange={onAvatarChange}
removeAvatar={avatarURL ? onAvatarRemove : undefined} removeAvatar={avatarURL ? onAvatarRemove : undefined}
placeholderName={displayName} placeholderName={displayName}
+1
View File
@@ -2664,6 +2664,7 @@
"allow_spellcheck": "Allow spell check", "allow_spellcheck": "Allow spell check",
"application_language": "Application language", "application_language": "Application language",
"application_language_reload_hint": "The app will reload after selecting another language", "application_language_reload_hint": "The app will reload after selecting another language",
"avatar_open_menu": "Open avatar menu",
"avatar_remove_progress": "Removing image...", "avatar_remove_progress": "Removing image...",
"avatar_save_progress": "Uploading image...", "avatar_save_progress": "Uploading image...",
"avatar_upload_error_text": "The file format is not supported or the image is larger than %(size)s.", "avatar_upload_error_text": "The file format is not supported or the image is larger than %(size)s.",
@@ -25,18 +25,18 @@ describe("<AvatarSetting />", () => {
stubClient(); stubClient();
}); });
it("renders avatar with specified alt text", async () => { it("renders avatar with specified accessible name", async () => {
const { queryByAltText } = render( const { getByRole } = render(
<AvatarSetting <AvatarSetting
placeholderId="blee" placeholderId="blee"
placeholderName="boo" placeholderName="boo"
avatarAltText="Avatar of Peter Fox" avatarAccessibleName="Avatar of Peter Fox"
avatar="mxc://example.org/my-avatar" avatar="mxc://example.org/my-avatar"
/>, />,
); );
const imgElement = queryByAltText("Avatar of Peter Fox"); const avatarButton = getByRole("button", { name: "Avatar of Peter Fox" });
expect(imgElement).toBeInTheDocument(); expect(avatarButton).toBeInTheDocument();
}); });
it("renders a file as the avatar when supplied", async () => { it("renders a file as the avatar when supplied", async () => {
@@ -44,12 +44,13 @@ describe("<AvatarSetting />", () => {
<AvatarSetting <AvatarSetting
placeholderId="blee" placeholderId="blee"
placeholderName="boo" placeholderName="boo"
avatarAltText="Avatar of Peter Fox" avatarAccessibleName="Avatar of Peter Fox"
avatar={AVATAR_FILE} avatar={AVATAR_FILE}
/>, />,
); );
const imgElement = await screen.findByRole("button", { name: "Avatar of Peter Fox" }); const avatarButton = await screen.findByRole("button", { name: "Avatar of Peter Fox" });
const imgElement = avatarButton.querySelector("img");
expect(imgElement).toBeInTheDocument(); expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute("src", "data:image/gif;base64," + BASE64_GIF); expect(imgElement).toHaveAttribute("src", "data:image/gif;base64," + BASE64_GIF);
}); });
@@ -63,7 +64,7 @@ describe("<AvatarSetting />", () => {
placeholderId="blee" placeholderId="blee"
placeholderName="boo" placeholderName="boo"
avatar="mxc://example.org/my-avatar" avatar="mxc://example.org/my-avatar"
avatarAltText="Avatar of Peter Fox" avatarAccessibleName="Avatar of Peter Fox"
onChange={onChange} onChange={onChange}
/>, />,
); );
@@ -82,7 +83,7 @@ describe("<AvatarSetting />", () => {
placeholderId="blee" placeholderId="blee"
placeholderName="boo" placeholderName="boo"
avatar="mxc://example.org/my-avatar" avatar="mxc://example.org/my-avatar"
avatarAltText="Avatar of Peter Fox" avatarAccessibleName="Avatar of Peter Fox"
onChange={onChange} onChange={onChange}
/>, />,
); );
@@ -102,7 +103,7 @@ describe("<AvatarSetting />", () => {
placeholderId="blee" placeholderId="blee"
placeholderName="boo" placeholderName="boo"
avatar="mxc://example.org/my-avatar" avatar="mxc://example.org/my-avatar"
avatarAltText="Avatar of Peter Fox" avatarAccessibleName="Avatar of Peter Fox"
onChange={onChange} onChange={onChange}
/>, />,
); );