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:
parent
a0fbccf612
commit
c401d6411b
|
@ -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,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"],
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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