chunk-010-accordion.js

I’ll build this as a standalone advanced accordion module for your RX Theme path, with accessibility, keyboard control, nested accordions, URL hash opening, groups, animations, and auto-init support.

Dear friend, place this full code in:

assets/static-js/static-chunks-js/chunk-010-accordion.js

/*!
 * RX Theme Advanced Accordion
 * File: assets/static-js/static-chunks-js/chunk-010-accordion.js
 * Version: 1.0.0
 *
 * Features:
 * - Accessible accordion with ARIA support
 * - Single-open or multiple-open mode
 * - Smooth height animation
 * - Keyboard support
 * - Nested accordion support
 * - URL hash deep-link opening
 * - Auto close on outside click option
 * - Open first item option
 * - Remember state with localStorage option
 * - Lazy content loading hook
 * - Custom events for developer extension
 * - Works with dynamic AJAX-loaded content
 */

(function () {
  "use strict";

  const RXAccordion = {
    instances: new Map(),

    defaults: {
      rootSelector: "[data-rx-accordion]",
      itemSelector: "[data-rx-accordion-item]",
      triggerSelector: "[data-rx-accordion-trigger]",
      panelSelector: "[data-rx-accordion-panel]",

      activeClass: "is-active",
      openingClass: "is-opening",
      closingClass: "is-closing",
      initializedClass: "rx-accordion-ready",

      singleOpen: false,
      closeOnOutsideClick: false,
      openFirst: false,
      allowAllClosed: true,
      rememberState: false,
      deepLink: true,
      animation: true,
      animationDuration: 300,
      scrollToOpened: false,
      scrollOffset: 90,
      lazyLoad: false,

      storagePrefix: "rxAccordion:",
    },

    init(options = {}) {
      const settings = Object.assign({}, this.defaults, options);
      const accordions = document.querySelectorAll(settings.rootSelector);

      if (!accordions.length) return;

      accordions.forEach((accordion, index) => {
        this.setupAccordion(accordion, settings, index);
      });

      this.bindGlobalEvents(settings);
    },

    setupAccordion(accordion, settings, index) {
      if (accordion.classList.contains(settings.initializedClass)) return;

      const accordionId =
        accordion.getAttribute("id") ||
        `rx-accordion-${Date.now()}-${index}`;

      accordion.setAttribute("id", accordionId);
      accordion.classList.add(settings.initializedClass);

      const localSettings = this.getLocalSettings(accordion, settings);
      const items = this.getDirectItems(accordion, localSettings);

      accordion.setAttribute("data-rx-accordion-id", accordionId);

      if (!items.length) return;

      const instance = {
        id: accordionId,
        element: accordion,
        settings: localSettings,
        items,
      };

      this.instances.set(accordionId, instance);

      items.forEach((item, itemIndex) => {
        this.setupItem(instance, item, itemIndex);
      });

      this.restoreState(instance);
      this.handleInitialOpen(instance);

      this.dispatch(accordion, "rxAccordionReady", {
        accordion,
        id: accordionId,
        items,
      });
    },

    getLocalSettings(accordion, settings) {
      const local = Object.assign({}, settings);

      if (accordion.hasAttribute("data-rx-single")) {
        local.singleOpen = accordion.getAttribute("data-rx-single") !== "false";
      }

      if (accordion.hasAttribute("data-rx-multiple")) {
        local.singleOpen = accordion.getAttribute("data-rx-multiple") === "false";
      }

      if (accordion.hasAttribute("data-rx-open-first")) {
        local.openFirst = accordion.getAttribute("data-rx-open-first") !== "false";
      }

      if (accordion.hasAttribute("data-rx-remember")) {
        local.rememberState = accordion.getAttribute("data-rx-remember") !== "false";
      }

      if (accordion.hasAttribute("data-rx-deeplink")) {
        local.deepLink = accordion.getAttribute("data-rx-deeplink") !== "false";
      }

      if (accordion.hasAttribute("data-rx-animation")) {
        local.animation = accordion.getAttribute("data-rx-animation") !== "false";
      }

      if (accordion.hasAttribute("data-rx-duration")) {
        const duration = parseInt(accordion.getAttribute("data-rx-duration"), 10);
        if (!Number.isNaN(duration)) {
          local.animationDuration = duration;
        }
      }

      if (accordion.hasAttribute("data-rx-scroll")) {
        local.scrollToOpened = accordion.getAttribute("data-rx-scroll") !== "false";
      }

      if (accordion.hasAttribute("data-rx-outside-close")) {
        local.closeOnOutsideClick =
          accordion.getAttribute("data-rx-outside-close") !== "false";
      }

      if (accordion.hasAttribute("data-rx-lazy")) {
        local.lazyLoad = accordion.getAttribute("data-rx-lazy") !== "false";
      }

      return local;
    },

    getDirectItems(accordion, settings) {
      return Array.from(accordion.querySelectorAll(settings.itemSelector)).filter(
        (item) => item.closest(settings.rootSelector) === accordion
      );
    },

    setupItem(instance, item, index) {
      const { settings, id } = instance;

      const trigger = item.querySelector(settings.triggerSelector);
      const panel = item.querySelector(settings.panelSelector);

      if (!trigger || !panel) return;

      const itemId =
        item.getAttribute("id") ||
        `${id}-item-${index + 1}`;

      const triggerId =
        trigger.getAttribute("id") ||
        `${itemId}-trigger`;

      const panelId =
        panel.getAttribute("id") ||
        `${itemId}-panel`;

      item.setAttribute("id", itemId);
      trigger.setAttribute("id", triggerId);
      panel.setAttribute("id", panelId);

      trigger.setAttribute("type", "button");
      trigger.setAttribute("aria-controls", panelId);

      panel.setAttribute("role", "region");
      panel.setAttribute("aria-labelledby", triggerId);

      const isOpen =
        item.classList.contains(settings.activeClass) ||
        item.hasAttribute("data-rx-open") ||
        trigger.getAttribute("aria-expanded") === "true";

      this.setState(instance, item, isOpen, false);

      trigger.addEventListener("click", (event) => {
        event.preventDefault();
        this.toggle(instance, item, true);
      });

      trigger.addEventListener("keydown", (event) => {
        this.handleKeyboard(event, instance, item);
      });
    },

    toggle(instance, item, userAction = false) {
      if (this.isOpen(instance, item)) {
        this.close(instance, item, userAction);
      } else {
        this.open(instance, item, userAction);
      }
    },

    open(instance, item, userAction = false) {
      const { settings, element } = instance;

      if (this.isOpen(instance, item)) return;

      if (settings.singleOpen) {
        instance.items.forEach((otherItem) => {
          if (otherItem !== item) {
            this.close(instance, otherItem, false);
          }
        });
      }

      this.setState(instance, item, true, settings.animation);
      this.lazyLoadPanel(instance, item);

      if (settings.rememberState) {
        this.saveState(instance);
      }

      if (settings.scrollToOpened && userAction) {
        this.scrollToItem(item, settings.scrollOffset);
      }

      this.dispatch(element, "rxAccordionOpen", {
        accordion: element,
        item,
        trigger: item.querySelector(settings.triggerSelector),
        panel: item.querySelector(settings.panelSelector),
      });
    },

    close(instance, item, userAction = false) {
      const { settings, element } = instance;

      if (!this.isOpen(instance, item)) return;

      if (!settings.allowAllClosed) {
        const openItems = instance.items.filter((i) => this.isOpen(instance, i));
        if (openItems.length <= 1) return;
      }

      this.setState(instance, item, false, settings.animation);

      if (settings.rememberState) {
        this.saveState(instance);
      }

      this.dispatch(element, "rxAccordionClose", {
        accordion: element,
        item,
        trigger: item.querySelector(settings.triggerSelector),
        panel: item.querySelector(settings.panelSelector),
        userAction,
      });
    },

    setState(instance, item, open, animate = true) {
      const { settings } = instance;

      const trigger = item.querySelector(settings.triggerSelector);
      const panel = item.querySelector(settings.panelSelector);

      if (!trigger || !panel) return;

      trigger.setAttribute("aria-expanded", open ? "true" : "false");
      item.setAttribute("data-rx-state", open ? "open" : "closed");

      if (open) {
        item.classList.add(settings.activeClass);
        panel.hidden = false;
      } else {
        item.classList.remove(settings.activeClass);
      }

      if (!settings.animation || !animate) {
        panel.style.height = "";
        panel.style.overflow = "";
        panel.style.transition = "";
        panel.hidden = !open;
        return;
      }

      this.animatePanel(item, panel, open, settings);
    },

    animatePanel(item, panel, open, settings) {
      panel.style.overflow = "hidden";
      panel.style.transition = `height ${settings.animationDuration}ms ease`;

      if (open) {
        item.classList.add(settings.openingClass);
        item.classList.remove(settings.closingClass);

        panel.hidden = false;
        panel.style.height = "0px";

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

        window.setTimeout(() => {
          panel.style.height = "";
          panel.style.overflow = "";
          panel.style.transition = "";
          item.classList.remove(settings.openingClass);
        }, settings.animationDuration);
      } else {
        item.classList.add(settings.closingClass);
        item.classList.remove(settings.openingClass);

        panel.style.height = `${panel.scrollHeight}px`;

        requestAnimationFrame(() => {
          panel.style.height = "0px";
        });

        window.setTimeout(() => {
          panel.hidden = true;
          panel.style.height = "";
          panel.style.overflow = "";
          panel.style.transition = "";
          item.classList.remove(settings.closingClass);
        }, settings.animationDuration);
      }
    },

    isOpen(instance, item) {
      const trigger = item.querySelector(instance.settings.triggerSelector);
      return trigger && trigger.getAttribute("aria-expanded") === "true";
    },

    handleKeyboard(event, instance, currentItem) {
      const { settings } = instance;

      const triggers = instance.items
        .map((item) => item.querySelector(settings.triggerSelector))
        .filter(Boolean);

      const currentTrigger = currentItem.querySelector(settings.triggerSelector);
      const currentIndex = triggers.indexOf(currentTrigger);

      if (currentIndex === -1) return;

      let nextIndex = null;

      switch (event.key) {
        case "ArrowDown":
          nextIndex = currentIndex + 1;
          if (nextIndex >= triggers.length) nextIndex = 0;
          break;

        case "ArrowUp":
          nextIndex = currentIndex - 1;
          if (nextIndex < 0) nextIndex = triggers.length - 1;
          break;

        case "Home":
          nextIndex = 0;
          break;

        case "End":
          nextIndex = triggers.length - 1;
          break;

        case "Enter":
        case " ":
          event.preventDefault();
          this.toggle(instance, currentItem, true);
          return;

        case "Escape":
          if (this.isOpen(instance, currentItem)) {
            event.preventDefault();
            this.close(instance, currentItem, true);
          }
          return;

        default:
          return;
      }

      if (nextIndex !== null) {
        event.preventDefault();
        triggers[nextIndex].focus();
      }
    },

    handleInitialOpen(instance) {
      const { settings } = instance;

      if (settings.deepLink && window.location.hash) {
        const hashTarget = instance.element.querySelector(window.location.hash);

        if (hashTarget) {
          const hashItem = hashTarget.matches(settings.itemSelector)
            ? hashTarget
            : hashTarget.closest(settings.itemSelector);

          if (hashItem && instance.items.includes(hashItem)) {
            this.open(instance, hashItem, false);
            this.scrollToItem(hashItem, settings.scrollOffset);
            return;
          }
        }
      }

      const hasOpenItem = instance.items.some((item) => this.isOpen(instance, item));

      if (!hasOpenItem && settings.openFirst && instance.items[0]) {
        this.open(instance, instance.items[0], false);
      }
    },

    saveState(instance) {
      try {
        const { settings, id, items } = instance;

        const openIds = items
          .filter((item) => this.isOpen(instance, item))
          .map((item) => item.getAttribute("id"));

        localStorage.setItem(
          `${settings.storagePrefix}${id}`,
          JSON.stringify(openIds)
        );
      } catch (error) {
        this.debug("Unable to save accordion state", error);
      }
    },

    restoreState(instance) {
      try {
        const { settings, id, items } = instance;

        if (!settings.rememberState) return;

        const saved = localStorage.getItem(`${settings.storagePrefix}${id}`);
        if (!saved) return;

        const openIds = JSON.parse(saved);

        if (!Array.isArray(openIds)) return;

        items.forEach((item) => {
          const shouldOpen = openIds.includes(item.getAttribute("id"));
          this.setState(instance, item, shouldOpen, false);
        });
      } catch (error) {
        this.debug("Unable to restore accordion state", error);
      }
    },

    lazyLoadPanel(instance, item) {
      const { settings, element } = instance;
      if (!settings.lazyLoad) return;

      const panel = item.querySelector(settings.panelSelector);
      if (!panel || panel.getAttribute("data-rx-loaded") === "true") return;

      const url = panel.getAttribute("data-rx-lazy-url");

      if (!url) {
        panel.setAttribute("data-rx-loaded", "true");
        return;
      }

      panel.setAttribute("data-rx-loading", "true");

      fetch(url, {
        method: "GET",
        credentials: "same-origin",
        headers: {
          "X-Requested-With": "XMLHttpRequest",
        },
      })
        .then((response) => {
          if (!response.ok) {
            throw new Error(`Accordion lazy load failed: ${response.status}`);
          }
          return response.text();
        })
        .then((html) => {
          panel.innerHTML = html;
          panel.setAttribute("data-rx-loaded", "true");
          panel.removeAttribute("data-rx-loading");

          this.dispatch(element, "rxAccordionLazyLoaded", {
            accordion: element,
            item,
            panel,
            html,
          });
        })
        .catch((error) => {
          panel.removeAttribute("data-rx-loading");
          panel.setAttribute("data-rx-load-error", "true");

          this.dispatch(element, "rxAccordionLazyError", {
            accordion: element,
            item,
            panel,
            error,
          });

          this.debug(error);
        });
    },

    scrollToItem(item, offset = 90) {
      const rect = item.getBoundingClientRect();
      const top = rect.top + window.pageYOffset - offset;

      window.scrollTo({
        top,
        behavior: "smooth",
      });
    },

    closeAll(accordionOrId) {
      const instance = this.getInstance(accordionOrId);
      if (!instance) return;

      instance.items.forEach((item) => {
        this.close(instance, item, false);
      });
    },

    openAll(accordionOrId) {
      const instance = this.getInstance(accordionOrId);
      if (!instance) return;

      instance.items.forEach((item) => {
        this.open(instance, item, false);
      });
    },

    destroy(accordionOrId) {
      const instance = this.getInstance(accordionOrId);
      if (!instance) return;

      const { element, settings, items } = instance;

      items.forEach((item) => {
        const trigger = item.querySelector(settings.triggerSelector);
        const panel = item.querySelector(settings.panelSelector);

        if (trigger) {
          trigger.removeAttribute("aria-expanded");
          trigger.removeAttribute("aria-controls");
        }

        if (panel) {
          panel.removeAttribute("role");
          panel.removeAttribute("aria-labelledby");
          panel.hidden = false;
          panel.style.height = "";
          panel.style.overflow = "";
          panel.style.transition = "";
        }

        item.classList.remove(
          settings.activeClass,
          settings.openingClass,
          settings.closingClass
        );

        item.removeAttribute("data-rx-state");
      });

      element.classList.remove(settings.initializedClass);
      this.instances.delete(instance.id);
    },

    refresh(root = document) {
      this.init({
        rootSelector: "[data-rx-accordion]",
      });

      const accordions = root.querySelectorAll("[data-rx-accordion]");
      accordions.forEach((accordion) => {
        const id = accordion.getAttribute("data-rx-accordion-id");
        const instance = this.instances.get(id);

        if (!instance) return;

        instance.items = this.getDirectItems(accordion, instance.settings);

        instance.items.forEach((item, index) => {
          this.setupItem(instance, item, index);
        });
      });
    },

    getInstance(accordionOrId) {
      if (typeof accordionOrId === "string") {
        return this.instances.get(accordionOrId) || null;
      }

      if (accordionOrId instanceof HTMLElement) {
        const id = accordionOrId.getAttribute("data-rx-accordion-id");
        return this.instances.get(id) || null;
      }

      return null;
    },

    bindGlobalEvents(settings) {
      if (this._globalEventsBound) return;
      this._globalEventsBound = true;

      document.addEventListener("click", (event) => {
        this.instances.forEach((instance) => {
          if (!instance.settings.closeOnOutsideClick) return;

          const clickedInside = instance.element.contains(event.target);

          if (!clickedInside) {
            instance.items.forEach((item) => {
              this.close(instance, item, true);
            });
          }
        });
      });

      window.addEventListener("hashchange", () => {
        this.instances.forEach((instance) => {
          if (!instance.settings.deepLink) return;

          const target = instance.element.querySelector(window.location.hash);
          if (!target) return;

          const item = target.matches(instance.settings.itemSelector)
            ? target
            : target.closest(instance.settings.itemSelector);

          if (item && instance.items.includes(item)) {
            this.open(instance, item, true);
          }
        });
      });

      document.addEventListener("rxAjaxContentLoaded", (event) => {
        const root = event.detail && event.detail.root ? event.detail.root : document;
        this.refresh(root);
      });
    },

    dispatch(element, eventName, detail = {}) {
      element.dispatchEvent(
        new CustomEvent(eventName, {
          detail,
          bubbles: true,
        })
      );
    },

    debug(...args) {
      if (window.RX_DEBUG === true) {
        console.warn("[RX Accordion]", ...args);
      }
    },
  };

  window.RXAccordion = RXAccordion;

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

Use this HTML structure:

<div 
  class="rx-accordion" 
  data-rx-accordion
  data-rx-single="true"
  data-rx-open-first="false"
  data-rx-remember="true"
  data-rx-deeplink="true"
  data-rx-animation="true"
  data-rx-duration="300"
>
  <div class="rx-accordion__item" data-rx-accordion-item id="faq-back-pain">
    <button class="rx-accordion__trigger" data-rx-accordion-trigger>
      What causes back pain?
      <span class="rx-accordion__icon" aria-hidden="true"></span>
    </button>

    <div class="rx-accordion__panel" data-rx-accordion-panel>
      <div class="rx-accordion__content">
        Back pain may come from muscle strain, disc problems, arthritis, poor posture, or nerve compression.
      </div>
    </div>
  </div>

  <div class="rx-accordion__item" data-rx-accordion-item id="faq-neck-pain">
    <button class="rx-accordion__trigger" data-rx-accordion-trigger>
      What causes neck pain?
      <span class="rx-accordion__icon" aria-hidden="true"></span>
    </button>

    <div class="rx-accordion__panel" data-rx-accordion-panel>
      <div class="rx-accordion__content">
        Neck pain may come from posture, muscle spasm, cervical disc disease, injury, or arthritis.
      </div>
    </div>
  </div>
</div>

Add this CSS in your accordion CSS file:

.rx-accordion {
  width: 100%;
}

.rx-accordion__item {
  border-bottom: 1px solid var(--rx-border-color, #e5e7eb);
}

.rx-accordion__trigger {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem 0;
  border: 0;
  background: transparent;
  color: var(--rx-text-color, #111827);
  font: inherit;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}

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

.rx-accordion__panel[hidden] {
  display: none;
}

.rx-accordion__content {
  padding: 0 0 1rem;
  color: var(--rx-muted-color, #4b5563);
  line-height: 1.7;
}

.rx-accordion__icon {
  width: 1rem;
  height: 1rem;
  position: relative;
  flex: 0 0 auto;
}

.rx-accordion__icon::before,
.rx-accordion__icon::after {
  content: "";
  position: absolute;
  background: currentColor;
  transition: transform 0.25s ease;
}

.rx-accordion__icon::before {
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  transform: translateY(-50%);
}

.rx-accordion__icon::after {
  left: 50%;
  top: 0;
  width: 2px;
  height: 100%;
  transform: translateX(-50%);
}

.rx-accordion__item.is-active .rx-accordion__icon::after {
  transform: translateX(-50%) rotate(90deg);
}

@media (prefers-reduced-motion: reduce) {
  .rx-accordion__panel,
  .rx-accordion__icon::before,
  .rx-accordion__icon::after {
    transition: none !important;
  }
}

Then enqueue it in functions.php:

function rx_theme_enqueue_accordion_script() {
    wp_enqueue_script(
        'rx-accordion',
        get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-010-accordion.js',
        array(),
        '1.0.0',
        true
    );
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_accordion_script' );

Best attributes for your RX Theme:

data-rx-single="true"

Means only one item opens at a time.

data-rx-remember="true"

Means browser remembers which accordion item was opened.

data-rx-deeplink="true"

Means this URL can open a specific item:

https://rxharun.com/example-page/#faq-back-pain

This file is safe as a static chunk file and good for advanced RX Theme frontend structure.

Leave a Reply

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