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

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 title

------

This is the same as e0d2de73d8 but
fixes the Ember-input-component-extension to use the public
Ember components TextField and TextArea instead of the private
TextSupport so the extension works in both normal Ember and
Ember CLI.
This commit is contained in:
Martin Brennan 2021-09-03 13:04:24 +10:00 committed by GitHub
parent a0fbccf612
commit c401d6411b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 178 additions and 69 deletions

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,15 @@
import TextField from "@ember/component/text-field";
import TextArea from "@ember/component/text-area";
export default {
name: "ember-input-component-extensions",
initialize() {
TextField.reopen({
attributeBindings: ["aria-describedby", "aria-invalid"],
});
TextArea.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"