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

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
diff --git a/app/assets/javascripts/discourse-loader.js b/app/assets/javascripts/discourse-loader.js
index f903dbe..54cef93 100644
--- a/app/assets/javascripts/discourse-loader.js
+++ b/app/assets/javascripts/discourse-loader.js
@@ -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,
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..c909a4e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js
@@ -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"],
+    });
+  },
+};
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

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

GitHub sha: e0d2de73d89cdea13e9681b2daaa52074ee510a5

This commit appears in #14204 which was approved by jjaffeux and pmusaraj. It was merged by martin.