Source: wtc-modal-view.js

/**
 * 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;