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/');