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

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 e0d2de73d89cdea13e9681b2daaa52074ee510a5 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.

diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.js b/app/assets/javascripts/discourse/app/components/d-modal-body.js
index 2ea7d11..1e46c44 100644
--- a/app/assets/javascripts/discourse/app/components/d-modal-body.js
+++ b/app/assets/javascripts/discourse/app/components/d-modal-body.js
@@ -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 || "";
   },
 });
diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js
index a08ec54..941a0ca 100644
--- a/app/assets/javascripts/discourse/app/components/d-modal.js
+++ b/app/assets/javascripts/discourse/app/components/d-modal.js
@@ -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() {
diff --git a/app/assets/javascripts/discourse/app/controllers/create-account.js b/app/assets/javascripts/discourse/app/controllers/create-account.js
index 52a3f72..b589574 100644
--- a/app/assets/javascripts/discourse/app/controllers/create-account.js
+++ b/app/assets/javascripts/discourse/app/controllers/create-account.js
@@ -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();
       },
     },
diff --git a/app/assets/javascripts/discourse/app/controllers/login.js b/app/assets/javascripts/discourse/app/controllers/login.js
index fe6b32b..a3a8934 100644
--- a/app/assets/javascripts/discourse/app/controllers/login.js
+++ b/app/assets/javascripts/discourse/app/controllers/login.js
@@ -428,7 +428,10 @@ export default Controller.extend(ModalFunctionality, {
     });
 
     next(() => {
-      showModal("createAccount", { modalClass: "create-account" });
+      showModal("createAccount", {
+        modalClass: "create-account",
+        titleAriaElementId: "create-account-title",
+      });
     });
   },
 });
diff --git a/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js
new file mode 100644
index 0000000..902f795
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js
@@ -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"],
+    });
+  },
+};
diff --git a/app/assets/javascripts/discourse/app/lib/show-modal.js b/app/assets/javascripts/discourse/app/lib/show-modal.js
index db3067f..dde3094 100644
--- a/app/assets/javascripts/discourse/app/lib/show-modal.js
+++ b/app/assets/javascripts/discourse/app/lib/show-modal.js
@@ -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,
diff --git a/app/assets/javascripts/discourse/app/mixins/name-validation.js b/app/assets/javascripts/discourse/app/mixins/name-validation.js
index 52623f5..65287c9 100644
--- a/app/assets/javascripts/discourse/app/mixins/name-validation.js
+++ b/app/assets/javascripts/discourse/app/mixins/name-validation.js
@@ -15,12 +15,14 @@ export default Mixin.create({
   },
 
   // Validate the name.
-  @discourseComputed("accountName")
-  nameValidation() {
-    if (this.siteSettings.full_name_required && isEmpty(this.accountName)) {

[... diff too long, it was truncated ...]

GitHub sha: c401d6411b3f494d4202b6e06ba2b0682ab84444

This commit appears in #14234 which was approved by tshenry. It was merged by martin.