chunk-011-tabs.js

I’ll build this as a reusable advanced tabs module for your RX Theme: accessible tabs, keyboard control, deep-link hash support, auto-activation/manual mode, nested tabs safety, responsive dropdown mode hooks, lazy content events, and WordPress-friendly initialization.

Dear friend, create/update this file:

assets/static-js/static-chunks-js/chunk-011-tabs.js

Use this advanced RX Theme tabs code:

/*!
 * RX Theme - Advanced Tabs Chunk
 * File: assets/static-js/static-chunks-js/chunk-011-tabs.js
 * Version: 1.0.0
 *
 * Features:
 * - Accessible ARIA tabs
 * - Keyboard navigation
 * - Click / hover activation support
 * - URL hash deep linking
 * - Auto activation / manual activation
 * - Nested tabs safe
 * - Responsive select/dropdown support
 * - Lazy panel reveal event
 * - LocalStorage active tab memory
 * - WordPress AJAX/re-init friendly
 */

(function () {
  'use strict';

  /**
   * Prevent double loading
   */
  if (window.RXThemeTabsLoaded) {
    return;
  }

  window.RXThemeTabsLoaded = true;

  /**
   * Main namespace
   */
  window.RXTheme = window.RXTheme || {};

  const RXTabs = {
    instances: [],
    defaults: {
      rootSelector: '[data-rx-tabs]',
      tabListSelector: '[data-rx-tab-list]',
      tabSelector: '[data-rx-tab]',
      panelSelector: '[data-rx-tab-panel]',
      activeClass: 'is-active',
      disabledClass: 'is-disabled',
      hiddenClass: 'is-hidden',
      initializedClass: 'rx-tabs-initialized',
      storagePrefix: 'rx-theme-tabs-active-',
      updateHash: false,
      readHash: true,
      remember: false,
      manual: false,
      activation: 'click', // click | hover
      loop: true,
      animate: true,
      animationDuration: 250,
      debug: false
    },

    /**
     * Initialize all tab groups
     */
    init(options = {}) {
      const settings = Object.assign({}, this.defaults, options);
      const roots = document.querySelectorAll(settings.rootSelector);

      roots.forEach((root, index) => {
        if (root.classList.contains(settings.initializedClass)) {
          return;
        }

        const instance = new RXTabsInstance(root, settings, index);
        instance.init();

        this.instances.push(instance);
      });

      return this.instances;
    },

    /**
     * Reinitialize after AJAX, pagination, or dynamic content load
     */
    refresh(context = document) {
      const roots = context.querySelectorAll(this.defaults.rootSelector);

      roots.forEach((root, index) => {
        if (!root.classList.contains(this.defaults.initializedClass)) {
          const instance = new RXTabsInstance(root, this.defaults, index);
          instance.init();
          this.instances.push(instance);
        }
      });
    },

    /**
     * Destroy all instances
     */
    destroyAll() {
      this.instances.forEach((instance) => instance.destroy());
      this.instances = [];
    },

    /**
     * Get instance by root element
     */
    getInstance(root) {
      return this.instances.find((instance) => instance.root === root) || null;
    }
  };

  /**
   * Single Tabs Instance
   */
  class RXTabsInstance {
    constructor(root, settings, index) {
      this.root = root;
      this.settings = Object.assign({}, settings);
      this.index = index;
      this.id = this.root.getAttribute('id') || `rx-tabs-${index + 1}`;
      this.tabList = null;
      this.tabs = [];
      this.panels = [];
      this.select = null;
      this.activeIndex = 0;
      this.boundEvents = [];
    }

    init() {
      this.applyDataOptions();
      this.prepareRoot();
      this.collectElements();

      if (!this.tabs.length || !this.panels.length) {
        this.log('Tabs or panels missing.');
        return;
      }

      this.setupAccessibility();
      this.createResponsiveSelect();
      this.bindEvents();
      this.activateInitialTab();

      this.root.classList.add(this.settings.initializedClass);
      this.dispatch('rxTabsReady', {
        id: this.id,
        root: this.root,
        tabs: this.tabs,
        panels: this.panels
      });
    }

    /**
     * Read per-component settings from data attributes
     */
    applyDataOptions() {
      const dataset = this.root.dataset;

      this.settings.updateHash = this.toBoolean(dataset.rxTabsUpdateHash, this.settings.updateHash);
      this.settings.readHash = this.toBoolean(dataset.rxTabsReadHash, this.settings.readHash);
      this.settings.remember = this.toBoolean(dataset.rxTabsRemember, this.settings.remember);
      this.settings.manual = this.toBoolean(dataset.rxTabsManual, this.settings.manual);
      this.settings.loop = this.toBoolean(dataset.rxTabsLoop, this.settings.loop);
      this.settings.animate = this.toBoolean(dataset.rxTabsAnimate, this.settings.animate);

      if (dataset.rxTabsActivation) {
        this.settings.activation = dataset.rxTabsActivation;
      }

      if (dataset.rxTabsDuration) {
        const duration = parseInt(dataset.rxTabsDuration, 10);
        if (!Number.isNaN(duration)) {
          this.settings.animationDuration = duration;
        }
      }
    }

    prepareRoot() {
      if (!this.root.id) {
        this.root.id = this.id;
      }

      this.root.setAttribute('data-rx-tabs-id', this.id);
    }

    collectElements() {
      this.tabList = this.root.querySelector(this.settings.tabListSelector);

      /**
       * Nested tabs safety:
       * Only select tabs/panels whose closest root is this.root.
       */
      this.tabs = Array.from(this.root.querySelectorAll(this.settings.tabSelector)).filter((tab) => {
        return tab.closest(this.settings.rootSelector) === this.root;
      });

      this.panels = Array.from(this.root.querySelectorAll(this.settings.panelSelector)).filter((panel) => {
        return panel.closest(this.settings.rootSelector) === this.root;
      });
    }

    setupAccessibility() {
      if (this.tabList) {
        this.tabList.setAttribute('role', 'tablist');
      }

      this.tabs.forEach((tab, index) => {
        const tabId = tab.id || `${this.id}-tab-${index + 1}`;
        const panel = this.getPanelForTab(tab, index);
        const panelId = panel ? panel.id || `${this.id}-panel-${index + 1}` : `${this.id}-panel-${index + 1}`;

        tab.id = tabId;
        tab.setAttribute('role', 'tab');
        tab.setAttribute('aria-selected', 'false');
        tab.setAttribute('tabindex', '-1');
        tab.setAttribute('aria-controls', panelId);
        tab.setAttribute('data-rx-tab-index', String(index));

        if (tab.matches('a[href]')) {
          tab.setAttribute('data-rx-original-href', tab.getAttribute('href') || '');
        }

        if (panel) {
          panel.id = panelId;
          panel.setAttribute('role', 'tabpanel');
          panel.setAttribute('aria-labelledby', tabId);
          panel.setAttribute('tabindex', '0');
          panel.setAttribute('data-rx-panel-index', String(index));
        }

        if (this.isDisabled(tab)) {
          tab.setAttribute('aria-disabled', 'true');
          tab.setAttribute('tabindex', '-1');
        }
      });
    }

    bindEvents() {
      this.tabs.forEach((tab, index) => {
        const clickHandler = (event) => {
          this.onTabClick(event, index);
        };

        const keydownHandler = (event) => {
          this.onKeydown(event, index);
        };

        tab.addEventListener('click', clickHandler);
        tab.addEventListener('keydown', keydownHandler);

        this.boundEvents.push({
          element: tab,
          type: 'click',
          handler: clickHandler
        });

        this.boundEvents.push({
          element: tab,
          type: 'keydown',
          handler: keydownHandler
        });

        if (this.settings.activation === 'hover') {
          const mouseenterHandler = () => {
            if (!this.isDisabled(tab)) {
              this.activate(index, {
                focus: false,
                updateHash: false,
                remember: true,
                source: 'hover'
              });
            }
          };

          tab.addEventListener('mouseenter', mouseenterHandler);

          this.boundEvents.push({
            element: tab,
            type: 'mouseenter',
            handler: mouseenterHandler
          });
        }
      });

      const hashHandler = () => {
        this.activateFromHash();
      };

      window.addEventListener('hashchange', hashHandler);

      this.boundEvents.push({
        element: window,
        type: 'hashchange',
        handler: hashHandler
      });
    }

    onTabClick(event, index) {
      const tab = this.tabs[index];

      if (!tab || this.isDisabled(tab)) {
        event.preventDefault();
        return;
      }

      event.preventDefault();

      this.activate(index, {
        focus: true,
        updateHash: this.settings.updateHash,
        remember: true,
        source: 'click'
      });
    }

    onKeydown(event, currentIndex) {
      const key = event.key;
      let nextIndex = null;

      switch (key) {
        case 'ArrowRight':
        case 'ArrowDown':
          nextIndex = this.getNextEnabledIndex(currentIndex);
          break;

        case 'ArrowLeft':
        case 'ArrowUp':
          nextIndex = this.getPreviousEnabledIndex(currentIndex);
          break;

        case 'Home':
          nextIndex = this.getFirstEnabledIndex();
          break;

        case 'End':
          nextIndex = this.getLastEnabledIndex();
          break;

        case 'Enter':
        case ' ':
          event.preventDefault();
          this.activate(currentIndex, {
            focus: true,
            updateHash: this.settings.updateHash,
            remember: true,
            source: 'keyboard-confirm'
          });
          return;

        default:
          return;
      }

      if (nextIndex === null || nextIndex === undefined || nextIndex < 0) {
        return;
      }

      event.preventDefault();

      if (this.settings.manual) {
        this.focusTab(nextIndex);
      } else {
        this.activate(nextIndex, {
          focus: true,
          updateHash: this.settings.updateHash,
          remember: true,
          source: 'keyboard-auto'
        });
      }
    }

    activateInitialTab() {
      let index = null;

      if (this.settings.readHash) {
        index = this.getIndexFromHash();
      }

      if (index === null && this.settings.remember) {
        index = this.getRememberedIndex();
      }

      if (index === null) {
        index = this.getIndexFromActiveMarkup();
      }

      if (index === null) {
        index = 0;
      }

      if (this.isDisabled(this.tabs[index])) {
        index = this.getFirstEnabledIndex();
      }

      this.activate(index, {
        focus: false,
        updateHash: false,
        remember: false,
        source: 'init'
      });
    }

    activate(index, options = {}) {
      const config = Object.assign({
        focus: false,
        updateHash: false,
        remember: false,
        source: 'api'
      }, options);

      const tab = this.tabs[index];
      const panel = this.getPanelForTab(tab, index);

      if (!tab || !panel || this.isDisabled(tab)) {
        return false;
      }

      const previousIndex = this.activeIndex;
      const previousTab = this.tabs[previousIndex];
      const previousPanel = this.getPanelForTab(previousTab, previousIndex);

      this.tabs.forEach((item, itemIndex) => {
        const itemPanel = this.getPanelForTab(item, itemIndex);
        const isActive = itemIndex === index;

        item.classList.toggle(this.settings.activeClass, isActive);
        item.setAttribute('aria-selected', isActive ? 'true' : 'false');
        item.setAttribute('tabindex', isActive ? '0' : '-1');

        if (itemPanel) {
          itemPanel.classList.toggle(this.settings.activeClass, isActive);
          itemPanel.hidden = !isActive;
          itemPanel.setAttribute('aria-hidden', isActive ? 'false' : 'true');

          if (isActive) {
            itemPanel.classList.remove(this.settings.hiddenClass);
          } else {
            itemPanel.classList.add(this.settings.hiddenClass);
          }
        }
      });

      this.activeIndex = index;

      if (this.select) {
        this.select.value = String(index);
      }

      if (config.focus) {
        this.focusTab(index);
      }

      if (config.updateHash) {
        this.updateHash(tab, panel);
      }

      if (config.remember && this.settings.remember) {
        this.rememberIndex(index);
      }

      if (this.settings.animate) {
        this.animatePanel(panel);
      }

      this.dispatch('rxTabsChange', {
        id: this.id,
        index,
        previousIndex,
        tab,
        panel,
        previousTab,
        previousPanel,
        source: config.source
      });

      /**
       * Useful for lazy scripts, sliders, charts, ads, embeds, etc.
       */
      this.dispatch('rxTabsPanelVisible', {
        id: this.id,
        index,
        tab,
        panel
      }, panel);

      return true;
    }

    focusTab(index) {
      const tab = this.tabs[index];

      if (tab && typeof tab.focus === 'function') {
        tab.focus({
          preventScroll: true
        });
      }
    }

    getPanelForTab(tab, index) {
      if (!tab) {
        return this.panels[index] || null;
      }

      const controls = tab.getAttribute('aria-controls');

      if (controls) {
        const panel = this.root.querySelector(`#${this.escapeCss(controls)}`);
        if (panel && panel.closest(this.settings.rootSelector) === this.root) {
          return panel;
        }
      }

      const target = tab.getAttribute('data-rx-tab-target');

      if (target) {
        const panel = this.root.querySelector(target);
        if (panel && panel.closest(this.settings.rootSelector) === this.root) {
          return panel;
        }
      }

      return this.panels[index] || null;
    }

    getIndexFromActiveMarkup() {
      const activeTab = this.tabs.find((tab) => {
        return tab.classList.contains(this.settings.activeClass) ||
          tab.getAttribute('aria-selected') === 'true' ||
          tab.hasAttribute('data-rx-tab-active');
      });

      if (!activeTab) {
        return null;
      }

      return this.tabs.indexOf(activeTab);
    }

    getIndexFromHash() {
      const hash = window.location.hash;

      if (!hash || hash.length < 2) {
        return null;
      }

      const cleanHash = decodeURIComponent(hash.slice(1));

      const panelIndex = this.panels.findIndex((panel) => {
        return panel.id === cleanHash;
      });

      if (panelIndex >= 0) {
        return panelIndex;
      }

      const tabIndex = this.tabs.findIndex((tab) => {
        return tab.id === cleanHash;
      });

      if (tabIndex >= 0) {
        return tabIndex;
      }

      return null;
    }

    activateFromHash() {
      if (!this.settings.readHash) {
        return;
      }

      const index = this.getIndexFromHash();

      if (index !== null && index !== this.activeIndex) {
        this.activate(index, {
          focus: false,
          updateHash: false,
          remember: true,
          source: 'hash'
        });
      }
    }

    updateHash(tab, panel) {
      const targetId = panel && panel.id ? panel.id : tab.id;

      if (!targetId) {
        return;
      }

      const currentScroll = {
        x: window.pageXOffset,
        y: window.pageYOffset
      };

      history.replaceState(null, '', `#${encodeURIComponent(targetId)}`);

      window.scrollTo(currentScroll.x, currentScroll.y);
    }

    rememberIndex(index) {
      try {
        localStorage.setItem(this.getStorageKey(), String(index));
      } catch (error) {
        this.log('LocalStorage remember failed.', error);
      }
    }

    getRememberedIndex() {
      try {
        const value = localStorage.getItem(this.getStorageKey());

        if (value === null) {
          return null;
        }

        const index = parseInt(value, 10);

        if (Number.isNaN(index) || index < 0 || index >= this.tabs.length) {
          return null;
        }

        return index;
      } catch (error) {
        this.log('LocalStorage read failed.', error);
        return null;
      }
    }

    getStorageKey() {
      return `${this.settings.storagePrefix}${this.id}`;
    }

    createResponsiveSelect() {
      const shouldCreate = this.toBoolean(this.root.dataset.rxTabsSelect, false);

      if (!shouldCreate) {
        return;
      }

      const select = document.createElement('select');
      select.className = 'rx-tabs-select';
      select.setAttribute('aria-label', this.root.dataset.rxTabsSelectLabel || 'Select tab');

      this.tabs.forEach((tab, index) => {
        const option = document.createElement('option');
        option.value = String(index);
        option.textContent = this.getTabText(tab) || `Tab ${index + 1}`;

        if (this.isDisabled(tab)) {
          option.disabled = true;
        }

        select.appendChild(option);
      });

      const wrapper = document.createElement('div');
      wrapper.className = 'rx-tabs-select-wrap';
      wrapper.appendChild(select);

      this.root.insertBefore(wrapper, this.root.firstChild);

      const changeHandler = () => {
        const index = parseInt(select.value, 10);

        if (!Number.isNaN(index)) {
          this.activate(index, {
            focus: false,
            updateHash: this.settings.updateHash,
            remember: true,
            source: 'select'
          });
        }
      };

      select.addEventListener('change', changeHandler);

      this.boundEvents.push({
        element: select,
        type: 'change',
        handler: changeHandler
      });

      this.select = select;
    }

    animatePanel(panel) {
      if (!panel) {
        return;
      }

      panel.style.removeProperty('height');
      panel.style.removeProperty('overflow');
      panel.style.removeProperty('transition');

      const prefersReducedMotion = window.matchMedia &&
        window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      if (prefersReducedMotion) {
        return;
      }

      panel.style.overflow = 'hidden';
      panel.style.height = '0px';
      panel.style.transition = `height ${this.settings.animationDuration}ms ease`;

      requestAnimationFrame(() => {
        panel.style.height = `${panel.scrollHeight}px`;

        window.setTimeout(() => {
          panel.style.removeProperty('height');
          panel.style.removeProperty('overflow');
          panel.style.removeProperty('transition');
        }, this.settings.animationDuration + 30);
      });
    }

    getNextEnabledIndex(currentIndex) {
      let next = currentIndex + 1;

      while (next < this.tabs.length) {
        if (!this.isDisabled(this.tabs[next])) {
          return next;
        }

        next++;
      }

      if (this.settings.loop) {
        return this.getFirstEnabledIndex();
      }

      return currentIndex;
    }

    getPreviousEnabledIndex(currentIndex) {
      let previous = currentIndex - 1;

      while (previous >= 0) {
        if (!this.isDisabled(this.tabs[previous])) {
          return previous;
        }

        previous--;
      }

      if (this.settings.loop) {
        return this.getLastEnabledIndex();
      }

      return currentIndex;
    }

    getFirstEnabledIndex() {
      return this.tabs.findIndex((tab) => !this.isDisabled(tab));
    }

    getLastEnabledIndex() {
      for (let index = this.tabs.length - 1; index >= 0; index--) {
        if (!this.isDisabled(this.tabs[index])) {
          return index;
        }
      }

      return 0;
    }

    isDisabled(tab) {
      if (!tab) {
        return true;
      }

      return tab.disabled ||
        tab.classList.contains(this.settings.disabledClass) ||
        tab.getAttribute('aria-disabled') === 'true' ||
        tab.hasAttribute('data-rx-tab-disabled');
    }

    getTabText(tab) {
      return (tab.textContent || '').replace(/\s+/g, ' ').trim();
    }

    dispatch(name, detail = {}, target = this.root) {
      const event = new CustomEvent(name, {
        bubbles: true,
        cancelable: true,
        detail
      });

      target.dispatchEvent(event);
    }

    destroy() {
      this.boundEvents.forEach((item) => {
        item.element.removeEventListener(item.type, item.handler);
      });

      this.boundEvents = [];

      this.tabs.forEach((tab) => {
        tab.removeAttribute('role');
        tab.removeAttribute('aria-selected');
        tab.removeAttribute('tabindex');
        tab.removeAttribute('aria-controls');
        tab.removeAttribute('data-rx-tab-index');
      });

      this.panels.forEach((panel) => {
        panel.removeAttribute('role');
        panel.removeAttribute('aria-labelledby');
        panel.removeAttribute('tabindex');
        panel.removeAttribute('data-rx-panel-index');
        panel.hidden = false;
        panel.classList.remove(this.settings.hiddenClass);
      });

      if (this.select) {
        const wrapper = this.select.closest('.rx-tabs-select-wrap');
        if (wrapper) {
          wrapper.remove();
        }
      }

      this.root.classList.remove(this.settings.initializedClass);
    }

    toBoolean(value, fallback) {
      if (value === undefined || value === null || value === '') {
        return fallback;
      }

      if (value === true || value === 'true' || value === '1' || value === 'yes') {
        return true;
      }

      if (value === false || value === 'false' || value === '0' || value === 'no') {
        return false;
      }

      return fallback;
    }

    escapeCss(value) {
      if (window.CSS && typeof window.CSS.escape === 'function') {
        return window.CSS.escape(value);
      }

      return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
    }

    log(...args) {
      if (this.settings.debug) {
        console.log('[RXTabs]', ...args);
      }
    }
  }

  /**
   * Public API
   */
  window.RXTheme.Tabs = RXTabs;

  /**
   * Auto init on DOM ready
   */
  function ready(callback) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', callback);
    } else {
      callback();
    }
  }

  ready(() => {
    RXTabs.init();
  });

  /**
   * WordPress / AJAX friendly refresh hooks
   */
  document.addEventListener('rxThemeAjaxLoaded', function (event) {
    RXTabs.refresh(event.detail && event.detail.context ? event.detail.context : document);
  });

  document.addEventListener('rxThemeContentUpdated', function (event) {
    RXTabs.refresh(event.detail && event.detail.context ? event.detail.context : document);
  });

  document.addEventListener('rxThemeInfiniteLoaded', function (event) {
    RXTabs.refresh(event.detail && event.detail.context ? event.detail.context : document);
  });

})();

Example HTML structure

Use this in your theme template:

<div
  class="rx-tabs"
  data-rx-tabs
  data-rx-tabs-update-hash="true"
  data-rx-tabs-read-hash="true"
  data-rx-tabs-remember="true"
  data-rx-tabs-select="true"
>
  <div class="rx-tabs__nav" data-rx-tab-list>
    <button class="rx-tabs__tab" data-rx-tab>Overview</button>
    <button class="rx-tabs__tab" data-rx-tab>Symptoms</button>
    <button class="rx-tabs__tab" data-rx-tab>Diagnosis</button>
    <button class="rx-tabs__tab" data-rx-tab>Treatment</button>
  </div>

  <div class="rx-tabs__panel" data-rx-tab-panel>
    Overview content here.
  </div>

  <div class="rx-tabs__panel" data-rx-tab-panel>
    Symptoms content here.
  </div>

  <div class="rx-tabs__panel" data-rx-tab-panel>
    Diagnosis content here.
  </div>

  <div class="rx-tabs__panel" data-rx-tab-panel>
    Treatment content here.
  </div>
</div>

Optional basic CSS

Add this to your main tabs CSS file:

.rx-tabs {
  width: 100%;
}

.rx-tabs__nav {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  border-bottom: 1px solid var(--rx-border-color, #e5e7eb);
  margin-bottom: 16px;
}

.rx-tabs__tab {
  appearance: none;
  border: 0;
  background: transparent;
  cursor: pointer;
  padding: 10px 14px;
  font: inherit;
  color: var(--rx-text-muted, #64748b);
  border-bottom: 2px solid transparent;
}

.rx-tabs__tab.is-active {
  color: var(--rx-primary, #2563eb);
  border-bottom-color: var(--rx-primary, #2563eb);
}

.rx-tabs__tab:focus-visible {
  outline: 2px solid var(--rx-primary, #2563eb);
  outline-offset: 2px;
}

.rx-tabs__tab.is-disabled,
.rx-tabs__tab[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

.rx-tabs__panel {
  display: block;
}

.rx-tabs__panel.is-hidden,
.rx-tabs__panel[hidden] {
  display: none;
}

.rx-tabs-select-wrap {
  display: none;
  margin-bottom: 16px;
}

.rx-tabs-select {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--rx-border-color, #e5e7eb);
  border-radius: 8px;
  background: var(--rx-surface, #ffffff);
  color: var(--rx-text, #111827);
}

@media (max-width: 640px) {
  .rx-tabs[data-rx-tabs-select="true"] .rx-tabs-select-wrap {
    display: block;
  }

  .rx-tabs[data-rx-tabs-select="true"] .rx-tabs__nav {
    display: none;
  }
}

Enqueue example in functions.php

function rx_theme_enqueue_tabs_chunk() {
    wp_enqueue_script(
        'rx-theme-tabs',
        get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-011-tabs.js',
        array(),
        '1.0.0',
        true
    );
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_tabs_chunk' );

This file is safe for advanced RX Theme use because it supports accessibility, SEO-friendly content visibility, keyboard navigation, deep links, AJAX refresh, and future dynamic WordPress blocks.

Leave a Reply

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