modal.js

I’ll give you a strong inc/js/modal.js file for RX Theme with accessible modal open/close, focus trap, ESC close, overlay close, scroll lock, YouTube/video cleanup, AJAX/content hooks, and WordPress-friendly custom events.

Below is a complete advanced rx-theme/inc/js/modal.js file.

/*!
 * RX Theme Modal System
 * File: inc/js/modal.js
 * Theme: RX Theme
 * Author: RxHarun
 *
 * Features:
 * - Multiple modals
 * - Open by data attribute
 * - Close by button, overlay, ESC
 * - Focus trap for accessibility
 * - Auto focus first input/button
 * - Body scroll lock
 * - Return focus to opener
 * - YouTube / iframe / video stop on close
 * - AJAX-ready content hooks
 * - Custom events for developers
 * - WordPress theme friendly
 */

(function () {
  'use strict';

  const RXModal = {
    activeModal: null,
    activeTrigger: null,
    lastFocusedElement: null,
    scrollY: 0,
    initialized: false,

    selectors: {
      modal: '[data-rx-modal]',
      openTrigger: '[data-rx-modal-open]',
      closeTrigger: '[data-rx-modal-close]',
      overlay: '[data-rx-modal-overlay]',
      content: '[data-rx-modal-content]',
      focusable:
        'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'
    },

    classes: {
      active: 'rx-modal-active',
      bodyOpen: 'rx-modal-open',
      opening: 'rx-modal-opening',
      closing: 'rx-modal-closing'
    },

    settings: {
      closeOnOverlay: true,
      closeOnEsc: true,
      trapFocus: true,
      lockScroll: true,
      autoFocus: true,
      stopMediaOnClose: true,
      animationDuration: 250
    },

    init() {
      if (this.initialized) return;

      this.bindEvents();
      this.prepareModals();

      this.initialized = true;

      document.dispatchEvent(
        new CustomEvent('rxModal:init', {
          detail: { instance: this }
        })
      );
    },

    prepareModals() {
      const modals = document.querySelectorAll(this.selectors.modal);

      modals.forEach((modal) => {
        if (!modal.hasAttribute('role')) {
          modal.setAttribute('role', 'dialog');
        }

        if (!modal.hasAttribute('aria-modal')) {
          modal.setAttribute('aria-modal', 'true');
        }

        if (!modal.hasAttribute('aria-hidden')) {
          modal.setAttribute('aria-hidden', 'true');
        }

        if (!modal.hasAttribute('tabindex')) {
          modal.setAttribute('tabindex', '-1');
        }
      });
    },

    bindEvents() {
      document.addEventListener('click', (event) => {
        const openBtn = event.target.closest(this.selectors.openTrigger);
        const closeBtn = event.target.closest(this.selectors.closeTrigger);
        const overlay = event.target.closest(this.selectors.overlay);

        if (openBtn) {
          event.preventDefault();

          const modalId = openBtn.getAttribute('data-rx-modal-open');
          this.open(modalId, openBtn);
          return;
        }

        if (closeBtn) {
          event.preventDefault();
          this.close();
          return;
        }

        if (
          overlay &&
          this.settings.closeOnOverlay &&
          this.activeModal &&
          overlay === event.target
        ) {
          this.close();
        }
      });

      document.addEventListener('keydown', (event) => {
        if (!this.activeModal) return;

        if (event.key === 'Escape' && this.settings.closeOnEsc) {
          event.preventDefault();
          this.close();
        }

        if (event.key === 'Tab' && this.settings.trapFocus) {
          this.handleFocusTrap(event);
        }
      });

      window.addEventListener('resize', () => {
        if (this.activeModal) {
          this.dispatchModalEvent('resize', this.activeModal);
        }
      });

      document.addEventListener('rxModal:openById', (event) => {
        if (event.detail && event.detail.id) {
          this.open(event.detail.id);
        }
      });

      document.addEventListener('rxModal:closeActive', () => {
        this.close();
      });
    },

    open(modalId, trigger = null) {
      if (!modalId) return;

      const modal = document.getElementById(modalId);

      if (!modal) {
        console.warn(`RX Modal: Modal with ID "${modalId}" not found.`);
        return;
      }

      if (this.activeModal && this.activeModal !== modal) {
        this.close(false);
      }

      this.activeModal = modal;
      this.activeTrigger = trigger;
      this.lastFocusedElement = document.activeElement;

      this.dispatchModalEvent('beforeOpen', modal);

      if (this.settings.lockScroll) {
        this.lockBodyScroll();
      }

      modal.classList.add(this.classes.opening);
      modal.classList.add(this.classes.active);
      modal.setAttribute('aria-hidden', 'false');

      document.body.classList.add(this.classes.bodyOpen);

      window.setTimeout(() => {
        modal.classList.remove(this.classes.opening);

        if (this.settings.autoFocus) {
          this.focusFirstElement(modal);
        }

        this.dispatchModalEvent('afterOpen', modal);
      }, this.settings.animationDuration);
    },

    close(restoreFocus = true) {
      if (!this.activeModal) return;

      const modal = this.activeModal;

      this.dispatchModalEvent('beforeClose', modal);

      modal.classList.add(this.classes.closing);

      window.setTimeout(() => {
        modal.classList.remove(this.classes.active);
        modal.classList.remove(this.classes.closing);
        modal.setAttribute('aria-hidden', 'true');

        document.body.classList.remove(this.classes.bodyOpen);

        if (this.settings.stopMediaOnClose) {
          this.stopMedia(modal);
        }

        if (this.settings.lockScroll) {
          this.unlockBodyScroll();
        }

        if (restoreFocus) {
          this.restoreFocus();
        }

        this.dispatchModalEvent('afterClose', modal);

        this.activeModal = null;
        this.activeTrigger = null;
      }, this.settings.animationDuration);
    },

    toggle(modalId, trigger = null) {
      const modal = document.getElementById(modalId);

      if (!modal) return;

      if (modal.classList.contains(this.classes.active)) {
        this.close();
      } else {
        this.open(modalId, trigger);
      }
    },

    handleFocusTrap(event) {
      const modal = this.activeModal;
      const focusableElements = this.getFocusableElements(modal);

      if (!focusableElements.length) {
        event.preventDefault();
        modal.focus();
        return;
      }

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      if (event.shiftKey) {
        if (document.activeElement === firstElement || document.activeElement === modal) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    },

    getFocusableElements(container) {
      if (!container) return [];

      return Array.from(container.querySelectorAll(this.selectors.focusable)).filter(
        (element) => {
          return (
            element.offsetWidth > 0 ||
            element.offsetHeight > 0 ||
            element === document.activeElement
          );
        }
      );
    },

    focusFirstElement(modal) {
      const focusableElements = this.getFocusableElements(modal);

      if (focusableElements.length) {
        focusableElements[0].focus();
      } else {
        modal.focus();
      }
    },

    restoreFocus() {
      if (
        this.lastFocusedElement &&
        typeof this.lastFocusedElement.focus === 'function'
      ) {
        this.lastFocusedElement.focus();
      } else if (
        this.activeTrigger &&
        typeof this.activeTrigger.focus === 'function'
      ) {
        this.activeTrigger.focus();
      }
    },

    lockBodyScroll() {
      this.scrollY = window.scrollY || document.documentElement.scrollTop;

      document.body.style.position = 'fixed';
      document.body.style.top = `-${this.scrollY}px`;
      document.body.style.left = '0';
      document.body.style.right = '0';
      document.body.style.width = '100%';
    },

    unlockBodyScroll() {
      document.body.style.position = '';
      document.body.style.top = '';
      document.body.style.left = '';
      document.body.style.right = '';
      document.body.style.width = '';

      window.scrollTo(0, this.scrollY);
    },

    stopMedia(modal) {
      const videos = modal.querySelectorAll('video, audio');

      videos.forEach((media) => {
        try {
          media.pause();
          media.currentTime = 0;
        } catch (error) {
          console.warn('RX Modal: Could not stop media.', error);
        }
      });

      const iframes = modal.querySelectorAll('iframe');

      iframes.forEach((iframe) => {
        const src = iframe.getAttribute('src');

        if (src) {
          iframe.setAttribute('src', '');
          iframe.setAttribute('src', src);
        }
      });
    },

    setContent(modalId, html) {
      const modal = document.getElementById(modalId);

      if (!modal) return;

      const content = modal.querySelector(this.selectors.content) || modal;

      content.innerHTML = html;

      this.dispatchModalEvent('contentUpdated', modal);
    },

    appendContent(modalId, html) {
      const modal = document.getElementById(modalId);

      if (!modal) return;

      const content = modal.querySelector(this.selectors.content) || modal;

      content.insertAdjacentHTML('beforeend', html);

      this.dispatchModalEvent('contentAppended', modal);
    },

    clearContent(modalId) {
      const modal = document.getElementById(modalId);

      if (!modal) return;

      const content = modal.querySelector(this.selectors.content);

      if (content) {
        content.innerHTML = '';
      }

      this.dispatchModalEvent('contentCleared', modal);
    },

    loadAjaxContent(modalId, url, options = {}) {
      const modal = document.getElementById(modalId);

      if (!modal || !url) return;

      const content = modal.querySelector(this.selectors.content) || modal;

      content.classList.add('rx-modal-loading');

      this.dispatchModalEvent('ajaxBeforeLoad', modal);

      fetch(url, {
        method: options.method || 'GET',
        headers: options.headers || {
          'X-Requested-With': 'XMLHttpRequest'
        },
        credentials: options.credentials || 'same-origin'
      })
        .then((response) => {
          if (!response.ok) {
            throw new Error(`HTTP error: ${response.status}`);
          }

          return response.text();
        })
        .then((html) => {
          content.innerHTML = html;
          content.classList.remove('rx-modal-loading');

          this.dispatchModalEvent('ajaxAfterLoad', modal);
        })
        .catch((error) => {
          content.classList.remove('rx-modal-loading');

          console.error('RX Modal AJAX Error:', error);

          this.dispatchModalEvent('ajaxError', modal, {
            error: error
          });
        });
    },

    dispatchModalEvent(name, modal, extraDetail = {}) {
      if (!modal) return;

      const event = new CustomEvent(`rxModal:${name}`, {
        bubbles: true,
        detail: {
          modal,
          modalId: modal.id || null,
          instance: this,
          ...extraDetail
        }
      });

      modal.dispatchEvent(event);
      document.dispatchEvent(event);
    },

    updateSettings(newSettings = {}) {
      this.settings = {
        ...this.settings,
        ...newSettings
      };

      document.dispatchEvent(
        new CustomEvent('rxModal:settingsUpdated', {
          detail: {
            settings: this.settings
          }
        })
      );
    },

    getActiveModal() {
      return this.activeModal;
    },

    isOpen(modalId) {
      const modal = document.getElementById(modalId);

      if (!modal) return false;

      return modal.classList.contains(this.classes.active);
    },

    closeAll() {
      if (this.activeModal) {
        this.close();
      }
    }
  };

  window.RXModal = RXModal;

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => RXModal.init());
  } else {
    RXModal.init();
  }
})();

Use this example HTML in your theme template:

<button type="button" data-rx-modal-open="rx-search-modal">
  Open Search
</button>

<div
  id="rx-search-modal"
  class="rx-modal"
  data-rx-modal
  aria-labelledby="rx-search-modal-title"
  aria-hidden="true"
>
  <div class="rx-modal__overlay" data-rx-modal-overlay>
    <div class="rx-modal__box" data-rx-modal-content>
      <button type="button" class="rx-modal__close" data-rx-modal-close>
        ×
      </button>

      <h2 id="rx-search-modal-title">Search RX Theme</h2>

      <form role="search" method="get" action="/">
        <input type="search" name="s" placeholder="Search articles..." />
        <button type="submit">Search</button>
      </form>
    </div>
  </div>
</div>

Basic CSS needed:

.rx-modal {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 999999;
}

.rx-modal.rx-modal-active {
  display: block;
}

.rx-modal__overlay {
  min-height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.65);
}

.rx-modal__box {
  width: min(720px, 100%);
  max-height: 90vh;
  overflow: auto;
  background: #fff;
  border-radius: 16px;
  padding: 24px;
  position: relative;
}

.rx-modal__close {
  position: absolute;
  top: 12px;
  right: 12px;
  cursor: pointer;
}

.rx-modal-open {
  overflow: hidden;
}

.rx-modal-loading {
  opacity: 0.6;
  pointer-events: none;
}

Add this in your functions.php or enqueue file:

function rx_theme_enqueue_modal_script() {
    wp_enqueue_script(
        'rx-theme-modal',
        get_template_directory_uri() . '/inc/js/modal.js',
        array(),
        wp_get_theme()->get( 'Version' ),
        true
    );
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_modal_script' );

You can open a modal manually with JavaScript:

RXModal.open('rx-search-modal');

You can close the active modal manually:

RXModal.close();

You can update modal content dynamically:

RXModal.setContent('rx-search-modal', '<h2>Hello RX Theme</h2><p>Dynamic modal content loaded.</p>');

You can load AJAX content:

RXModal.loadAjaxContent('rx-search-modal', '/sample-page/');

Leave a Reply

Your email address will not be published. Required fields are marked *