modal.js

import { queryAll, queryOne } from '@ecl/dom-utils';
import { createFocusTrap } from 'focus-trap';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.toggleSelector Selector for the modal toggle
 * @param {String} options.closeSelector Selector for closing the modal
 * @param {Boolean} options.attachClickListener Whether or not to bind click events on toggle
 * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
 */
export class Modal {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {Modal} An instance of Modal.
   */
  static autoInit(root, { MODAL: defaultOptions = {} } = {}) {
    const modal = new Modal(root, defaultOptions);
    modal.init();
    root.ECLModal = modal;
    return modal;
  }

  constructor(
    element,
    {
      toggleSelector = '',
      closeSelector = '[data-ecl-modal-close]',
      scrollSelector = '[data-ecl-modal-scroll]',
      attachClickListener = true,
      attachKeyListener = true,
    } = {},
  ) {
    // Check element
    if (!element || element.nodeType !== Node.ELEMENT_NODE) {
      throw new TypeError(
        'DOM element should be given to initialize this widget.',
      );
    }

    this.element = element;

    // Options
    this.toggleSelector = toggleSelector;
    this.closeSelector = closeSelector;
    this.scrollSelector = scrollSelector;
    this.attachClickListener = attachClickListener;
    this.attachKeyListener = attachKeyListener;

    // Private variables
    this.toggle = null;
    this.close = null;
    this.scroll = null;
    this.focusTrap = null;

    // Bind `this` for use in callbacks
    this.openModal = this.openModal.bind(this);
    this.closeModal = this.closeModal.bind(this);
    this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
    this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  }

  /**
   * Initialise component.
   */
  init() {
    if (!ECL) {
      throw new TypeError('Called init but ECL is not present');
    }
    ECL.components = ECL.components || new Map();
    // Bind global events
    if (this.attachKeyListener) {
      document.addEventListener('keyup', this.handleKeyboardGlobal);
    }

    // Get toggle element
    if (this.toggleSelector === '') {
      this.toggleSelector = `#${this.element.getAttribute(
        'data-ecl-modal-toggle',
      )}`;
    }
    this.toggle = document.querySelector(this.toggleSelector);

    // Apply aria to toggle
    if (this.toggle) {
      this.toggle.setAttribute('aria-controls', this.element.id);
      if (!this.toggle.getAttribute('aria-haspopup')) {
        this.toggle.setAttribute('aria-haspopup', 'dialog');
      }
    }

    // Get other elements
    this.close = queryAll(this.closeSelector, this.element);
    this.scroll = queryOne(this.scrollSelector, this.element);

    // Create focus trap
    this.focusTrap = createFocusTrap(this.element);

    // Polyfill to support <dialog>
    this.isDialogSupported = true;
    if (!window.HTMLDialogElement) {
      this.isDialogSupported = false;
    }

    // Bind click event on toggle
    if (this.toggle && this.attachClickListener) {
      this.toggle.addEventListener('click', this.handleClickOnToggle);
    }

    // Bind click event on close buttons
    if (this.close && this.attachClickListener) {
      this.close.forEach((close) => {
        close.addEventListener('click', this.closeModal);
      });
    }

    // Set ecl initialized attribute
    this.element.setAttribute('data-ecl-auto-initialized', 'true');
    ECL.components.set(this.element, this);
  }

  /**
   * Destroy component.
   */
  destroy() {
    if (this.toggle && this.attachClickListener) {
      this.toggle.removeEventListener('click', this.handleClickOnToggle);
    }

    if (this.attachKeyListener) {
      document.removeEventListener('keyup', this.handleKeyboardGlobal);
    }

    if (this.close && this.attachClickListener) {
      this.close.forEach((close) => {
        close.removeEventListener('click', this.closeModal);
      });
    }

    this.element.removeAttribute('data-ecl-auto-initialized');
    ECL.components.delete(this.element);
  }

  /**
   * Check if there is a scroll and display overflow.
   */
  checkScroll() {
    if (!this.scroll) return;

    this.scroll.parentNode.classList.remove('ecl-modal__body--has-scroll');
    if (this.scroll.scrollHeight > this.scroll.clientHeight) {
      this.scroll.parentNode.classList.add('ecl-modal__body--has-scroll');
    }
  }

  /**
   * Toggles between collapsed/expanded states.
   *
   * @param {Event} e
   */
  handleClickOnToggle(e) {
    e.preventDefault();

    // Get current status
    const isExpanded = this.toggle.getAttribute('aria-expanded') === 'true';

    // Toggle the modal
    if (isExpanded) {
      this.closeModal();
      return;
    }

    this.openModal();
  }

  /**
   * Open the modal.
   */
  openModal() {
    if (this.isDialogSupported) {
      this.element.showModal();
    } else {
      this.element.setAttribute('open', '');
    }

    // Check scroll
    this.checkScroll();

    // Trap focus
    this.focusTrap.activate();
  }

  /**
   * Close the modal.
   */
  closeModal() {
    if (this.isDialogSupported) {
      this.element.close();
    } else {
      this.element.removeAttribute('open');
    }

    // Untrap focus
    if (this.focusTrap.active) {
      this.focusTrap.deactivate();
    }
  }

  /**
   * Handles global keyboard events, triggered outside of the modal.
   *
   * @param {Event} e
   */
  handleKeyboardGlobal(e) {
    // Detect press on Escape
    if (e.key === 'Escape' || e.key === 'Esc') {
      this.closeModal();
    }
  }
}

export default Modal;