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.
218 lines
5.4 KiB
JavaScript
218 lines
5.4 KiB
JavaScript
import Component from "@ember/component";
|
|
import I18n from "I18n";
|
|
import { next, schedule } from "@ember/runloop";
|
|
import discourseComputed, { bind, on } from "discourse-common/utils/decorators";
|
|
|
|
export default Component.extend({
|
|
classNameBindings: [
|
|
":modal",
|
|
":d-modal",
|
|
"modalClass",
|
|
"modalStyle",
|
|
"hasPanels",
|
|
],
|
|
attributeBindings: [
|
|
"data-keyboard",
|
|
"aria-modal",
|
|
"role",
|
|
"ariaLabelledby:aria-labelledby",
|
|
],
|
|
submitOnEnter: true,
|
|
dismissable: true,
|
|
title: null,
|
|
titleAriaElementId: null,
|
|
subtitle: null,
|
|
role: "dialog",
|
|
headerClass: null,
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
|
|
// If we need to render a second modal for any reason, we can't
|
|
// use `elementId`
|
|
if (this.modalStyle !== "inline-modal") {
|
|
this.set("elementId", "discourse-modal");
|
|
this.set("modalStyle", "fixed-modal");
|
|
}
|
|
},
|
|
|
|
// We handle ESC ourselves
|
|
"data-keyboard": "false",
|
|
// Inform screenreaders of the modal
|
|
"aria-modal": "true",
|
|
|
|
@discourseComputed("title", "titleAriaElementId")
|
|
ariaLabelledby(title, titleAriaElementId) {
|
|
if (titleAriaElementId) {
|
|
return titleAriaElementId;
|
|
}
|
|
if (title) {
|
|
return "discourse-modal-title";
|
|
}
|
|
|
|
return;
|
|
},
|
|
|
|
@on("didInsertElement")
|
|
setUp() {
|
|
this.appEvents.on("modal:body-shown", this, "_modalBodyShown");
|
|
document.documentElement.addEventListener(
|
|
"keydown",
|
|
this._handleModalEvents
|
|
);
|
|
},
|
|
|
|
@on("willDestroyElement")
|
|
cleanUp() {
|
|
this.appEvents.off("modal:body-shown", this, "_modalBodyShown");
|
|
document.documentElement.removeEventListener(
|
|
"keydown",
|
|
this._handleModalEvents
|
|
);
|
|
},
|
|
|
|
triggerClickOnEnter(e) {
|
|
if (!this.submitOnEnter) {
|
|
return false;
|
|
}
|
|
|
|
// skip when in a form or a textarea element
|
|
if (
|
|
e.target.closest("form") ||
|
|
(document.activeElement && document.activeElement.nodeName === "TEXTAREA")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
mouseDown(e) {
|
|
if (!this.dismissable) {
|
|
return;
|
|
}
|
|
const $target = $(e.target);
|
|
if (
|
|
$target.hasClass("modal-middle-container") ||
|
|
$target.hasClass("modal-outer-container")
|
|
) {
|
|
// Send modal close (which bubbles to ApplicationRoute) if clicked outside.
|
|
// We do this because some CSS of ours seems to cover the backdrop and makes
|
|
// it unclickable.
|
|
return (
|
|
this.attrs.closeModal && this.attrs.closeModal("initiatedByClickOut")
|
|
);
|
|
}
|
|
},
|
|
|
|
_modalBodyShown(data) {
|
|
if (this.isDestroying || this.isDestroyed) {
|
|
return;
|
|
}
|
|
|
|
if (data.fixed) {
|
|
this.element.classList.remove("hidden");
|
|
}
|
|
|
|
if (data.title) {
|
|
this.set("title", I18n.t(data.title));
|
|
} else if (data.rawTitle) {
|
|
this.set("title", data.rawTitle);
|
|
}
|
|
|
|
if (data.subtitle) {
|
|
this.set("subtitle", I18n.t(data.subtitle));
|
|
} else if (data.rawSubtitle) {
|
|
this.set("subtitle", data.rawSubtitle);
|
|
} else {
|
|
// if no subtitle provided, makes sure the previous subtitle
|
|
// of another modal is not used
|
|
this.set("subtitle", null);
|
|
}
|
|
|
|
if ("submitOnEnter" in data) {
|
|
this.set("submitOnEnter", data.submitOnEnter);
|
|
}
|
|
|
|
if ("dismissable" in data) {
|
|
this.set("dismissable", data.dismissable);
|
|
} else {
|
|
this.set("dismissable", true);
|
|
}
|
|
|
|
this.set("headerClass", data.headerClass || null);
|
|
|
|
schedule("afterRender", () => {
|
|
this._trapTab();
|
|
});
|
|
},
|
|
|
|
@bind
|
|
_handleModalEvents(event) {
|
|
if (this.element.classList.contains("hidden")) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape" && this.dismissable) {
|
|
next(() => this.attrs.closeModal("initiatedByESC"));
|
|
}
|
|
if (event.key === "Enter" && this.triggerClickOnEnter(event)) {
|
|
this.element?.querySelector(".modal-footer .btn-primary")?.click();
|
|
}
|
|
if (event.key === "Tab") {
|
|
this._trapTab(event);
|
|
}
|
|
},
|
|
|
|
_trapTab(event) {
|
|
if (this.element.classList.contains("hidden")) {
|
|
return true;
|
|
}
|
|
|
|
const innerContainer = this.element.querySelector(".modal-inner-container");
|
|
if (!innerContainer) {
|
|
return;
|
|
}
|
|
|
|
let focusableElements =
|
|
'[autofocus], a, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
|
|
if (!event) {
|
|
// on first trap we don't allow to focus modal-close
|
|
// and apply manual focus only if we don't have any autofocus element
|
|
const autofocusedElement = innerContainer.querySelector("[autofocus]");
|
|
if (
|
|
!autofocusedElement ||
|
|
document.activeElement !== autofocusedElement
|
|
) {
|
|
innerContainer
|
|
.querySelectorAll(focusableElements + ", button:not(.modal-close)")[0]
|
|
?.focus();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
focusableElements = focusableElements + ", button:enabled";
|
|
const firstFocusableElement = innerContainer.querySelectorAll(
|
|
focusableElements
|
|
)?.[0];
|
|
const focusableContent = innerContainer.querySelectorAll(focusableElements);
|
|
const lastFocusableElement = focusableContent[focusableContent.length - 1];
|
|
|
|
if (event.shiftKey) {
|
|
if (document.activeElement === firstFocusableElement) {
|
|
lastFocusableElement?.focus();
|
|
event.preventDefault();
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastFocusableElement) {
|
|
(
|
|
innerContainer.querySelector(".modal-close") || firstFocusableElement
|
|
)?.focus();
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
},
|
|
});
|