Source: wtc-modal-view.js

import _u from 'wtc-utility-helpers';

let instance = null;

/**
 * A Modal class which can display programatically-generated content, or pull in content from an existing DOM node.
 *
 * @static
 * @author Marlon Marcello <marlon@wethecollective.com>
 * @version 1.2.4
 * @requirements wtc-utility-helpers
 * @created Nov 23, 2016
 */
class Modal {
  /**
   * Creates base DOM element.
   * @example
   * const myModal = new Modal();
   * 
   * @returns {Class} The Modal instance
   */
  constructor() {
    if (instance) {
      return instance;
    }

    // state = open or closed
    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.className = 'modal';
    this.classNameOpen = 'modal--open';
    this.optionalClass = null;
    this.appended = false;
    this.onOpen = null;
    this.onClose = 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.modalFocusStart.setAttribute('tabindex', -1);
    this.modalFocusEnd.setAttribute('tabindex', 0);
    
    // 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.modal);

    instance = this;

    this.modalClose.addEventListener('click', (e) => {
      Modal.close();
    });

    this.modalOverlay.addEventListener('click', (e) => {
      Modal.close();
    });

    return this;
  }

  /**
   * Closes modal, removes content and optional class,
   * and shifts user focus back to triggering element, if specified.
   * @static
   * @return {Class} Modal instance.
   */
  static close() {
    const modal = Modal.instance;

    if(modal.state) {
      modal.modal.classList.remove(modal.classNameOpen);
      
      // if a focusOnClose element was passed in when the modal opened, focus it!
      if (modal.focusOnClose) {
        // check if the element is in fact focusable
        if (modal.focusOnClose instanceof HTMLButtonElement ||
            modal.focusOnClose.getAttribute('tabindex') ||
            modal.focusOnClose instanceof HTMLAnchorElement) {
          modal.focusOnClose.focus();
        } else {
          console.error('focusOnClose element must be a focusable alement, or must have a tabindex attribute.');
        }
      }

      // This gives us time to animate and transition
      setTimeout(() => {
        if (modal.optionalClass) {
          modal.modal.classList.remove(modal.optionalClass);
          modal.optionalClass = null;
        }

        modal.state = false;
        modal.modalContent.innerHTML = '';
      }, 500);
      
      modal.modalFocusEnd.removeEventListener('focus', Modal.focusFirstElement);

      if (Modal.hash) {
        history.replaceState("", document.title, window.location.pathname);
      }

      if (modal.onClose) {
        modal.onClose();
      }
      _u.fireCustomEvent('wtc-modal-close', { modal: this });
    }

    return modal;
  }

  /**
   * 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
   * @static
   * 
   * @example
   * const triggerButton = document.querySelector('trigger');
   * const testContent = '<p>Some sample content!</p>';
   * 
   * triggerButton.addEventListener('click', () => {
   *   // Passing `this` as the third argument sets our trigger as the focused item once the Modal closes.
   *   myModal.open(testContent, 'test-modal-class', this);
   * });
   * 
   * @param {string|HTMLElement} content - String or DOMNode to be added as the modal content.
   * @param {string} optionalClass - Optional CSS class to add to the modal element.
   * @param {HTMLElement} [focusOnClose] - Element which will receive focus after the modal is closed. Typically, this will be the element which triggered the modal in the first place.
   *
   * @return {Class} Modal instance
   */
  static open(content, optionalClass, focusOnClose) {
    const modal = Modal.instance;

    if(!modal.state) {
      if(content) {
        if (typeof content == 'string') {
          modal.modalContent.innerHTML = content;
        } else {
          modal.modalContent.appendChild(content);
        }
        
        if (focusOnClose && focusOnClose instanceof HTMLElement) {
          modal.focusOnClose = focusOnClose;
        }
      }

      if (optionalClass) {
        modal.modal.classList.add(optionalClass);
        modal.optionalClass = optionalClass;
      }

      // This is here to avoid a flash of content for the first time
      let delay = modal.appended ? 0 : 100;
      if (!modal.appended) modal.appended = true;
      setTimeout(()=> {
        modal.modal.classList.add(modal.classNameOpen);
      }, delay);

      modal.state = true;
      
      Modal.focusFirstElement();

      if (modal.onOpen) modal.onOpen();

      this.onKeyDown = (e) => {
        if (e.keyCode == 27) {
          Modal.close();
          document.removeEventListener('keydown', this.onKeyDown, false);
        }
      }
      document.addEventListener('keydown', this.onKeyDown, false);
      modal.modalFocusEnd.addEventListener('focus', Modal.focusFirstElement);

      _u.fireCustomEvent('wtc-modal-open', { modal: this });
    }

    return modal;
  }

  /**
   * Sets a callback to run when the modal opens
   * @static
   * @param {Function} callback - Callback function
   * @return {Class} Modal instance
   */
  static set onOpen(callback = null) {
    if (typeof callback == 'function') {
      this.instance.onOpen = callback;
    } else {
      throw 'Must be a function';
    }

    return this.instance;
  }

  /**
   * Sets a callback to run when the modal closes
   * @static
   * @param {Function} callback - Callback function
   * @return {Class} Modal instance
   */
  static set onClose(callback = null) {
    if (typeof callback == 'function') {
      this.instance.onClose = callback;
    } else {
      throw 'Must be a function';
    }

    return this.instance;
  }
  
  /**
   * Shifts focus to the very beginning of the modal element—just before the close button.
   * @static
   */
  static focusFirstElement() {
    Modal.instance.modalFocusStart.focus();
  }

  /**
   * Gets the modal DOM element
   * @return {HTMLElement}
   */
  static get modal() {
    return this.instance.modal;
  }

  /**
   * Gets the modal content DOM element
   * @return {HTMLElement}
   */
  static get modalContent() {
    return this.instance.modalContent;
  }

  /**
   * Gets the main Modal instance
   * @return {Class}
   */
  static get instance() {
    if(!instance) {
      instance = new Modal();
    }

    return instance;
  }
}

export default Modal;