A11Y: Improve create account modal for screen readers (#14204)

Improves the create account modal for screen readers by doing the following:

* Making the `modal-alert` section into an `aria-role="alert"` region and making it show and hide using height instead of display:none so screen readers pick it up. Made a change so the field-related error messages are always shown beneath the field.
* Add `aria-invalid` and `aria-describedby` attributes to each field in the modal, so the screen reader will read out the error hint on error. This necessitated an Ember component extension to allow both the `aria-*` attributes to be bound and to render on `{{input}}`.
* Moved the social login buttons to the right in the HTML structure so they are not read out first.
* Added `aria-label` attributes to the login buttons so they can have different content for screen readers.
* In some cases for modals, the title that should be used for the `aria-labelledby` attribute is within the modal content and not the discourse-modal-title title. This introduces a new titleAriaElementId property to the d-modal component that is then used by the create-account modal to read out the
This commit is contained in:
Martin Brennan 2021-09-03 08:59:22 +10:00 committed by GitHub
parent 75041dbbeb
commit e0d2de73d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 177 additions and 69 deletions

View File

@ -163,6 +163,9 @@ var define, requirejs;
"@ember/object/internals": {
guidFor: Ember.guidFor,
},
"@ember/views/text-support": {
default: Ember.TextSupport,
},
I18n: {
// eslint-disable-next-line
default: I18n,

View File

@ -8,7 +8,10 @@ export default Component.extend({
didInsertElement() {
this._super(...arguments);
$("#modal-alert").hide();
this._modalAlertElement = document.getElementById("modal-alert");
if (this._modalAlertElement) {
this._modalAlertElement.innerHTML = "";
}
let fixedParent = $(this.element).closest(".d-modal.fixed-modal");
if (fixedParent.length) {
@ -55,10 +58,10 @@ export default Component.extend({
},
_clearFlash() {
const modalAlert = document.getElementById("modal-alert");
if (modalAlert) {
modalAlert.style.display = "none";
modalAlert.classList.remove(
if (this._modalAlertElement) {
this._modalAlertElement.innerHTML = "";
this._modalAlertElement.classList.remove(
"alert",
"alert-error",
"alert-info",
"alert-success",
@ -69,10 +72,14 @@ export default Component.extend({
_flash(msg) {
this._clearFlash();
if (!this._modalAlertElement) {
return;
}
$("#modal-alert")
.addClass(`alert alert-${msg.messageClass || "success"}`)
.html(msg.text || "")
.fadeIn();
this._modalAlertElement.classList.add(
"alert",
`alert-${msg.messageClass || "success"}`
);
this._modalAlertElement.innerHTML = msg.text || "";
},
});

View File

@ -1,8 +1,7 @@
import { computed } from "@ember/object";
import Component from "@ember/component";
import I18n from "I18n";
import { next, schedule } from "@ember/runloop";
import { bind, on } from "discourse-common/utils/decorators";
import discourseComputed, { bind, on } from "discourse-common/utils/decorators";
export default Component.extend({
classNameBindings: [
@ -21,6 +20,7 @@ export default Component.extend({
submitOnEnter: true,
dismissable: true,
title: null,
titleAriaElementId: null,
subtitle: null,
role: "dialog",
headerClass: null,
@ -41,9 +41,17 @@ export default Component.extend({
// Inform screenreaders of the modal
"aria-modal": "true",
ariaLabelledby: computed("title", function () {
return this.title ? "discourse-modal-title" : null;
}),
@discourseComputed("title", "titleAriaElementId")
ariaLabelledby(title, titleAriaElementId) {
if (titleAriaElementId) {
return titleAriaElementId;
}
if (title) {
return "discourse-modal-title";
}
return;
},
@on("didInsertElement")
setUp() {

View File

@ -140,16 +140,19 @@ export default Controller.extend(
"serverAccountEmail",
"serverEmailValidation",
"accountEmail",
"rejectedEmails.[]"
"rejectedEmails.[]",
"forceValidationReason"
)
emailValidation(
serverAccountEmail,
serverEmailValidation,
email,
rejectedEmails
rejectedEmails,
forceValidationReason
) {
const failedAttrs = {
failed: true,
ok: false,
element: document.querySelector("#new-account-email"),
};
@ -162,6 +165,9 @@ export default Controller.extend(
return EmberObject.create(
Object.assign(failedAttrs, {
message: I18n.t("user.email.required"),
reason: forceValidationReason
? I18n.t("user.email.required")
: null,
})
);
}
@ -426,6 +432,7 @@ export default Controller.extend(
createAccount() {
this.clearFlash();
this.set("forceValidationReason", true);
const validation = [
this.emailValidation,
this.usernameValidation,
@ -435,23 +442,22 @@ export default Controller.extend(
].find((v) => v.failed);
if (validation) {
if (validation.message) {
this.flash(validation.message, "error");
}
const element = validation.element;
if (element.tagName === "DIV") {
if (element.scrollIntoView) {
element.scrollIntoView();
if (element) {
if (element.tagName === "DIV") {
if (element.scrollIntoView) {
element.scrollIntoView();
}
element.click();
} else {
element.focus();
}
element.click();
} else {
element.focus();
}
return;
}
this.set("forceValidationReason", false);
this.performAccountCreation();
},
},

View File

@ -428,7 +428,10 @@ export default Controller.extend(ModalFunctionality, {
});
next(() => {
showModal("createAccount", { modalClass: "create-account" });
showModal("createAccount", {
modalClass: "create-account",
titleAriaElementId: "create-account-title",
});
});
},
});

View File

@ -0,0 +1,11 @@
import TextSupport from "@ember/views/text-support";
export default {
name: "ember-input-component-extensions",
initialize() {
TextSupport.reopen({
attributeBindings: ["aria-describedby", "aria-invalid"],
});
},
};

View File

@ -47,6 +47,10 @@ export default function (name, opts) {
modalController.set("title", null);
}
if (opts.titleAriaElementId) {
modalController.set("titleAriaElementId", opts.titleAriaElementId);
}
if (opts.panels) {
modalController.setProperties({
panels: opts.panels,

View File

@ -15,12 +15,14 @@ export default Mixin.create({
},
// Validate the name.
@discourseComputed("accountName")
nameValidation() {
if (this.siteSettings.full_name_required && isEmpty(this.accountName)) {
@discourseComputed("accountName", "forceValidationReason")
nameValidation(accountName, forceValidationReason) {
if (this.siteSettings.full_name_required && isEmpty(accountName)) {
return EmberObject.create({
failed: true,
ok: false,
message: I18n.t("user.name.required"),
reason: forceValidationReason ? I18n.t("user.name.required") : null,
element: document.querySelector("#new-account-name"),
});
}

View File

@ -33,7 +33,8 @@ export default Mixin.create({
"rejectedPasswords.[]",
"accountUsername",
"accountEmail",
"passwordMinLength"
"passwordMinLength",
"forceValidationReason"
)
passwordValidation(
password,
@ -41,10 +42,12 @@ export default Mixin.create({
rejectedPasswords,
accountUsername,
accountEmail,
passwordMinLength
passwordMinLength,
forceValidationReason
) {
const failedAttrs = {
failed: true,
ok: false,
element: document.querySelector("#new-account-password"),
};
@ -67,6 +70,9 @@ export default Mixin.create({
return EmberObject.create(
Object.assign(failedAttrs, {
message: I18n.t("user.password.required"),
reason: forceValidationReason
? I18n.t("user.password.required")
: null,
})
);
}

View File

@ -11,6 +11,7 @@ function failedResult(attrs) {
let result = EmberObject.create({
shouldCheck: false,
failed: true,
ok: false,
element: document.querySelector("#new-account-username"),
});
result.setProperties(attrs);
@ -60,7 +61,12 @@ export default Mixin.create({
}
if (isEmpty(username)) {
return failedResult({ message: I18n.t("user.username.required") });
return failedResult({
message: I18n.t("user.username.required"),
reason: this.forceValidationReason
? I18n.t("user.username.required")
: null,
});
}
if (username.length < this.siteSettings.min_username_length) {

View File

@ -13,6 +13,11 @@ const LoginMethod = EmberObject.extend({
return this.title_override || I18n.t(`login.${this.name}.title`);
},
@discourseComputed
screenReaderTitle() {
return this.title_override || I18n.t(`login.${this.name}.sr_title`);
},
@discourseComputed
prettyName() {
return this.pretty_name_override || I18n.t(`login.${this.name}.name`);

View File

@ -267,11 +267,18 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
const returnPath = encodeURIComponent(window.location.pathname);
window.location = getURL("/session/sso?return_path=" + returnPath);
} else {
this._autoLogin("createAccount", "create-account", { signup: true });
this._autoLogin("createAccount", "create-account", {
signup: true,
titleAriaElementId: "create-account-title",
});
}
},
_autoLogin(modal, modalClass, { notAuto = null, signup = false } = {}) {
_autoLogin(
modal,
modalClass,
{ notAuto = null, signup = false, titleAriaElementId = null } = {}
) {
const methods = findAll();
if (!this.siteSettings.enable_local_logins && methods.length === 1) {
@ -279,7 +286,7 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
signup: signup,
});
} else {
showModal(modal);
showModal(modal, { titleAriaElementId });
this.controllerFor("modal").set("modalClass", modalClass);
if (notAuto) {
notAuto();

View File

@ -30,7 +30,7 @@
{{/if}}
</div>
<div id="modal-alert"></div>
<div id="modal-alert" role="alert"></div>
{{yield}}

View File

@ -1,5 +1,5 @@
{{#each buttons as |b|}}
<button type="button" class="btn btn-social {{b.name}}" {{action externalLogin b}}>
<button type="button" class="btn btn-social {{b.name}}" {{action externalLogin b}} aria-label={{b.screenReaderTitle}}>
{{#if b.isGoogle}}
<svg class="fa d-icon d-icon-custom-google-oauth2 svg-icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>
{{else if b.icon}}

View File

@ -1,6 +1,7 @@
{{#d-modal
modalClass=modalClass
title=title
titleAriaElementId=titleAriaElementId
subtitle=subtitle
panels=panels
selectedPanel=selectedPanel

View File

@ -1,17 +1,12 @@
{{#create-account email=accountEmail disabled=submitDisabled action=(action "createAccount")}}
{{#unless complete}}
{{plugin-outlet name="create-account-before-modal-body"}}
{{#d-modal-body class=modalBodyClasses}}
{{#d-modal-body class=modalBodyClasses preventModalAlertHiding=true}}
<div class="create-account-form">
<div class="login-welcome-header">
<div class="login-welcome-header" id="create-account-title">
<h1 class="login-title">{{i18n "create_account.header_title"}}</h1> <img src={{wavingHandURL}} alt="" class="waving-hand">
<p class="login-subheader">{{i18n "create_account.subheader_title"}}</p>
</div>
{{#unless hasAuthOptions}}
<div class="create-account-login-buttons">
{{login-buttons externalLogin=(action "externalLogin")}}
</div>
{{/unless}}
{{#if showCreateForm}}
<div class="login-form">
@ -22,7 +17,18 @@
</div>
{{/if}}
<div class="input-group create-account-email">
{{input type="email" disabled=emailDisabled value=accountEmail id="new-account-email" name="email" class=(value-entered accountEmail) autofocus="autofocus" focusOut=(action "checkEmailAvailability")}}
{{input
type="email"
disabled=emailDisabled
value=accountEmail
id="new-account-email"
name="email"
class=(value-entered accountEmail)
autofocus="autofocus"
focusOut=(action "checkEmailAvailability")
aria-describedby="account-email-validation"
aria-invalid=emailValidation.failed
}}
<label class="alt-placeholder" for="new-account-email">
{{i18n "user.email.title"}}
{{~#if userFields~}}
@ -34,8 +40,17 @@
</div>
<div class="input-group">
{{input value=accountUsername disabled=usernameDisabled class=(value-entered accountUsername) id="new-account-username" name="username" maxlength=maxUsernameLength
autocomplete="discourse"}}
{{input
value=accountUsername
disabled=usernameDisabled
class=(value-entered accountUsername)
id="new-account-username"
name="username"
maxlength=maxUsernameLength
aria-describedby="username-validation"
aria-invalid=usernameValidation.failed
autocomplete="discourse"
}}
<label class="alt-placeholder" for="new-account-username">
{{i18n "user.username.title"}}
{{~#if userFields~}}
@ -49,7 +64,14 @@
<div class="input-group">
{{#if fullnameRequired}}
{{text-field disabled=nameDisabled value=accountName id="new-account-name" class=(value-entered accountName)}}
{{text-field
disabled=nameDisabled
value=accountName
id="new-account-name"
class=(value-entered accountName)
aria-describedby="fullname-validation"
aria-invalid=nameValidation.failed
}}
<label class="alt-placeholder" for="new-account-name">
{{i18n "user.name.title"}}
{{#if siteSettings.full_name_required}}
@ -59,26 +81,35 @@
{{/if}}
</label>
{{input-tip validation=nameValidation}}
{{input-tip validation=nameValidation id="fullname-validation"}}
<span class="more-info">{{nameInstructions}}</span>
{{/if}}
</div>
{{plugin-outlet
name="create-account-before-password"
noTags=true
args=(hash
accountName=accountName
accountUsername=accountUsername
accountPassword=accountPassword
userFields=userFields
authOptions=authOptions
)
name="create-account-before-password"
noTags=true
args=(hash
accountName=accountName
accountUsername=accountUsername
accountPassword=accountPassword
userFields=userFields
authOptions=authOptions
)
}}
<div class="input-group">
{{#if passwordRequired}}
{{password-field value=accountPassword class=(value-entered accountPassword) type="password" id="new-account-password" autocomplete="current-password" capsLockOn=capsLockOn}}
{{password-field
value=accountPassword
class=(value-entered accountPassword)
type="password"
id="new-account-password"
autocomplete="current-password"
capsLockOn=capsLockOn
aria-describedby="password-validation"
aria-invalid=passwordValidation.failed
}}
<label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}}
{{~#if userFields~}}
@ -86,7 +117,7 @@
{{/if}}
</label>
{{input-tip validation=passwordValidation}}
{{input-tip validation=passwordValidation id="password-validation"}}
<span class="more-info">{{passwordInstructions}}</span>
<div class="caps-lock-warning {{unless capsLockOn " hidden"}}">
{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}
@ -156,6 +187,11 @@
{{plugin-outlet name="create-account-after-modal-footer" tagName=""}}
{{/if}}
{{#unless hasAuthOptions}}
<div class="create-account-login-buttons">
{{login-buttons externalLogin=(action "externalLogin")}}
</div>
{{/unless}}
{{#if skipConfirmation}}
{{loading-spinner size="large"}}

View File

@ -2,7 +2,6 @@ import {
acceptance,
exists,
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
@ -39,9 +38,8 @@ acceptance("Create Account - User Fields", function (needs) {
assert.ok(exists(".user-field"), "it has at least one user field");
await click(".modal-footer .btn-primary");
assert.ok(exists("#modal-alert"), "it shows the required field alert");
assert.equal(
queryAll("#modal-alert").text(),
query("#account-email-validation").innerText.trim(),
"Please enter an email address"
);
@ -63,12 +61,8 @@ acceptance("Create Account - User Fields", function (needs) {
);
await click(".modal-footer .btn-primary");
assert.equal(query("#modal-alert").style.display, "");
await fillIn(".user-field input[type=text]:nth-of-type(1)", "Barky");
await click(".user-field input[type=checkbox]");
await click(".modal-footer .btn-primary");
assert.equal(query("#modal-alert").style.display, "none");
});
});

View File

@ -60,7 +60,10 @@
min-height: 35px;
}
#modal-alert:empty {
display: none;
min-height: 0px;
padding: 0px;
overflow: hidden;
display: inline;
}
.login-welcome-header {
z-index: z("modal", "content");

View File

@ -1897,21 +1897,27 @@ en:
google_oauth2:
name: "Google"
title: "with Google"
sr_title: "Login with Google"
twitter:
name: "Twitter"
title: "with Twitter"
sr_title: "Login with Twitter"
instagram:
name: "Instagram"
title: "with Instagram"
sr_title: "Login with Instagram"
facebook:
name: "Facebook"
title: "with Facebook"
sr_title: "Login with Facebook"
github:
name: "GitHub"
title: "with GitHub"
sr_title: "Login with GitHub"
discord:
name: "Discord"
title: "with Discord"
sr_title: "Login with Discord"
second_factor_toggle:
totp: "Use an authenticator app instead"
backup_code: "Use a backup code instead"