I’ll build this as a strong RX Theme front-end inc/js/app.js file: modular, safe, WordPress-friendly, and full of optional features that only run when matching HTML exists.
My dear friend, create this file:
rx-theme/inc/js/app.js
Then enqueue it from functions.php using your theme script handle. This app.js is advanced, but safe: every feature checks whether the required HTML exists before running.
/*!
* RX Theme - Main App JavaScript
* File: inc/js/app.js
* Theme: RX Theme
* Author: RxHarun
*
* Purpose:
* - Mobile menu
* - Sticky header
* - Scroll progress
* - Back to top
* - Dark mode
* - Search overlay
* - Dropdown menu accessibility
* - Table of contents
* - Lazy loading helpers
* - Copy code button
* - Accordion
* - Tabs
* - Modal
* - Notice dismiss
* - FAQ schema UI helpers
* - Reading time
* - Smooth scroll
* - External link handling
* - Form validation
* - Comment form helpers
* - AJAX-ready helpers
*/
(function () {
'use strict';
/**
* RX Theme global namespace.
*/
const RX = {
version: '1.0.0',
body: document.body,
html: document.documentElement,
win: window,
doc: document,
settings: {
activeClass: 'is-active',
openClass: 'is-open',
hiddenClass: 'is-hidden',
loadedClass: 'is-loaded',
fixedClass: 'is-fixed',
stickyClass: 'is-sticky',
darkClass: 'rx-dark-mode',
noScrollClass: 'rx-no-scroll',
focusClass: 'rx-focus-visible',
animationClass: 'rx-animate-in',
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
},
selectors: {
siteHeader: '.rx-site-header',
mobileToggle: '.rx-menu-toggle',
mobileMenu: '.rx-mobile-menu',
mainNavigation: '.rx-main-navigation',
dropdownToggle: '.menu-item-has-children > a, .rx-dropdown-toggle',
searchToggle: '.rx-search-toggle',
searchOverlay: '.rx-search-overlay',
searchClose: '.rx-search-close',
darkToggle: '.rx-dark-toggle',
scrollTop: '.rx-scroll-top',
progressBar: '.rx-reading-progress',
toc: '.rx-table-of-contents',
content: '.entry-content, .rx-content, article',
lazy: '[data-rx-lazy]',
accordion: '[data-rx-accordion]',
accordionTrigger: '[data-rx-accordion-trigger]',
tabs: '[data-rx-tabs]',
tabButton: '[data-rx-tab-button]',
tabPanel: '[data-rx-tab-panel]',
modalTrigger: '[data-rx-modal-open]',
modalClose: '[data-rx-modal-close]',
modal: '[data-rx-modal]',
dismiss: '[data-rx-dismiss]',
copyCode: 'pre code',
readingTime: '[data-rx-reading-time]',
smoothLink: 'a[href^="#"]',
externalLinks: 'a[href^="http"]',
form: 'form[data-rx-validate]',
commentForm: '#commentform'
}
};
/**
* Utility helpers
*/
RX.utils = {
qs(selector, scope = document) {
return scope.querySelector(selector);
},
qsa(selector, scope = document) {
return Array.prototype.slice.call(scope.querySelectorAll(selector));
},
exists(selector, scope = document) {
return !!scope.querySelector(selector);
},
on(element, event, handler, options) {
if (!element) return;
element.addEventListener(event, handler, options || false);
},
off(element, event, handler, options) {
if (!element) return;
element.removeEventListener(event, handler, options || false);
},
addClass(element, className) {
if (element && className) element.classList.add(className);
},
removeClass(element, className) {
if (element && className) element.classList.remove(className);
},
toggleClass(element, className, force) {
if (element && className) element.classList.toggle(className, force);
},
hasClass(element, className) {
return element && element.classList.contains(className);
},
attr(element, name, value) {
if (!element) return null;
if (typeof value === 'undefined') return element.getAttribute(name);
element.setAttribute(name, value);
return value;
},
removeAttr(element, name) {
if (element) element.removeAttribute(name);
},
debounce(fn, delay = 200) {
let timer;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
},
throttle(fn, limit = 100) {
let waiting = false;
return function throttled(...args) {
if (!waiting) {
fn.apply(this, args);
waiting = true;
setTimeout(() => {
waiting = false;
}, limit);
}
};
},
ready(callback) {
if (document.readyState !== 'loading') {
callback();
} else {
document.addEventListener('DOMContentLoaded', callback);
}
},
isVisible(element) {
if (!element) return false;
return !!(
element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length
);
},
getFocusable(container) {
if (!container) return [];
const selectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
return RX.utils.qsa(selectors.join(','), container).filter((el) => {
return RX.utils.isVisible(el);
});
},
lockScroll() {
RX.body.classList.add(RX.settings.noScrollClass);
},
unlockScroll() {
RX.body.classList.remove(RX.settings.noScrollClass);
},
storage: {
get(key, fallback = null) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : value;
} catch (error) {
return fallback;
}
},
set(key, value) {
try {
localStorage.setItem(key, value);
} catch (error) {
return false;
}
return true;
},
remove(key) {
try {
localStorage.removeItem(key);
} catch (error) {
return false;
}
return true;
}
},
createId(prefix = 'rx-id') {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
},
escapeHTML(value) {
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}
};
/**
* Mark JS active.
*/
RX.jsEnabled = function () {
RX.html.classList.remove('no-js');
RX.html.classList.add('js');
RX.body.classList.add('rx-js-ready');
};
/**
* Mobile menu.
*
* Expected HTML:
* <button class="rx-menu-toggle" aria-controls="rx-mobile-menu" aria-expanded="false">Menu</button>
* <nav id="rx-mobile-menu" class="rx-mobile-menu"></nav>
*/
RX.mobileMenu = function () {
const toggle = RX.utils.qs(RX.selectors.mobileToggle);
const menu = RX.utils.qs(RX.selectors.mobileMenu);
if (!toggle || !menu) return;
const openMenu = () => {
toggle.setAttribute('aria-expanded', 'true');
menu.classList.add(RX.settings.openClass);
RX.body.classList.add('rx-menu-open');
RX.utils.lockScroll();
};
const closeMenu = () => {
toggle.setAttribute('aria-expanded', 'false');
menu.classList.remove(RX.settings.openClass);
RX.body.classList.remove('rx-menu-open');
RX.utils.unlockScroll();
};
const toggleMenu = () => {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
expanded ? closeMenu() : openMenu();
};
RX.utils.on(toggle, 'click', toggleMenu);
RX.utils.on(document, 'keydown', function (event) {
if (event.key === 'Escape') {
closeMenu();
}
});
RX.utils.on(document, 'click', function (event) {
const isInside = menu.contains(event.target) || toggle.contains(event.target);
if (!isInside && menu.classList.contains(RX.settings.openClass)) {
closeMenu();
}
});
RX.utils.on(window, 'resize', RX.utils.debounce(function () {
if (window.innerWidth > 1024) {
closeMenu();
}
}, 150));
};
/**
* Dropdown menu accessibility.
*/
RX.dropdownMenu = function () {
const dropdownLinks = RX.utils.qsa(RX.selectors.dropdownToggle);
if (!dropdownLinks.length) return;
dropdownLinks.forEach((link) => {
const parent = link.parentElement;
const submenu = parent ? parent.querySelector('ul, .sub-menu') : null;
if (!parent || !submenu) return;
if (!link.id) {
link.id = RX.utils.createId('rx-dropdown-link');
}
const button = document.createElement('button');
button.className = 'rx-submenu-toggle';
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', submenu.id || RX.utils.createId('rx-submenu'));
button.setAttribute('aria-label', 'Toggle submenu');
button.innerHTML = '<span aria-hidden="true">+</span>';
if (!submenu.id) {
submenu.id = button.getAttribute('aria-controls');
}
parent.insertBefore(button, submenu);
RX.utils.on(button, 'click', function () {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
parent.classList.toggle(RX.settings.openClass, !expanded);
submenu.classList.toggle(RX.settings.openClass, !expanded);
});
RX.utils.on(parent, 'mouseenter', function () {
if (window.innerWidth > 1024) {
parent.classList.add(RX.settings.openClass);
}
});
RX.utils.on(parent, 'mouseleave', function () {
if (window.innerWidth > 1024) {
parent.classList.remove(RX.settings.openClass);
}
});
});
RX.utils.on(document, 'keydown', function (event) {
if (event.key !== 'Escape') return;
RX.utils.qsa('.menu-item-has-children.is-open').forEach((item) => {
item.classList.remove(RX.settings.openClass);
const button = item.querySelector('.rx-submenu-toggle');
const submenu = item.querySelector('.sub-menu, ul');
if (button) button.setAttribute('aria-expanded', 'false');
if (submenu) submenu.classList.remove(RX.settings.openClass);
});
});
};
/**
* Sticky header.
*/
RX.stickyHeader = function () {
const header = RX.utils.qs(RX.selectors.siteHeader);
if (!header) return;
let lastScroll = window.scrollY;
const updateHeader = RX.utils.throttle(function () {
const currentScroll = window.scrollY;
header.classList.toggle(RX.settings.stickyClass, currentScroll > 20);
header.classList.toggle('rx-scroll-down', currentScroll > lastScroll && currentScroll > 120);
header.classList.toggle('rx-scroll-up', currentScroll < lastScroll);
lastScroll = currentScroll <= 0 ? 0 : currentScroll;
}, 80);
updateHeader();
RX.utils.on(window, 'scroll', updateHeader, { passive: true });
};
/**
* Scroll reading progress bar.
*
* Expected:
* <div class="rx-reading-progress"></div>
*/
RX.readingProgress = function () {
const bar = RX.utils.qs(RX.selectors.progressBar);
if (!bar) return;
const updateProgress = RX.utils.throttle(function () {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
bar.style.width = `${Math.min(100, Math.max(0, progress))}%`;
bar.setAttribute('aria-valuenow', String(Math.round(progress)));
}, 50);
bar.setAttribute('role', 'progressbar');
bar.setAttribute('aria-valuemin', '0');
bar.setAttribute('aria-valuemax', '100');
updateProgress();
RX.utils.on(window, 'scroll', updateProgress, { passive: true });
RX.utils.on(window, 'resize', RX.utils.debounce(updateProgress, 150));
};
/**
* Back to top button.
*
* Expected:
* <button class="rx-scroll-top">Top</button>
*/
RX.scrollTop = function () {
const button = RX.utils.qs(RX.selectors.scrollTop);
if (!button) return;
const toggleButton = RX.utils.throttle(function () {
button.classList.toggle(RX.settings.activeClass, window.scrollY > 400);
}, 100);
RX.utils.on(button, 'click', function () {
window.scrollTo({
top: 0,
behavior: RX.settings.reducedMotion ? 'auto' : 'smooth'
});
});
toggleButton();
RX.utils.on(window, 'scroll', toggleButton, { passive: true });
};
/**
* Dark mode.
*
* Expected:
* <button class="rx-dark-toggle">Dark</button>
*/
RX.darkMode = function () {
const toggle = RX.utils.qs(RX.selectors.darkToggle);
const storageKey = 'rx-theme-color-mode';
const getSystemMode = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const applyMode = (mode) => {
const finalMode = mode === 'system' ? getSystemMode() : mode;
RX.html.classList.toggle(RX.settings.darkClass, finalMode === 'dark');
RX.html.setAttribute('data-rx-theme', finalMode);
if (toggle) {
toggle.setAttribute('aria-pressed', String(finalMode === 'dark'));
toggle.setAttribute('data-current-mode', finalMode);
}
};
const savedMode = RX.utils.storage.get(storageKey, 'system');
applyMode(savedMode);
if (!toggle) return;
RX.utils.on(toggle, 'click', function () {
const current = RX.utils.storage.get(storageKey, 'system');
let next = 'dark';
if (current === 'dark') {
next = 'light';
} else if (current === 'light') {
next = 'system';
}
RX.utils.storage.set(storageKey, next);
applyMode(next);
});
const media = window.matchMedia('(prefers-color-scheme: dark)');
if (media && typeof media.addEventListener === 'function') {
media.addEventListener('change', function () {
if (RX.utils.storage.get(storageKey, 'system') === 'system') {
applyMode('system');
}
});
}
};
/**
* Search overlay.
*
* Expected:
* <button class="rx-search-toggle">Search</button>
* <div class="rx-search-overlay">
* <button class="rx-search-close">Close</button>
* <input type="search">
* </div>
*/
RX.searchOverlay = function () {
const toggles = RX.utils.qsa(RX.selectors.searchToggle);
const overlay = RX.utils.qs(RX.selectors.searchOverlay);
const close = RX.utils.qs(RX.selectors.searchClose, overlay || document);
if (!toggles.length || !overlay) return;
const input = overlay.querySelector('input[type="search"], input[type="text"]');
const openSearch = () => {
overlay.classList.add(RX.settings.openClass);
overlay.setAttribute('aria-hidden', 'false');
RX.body.classList.add('rx-search-open');
RX.utils.lockScroll();
setTimeout(() => {
if (input) input.focus();
}, 80);
};
const closeSearch = () => {
overlay.classList.remove(RX.settings.openClass);
overlay.setAttribute('aria-hidden', 'true');
RX.body.classList.remove('rx-search-open');
RX.utils.unlockScroll();
};
toggles.forEach((toggle) => {
RX.utils.on(toggle, 'click', openSearch);
});
RX.utils.on(close, 'click', closeSearch);
RX.utils.on(document, 'keydown', function (event) {
if (event.key === 'Escape') {
closeSearch();
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
openSearch();
}
});
RX.utils.on(overlay, 'click', function (event) {
if (event.target === overlay) {
closeSearch();
}
});
};
/**
* Smooth scroll for anchor links.
*/
RX.smoothScroll = function () {
const links = RX.utils.qsa(RX.selectors.smoothLink);
if (!links.length) return;
links.forEach((link) => {
RX.utils.on(link, 'click', function (event) {
const hash = link.getAttribute('href');
if (!hash || hash === '#') return;
const target = document.querySelector(hash);
if (!target) return;
event.preventDefault();
target.scrollIntoView({
behavior: RX.settings.reducedMotion ? 'auto' : 'smooth',
block: 'start'
});
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
history.pushState(null, '', hash);
});
});
};
/**
* Table of contents generator.
*
* Expected:
* <nav class="rx-table-of-contents"></nav>
*/
RX.tableOfContents = function () {
const toc = RX.utils.qs(RX.selectors.toc);
const content = RX.utils.qs(RX.selectors.content);
if (!toc || !content) return;
const headings = RX.utils.qsa('h2, h3', content).filter((heading) => {
return heading.textContent.trim().length > 0;
});
if (headings.length < 2) {
toc.style.display = 'none';
return;
}
const list = document.createElement('ol');
list.className = 'rx-toc-list';
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `rx-heading-${index + 1}`;
}
const item = document.createElement('li');
item.className = `rx-toc-item rx-toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.textContent = heading.textContent.trim();
item.appendChild(link);
list.appendChild(item);
});
toc.innerHTML = '';
toc.appendChild(list);
const tocLinks = RX.utils.qsa('a', toc);
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
function (entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
tocLinks.forEach((link) => link.classList.remove(RX.settings.activeClass));
const active = toc.querySelector(`a[href="#${entry.target.id}"]`);
if (active) active.classList.add(RX.settings.activeClass);
});
},
{
rootMargin: '-20% 0px -70% 0px',
threshold: 0
}
);
headings.forEach((heading) => observer.observe(heading));
}
};
/**
* Lazy loading helper.
*
* Expected:
* <img data-rx-lazy data-src="image.jpg" alt="">
*/
RX.lazyLoad = function () {
const lazyItems = RX.utils.qsa(RX.selectors.lazy);
if (!lazyItems.length) return;
const loadItem = (item) => {
const src = item.getAttribute('data-src');
const srcset = item.getAttribute('data-srcset');
const bg = item.getAttribute('data-bg');
if (src) item.setAttribute('src', src);
if (srcset) item.setAttribute('srcset', srcset);
if (bg) item.style.backgroundImage = `url("${bg}")`;
item.classList.add(RX.settings.loadedClass);
item.removeAttribute('data-rx-lazy');
};
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
function (entries, obs) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
loadItem(entry.target);
obs.unobserve(entry.target);
});
},
{
rootMargin: '200px 0px'
}
);
lazyItems.forEach((item) => observer.observe(item));
} else {
lazyItems.forEach(loadItem);
}
};
/**
* Copy code button for code blocks.
*/
RX.copyCode = function () {
const codeBlocks = RX.utils.qsa(RX.selectors.copyCode);
if (!codeBlocks.length || !navigator.clipboard) return;
codeBlocks.forEach((code) => {
const pre = code.closest('pre');
if (!pre || pre.classList.contains('rx-copy-ready')) return;
pre.classList.add('rx-copy-ready');
const button = document.createElement('button');
button.type = 'button';
button.className = 'rx-copy-code-button';
button.textContent = 'Copy';
button.setAttribute('aria-label', 'Copy code');
pre.appendChild(button);
RX.utils.on(button, 'click', async function () {
try {
await navigator.clipboard.writeText(code.innerText);
button.textContent = 'Copied';
button.classList.add(RX.settings.activeClass);
setTimeout(() => {
button.textContent = 'Copy';
button.classList.remove(RX.settings.activeClass);
}, 1800);
} catch (error) {
button.textContent = 'Failed';
setTimeout(() => {
button.textContent = 'Copy';
}, 1800);
}
});
});
};
/**
* Accordion.
*
* Expected:
* <div data-rx-accordion>
* <button data-rx-accordion-trigger>Question</button>
* <div>Answer</div>
* </div>
*/
RX.accordion = function () {
const accordions = RX.utils.qsa(RX.selectors.accordion);
if (!accordions.length) return;
accordions.forEach((accordion) => {
const triggers = RX.utils.qsa(RX.selectors.accordionTrigger, accordion);
triggers.forEach((trigger) => {
const panel = trigger.nextElementSibling;
if (!panel) return;
const triggerId = trigger.id || RX.utils.createId('rx-accordion-trigger');
const panelId = panel.id || RX.utils.createId('rx-accordion-panel');
trigger.id = triggerId;
panel.id = panelId;
trigger.setAttribute('aria-controls', panelId);
trigger.setAttribute('aria-expanded', 'false');
panel.setAttribute('role', 'region');
panel.setAttribute('aria-labelledby', triggerId);
panel.hidden = true;
RX.utils.on(trigger, 'click', function () {
const expanded = trigger.getAttribute('aria-expanded') === 'true';
const allowMultiple = accordion.getAttribute('data-rx-accordion') === 'multiple';
if (!allowMultiple) {
triggers.forEach((otherTrigger) => {
const otherPanel = otherTrigger.nextElementSibling;
otherTrigger.setAttribute('aria-expanded', 'false');
otherTrigger.classList.remove(RX.settings.activeClass);
if (otherPanel) {
otherPanel.hidden = true;
otherPanel.classList.remove(RX.settings.openClass);
}
});
}
trigger.setAttribute('aria-expanded', String(!expanded));
trigger.classList.toggle(RX.settings.activeClass, !expanded);
panel.hidden = expanded;
panel.classList.toggle(RX.settings.openClass, !expanded);
});
});
});
};
/**
* Tabs.
*
* Expected:
* <div data-rx-tabs>
* <button data-rx-tab-button data-tab="one">One</button>
* <div data-rx-tab-panel data-tab="one">Panel One</div>
* </div>
*/
RX.tabs = function () {
const tabGroups = RX.utils.qsa(RX.selectors.tabs);
if (!tabGroups.length) return;
tabGroups.forEach((group) => {
const buttons = RX.utils.qsa(RX.selectors.tabButton, group);
const panels = RX.utils.qsa(RX.selectors.tabPanel, group);
if (!buttons.length || !panels.length) return;
group.setAttribute('role', 'tablist');
buttons.forEach((button, index) => {
const key = button.getAttribute('data-tab') || String(index);
const panel = panels.find((item) => item.getAttribute('data-tab') === key) || panels[index];
if (!panel) return;
const buttonId = button.id || RX.utils.createId('rx-tab');
const panelId = panel.id || RX.utils.createId('rx-tab-panel');
button.id = buttonId;
panel.id = panelId;
button.setAttribute('role', 'tab');
button.setAttribute('aria-controls', panelId);
button.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
button.setAttribute('tabindex', index === 0 ? '0' : '-1');
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', buttonId);
panel.hidden = index !== 0;
RX.utils.on(button, 'click', function () {
buttons.forEach((btn) => {
btn.setAttribute('aria-selected', 'false');
btn.setAttribute('tabindex', '-1');
btn.classList.remove(RX.settings.activeClass);
});
panels.forEach((p) => {
p.hidden = true;
p.classList.remove(RX.settings.activeClass);
});
button.setAttribute('aria-selected', 'true');
button.setAttribute('tabindex', '0');
button.classList.add(RX.settings.activeClass);
panel.hidden = false;
panel.classList.add(RX.settings.activeClass);
});
RX.utils.on(button, 'keydown', function (event) {
const currentIndex = buttons.indexOf(button);
let nextIndex = currentIndex;
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % buttons.length;
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
if (nextIndex !== currentIndex) {
event.preventDefault();
buttons[nextIndex].focus();
buttons[nextIndex].click();
}
});
});
});
};
/**
* Modal.
*
* Expected:
* <button data-rx-modal-open="my-modal">Open</button>
* <div data-rx-modal="my-modal">
* <button data-rx-modal-close>Close</button>
* </div>
*/
RX.modal = function () {
const triggers = RX.utils.qsa(RX.selectors.modalTrigger);
const modals = RX.utils.qsa(RX.selectors.modal);
if (!triggers.length || !modals.length) return;
let lastFocused = null;
const getModal = (name) => {
return modals.find((modal) => modal.getAttribute('data-rx-modal') === name);
};
const openModal = (modal) => {
if (!modal) return;
lastFocused = document.activeElement;
modal.classList.add(RX.settings.openClass);
modal.setAttribute('aria-hidden', 'false');
RX.utils.lockScroll();
const focusable = RX.utils.getFocusable(modal);
if (focusable.length) {
focusable[0].focus();
}
};
const closeModal = (modal) => {
if (!modal) return;
modal.classList.remove(RX.settings.openClass);
modal.setAttribute('aria-hidden', 'true');
RX.utils.unlockScroll();
if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
};
modals.forEach((modal) => {
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
RX.utils.qsa(RX.selectors.modalClose, modal).forEach((close) => {
RX.utils.on(close, 'click', () => closeModal(modal));
});
RX.utils.on(modal, 'click', function (event) {
if (event.target === modal) {
closeModal(modal);
}
});
RX.utils.on(modal, 'keydown', function (event) {
if (event.key !== 'Tab') return;
const focusable = RX.utils.getFocusable(modal);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
});
triggers.forEach((trigger) => {
RX.utils.on(trigger, 'click', function () {
const name = trigger.getAttribute('data-rx-modal-open');
openModal(getModal(name));
});
});
RX.utils.on(document, 'keydown', function (event) {
if (event.key !== 'Escape') return;
modals.forEach((modal) => {
if (modal.classList.contains(RX.settings.openClass)) {
closeModal(modal);
}
});
});
};
/**
* Dismissible notices.
*
* Expected:
* <div class="notice">
* <button data-rx-dismiss>Dismiss</button>
* </div>
*/
RX.dismissible = function () {
const buttons = RX.utils.qsa(RX.selectors.dismiss);
if (!buttons.length) return;
buttons.forEach((button) => {
RX.utils.on(button, 'click', function () {
const targetSelector = button.getAttribute('data-rx-dismiss');
const target = targetSelector
? document.querySelector(targetSelector)
: button.closest('.notice, .rx-notice, .rx-alert, .rx-banner');
if (!target) return;
target.classList.add(RX.settings.hiddenClass);
const storageKey = target.getAttribute('data-rx-dismiss-key');
if (storageKey) {
RX.utils.storage.set(`rx-dismiss-${storageKey}`, '1');
}
});
});
RX.utils.qsa('[data-rx-dismiss-key]').forEach((item) => {
const key = item.getAttribute('data-rx-dismiss-key');
if (RX.utils.storage.get(`rx-dismiss-${key}`) === '1') {
item.classList.add(RX.settings.hiddenClass);
}
});
};
/**
* Reading time.
*
* Expected:
* <span data-rx-reading-time></span>
*/
RX.readingTime = function () {
const output = RX.utils.qs(RX.selectors.readingTime);
const content = RX.utils.qs(RX.selectors.content);
if (!output || !content) return;
const words = content.textContent.trim().split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.ceil(words / 220));
output.textContent = `${minutes} min read`;
output.setAttribute('datetime', `PT${minutes}M`);
};
/**
* External links improvement.
*/
RX.externalLinks = function () {
const currentHost = window.location.hostname;
const links = RX.utils.qsa(RX.selectors.externalLinks);
if (!links.length) return;
links.forEach((link) => {
try {
const url = new URL(link.href);
if (url.hostname !== currentHost) {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
if (!link.classList.contains('rx-no-external-icon')) {
link.classList.add('rx-external-link');
}
}
} catch (error) {
// Invalid URL. Ignore safely.
}
});
};
/**
* Image enhancements.
*/
RX.imageEnhancements = function () {
const images = RX.utils.qsa('img');
if (!images.length) return;
images.forEach((img) => {
if (!img.hasAttribute('loading')) {
img.setAttribute('loading', 'lazy');
}
if (!img.hasAttribute('decoding')) {
img.setAttribute('decoding', 'async');
}
RX.utils.on(img, 'load', function () {
img.classList.add('rx-image-loaded');
});
RX.utils.on(img, 'error', function () {
img.classList.add('rx-image-error');
});
});
};
/**
* Responsive tables.
*/
RX.responsiveTables = function () {
const content = RX.utils.qs(RX.selectors.content);
if (!content) return;
const tables = RX.utils.qsa('table', content);
tables.forEach((table) => {
if (table.closest('.rx-table-wrap')) return;
const wrapper = document.createElement('div');
wrapper.className = 'rx-table-wrap';
wrapper.setAttribute('tabindex', '0');
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
});
};
/**
* Form validation.
*
* Expected:
* <form data-rx-validate>
*/
RX.formValidation = function () {
const forms = RX.utils.qsa(RX.selectors.form);
if (!forms.length) return;
forms.forEach((form) => {
RX.utils.on(form, 'submit', function (event) {
let valid = true;
RX.utils.qsa('[required]', form).forEach((field) => {
const errorId = field.id ? `${field.id}-error` : RX.utils.createId('rx-field-error');
let error = document.getElementById(errorId);
if (!field.id) {
field.id = RX.utils.createId('rx-field');
}
if (!error) {
error = document.createElement('div');
error.id = errorId;
error.className = 'rx-field-error';
error.setAttribute('role', 'alert');
field.insertAdjacentElement('afterend', error);
}
if (!field.value.trim()) {
valid = false;
field.classList.add('rx-field-invalid');
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby', errorId);
error.textContent = 'This field is required.';
} else {
field.classList.remove('rx-field-invalid');
field.removeAttribute('aria-invalid');
error.textContent = '';
}
});
if (!valid) {
event.preventDefault();
const firstInvalid = form.querySelector('.rx-field-invalid');
if (firstInvalid) firstInvalid.focus();
}
});
});
};
/**
* WordPress comment form enhancements.
*/
RX.commentForm = function () {
const form = RX.utils.qs(RX.selectors.commentForm);
if (!form) return;
const textarea = form.querySelector('textarea');
if (textarea) {
const counter = document.createElement('div');
counter.className = 'rx-comment-counter';
counter.setAttribute('aria-live', 'polite');
textarea.insertAdjacentElement('afterend', counter);
const updateCounter = () => {
const count = textarea.value.trim().length;
counter.textContent = `${count} characters`;
};
RX.utils.on(textarea, 'input', RX.utils.debounce(updateCounter, 100));
updateCounter();
}
RX.utils.on(form, 'submit', function () {
const submit = form.querySelector('[type="submit"]');
if (submit) {
submit.classList.add('rx-is-submitting');
submit.setAttribute('aria-busy', 'true');
}
});
};
/**
* Animate items on scroll.
*
* Expected:
* Add class .rx-animate to elements.
*/
RX.scrollAnimations = function () {
const items = RX.utils.qsa('.rx-animate');
if (!items.length) return;
if (RX.settings.reducedMotion) {
items.forEach((item) => item.classList.add(RX.settings.animationClass));
return;
}
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
function (entries, obs) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add(RX.settings.animationClass);
obs.unobserve(entry.target);
});
},
{
threshold: 0.15
}
);
items.forEach((item) => observer.observe(item));
} else {
items.forEach((item) => item.classList.add(RX.settings.animationClass));
}
};
/**
* Active section link highlight.
*
* Expected:
* <a class="rx-section-link" href="#section-id">
*/
RX.activeSectionLinks = function () {
const links = RX.utils.qsa('.rx-section-link[href^="#"]');
if (!links.length || !('IntersectionObserver' in window)) return;
const targets = links
.map((link) => {
const id = link.getAttribute('href');
return id ? document.querySelector(id) : null;
})
.filter(Boolean);
if (!targets.length) return;
const observer = new IntersectionObserver(
function (entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
links.forEach((link) => link.classList.remove(RX.settings.activeClass));
const active = links.find((link) => {
return link.getAttribute('href') === `#${entry.target.id}`;
});
if (active) {
active.classList.add(RX.settings.activeClass);
}
});
},
{
rootMargin: '-30% 0px -60% 0px'
}
);
targets.forEach((target) => observer.observe(target));
};
/**
* Print button.
*
* Expected:
* <button data-rx-print>Print</button>
*/
RX.printButton = function () {
const buttons = RX.utils.qsa('[data-rx-print]');
if (!buttons.length) return;
buttons.forEach((button) => {
RX.utils.on(button, 'click', function () {
window.print();
});
});
};
/**
* Share buttons.
*
* Expected:
* <button data-rx-share>Share</button>
*/
RX.shareButton = function () {
const buttons = RX.utils.qsa('[data-rx-share]');
if (!buttons.length) return;
buttons.forEach((button) => {
RX.utils.on(button, 'click', async function () {
const shareData = {
title: document.title,
text: button.getAttribute('data-rx-share-text') || document.title,
url: button.getAttribute('data-rx-share-url') || window.location.href
};
if (navigator.share) {
try {
await navigator.share(shareData);
} catch (error) {
// User cancelled share. Ignore.
}
} else if (navigator.clipboard) {
await navigator.clipboard.writeText(shareData.url);
const original = button.textContent;
button.textContent = 'Link copied';
setTimeout(() => {
button.textContent = original;
}, 1600);
}
});
});
};
/**
* Font size controls.
*
* Expected:
* <button data-rx-font="increase">A+</button>
* <button data-rx-font="decrease">A-</button>
* <button data-rx-font="reset">Reset</button>
*/
RX.fontControls = function () {
const buttons = RX.utils.qsa('[data-rx-font]');
const storageKey = 'rx-font-scale';
if (!buttons.length) return;
const applyScale = (scale) => {
RX.html.style.setProperty('--rx-font-scale', scale);
RX.utils.storage.set(storageKey, String(scale));
};
let scale = parseFloat(RX.utils.storage.get(storageKey, '1'));
if (Number.isNaN(scale)) scale = 1;
applyScale(scale);
buttons.forEach((button) => {
RX.utils.on(button, 'click', function () {
const action = button.getAttribute('data-rx-font');
if (action === 'increase') scale = Math.min(1.3, scale + 0.05);
if (action === 'decrease') scale = Math.max(0.85, scale - 0.05);
if (action === 'reset') scale = 1;
applyScale(Number(scale.toFixed(2)));
});
});
};
/**
* Simple client-side post filter.
*
* Expected:
* <input data-rx-filter-input>
* <article data-rx-filter-item data-title="post title">
*/
RX.postFilter = function () {
const input = RX.utils.qs('[data-rx-filter-input]');
const items = RX.utils.qsa('[data-rx-filter-item]');
if (!input || !items.length) return;
const filter = RX.utils.debounce(function () {
const query = input.value.trim().toLowerCase();
items.forEach((item) => {
const title = (
item.getAttribute('data-title') ||
item.textContent ||
''
).toLowerCase();
item.hidden = query.length > 0 && !title.includes(query);
});
}, 120);
RX.utils.on(input, 'input', filter);
};
/**
* Password visibility toggle.
*
* Expected:
* <button data-rx-password-toggle="#password-field">Show</button>
*/
RX.passwordToggle = function () {
const buttons = RX.utils.qsa('[data-rx-password-toggle]');
if (!buttons.length) return;
buttons.forEach((button) => {
RX.utils.on(button, 'click', function () {
const selector = button.getAttribute('data-rx-password-toggle');
const field = selector ? document.querySelector(selector) : null;
if (!field) return;
const isPassword = field.getAttribute('type') === 'password';
field.setAttribute('type', isPassword ? 'text' : 'password');
button.setAttribute('aria-pressed', String(isPassword));
button.textContent = isPassword ? 'Hide' : 'Show';
});
});
};
/**
* Auto year.
*
* Expected:
* <span data-rx-year></span>
*/
RX.autoYear = function () {
const items = RX.utils.qsa('[data-rx-year]');
if (!items.length) return;
const year = new Date().getFullYear();
items.forEach((item) => {
item.textContent = String(year);
});
};
/**
* Last updated date formatter.
*
* Expected:
* <time data-rx-format-date datetime="2026-05-14"></time>
*/
RX.formatDates = function () {
const dates = RX.utils.qsa('[data-rx-format-date]');
if (!dates.length || !window.Intl) return;
dates.forEach((dateEl) => {
const raw = dateEl.getAttribute('datetime') || dateEl.textContent;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return;
dateEl.textContent = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
});
};
/**
* Basic AJAX helper for WordPress.
*
* Requires localized object from PHP:
*
* wp_localize_script(
* 'rx-theme-app',
* 'rxTheme',
* array(
* 'ajaxUrl' => admin_url('admin-ajax.php'),
* 'nonce' => wp_create_nonce('rx_theme_nonce')
* )
* );
*/
RX.ajax = {
request(action, data = {}) {
if (!window.rxTheme || !window.rxTheme.ajaxUrl) {
return Promise.reject(new Error('rxTheme.ajaxUrl is missing.'));
}
const formData = new FormData();
formData.append('action', action);
if (window.rxTheme.nonce) {
formData.append('nonce', window.rxTheme.nonce);
}
Object.keys(data).forEach((key) => {
formData.append(key, data[key]);
});
return fetch(window.rxTheme.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: formData
}).then((response) => {
if (!response.ok) {
throw new Error('Network response failed.');
}
return response.json();
});
}
};
/**
* AJAX load more posts.
*
* Expected:
* <button data-rx-load-more data-page="1" data-max="5">Load More</button>
* <div data-rx-load-target></div>
*/
RX.loadMore = function () {
const button = RX.utils.qs('[data-rx-load-more]');
const target = RX.utils.qs('[data-rx-load-target]');
if (!button || !target) return;
RX.utils.on(button, 'click', function () {
const page = parseInt(button.getAttribute('data-page') || '1', 10);
const max = parseInt(button.getAttribute('data-max') || '1', 10);
if (page >= max) {
button.hidden = true;
return;
}
button.disabled = true;
button.classList.add('rx-loading');
RX.ajax
.request('rx_load_more_posts', {
page: page + 1
})
.then((response) => {
if (response && response.success && response.data && response.data.html) {
target.insertAdjacentHTML('beforeend', response.data.html);
button.setAttribute('data-page', String(page + 1));
if (page + 1 >= max) {
button.hidden = true;
}
}
})
.catch(() => {
button.classList.add('rx-error');
})
.finally(() => {
button.disabled = false;
button.classList.remove('rx-loading');
});
});
};
/**
* Newsletter/simple AJAX form.
*
* Expected:
* <form data-rx-ajax-form data-action="rx_newsletter_submit">
*/
RX.ajaxForm = function () {
const forms = RX.utils.qsa('[data-rx-ajax-form]');
if (!forms.length) return;
forms.forEach((form) => {
RX.utils.on(form, 'submit', function (event) {
event.preventDefault();
const action = form.getAttribute('data-action');
const message = form.querySelector('[data-rx-form-message]');
if (!action) return;
const data = {};
const formData = new FormData(form);
formData.forEach((value, key) => {
data[key] = value;
});
form.classList.add('rx-loading');
RX.ajax
.request(action, data)
.then((response) => {
if (message) {
message.textContent =
response && response.data && response.data.message
? response.data.message
: 'Submitted successfully.';
}
if (response && response.success) {
form.reset();
}
})
.catch(() => {
if (message) {
message.textContent = 'Something went wrong. Please try again.';
}
})
.finally(() => {
form.classList.remove('rx-loading');
});
});
});
};
/**
* Medical article helper:
* Converts glossary terms with data-rx-term into accessible tooltips.
*
* Expected:
* <span data-rx-term="Simple explanation">Medical term</span>
*/
RX.termTooltips = function () {
const terms = RX.utils.qsa('[data-rx-term]');
if (!terms.length) return;
terms.forEach((term) => {
const text = term.getAttribute('data-rx-term');
if (!text) return;
const tooltipId = RX.utils.createId('rx-term');
term.setAttribute('tabindex', '0');
term.setAttribute('aria-describedby', tooltipId);
term.classList.add('rx-term');
const tooltip = document.createElement('span');
tooltip.id = tooltipId;
tooltip.className = 'rx-term-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.textContent = text;
term.appendChild(tooltip);
});
};
/**
* Medical article helper:
* Citation copy button.
*
* Expected:
* <button data-rx-copy-citation data-citation="Citation text">Copy citation</button>
*/
RX.copyCitation = function () {
const buttons = RX.utils.qsa('[data-rx-copy-citation]');
if (!buttons.length || !navigator.clipboard) return;
buttons.forEach((button) => {
RX.utils.on(button, 'click', async function () {
const citation = button.getAttribute('data-citation') || '';
if (!citation) return;
const oldText = button.textContent;
try {
await navigator.clipboard.writeText(citation);
button.textContent = 'Citation copied';
} catch (error) {
button.textContent = 'Copy failed';
}
setTimeout(() => {
button.textContent = oldText;
}, 1600);
});
});
};
/**
* Highlight search terms from URL.
*
* Example:
* /?highlight=neutropenia
*/
RX.highlightFromUrl = function () {
const params = new URLSearchParams(window.location.search);
const term = params.get('highlight');
if (!term || term.length < 3) return;
const content = RX.utils.qs(RX.selectors.content);
if (!content) return;
const safeTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${safeTerm})`, 'gi');
RX.utils.qsa('p, li', content).forEach((node) => {
if (node.children.length > 3) return;
node.innerHTML = node.innerHTML.replace(regex, '<mark class="rx-highlight">$1</mark>');
});
};
/**
* Simple notice for offline/online state.
*/
RX.networkStatus = function () {
const notice = document.createElement('div');
notice.className = 'rx-network-status';
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
notice.hidden = true;
document.body.appendChild(notice);
const show = (message) => {
notice.textContent = message;
notice.hidden = false;
setTimeout(() => {
notice.hidden = true;
}, 2500);
};
RX.utils.on(window, 'offline', function () {
show('You are offline.');
RX.body.classList.add('rx-is-offline');
});
RX.utils.on(window, 'online', function () {
show('You are back online.');
RX.body.classList.remove('rx-is-offline');
});
};
/**
* Keyboard focus style helper.
*/
RX.focusVisible = function () {
let usingKeyboard = false;
RX.utils.on(document, 'keydown', function (event) {
if (event.key === 'Tab') {
usingKeyboard = true;
RX.body.classList.add(RX.settings.focusClass);
}
});
RX.utils.on(document, 'mousedown', function () {
usingKeyboard = false;
RX.body.classList.remove(RX.settings.focusClass);
});
RX.utils.on(document, 'focusin', function () {
if (usingKeyboard) {
RX.body.classList.add(RX.settings.focusClass);
}
});
};
/**
* Prevent empty links from jumping.
*/
RX.emptyLinks = function () {
RX.utils.qsa('a[href="#"]').forEach((link) => {
RX.utils.on(link, 'click', function (event) {
event.preventDefault();
});
});
};
/**
* Init all modules.
*/
RX.init = function () {
RX.jsEnabled();
RX.focusVisible();
RX.mobileMenu();
RX.dropdownMenu();
RX.stickyHeader();
RX.readingProgress();
RX.scrollTop();
RX.darkMode();
RX.searchOverlay();
RX.smoothScroll();
RX.tableOfContents();
RX.lazyLoad();
RX.copyCode();
RX.accordion();
RX.tabs();
RX.modal();
RX.dismissible();
RX.readingTime();
RX.externalLinks();
RX.imageEnhancements();
RX.responsiveTables();
RX.formValidation();
RX.commentForm();
RX.scrollAnimations();
RX.activeSectionLinks();
RX.printButton();
RX.shareButton();
RX.fontControls();
RX.postFilter();
RX.passwordToggle();
RX.autoYear();
RX.formatDates();
RX.loadMore();
RX.ajaxForm();
RX.termTooltips();
RX.copyCitation();
RX.highlightFromUrl();
RX.networkStatus();
RX.emptyLinks();
RX.body.classList.add('rx-app-ready');
document.dispatchEvent(
new CustomEvent('rxThemeReady', {
detail: {
version: RX.version,
app: RX
}
})
);
};
/**
* Start app.
*/
RX.utils.ready(RX.init);
/**
* Expose safely for custom extensions.
*/
window.RXTheme = RX;
})();
Add this in your functions.php:
function rx_theme_enqueue_scripts() {
wp_enqueue_script(
'rx-theme-app',
get_template_directory_uri() . '/inc/js/app.js',
array(),
wp_get_theme()->get( 'Version' ),
true
);
wp_localize_script(
'rx-theme-app',
'rxTheme',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'rx_theme_nonce' ),
'homeUrl' => home_url( '/' ),
)
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_scripts' );
For cache safety, you can use this better version:
function rx_theme_enqueue_scripts() {
$app_js_path = get_template_directory() . '/inc/js/app.js';
$app_js_uri = get_template_directory_uri() . '/inc/js/app.js';
wp_enqueue_script(
'rx-theme-app',
$app_js_uri,
array(),
file_exists( $app_js_path ) ? filemtime( $app_js_path ) : wp_get_theme()->get( 'Version' ),
true
);
wp_localize_script(
'rx-theme-app',
'rxTheme',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'rx_theme_nonce' ),
'homeUrl' => home_url( '/' ),
)
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_scripts' );
This file is big, but it will not break the theme because each feature activates only when its matching class or data-rx-* attribute exists.