Accessibility improvements in settings (#32968)
* Add more playwright axe tests to settings dialogs * Fix axe issue heading-order * Fix heading order * Iterate * Update snapshots * Iterate * Update snapshots * Iterate * Update screenshot * Iterate * Iterate * Update snapshot
This commit is contained in:
@@ -8,7 +8,15 @@ 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.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type CSSProperties, type RefObject, type SyntheticEvent, useRef, useState } from "react";
|
||||
import React, {
|
||||
type JSX,
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
type SyntheticEvent,
|
||||
useRef,
|
||||
useState,
|
||||
type AriaRole,
|
||||
} from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
import FocusLock from "react-focus-lock";
|
||||
@@ -74,27 +82,31 @@ export interface MenuProps extends IPosition {
|
||||
|
||||
export interface IProps extends MenuProps {
|
||||
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
|
||||
hasBackground?: boolean;
|
||||
"hasBackground"?: boolean;
|
||||
// whether this context menu should be focus managed. If false it must handle itself
|
||||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
menuClassName?: string;
|
||||
"managed"?: boolean;
|
||||
"wrapperClassName"?: string;
|
||||
"menuClassName"?: string;
|
||||
|
||||
// If true, this context menu will be mounted as a child to the parent container. Otherwise
|
||||
// it will be mounted to a container at the root of the DOM.
|
||||
mountAsChild?: boolean;
|
||||
"mountAsChild"?: boolean;
|
||||
|
||||
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
|
||||
// within an existing FocusLock e.g inside a modal.
|
||||
focusLock?: boolean;
|
||||
"focusLock"?: boolean;
|
||||
|
||||
// call onFinished on any interaction with the menu
|
||||
closeOnInteraction?: boolean;
|
||||
"closeOnInteraction"?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished(this: void): void;
|
||||
// on resize callback
|
||||
windowResize?(this: void): void;
|
||||
|
||||
// Role & label for accessibility
|
||||
"role"?: AriaRole;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -257,9 +269,11 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
|
||||
focusLock,
|
||||
managed,
|
||||
wrapperClassName,
|
||||
chevronFace: propsChevronFace,
|
||||
chevronOffset: propsChevronOffset,
|
||||
"chevronFace": propsChevronFace,
|
||||
"chevronOffset": propsChevronOffset,
|
||||
mountAsChild,
|
||||
role,
|
||||
"aria-label": ariaLabel,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
@@ -424,6 +438,8 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
|
||||
onClick={this.onClick}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
role={role}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{background}
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -76,8 +76,10 @@ export default class SetIntegrationManager extends React.Component<EmptyObject,
|
||||
>
|
||||
<div className="mx_SettingsFlag">
|
||||
<div className="mx_SetIntegrationManager_heading_manager">
|
||||
<Heading size="3">{_t("integration_manager|manage_title")}</Heading>
|
||||
<Heading id="mx_SetIntegrationManager_ManagerName" size="4">
|
||||
<Heading as="h2" size="3">
|
||||
{_t("integration_manager|manage_title")}
|
||||
</Heading>
|
||||
<Heading id="mx_SetIntegrationManager_ManagerName" as="h3" size="4">
|
||||
{managerName}
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,10 @@ import { Heading } from "@vector-im/compound-web";
|
||||
* The heading for a settings section.
|
||||
*/
|
||||
interface SettingsHeaderProps {
|
||||
/**
|
||||
* The component to render the heading as, defaults to h2
|
||||
*/
|
||||
as?: React.ComponentProps<typeof Heading>["as"];
|
||||
/**
|
||||
* Whether the user has a recommended tag.
|
||||
*/
|
||||
@@ -23,12 +27,12 @@ interface SettingsHeaderProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
|
||||
export function SettingsHeader({ hasRecommendedTag = false, label, as = "h2" }: SettingsHeaderProps): JSX.Element {
|
||||
const classes = classNames("mx_SettingsHeader", {
|
||||
mx_SettingsHeader_recommended: hasRecommendedTag,
|
||||
});
|
||||
return (
|
||||
<Heading className={classes} as="h2" size="sm" weight="semibold">
|
||||
<Heading className={classes} as={as} size="sm" weight="semibold">
|
||||
{label}
|
||||
</Heading>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type ThirdPartyIdentifier } from "../../../AddThreepid";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { AddRemoveThreepids } from "./AddRemoveThreepids";
|
||||
import Heading from "../typography/Heading.tsx";
|
||||
import { SettingsSection } from "./shared/SettingsSection.tsx";
|
||||
|
||||
type LoadingState = "loading" | "loaded" | "error";
|
||||
|
||||
@@ -82,8 +82,7 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
|
||||
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
|
||||
|
||||
return (
|
||||
<div className="mx_UserPersonalInfoSettings">
|
||||
<Heading size="2">{_t("settings|general|personal_info")}</Heading>
|
||||
<SettingsSection heading={_t("settings|general|personal_info")} className="mx_UserPersonalInfoSettings">
|
||||
<SettingsSubsection
|
||||
heading={_t("settings|general|emails_heading")}
|
||||
stretchContent
|
||||
@@ -123,6 +122,6 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
|
||||
/>
|
||||
</ThreepidSectionWrapper>
|
||||
</SettingsSubsection>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ import AccessibleButton from "../elements/AccessibleButton";
|
||||
import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog";
|
||||
import Modal from "../../../Modal";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import Heading from "../typography/Heading.tsx";
|
||||
import { SettingsSection } from "./shared/SettingsSection.tsx";
|
||||
|
||||
const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => (
|
||||
<>
|
||||
@@ -194,54 +194,55 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
|
||||
const someFieldsDisabled = !canSetDisplayName || !canSetAvatar;
|
||||
|
||||
return (
|
||||
<div className="mx_UserProfileSettings">
|
||||
<Heading size="2">{_t("common|profile")}</Heading>
|
||||
<div>
|
||||
{someFieldsDisabled
|
||||
? _t("settings|general|profile_subtitle_oidc")
|
||||
: _t("settings|general|profile_subtitle")}
|
||||
</div>
|
||||
<div className="mx_UserProfileSettings_profile">
|
||||
<AvatarSetting
|
||||
avatar={avatarURL ?? undefined}
|
||||
avatarAccessibleName={_t("common|user_avatar")}
|
||||
onChange={onAvatarChange}
|
||||
removeAvatar={avatarURL ? onAvatarRemove : undefined}
|
||||
placeholderName={displayName}
|
||||
placeholderId={client.getUserId() ?? ""}
|
||||
disabled={!canSetAvatar}
|
||||
/>
|
||||
<EditInPlace
|
||||
className="mx_UserProfileSettings_profile_displayName"
|
||||
label={_t("settings|general|display_name")}
|
||||
value={displayName}
|
||||
saveButtonLabel={_t("common|save")}
|
||||
cancelButtonLabel={_t("common|cancel")}
|
||||
savedLabel={_t("common|saved")}
|
||||
savingLabel={_t("common|updating")}
|
||||
onChange={onDisplayNameChanged}
|
||||
onCancel={onDisplayNameCancel}
|
||||
onSave={onDisplayNameSave}
|
||||
disabled={!canSetDisplayName}
|
||||
>
|
||||
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
|
||||
</EditInPlace>
|
||||
</div>
|
||||
{avatarError && (
|
||||
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">
|
||||
{maxUploadSize === undefined
|
||||
? _t("settings|general|avatar_upload_error_text_generic")
|
||||
: _t("settings|general|avatar_upload_error_text", { size: formatBytes(maxUploadSize) })}
|
||||
</Alert>
|
||||
)}
|
||||
{userIdentifier && <UsernameBox username={userIdentifier} />}
|
||||
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
|
||||
{externalAccountManagementUrl && (
|
||||
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
|
||||
<SettingsSection heading={_t("common|profile")}>
|
||||
<div className="mx_UserProfileSettings">
|
||||
<div>
|
||||
{someFieldsDisabled
|
||||
? _t("settings|general|profile_subtitle_oidc")
|
||||
: _t("settings|general|profile_subtitle")}
|
||||
</div>
|
||||
<div className="mx_UserProfileSettings_profile">
|
||||
<AvatarSetting
|
||||
avatar={avatarURL ?? undefined}
|
||||
avatarAccessibleName={_t("common|user_avatar")}
|
||||
onChange={onAvatarChange}
|
||||
removeAvatar={avatarURL ? onAvatarRemove : undefined}
|
||||
placeholderName={displayName}
|
||||
placeholderId={client.getUserId() ?? ""}
|
||||
disabled={!canSetAvatar}
|
||||
/>
|
||||
<EditInPlace
|
||||
className="mx_UserProfileSettings_profile_displayName"
|
||||
label={_t("settings|general|display_name")}
|
||||
value={displayName}
|
||||
saveButtonLabel={_t("common|save")}
|
||||
cancelButtonLabel={_t("common|cancel")}
|
||||
savedLabel={_t("common|saved")}
|
||||
savingLabel={_t("common|updating")}
|
||||
onChange={onDisplayNameChanged}
|
||||
onCancel={onDisplayNameCancel}
|
||||
onSave={onDisplayNameSave}
|
||||
disabled={!canSetDisplayName}
|
||||
>
|
||||
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
|
||||
</EditInPlace>
|
||||
</div>
|
||||
{avatarError && (
|
||||
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">
|
||||
{maxUploadSize === undefined
|
||||
? _t("settings|general|avatar_upload_error_text_generic")
|
||||
: _t("settings|general|avatar_upload_error_text", { size: formatBytes(maxUploadSize) })}
|
||||
</Alert>
|
||||
)}
|
||||
<SignOutButton />
|
||||
</Flex>
|
||||
</div>
|
||||
{userIdentifier && <UsernameBox username={userIdentifier} />}
|
||||
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
|
||||
{externalAccountManagementUrl && (
|
||||
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
|
||||
)}
|
||||
<SignOutButton />
|
||||
</Flex>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@ export interface DeviceTileProps {
|
||||
}
|
||||
|
||||
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
|
||||
return <Heading size="4">{device.display_name || device.device_id}</Heading>;
|
||||
return (
|
||||
<Heading as="h3" size="4">
|
||||
{device.display_name || device.device_id}
|
||||
</Heading>
|
||||
);
|
||||
};
|
||||
|
||||
const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, isSelected, onClick }) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
|
||||
import { InlineField, InlineSpinner, Label, Link, Root, ToggleControl } from "@vector-im/compound-web";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import { SettingsSection } from "../shared/SettingsSection";
|
||||
@@ -56,9 +56,9 @@ export const KeyStoragePanel: React.FC<Props> = ({ onKeyStorageDisableClick }) =
|
||||
}
|
||||
subHeading={_t("settings|encryption|key_storage|description", undefined, {
|
||||
a: (sub) => (
|
||||
<a href={SdkConfig.get("help_key_storage_url")} target="_blank" rel="noreferrer noopener">
|
||||
<Link href={SdkConfig.get("help_key_storage_url")} target="_blank">
|
||||
{sub}
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classnames from "classnames";
|
||||
import React, { type HTMLAttributes } from "react";
|
||||
import React, { type ComponentProps, createContext, type HTMLAttributes, useContext } from "react";
|
||||
|
||||
import Heading from "../../typography/Heading";
|
||||
import { SettingsHeader } from "../SettingsHeader";
|
||||
@@ -19,15 +19,30 @@ export interface SettingsSectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
legacy?: boolean;
|
||||
}
|
||||
|
||||
function renderHeading(heading: string | React.ReactNode | undefined, legacy: boolean): React.ReactNode | undefined {
|
||||
type HeadingLevel = 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/**
|
||||
* React context to correctly set heading levels in nested settings sections dynamically
|
||||
*/
|
||||
export const HeadingLevelContext = createContext<HeadingLevel>(2);
|
||||
|
||||
function SectionHeading({
|
||||
heading,
|
||||
legacy,
|
||||
level,
|
||||
}: {
|
||||
heading: string | React.ReactNode | undefined;
|
||||
legacy: boolean;
|
||||
level: HeadingLevel;
|
||||
}): React.ReactNode | undefined {
|
||||
switch (typeof heading) {
|
||||
case "string":
|
||||
return legacy ? (
|
||||
<Heading as="h2" size="3">
|
||||
<Heading as={`h${level}`} size={(level + 1).toString() as ComponentProps<typeof Heading>["size"]}>
|
||||
{heading}
|
||||
</Heading>
|
||||
) : (
|
||||
<SettingsHeader label={heading} />
|
||||
<SettingsHeader as={`h${level}`} label={heading} />
|
||||
);
|
||||
case "undefined":
|
||||
return undefined;
|
||||
@@ -60,22 +75,28 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
legacy = true,
|
||||
children,
|
||||
...rest
|
||||
}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={classnames("mx_SettingsSection", className, {
|
||||
mx_SettingsSection_newUi: !legacy,
|
||||
})}
|
||||
>
|
||||
{heading &&
|
||||
(subHeading ? (
|
||||
<div className="mx_SettingsSection_header">
|
||||
{renderHeading(heading, legacy)}
|
||||
{subHeading}
|
||||
</div>
|
||||
) : (
|
||||
renderHeading(heading, legacy)
|
||||
))}
|
||||
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
|
||||
</div>
|
||||
);
|
||||
}) => {
|
||||
const level = useContext(HeadingLevelContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={classnames("mx_SettingsSection", className, {
|
||||
mx_SettingsSection_newUi: !legacy,
|
||||
})}
|
||||
>
|
||||
{heading &&
|
||||
(subHeading ? (
|
||||
<div className="mx_SettingsSection_header">
|
||||
<SectionHeading heading={heading} legacy={legacy} level={level} />
|
||||
{subHeading}
|
||||
</div>
|
||||
) : (
|
||||
<SectionHeading heading={heading} legacy={legacy} level={level} />
|
||||
))}
|
||||
<HeadingLevelContext.Provider value={heading ? ((level + 1) as HeadingLevel) : level}>
|
||||
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
|
||||
</HeadingLevelContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,10 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { type HTMLAttributes } from "react";
|
||||
import React, { type HTMLAttributes, useContext } from "react";
|
||||
import { Form, Separator } from "@vector-im/compound-web";
|
||||
|
||||
import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
|
||||
import { HeadingLevelContext } from "./SettingsSection.tsx";
|
||||
|
||||
export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
heading?: string | React.ReactNode;
|
||||
@@ -45,6 +46,8 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
|
||||
formWrap,
|
||||
...rest
|
||||
}) => {
|
||||
const level = useContext(HeadingLevelContext);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
{...rest}
|
||||
@@ -52,7 +55,11 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
|
||||
mx_SettingsSubsection_newUi: !legacy,
|
||||
})}
|
||||
>
|
||||
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} /> : <>{heading}</>}
|
||||
{typeof heading === "string" ? (
|
||||
<SettingsSubsectionHeading heading={heading} as={`h${level}`} />
|
||||
) : (
|
||||
<>{heading}</>
|
||||
)}
|
||||
{!!description && (
|
||||
<div className="mx_SettingsSubsection_description">
|
||||
<SettingsSubsectionText>{description}</SettingsSubsectionText>
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivEl
|
||||
|
||||
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({
|
||||
heading,
|
||||
as = "h3",
|
||||
as = "h2",
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
|
||||
@@ -45,7 +45,7 @@ const AccountSection: React.FC<AccountSectionProps> = ({
|
||||
if (!canChangePassword) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSubsection
|
||||
heading={_t("settings|general|account_section")}
|
||||
stretchContent
|
||||
@@ -59,7 +59,7 @@ const AccountSection: React.FC<AccountSectionProps> = ({
|
||||
onFinished={onPasswordChanged}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
</>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -179,21 +179,19 @@ const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
|
||||
|
||||
return (
|
||||
<SettingsTab data-testid="mx_AccountUserSettingsTab">
|
||||
<SettingsSection>
|
||||
<UserProfileSettings
|
||||
externalAccountManagementUrl={externalAccountManagementUrl}
|
||||
canSetDisplayName={canSetDisplayName}
|
||||
canSetAvatar={canSetAvatar}
|
||||
/>
|
||||
{(!isAccountManagedExternally || canMake3pidChanges) && (
|
||||
<UserPersonalInfoSettings canMake3pidChanges={canMake3pidChanges} />
|
||||
)}
|
||||
<AccountSection
|
||||
canChangePassword={canChangePassword}
|
||||
onPasswordChanged={onPasswordChanged}
|
||||
onPasswordChangeError={onPasswordChangeError}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<UserProfileSettings
|
||||
externalAccountManagementUrl={externalAccountManagementUrl}
|
||||
canSetDisplayName={canSetDisplayName}
|
||||
canSetAvatar={canSetAvatar}
|
||||
/>
|
||||
{(!isAccountManagedExternally || canMake3pidChanges) && (
|
||||
<UserPersonalInfoSettings canMake3pidChanges={canMake3pidChanges} />
|
||||
)}
|
||||
<AccountSection
|
||||
canChangePassword={canChangePassword}
|
||||
onPasswordChanged={onPasswordChanged}
|
||||
onPasswordChangeError={onPasswordChangeError}
|
||||
/>
|
||||
{accountManagementSection}
|
||||
</SettingsTab>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,8 @@ const QuickSettingsButton: React.FC<{
|
||||
onFinished={closeMenu}
|
||||
managed={false}
|
||||
focusLock={true}
|
||||
role="region"
|
||||
aria-label={_t("quick_settings|title")}
|
||||
>
|
||||
<h2>{_t("quick_settings|title")}</h2>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
|
||||
|
||||
return (
|
||||
<div className="mx_QuickThemeSwitcher">
|
||||
<h4 className="mx_QuickThemeSwitcher_heading">{_t("common|theme")}</h4>
|
||||
<h3 className="mx_QuickThemeSwitcher_heading">{_t("common|theme")}</h3>
|
||||
<Dropdown
|
||||
id="mx_QuickSettingsButton_themePickerDropdown"
|
||||
onOptionChange={onOptionChange}
|
||||
|
||||
Reference in New Issue
Block a user