Spike AXE A11Y testing in Cypress (#9111)

* Spike AXE A11Y testing in Cypress

* Fix NewRoomIntro breaking html/aria list rules

* Fix HeaderButtons breaking aria role semantics rules

* missing type

* Switch left panel from aside to nav and include space panel

* Give the page a main heading of the room name when viewing a room

* Use header landmark on RoomHeader

* Improve aria attributes on composer when autocomplete is closed

* Fix aria-owns on RoomHeader

* Give Spinner an aria role

* Give server picker help button an aria label

* Improve auth aria attributes and semantics

* Improve heading semantics in use case selection screen

* Fix autocomplete attribute to be valid

* Fix heading semantics on login page

* Improve Cypress axe testing

* Add axe tests

* Stop synapse after the timeline tests

* Await spinners to fade before percy snapshotting timeline tests

* Improve naming of plugin

* Update snapshots

* Fix accidental heading change

* Fix double synapse stoppage

* Fix Cypress timeline avatar assertions to be DPI agnostic

* Fix aria attributes on date separators

* delint

* Update snapshots

* Revert style change

* Skip redundant call
This commit is contained in:
Michael Telatynski 2022-08-01 08:31:14 +01:00 committed by GitHub
parent 05cc5f62dd
commit d5db131eef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 244 additions and 83 deletions

View file

@ -33,9 +33,12 @@ describe("Registration", () => {
}); });
it("registers an account and lands on the home screen", () => { it("registers an account and lands on the home screen", () => {
cy.injectAxe();
cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click();
cy.get(".mx_ServerPickerDialog_continue").should("be.visible"); cy.get(".mx_ServerPickerDialog_continue").should("be.visible");
cy.percySnapshot("Server Picker"); cy.percySnapshot("Server Picker");
cy.checkA11y();
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);
cy.get(".mx_ServerPickerDialog_continue").click(); cy.get(".mx_ServerPickerDialog_continue").click();
@ -46,6 +49,7 @@ describe("Registration", () => {
// Hide the server text as it contains the randomly allocated Synapse port // Hide the server text as it contains the randomly allocated Synapse port
const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }"; const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }";
cy.percySnapshot("Registration", { percyCSS }); cy.percySnapshot("Registration", { percyCSS });
cy.checkA11y();
cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_username").type("alice");
cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_password").type("totally a great password");
@ -55,16 +59,21 @@ describe("Registration", () => {
cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible"); cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible");
cy.percySnapshot("Registration email prompt", { percyCSS }); cy.percySnapshot("Registration email prompt", { percyCSS });
cy.checkA11y();
cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click();
cy.stopMeasuring("create-account"); cy.stopMeasuring("create-account");
cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible"); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible");
cy.percySnapshot("Registration terms prompt", { percyCSS }); cy.percySnapshot("Registration terms prompt", { percyCSS });
cy.checkA11y();
cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click();
cy.startMeasuring("from-submit-to-home"); cy.startMeasuring("from-submit-to-home");
cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click();
cy.get(".mx_UseCaseSelection_skip").should("exist");
cy.percySnapshot("Use-case selection screen");
cy.checkA11y();
cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click(); cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click();
cy.url().should('contain', '/#/home'); cy.url().should('contain', '/#/home');

View file

@ -20,7 +20,6 @@ import { MessageEvent } from "matrix-events-sdk";
import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import type { EventType } from "matrix-js-sdk/src/@types/event"; import type { EventType } from "matrix-js-sdk/src/@types/event";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import { SynapseInstance } from "../../plugins/synapsedocker"; import { SynapseInstance } from "../../plugins/synapsedocker";
import { SettingLevel } from "../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout"; import { Layout } from "../../../src/settings/enums/Layout";
@ -46,10 +45,14 @@ const expectDisplayName = (e: JQuery<HTMLElement>, displayName: string): void =>
}; };
const expectAvatar = (e: JQuery<HTMLElement>, avatarUrl: string): void => { const expectAvatar = (e: JQuery<HTMLElement>, avatarUrl: string): void => {
cy.getClient().then((cli: MatrixClient) => { cy.all([
cy.window({ log: false }),
cy.getClient(),
]).then(([win, cli]) => {
const size = AVATAR_SIZE * win.devicePixelRatio;
expect(e.find(".mx_BaseAvatar_image").attr("src")).to.equal( expect(e.find(".mx_BaseAvatar_image").attr("src")).to.equal(
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp(avatarUrl, AVATAR_SIZE, AVATAR_SIZE, AVATAR_RESIZE_METHOD), cli.mxcUrlToHttp(avatarUrl, size, size, AVATAR_RESIZE_METHOD),
); );
}); });
}; };
@ -75,15 +78,17 @@ describe("Timeline", () => {
cy.startSynapse("default").then(data => { cy.startSynapse("default").then(data => {
synapse = data; synapse = data;
cy.initTestUser(synapse, OLD_NAME).then(() => cy.initTestUser(synapse, OLD_NAME).then(() =>
cy.window({ log: false }).then(() => { cy.createRoom({ name: ROOM_NAME }).then(_room1Id => {
cy.createRoom({ name: ROOM_NAME }).then(_room1Id => { roomId = _room1Id;
roomId = _room1Id;
});
}), }),
); );
}); });
}); });
afterEach(() => {
cy.stopSynapse(synapse);
});
describe("useOnlyCurrentProfiles", () => { describe("useOnlyCurrentProfiles", () => {
beforeEach(() => { beforeEach(() => {
cy.uploadContent(OLD_AVATAR).then((url) => { cy.uploadContent(OLD_AVATAR).then((url) => {
@ -95,10 +100,6 @@ describe("Timeline", () => {
}); });
}); });
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should show historical profiles if disabled", () => { it("should show historical profiles if disabled", () => {
cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false); cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false);
sendEvent(roomId); sendEvent(roomId);
@ -146,11 +147,16 @@ describe("Timeline", () => {
}); });
describe("message displaying", () => { describe("message displaying", () => {
beforeEach(() => {
cy.injectAxe();
});
it("should create and configure a room on IRC layout", () => { it("should create and configure a room on IRC layout", () => {
cy.visit("/#/room/" + roomId); cy.visit("/#/room/" + roomId);
cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
cy.contains(".mx_RoomView_body .mx_GenericEventListSummary[data-layout=irc] " + cy.contains(".mx_RoomView_body .mx_GenericEventListSummary[data-layout=irc] " +
".mx_GenericEventListSummary_summary", "created and configured the room."); ".mx_GenericEventListSummary_summary", "created and configured the room.");
cy.get(".mx_Spinner").should("not.exist");
cy.percySnapshot("Configured room on IRC layout"); cy.percySnapshot("Configured room on IRC layout");
}); });
@ -174,10 +180,12 @@ describe("Timeline", () => {
.should('have.css', "margin-inline-start", "104px") .should('have.css', "margin-inline-start", "104px")
.should('have.css', "inset-inline-start", "0px"); .should('have.css', "inset-inline-start", "0px");
cy.get(".mx_Spinner").should("not.exist");
// Exclude timestamp from snapshot // Exclude timestamp from snapshot
const percyCSS = ".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp " const percyCSS = ".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp "
+ "{ visibility: hidden !important; }"; + "{ visibility: hidden !important; }";
cy.percySnapshot("Event line with inline start margin on IRC layout", { percyCSS }); cy.percySnapshot("Event line with inline start margin on IRC layout", { percyCSS });
cy.checkA11y();
}); });
it("should set inline start padding to a hidden event line", () => { it("should set inline start padding to a hidden event line", () => {

View file

@ -41,8 +41,11 @@ describe("Login", () => {
}); });
it("logs in with an existing account and lands on the home screen", () => { it("logs in with an existing account and lands on the home screen", () => {
cy.injectAxe();
cy.get("#mx_LoginForm_username", { timeout: 15000 }).should("be.visible"); cy.get("#mx_LoginForm_username", { timeout: 15000 }).should("be.visible");
cy.percySnapshot("Login"); cy.percySnapshot("Login");
cy.checkA11y();
cy.get(".mx_ServerPicker_change").click(); cy.get(".mx_ServerPicker_change").click();
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);

View file

@ -22,6 +22,7 @@ import { performance } from "./performance";
import { synapseDocker } from "./synapsedocker"; import { synapseDocker } from "./synapsedocker";
import { webserver } from "./webserver"; import { webserver } from "./webserver";
import { docker } from "./docker"; import { docker } from "./docker";
import { log } from "./log";
/** /**
* @type {Cypress.PluginConfig} * @type {Cypress.PluginConfig}
@ -31,4 +32,5 @@ export default function(on: PluginEvents, config: PluginConfigOptions) {
performance(on, config); performance(on, config);
synapseDocker(on, config); synapseDocker(on, config);
webserver(on, config); webserver(on, config);
log(on, config);
} }

35
cypress/plugins/log.ts Normal file
View file

@ -0,0 +1,35 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
export function log(on: PluginEvents, config: PluginConfigOptions) {
on("task", {
log(message: string) {
console.log(message);
return null;
},
table(message: string) {
console.table(message);
return null;
},
});
}

61
cypress/support/axe.ts Normal file
View file

@ -0,0 +1,61 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import "cypress-axe";
import * as axe from "axe-core";
import { Options } from "cypress-axe";
import Chainable = Cypress.Chainable;
function terminalLog(violations: axe.Result[]): void {
cy.task(
'log',
`${violations.length} accessibility violation${
violations.length === 1 ? '' : 's'
} ${violations.length === 1 ? 'was' : 'were'} detected`,
);
// pluck specific keys to keep the table readable
const violationData = violations.map(({ id, impact, description, nodes }) => ({
id,
impact,
description,
nodes: nodes.length,
}));
cy.task('table', violationData);
}
Cypress.Commands.overwrite("checkA11y", (
originalFn: Chainable["checkA11y"],
context?: string | Node | axe.ContextObject | undefined,
options: Options = {},
violationCallback?: ((violations: axe.Result[]) => void) | undefined,
skipFailures?: boolean,
): void => {
return originalFn(context, {
...options,
rules: {
// Disable contrast checking for now as we have too many issues with it
'color-contrast': {
enabled: false,
},
...options.rules,
},
}, violationCallback ?? terminalLog, skipFailures);
});

View file

@ -36,3 +36,4 @@ import "./iframes";
import "./timeline"; import "./timeline";
import "./network"; import "./network";
import "./composer"; import "./composer";
import "./axe";

View file

@ -7,7 +7,11 @@
"dom", "dom",
"dom.iterable" "dom.iterable"
], ],
"types": ["cypress", "@percy/cypress"], "types": [
"cypress",
"cypress-axe",
"@percy/cypress"
],
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "node", "moduleResolution": "node",

View file

@ -167,10 +167,12 @@
"@typescript-eslint/parser": "^5.6.0", "@typescript-eslint/parser": "^5.6.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"allchange": "^1.0.6", "allchange": "^1.0.6",
"axe-core": "^4.4.3",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"blob-polyfill": "^6.0.20211015", "blob-polyfill": "^6.0.20211015",
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"cypress": "^10.3.0", "cypress": "^10.3.0",
"cypress-axe": "^1.0.0",
"cypress-real-events": "^1.7.1", "cypress-real-events": "^1.7.1",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2", "enzyme-to-json": "^3.6.2",

View file

@ -29,20 +29,20 @@ limitations under the License.
flex-direction: column; flex-direction: column;
} }
h2 { h1 {
font-size: $font-24px; font-size: $font-24px;
font-weight: 600; font-weight: $font-semi-bold;
margin-top: 8px; margin-top: 8px;
color: $authpage-primary-color; color: $authpage-primary-color;
} }
h3 { h2 {
font-size: $font-14px; font-size: $font-14px;
font-weight: 600; font-weight: $font-semi-bold;
color: $authpage-secondary-color; color: $authpage-secondary-color;
} }
h3.mx_AuthBody_centered { h2.mx_AuthBody_centered {
text-align: center; text-align: center;
} }

View file

@ -35,11 +35,11 @@ limitations under the License.
} }
} }
> h4 { > h2 {
font-size: $font-15px; font-size: $font-15px;
font-weight: $font-semi-bold; font-weight: $font-semi-bold;
color: $secondary-content; color: $secondary-content;
margin-left: 8px; margin: 16px 0 16px 8px;
} }
> a { > a {

View file

@ -24,7 +24,7 @@ limitations under the License.
font-size: $font-14px; font-size: $font-14px;
line-height: $font-20px; line-height: $font-20px;
> h3 { > h2 {
font-weight: $font-semi-bold; font-weight: $font-semi-bold;
margin: 0 0 20px; margin: 0 0 20px;
grid-column: 1; grid-column: 1;

View file

@ -45,7 +45,7 @@ limitations under the License.
text-align: center; text-align: center;
} }
h4 { h3 {
margin: 0; margin: 0;
font-weight: 400; font-weight: 400;
font-size: $font-16px; font-size: $font-16px;

View file

@ -30,9 +30,12 @@ limitations under the License.
border-bottom: 1px solid $menu-selected-color; border-bottom: 1px solid $menu-selected-color;
} }
.mx_DateSeparator > div { .mx_DateSeparator > h2 {
margin: 0 25px; margin: 0 25px;
flex: 0 0 auto; flex: 0 0 auto;
font-size: inherit;
font-weight: inherit;
color: inherit;
} }
.mx_DateSeparator_jumpToDateMenu { .mx_DateSeparator_jumpToDateMenu {

View file

@ -382,7 +382,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
<aside className="mx_LeftPanel_roomListContainer"> <div className="mx_LeftPanel_roomListContainer">
{ this.renderSearchDialExplore() } { this.renderSearchDialExplore() }
{ this.renderBreadcrumbs() } { this.renderBreadcrumbs() }
{ !this.props.isMinimized && ( { !this.props.isMinimized && (
@ -401,7 +401,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{ roomList } { roomList }
</div> </div>
</div> </div>
</aside> </div>
</div> </div>
); );
} }

View file

@ -674,7 +674,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<div className={bodyClasses}> <div className={bodyClasses}>
<div className='mx_LeftPanel_outerWrapper'> <div className='mx_LeftPanel_outerWrapper'>
<LeftPanelLiveShareWarning isMinimized={this.props.collapseLhs || false} /> <LeftPanelLiveShareWarning isMinimized={this.props.collapseLhs || false} />
<div className='mx_LeftPanel_wrapper'> <nav className='mx_LeftPanel_wrapper'>
<BackdropPanel <BackdropPanel
blurMultiplier={0.5} blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage} backgroundImage={this.state.backgroundImage}
@ -693,7 +693,7 @@ class LoggedInView extends React.Component<IProps, IState> {
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
/> />
</div> </div>
</div> </nav>
</div> </div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" /> <ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
<div className="mx_RoomView_wrapper"> <div className="mx_RoomView_wrapper">

View file

@ -100,11 +100,11 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
return ( return (
<AuthPage> <AuthPage>
<CompleteSecurityBody> <CompleteSecurityBody>
<h2 className="mx_CompleteSecurity_header"> <h1 className="mx_CompleteSecurity_header">
{ icon } { icon }
{ title } { title }
{ skipButton } { skipButton }
</h2> </h1>
<div className="mx_CompleteSecurity_body"> <div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} /> <SetupEncryptionBody onFinished={this.props.onFinished} />
</div> </div>

View file

@ -437,7 +437,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
<AuthPage> <AuthPage>
<AuthHeader /> <AuthHeader />
<AuthBody> <AuthBody>
<h2> { _t('Set a new password') } </h2> <h1> { _t('Set a new password') } </h1>
{ resetPasswordJsx } { resetPasswordJsx }
</AuthBody> </AuthBody>
</AuthPage> </AuthPage>

View file

@ -600,10 +600,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
<AuthPage> <AuthPage>
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} /> <AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody> <AuthBody>
<h2> <h1>
{ _t('Sign in') } { _t('Sign in') }
{ loader } { loader }
</h2> </h1>
{ errorTextSection } { errorTextSection }
{ serverDeadSection } { serverDeadSection }
<ServerPicker <ServerPicker

View file

@ -507,9 +507,9 @@ export default class Registration extends React.Component<IProps, IState> {
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text // when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) { if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context // i18n: ssoButtons is a placeholder to help translators understand context
continueWithSection = <h3 className="mx_AuthBody_centered"> continueWithSection = <h2 className="mx_AuthBody_centered">
{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() } { _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() }
</h3>; </h2>;
} }
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
@ -521,7 +521,7 @@ export default class Registration extends React.Component<IProps, IState> {
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin} fragmentAfterLogin={this.props.fragmentAfterLogin}
/> />
<h3 className="mx_AuthBody_centered"> <h2 className="mx_AuthBody_centered">
{ _t( { _t(
"%(ssoButtons)s Or %(usernamePassword)s", "%(ssoButtons)s Or %(usernamePassword)s",
{ {
@ -529,7 +529,7 @@ export default class Registration extends React.Component<IProps, IState> {
usernamePassword: "", usernamePassword: "",
}, },
).trim() } ).trim() }
</h3> </h2>
</React.Fragment>; </React.Fragment>;
} }
@ -617,7 +617,7 @@ export default class Registration extends React.Component<IProps, IState> {
} else { } else {
// regardless of whether we're the client that started the registration or not, we should // regardless of whether we're the client that started the registration or not, we should
// try our credentials anyway // try our credentials anyway
regDoneText = <h3>{ _t( regDoneText = <h2>{ _t(
"<a>Log in</a> to your new account.", {}, "<a>Log in</a> to your new account.", {},
{ {
a: (sub) => <AccessibleButton a: (sub) => <AccessibleButton
@ -630,10 +630,10 @@ export default class Registration extends React.Component<IProps, IState> {
}} }}
>{ sub }</AccessibleButton>, >{ sub }</AccessibleButton>,
}, },
) }</h3>; ) }</h2>;
} }
body = <div> body = <div>
<h2>{ _t("Registration Successful") }</h2> <h1>{ _t("Registration Successful") }</h1>
{ regDoneText } { regDoneText }
</div>; </div>;
} else { } else {

View file

@ -298,7 +298,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
return <> return <>
<p>{ introText }</p> <p>{ introText }</p>
{ this.renderSsoForm(null) } { this.renderSsoForm(null) }
<h3 className="mx_AuthBody_centered"> <h2 className="mx_AuthBody_centered">
{ _t( { _t(
"%(ssoButtons)s Or %(usernamePassword)s", "%(ssoButtons)s Or %(usernamePassword)s",
{ {
@ -306,7 +306,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
usernamePassword: "", usernamePassword: "",
}, },
).trim() } ).trim() }
</h3> </h2>
{ this.renderPasswordForm(null) } { this.renderPasswordForm(null) }
</>; </>;
} }
@ -327,16 +327,16 @@ export default class SoftLogout extends React.Component<IProps, IState> {
<AuthPage> <AuthPage>
<AuthHeader /> <AuthHeader />
<AuthBody> <AuthBody>
<h2> <h1>
{ _t("You're signed out") } { _t("You're signed out") }
</h2> </h1>
<h3>{ _t("Sign in") }</h3> <h2>{ _t("Sign in") }</h2>
<div> <div>
{ this.renderSignInSection() } { this.renderSignInSection() }
</div> </div>
<h3>{ _t("Clear personal data") }</h3> <h2>{ _t("Clear personal data") }</h2>
<p> <p>
{ _t( { _t(
"Warning: Your personal data (including encryption keys) is still stored " + "Warning: Your personal data (including encryption keys) is still stored " +

View file

@ -33,7 +33,7 @@ export function AuthHeaderDisplay({ title, icon, serverPicker, children }: Props
return ( return (
<Fragment> <Fragment>
{ current?.icon ?? icon } { current?.icon ?? icon }
<h2>{ current?.title ?? title }</h2> <h1>{ current?.title ?? title }</h1>
{ children } { children }
{ current?.hideServerPicker !== true && serverPicker } { current?.hideServerPicker !== true && serverPicker }
</Fragment> </Fragment>

View file

@ -22,7 +22,7 @@ interface Props {
} }
export default function AuthBody({ flex, children }: PropsWithChildren<Props>) { export default function AuthBody({ flex, children }: PropsWithChildren<Props>) {
return <div className={classNames("mx_AuthBody", { "mx_AuthBody_flex": flex })}> return <main className={classNames("mx_AuthBody", { "mx_AuthBody_flex": flex })}>
{ children } { children }
</div>; </main>;
} }

View file

@ -23,9 +23,9 @@ import { _t } from '../../../languageHandler';
export default class AuthFooter extends React.Component { export default class AuthFooter extends React.Component {
public render(): React.ReactNode { public render(): React.ReactNode {
return ( return (
<div className="mx_AuthFooter"> <footer className="mx_AuthFooter" role="contentinfo">
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a> <a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>
</div> </footer>
); );
} }
} }

View file

@ -18,8 +18,8 @@ import React from 'react';
export default class AuthHeaderLogo extends React.PureComponent { export default class AuthHeaderLogo extends React.PureComponent {
public render(): React.ReactNode { public render(): React.ReactNode {
return <div className="mx_AuthHeaderLogo"> return <aside className="mx_AuthHeaderLogo">
Matrix Matrix
</div>; </aside>;
} }
} }

View file

@ -422,7 +422,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
<Field <Field
id="mx_LoginForm_password" id="mx_LoginForm_password"
className={pwFieldClass} className={pwFieldClass}
autoComplete="password" autoComplete="current-password"
type="password" type="password"
name="password" name="password"
label={_t('Password')} label={_t('Password')}

View file

@ -206,6 +206,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
checked={!this.state.defaultChosen} checked={!this.state.defaultChosen}
onChange={this.onOtherChosen} onChange={this.onOtherChosen}
childrenInLabel={false} childrenInLabel={false}
aria-label={_t("Other homeserver")}
> >
<Field <Field
type="text" type="text"
@ -230,7 +231,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
{ _t("Continue") } { _t("Continue") }
</AccessibleButton> </AccessibleButton>
<h4>{ _t("Learn more") }</h4> <h2>{ _t("Learn more") }</h2>
<a href="https://matrix.org/faq/#what-is-a-homeserver%3F" target="_blank" rel="noreferrer noopener"> <a href="https://matrix.org/faq/#what-is-a-homeserver%3F" target="_blank" rel="noreferrer noopener">
{ _t("About homeservers") } { _t("About homeservers") }
</a> </a>

View file

@ -85,8 +85,13 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
} }
return <div className="mx_ServerPicker"> return <div className="mx_ServerPicker">
<h3>{ title || _t("Homeserver") }</h3> <h2>{ title || _t("Homeserver") }</h2>
{ !disableCustomUrls ? <AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} /> : null } { !disableCustomUrls ? (
<AccessibleButton
className="mx_ServerPicker_help"
onClick={onHelpClick}
aria-label={_t("Help")}
/>): null }
<span className="mx_ServerPicker_server" title={typeof serverName === "string" ? serverName : undefined}> <span className="mx_ServerPicker_server" title={typeof serverName === "string" ? serverName : undefined}>
{ serverName } { serverName }
</span> </span>

View file

@ -39,6 +39,7 @@ export default class Spinner extends React.PureComponent<IProps> {
className="mx_Spinner_icon" className="mx_Spinner_icon"
style={{ width: w, height: h }} style={{ width: w, height: h }}
aria-label={_t("Loading...")} aria-label={_t("Loading...")}
role="progressbar"
/> />
</div> </div>
); );

View file

@ -57,7 +57,7 @@ export function UseCaseSelection({ onFinished }: Props) {
</div> </div>
<div className="mx_UseCaseSelection_info mx_UseCaseSelection_slideInDelayed"> <div className="mx_UseCaseSelection_info mx_UseCaseSelection_slideInDelayed">
<h2>{ _t("Who will you chat to the most?") }</h2> <h2>{ _t("Who will you chat to the most?") }</h2>
<h4>{ _t("We'll help you get connected.") }</h4> <h3>{ _t("We'll help you get connected.") }</h3>
</div> </div>
<div className="mx_UseCaseSelection_options mx_UseCaseSelection_slideInDelayed"> <div className="mx_UseCaseSelection_options mx_UseCaseSelection_slideInDelayed">
<UseCaseSelectionButton <UseCaseSelectionButton

View file

@ -223,7 +223,7 @@ export default class DateSeparator extends React.Component<IProps, IState> {
isExpanded={!!this.state.contextMenuPosition} isExpanded={!!this.state.contextMenuPosition}
title={_t("Jump to date")} title={_t("Jump to date")}
> >
<div aria-hidden="true">{ this.getLabel() }</div> <h2 aria-hidden="true">{ this.getLabel() }</h2>
<div className="mx_DateSeparator_chevron" /> <div className="mx_DateSeparator_chevron" />
{ contextMenu } { contextMenu }
</ContextMenuTooltipButton> </ContextMenuTooltipButton>
@ -237,15 +237,15 @@ export default class DateSeparator extends React.Component<IProps, IState> {
if (this.state.jumpToDateEnabled) { if (this.state.jumpToDateEnabled) {
dateHeaderContent = this.renderJumpToDateMenu(); dateHeaderContent = this.renderJumpToDateMenu();
} else { } else {
dateHeaderContent = <div aria-hidden="true">{ label }</div>; dateHeaderContent = <h2 aria-hidden="true">{ label }</h2>;
} }
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one // ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return <h2 className="mx_DateSeparator" role="separator" tabIndex={-1} aria-label={label}> return <div className="mx_DateSeparator" role="separator" tabIndex={-1} aria-label={label}>
<hr role="none" /> <hr role="none" />
{ dateHeaderContent } { dateHeaderContent }
<hr role="none" /> <hr role="none" />
</h2>; </div>;
} }
} }

View file

@ -95,7 +95,7 @@ export default abstract class HeaderButtons<P = {}> extends React.Component<IPro
public abstract renderButtons(): JSX.Element; public abstract renderButtons(): JSX.Element;
public render() { public render() {
return <div className="mx_HeaderButtons"> return <div className="mx_HeaderButtons" role="tablist">
{ this.renderButtons() } { this.renderButtons() }
</div>; </div>;
} }

View file

@ -760,7 +760,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const { completionIndex } = this.state; const { completionIndex } = this.state;
const hasAutocomplete = Boolean(this.state.autoComplete); const hasAutocomplete = Boolean(this.state.autoComplete);
let activeDescendant; let activeDescendant: string;
if (hasAutocomplete && completionIndex >= 0) { if (hasAutocomplete && completionIndex >= 0) {
activeDescendant = generateCompletionDomId(completionIndex); activeDescendant = generateCompletionDomId(completionIndex);
} }
@ -784,8 +784,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
aria-multiline="true" aria-multiline="true"
aria-autocomplete="list" aria-autocomplete="list"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={hasAutocomplete} aria-expanded={hasAutocomplete ? true : undefined}
aria-owns="mx_Autocomplete" aria-owns={hasAutocomplete ? "mx_Autocomplete" : undefined}
aria-activedescendant={activeDescendant} aria-activedescendant={activeDescendant}
dir="auto" dir="auto"
aria-disabled={this.props.disabled} aria-disabled={this.props.disabled}

View file

@ -219,8 +219,7 @@ const NewRoomIntro = () => {
<span> { subText } { subButton } </span> <span> { subText } { subButton } </span>
); );
return <div className="mx_NewRoomIntro"> return <li className="mx_NewRoomIntro">
{ !hasExpectedEncryptionSettings(cli, room) && ( { !hasExpectedEncryptionSettings(cli, room) && (
<EventTileBubble <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon_warning" className="mx_cryptoEvent mx_cryptoEvent_icon_warning"
@ -230,7 +229,7 @@ const NewRoomIntro = () => {
) } ) }
{ body } { body }
</div>; </li>;
}; };
export default NewRoomIntro; export default NewRoomIntro;

View file

@ -45,6 +45,8 @@ import { NotificationStateEvents } from '../../../stores/notifications/Notificat
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning'; import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning';
import { BetaPill } from "../beta/BetaCard"; import { BetaPill } from "../beta/BetaCard";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
export interface ISearchInfo { export interface ISearchInfo {
searchTerm: string; searchTerm: string;
@ -71,6 +73,7 @@ interface IProps {
interface IState { interface IState {
contextMenuPosition?: DOMRect; contextMenuPosition?: DOMRect;
rightPanelOpen: boolean;
} }
export default class RoomHeader extends React.Component<IProps, IState> { export default class RoomHeader extends React.Component<IProps, IState> {
@ -89,23 +92,29 @@ export default class RoomHeader extends React.Component<IProps, IState> {
super(props, context); super(props, context);
const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room);
notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.state = {}; this.state = {
rightPanelOpen: RightPanelStore.instance.isOpen,
};
} }
public componentDidMount() { public componentDidMount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
cli.on(RoomStateEvent.Events, this.onRoomStateEvents); cli.on(RoomStateEvent.Events, this.onRoomStateEvents);
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { cli?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room); const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room);
notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate); notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate);
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
} }
private onRightPanelStoreUpdate = () => {
this.setState({ rightPanelOpen: RightPanelStore.instance.isOpen });
};
private onRoomStateEvents = (event: MatrixEvent) => { private onRoomStateEvents = (event: MatrixEvent) => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return; return;
@ -230,7 +239,9 @@ export default class RoomHeader extends React.Component<IProps, IState> {
const roomName = <RoomName room={this.props.room}> const roomName = <RoomName room={this.props.room}>
{ (name) => { { (name) => {
const roomName = name || oobName; const roomName = name || oobName;
return <div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>; return <div dir="auto" className={textClasses} title={roomName} role="heading" aria-level={1}>
{ roomName }
</div>;
} } } }
</RoomName>; </RoomName>;
@ -311,8 +322,11 @@ export default class RoomHeader extends React.Component<IProps, IState> {
) : null; ) : null;
return ( return (
<div className="mx_RoomHeader light-panel"> <header className="mx_RoomHeader light-panel">
<div className="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel"> <div
className="mx_RoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div> <div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
<div className="mx_RoomHeader_e2eIcon">{ e2eIcon }</div> <div className="mx_RoomHeader_e2eIcon">{ e2eIcon }</div>
{ name } { name }
@ -322,7 +336,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
{ buttons } { buttons }
</div> </div>
<RoomLiveShareWarning roomId={this.props.room.roomId} /> <RoomLiveShareWarning roomId={this.props.room.roomId} />
</div> </header>
); );
} }
} }

View file

@ -2408,6 +2408,7 @@
"You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.", "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.",
"Join millions for free on the largest public server": "Join millions for free on the largest public server", "Join millions for free on the largest public server": "Join millions for free on the largest public server",
"Homeserver": "Homeserver", "Homeserver": "Homeserver",
"Help": "Help",
"Choose a locale": "Choose a locale", "Choose a locale": "Choose a locale",
"Continue with %(provider)s": "Continue with %(provider)s", "Continue with %(provider)s": "Continue with %(provider)s",
"Sign in with single sign-on": "Sign in with single sign-on", "Sign in with single sign-on": "Sign in with single sign-on",

View file

@ -6,7 +6,7 @@ exports[`DateSeparator renders the date separator correctly 1`] = `
roomId="!unused:example.org" roomId="!unused:example.org"
ts={1639728540000} ts={1639728540000}
> >
<h2 <div
aria-label="Today" aria-label="Today"
className="mx_DateSeparator" className="mx_DateSeparator"
role="separator" role="separator"
@ -15,15 +15,15 @@ exports[`DateSeparator renders the date separator correctly 1`] = `
<hr <hr
role="none" role="none"
/> />
<div <h2
aria-hidden="true" aria-hidden="true"
> >
Today Today
</div> </h2>
<hr <hr
role="none" role="none"
/> />
</h2> </div>
</DateSeparator> </DateSeparator>
`; `;
@ -33,7 +33,7 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep
roomId="!unused:example.org" roomId="!unused:example.org"
ts={1639728540000} ts={1639728540000}
> >
<h2 <div
aria-label="Fri, Dec 17 2021" aria-label="Fri, Dec 17 2021"
className="mx_DateSeparator" className="mx_DateSeparator"
role="separator" role="separator"
@ -88,11 +88,11 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
<div <h2
aria-hidden="true" aria-hidden="true"
> >
Fri, Dec 17 2021 Fri, Dec 17 2021
</div> </h2>
<div <div
className="mx_DateSeparator_chevron" className="mx_DateSeparator_chevron"
/> />
@ -103,6 +103,6 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep
<hr <hr
role="none" role="none"
/> />
</h2> </div>
</DateSeparator> </DateSeparator>
`; `;

View file

@ -31,6 +31,7 @@ exports[`FontScalingPanel renders the font scaling UI 1`] = `
<div <div
aria-label="Loading..." aria-label="Loading..."
className="mx_Spinner_icon" className="mx_Spinner_icon"
role="progressbar"
style={ style={
Object { Object {
"height": 32, "height": 32,

View file

@ -12,6 +12,7 @@ exports[`Module Components should override the factory for a ModuleSpinner 1`] =
<div <div
aria-label="Loading..." aria-label="Loading..."
className="mx_Spinner_icon" className="mx_Spinner_icon"
role="progressbar"
style={ style={
Object { Object {
"height": 32, "height": 32,

View file

@ -2714,6 +2714,11 @@ axe-core@^4.4.2:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c"
integrity sha512-LVAaGp/wkkgYJcjmHsoKx4juT1aQvJyPcW09MLCjVTh3V2cc6PnyempiLMNH5iMdfIX/zdbjUx2KDjMLCTdPeA== integrity sha512-LVAaGp/wkkgYJcjmHsoKx4juT1aQvJyPcW09MLCjVTh3V2cc6PnyempiLMNH5iMdfIX/zdbjUx2KDjMLCTdPeA==
axe-core@^4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
axobject-query@^2.2.0: axobject-query@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@ -3539,6 +3544,11 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
cypress-axe@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-1.0.0.tgz#ab4e9486eaa3bb956a90a1ae40d52df42827b4f0"
integrity sha512-QBlNMAd5eZoyhG8RGGR/pLtpHGkvgWXm2tkP68scJ+AjYiNNOlJihxoEwH93RT+rWOLrefw4iWwEx8kpEcrvJA==
cypress-real-events@^1.7.1: cypress-real-events@^1.7.1:
version "1.7.1" version "1.7.1"
resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.1.tgz#8f430d67c29ea4f05b9c5b0311780120cbc9b935" resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.1.tgz#8f430d67c29ea4f05b9c5b0311780120cbc9b935"