/**
* A Modal class which can display programatically-generated content, or pull in content from an existing DOM node.
*
* @example
* const myModal = new Modal();
* const triggerButton = document.querySelector('trigger');
*
* myModal.optionalClass = "modal--myModal";
* myModal.content = '<p>Some sample content!</p>';
* myModal.focusOnClose = triggerButton;
*
* triggerButton.addEventListener('click', () => {
* myModal.open();
* });
*/
class Modal {
constructor() {
this.state = false;
this.modal = document.createElement("div");
this.modalOverlay = document.createElement("div");
this.modalFocusStart = document.createElement("div");
this.modalFocusEnd = document.createElement("div");
this.modalClose = document.createElement("button");
this.modalClose.innerHTML = "<span>Close</span>";
this.modalWrapper = document.createElement("div");
this.modalContent = document.createElement("div");
this.wrapperOfContent = document.createElement("div");
this.className = "modal";
this.classNameOpen = "modal--open";
this.appended = false;
this.storeContent = false;
this.inOutDuration = 400;
// getters and setters variables
this._onOpen = null;
this._onClose = null;
this._onCloseStart = null;
this._focusOnClose = null;
this._content = null;
// add the classes and focus attributes
this.modal.classList.add(this.className);
this.modalOverlay.classList.add(`${this.className}__overlay`);
this.modalFocusStart.classList.add(`${this.className}__focus-start`);
this.modalFocusEnd.classList.add(`${this.className}__focus-end`);
this.modalClose.classList.add(`${this.className}__close`);
this.modalWrapper.classList.add(`${this.className}__wrapper`);
this.modalContent.classList.add(`${this.className}__content`);
this.wrapperOfContent.classList.add(`${this.className}-content-wrapper`);
// adds role of dialog for a11y
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role
this.modal.setAttribute("role", "dialog");
this.modalFocusEnd.setAttribute("tabindex", 0);
this.modalFocusStart.setAttribute("tabindex", 0);
this.modalContent.setAttribute("tabindex", -1);
// create the markup structure
this.modalWrapper.appendChild(this.modalFocusStart);
this.modalWrapper.appendChild(this.modalClose);
this.modalWrapper.appendChild(this.modalContent);
this.modalWrapper.appendChild(this.modalFocusEnd);
this.modal.appendChild(this.modalOverlay);
this.modal.appendChild(this.modalWrapper);
document.body.appendChild(this.wrapperOfContent);
this.modalFocusEnd.addEventListener("focus", () => {
this.modalClose.focus();
});
this.modalFocusStart.addEventListener(
"focus",
this.focusLastElement.bind(this)
);
this.modalClose.addEventListener("click", this.close.bind(this));
this.modalOverlay.addEventListener("click", this.close.bind(this));
}
/**
* Closes modal, removes content and optional class,
* and shifts user focus back to triggering element, if specified.
*/
close() {
if (this.state) {
console.log('closing')
if(this._onCloseStart) {
console.log('calling onCloseStart')
this._onCloseStart(this.modal, () => {
this._completeClose()
})
}
else {
console.log('onCloseStart not set, so just finishing up')
this._completeClose()
}
}
}
_completeClose() {
if (this.state) {
console.log('onCompleteClose')
this.modal.classList.remove(this.classNameOpen);
// if a focusOnClose element was passed in when the modal opened, focus it!
if (this.focusOnClose) this.focusOnClose.focus();
// This gives us time to animate and transition
setTimeout(() => {
this.state = false;
// Setting the modal to display: none; when closed, just to prevent anything from still being
// focussable. Mainly the close button.
this.modal.style.display = "none";
// We only remove the optional classNames when the timeout is complete,
// and everything is "gone" from view.
// This way, we're able to target our various custom modals in CSS,
// via their specific classNames (i.e. ".modal--video", ".modal--video.modal--open"):
if (this.optionalClass) {
if (typeof this.optionalClass === "string")
this.modal.classList.remove(this.optionalClass);
else if (this.optionalClass instanceof Array)
this.modal.classList.remove(...this.optionalClass);
}
// On close, we take the content from the modal and apply it to our static modal wrapper.
// This prevents the content from stil being tabbable in the DOM.
if (this.storeContent) this.wrapperOfContent.appendChild(this._content);
else this.modalContent.innerHTML = "";
if (this.onClose) this.onClose();
}, this.inOutDuration);
if (Modal.hash)
history.replaceState("", document.title, window.location.pathname);
}
}
/**
* Get current url hash
* @static
* @return {String} hash string or null if none.
*/
static get hash() {
let URLhash = /#\!?\/(.+)\//i.exec(window.location.hash);
if (URLhash && URLhash.length > 1) {
return URLhash[1];
} else {
return null;
}
}
/**
* Opens modal, adds content and optional CSS class
*/
open() {
if (!this.state) {
if (this.optionalClass) {
if (typeof this.optionalClass === "string")
this.modal.classList.add(this.optionalClass);
else if (this.optionalClass instanceof Array)
this.modal.classList.add(...this.optionalClass);
}
document.body.appendChild(this.modal);
// This is here to avoid a flash of content for the first time
let delay = this.appended ? 0 : 100;
if (!this.appended) this.appended = true;
// Appending the content back to the modal.
this.modalContent.append(this._content);
// Setting the modal back to block.
this.modal.style.display = "block";
setTimeout(() => {
this.modal.classList.add(this.classNameOpen);
this.focusFirstElement();
if (this.onOpen) this.onOpen(this.modal);
}, delay);
this.state = true;
const onKeyDown = (e) => {
if (e.keyCode == 27) {
this.close();
document.removeEventListener("keydown", onKeyDown.bind(this), false);
}
};
document.addEventListener("keydown", onKeyDown.bind(this), false);
}
}
/**
* Shifts focus to the first element inside the content
*/
focusFirstElement() {
// traverse tree down
const findFirst = (parent) => {
if (!parent.firstElementChild) return parent;
return findFirst(parent.firstElementChild);
};
let finalElement = findFirst(this.modalContent.firstElementChild);
finalElement.setAttribute("tabindex", -1);
finalElement.focus();
}
/**
* Shifts focus to the last element inside the content
*/
focusLastElement() {
const focusableElements = this.modalContent.querySelectorAll(
'[href], button, [tabindex="0"], [role="button"]'
);
if (focusableElements.length > 0) {
const lastFocusableElement =
focusableElements[focusableElements.length - 1];
lastFocusableElement.focus();
} else {
this.modalClose.focus();
}
}
/**
* Gets the element that will be focused when the modal closes
*
* @return {HTMLElement}
*/
get focusOnClose() {
return this._focusOnClose;
}
/**
* Sets the element that will be focused when the modal closes.
* Setter. Usage: `modalInstance.focusOnClose = myElement`
*
* @param {HTMLElement} element Must be a focusable element
*/
set focusOnClose(element) {
if (
!element instanceof HTMLButtonElement &&
!element instanceof HTMLAnchorElement &&
!element.getAttribute("tabindex")
)
return;
this._focusOnClose = element;
}
/**
* Gets the function that is called when the modal opens
*
* @return {Function}
*/
get onOpen() {
return this._onOpen;
}
/**
* Sets the function that is called when the modal opens.
* Setter. Usage: `modalInstance.onOpen = myFunction`
*
* @param {Function} callback
*/
set onOpen(callback) {
if (!callback || typeof callback !== "function") return;
this._onOpen = callback;
}
/**
* Get the function that is called when the modal closes
*
* @return {Function}
*/
get onClose() {
return this._onClose;
}
/**
* Sets the function that is called when the modal closes.
* Setter. Usage: `modalInstance.onClose = myFunction`
*
* @param {Function} callback
*/
set onClose(callback) {
if (!callback || typeof callback !== "function") return;
this._onClose = callback;
}
/**
* Get the function that is called just before the modal closes
*
* @return {Function}
*/
get onCloseStart() {
return this._onCloseStart;
}
/**
* Sets the function that is called just before the modal closes.
* If this is set, when modalInstance.close()` is called it will
* run the set function with a callback. It will then wait for
* that callback to be run before completing the close function and
* calling onClose.
* Setter. Usage:
* `modalInstance.onClose = (cb) => {
* // do some animation
* cb();
* }
*
* modalInstance.close();
* `
*
* @param {Function} callback
*/
set onCloseStart(callback) {
if (!callback || typeof callback !== "function") return;
this._onCloseStart = callback;
}
/**
* Sets an optional class name on the modal for custom styling.
* Setter. Usage: `modalInstance.optionalClass = "modal--myclass"`
*
* @param {String|Array} className
*/
set optionalClass(className) {
if (!className) return;
this._optionalClass = className;
}
/**
* Gets the optional class name
*
* @return {String|Array} optionalClass
*/
get optionalClass() {
return this._optionalClass || "";
}
/**
* Sets the content of the close button, useful for localizing.
* Setter. Usage: `modalInstance.closeButtonContent = "<String of HTML!>"`
*
* @param {string|HTMLElement} content
*/
set closeButtonContent(content) {
if (!content) return;
if (typeof content === "string") {
this.modalClose.innerHTML = content;
return;
}
if (content instanceof HTMLElement) {
this.modalClose.innerHTML = "";
this.modalClose.appendChild(content);
return;
}
}
/**
* Sets the content of the modal.
* Setter. Usage: `modalInstance.content = MyHTMLElement`
*
* @param {string|HTMLElement} content
*/
set content(content) {
if (!content) return;
if (typeof content !== "string" && !content instanceof HTMLElement) return;
if (this.storeContent) this.wrapperOfContent.appendChild(this._content);
this._content = content;
if (typeof content === "string") {
this.storeContent = false;
this.modalContent.innerHTML = this._content;
} else {
this.storeContent = true;
this.modalContent.innerHTML = "";
this.modalContent.appendChild(this._content);
return;
}
}
}
export default Modal;