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:
parent
75041dbbeb
commit
e0d2de73d8
|
@ -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,
|
||||
|
|
|
@ -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 || "";
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
|
|
|
@ -428,7 +428,10 @@ export default Controller.extend(ModalFunctionality, {
|
|||
});
|
||||
|
||||
next(() => {
|
||||
showModal("createAccount", { modalClass: "create-account" });
|
||||
showModal("createAccount", {
|
||||
modalClass: "create-account",
|
||||
titleAriaElementId: "create-account-title",
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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"],
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div id="modal-alert"></div>
|
||||
<div id="modal-alert" role="alert"></div>
|
||||
|
||||
{{yield}}
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{{#d-modal
|
||||
modalClass=modalClass
|
||||
title=title
|
||||
titleAriaElementId=titleAriaElementId
|
||||
subtitle=subtitle
|
||||
panels=panels
|
||||
selectedPanel=selectedPanel
|
||||
|
|
|
@ -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"}}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue