chunk-027-load-more.js

I’ll build this as a standalone advanced “Load More” chunk for RX Theme, designed for WordPress/AJAX, REST fallback, accessibility, caching, pagination safety, and easy integration with your theme buttons.

Yes dear friend. Put this full advanced code inside:

assets/static-js/static-chunks-js/chunk-027-load-more.js

This file supports WordPress AJAX, REST API fallback, button load more, infinite scroll, accessibility, history URL update, cache, retry, loading skeleton, duplicate post protection, and custom events for future RX Theme expansion.

/*!
 * RX Theme - Advanced Load More Chunk
 * File: assets/static-js/static-chunks-js/chunk-027-load-more.js
 * Version: 1.0.0
 *
 * Required optional global config from PHP:
 *
 * window.RX_LOAD_MORE_CONFIG = {
 *   ajaxUrl: "https://example.com/wp-admin/admin-ajax.php",
 *   restUrl: "https://example.com/wp-json/rx-theme/v1/load-more",
 *   nonce: "YOUR_NONCE",
 *   action: "rx_theme_load_more",
 *   postsContainer: "[data-rx-posts-container]",
 *   loadMoreButton: "[data-rx-load-more]",
 *   paginationWrapper: "[data-rx-pagination]",
 *   nextLink: ".next.page-numbers",
 *   postItem: "[data-rx-post-id]",
 *   pageParam: "rxpage",
 *   startPage: 1,
 *   maxPages: 1,
 *   queryArgs: {},
 *   infiniteScroll: false,
 *   updateHistory: true,
 *   cache: true,
 *   debug: false
 * };
 */

(function () {
  "use strict";

  if (window.RXThemeLoadMoreLoaded) {
    return;
  }

  window.RXThemeLoadMoreLoaded = true;

  var RXLoadMore = {
    version: "1.0.0",

    defaults: {
      ajaxUrl: "",
      restUrl: "",
      nonce: "",
      action: "rx_theme_load_more",

      postsContainer: "[data-rx-posts-container]",
      loadMoreButton: "[data-rx-load-more]",
      paginationWrapper: "[data-rx-pagination]",
      nextLink: ".next.page-numbers",
      postItem: "[data-rx-post-id]",

      pageParam: "rxpage",
      startPage: 1,
      maxPages: 1,
      queryArgs: {},

      method: "POST",
      responseType: "json",

      infiniteScroll: false,
      infiniteRootMargin: "500px 0px",
      infiniteThreshold: 0,

      updateHistory: true,
      cache: true,
      cacheLimit: 20,

      appendMode: "append",
      scrollAfterLoad: false,
      focusFirstNewPost: false,

      loadingClass: "is-loading",
      loadedClass: "is-loaded",
      disabledClass: "is-disabled",
      hiddenClass: "is-hidden",
      errorClass: "has-error",

      buttonLoadingText: "Loading...",
      buttonDefaultText: "Load More",
      buttonEndText: "No more posts",
      buttonErrorText: "Try again",

      liveRegionText: "More posts loaded.",
      endMessageText: "No more posts available.",
      errorMessageText: "Unable to load more posts. Please try again.",

      skeleton: true,
      skeletonCount: 3,
      skeletonClass: "rx-load-more-skeleton",

      retry: true,
      retryLimit: 2,
      retryDelay: 900,

      duplicateProtection: true,
      debug: false
    },

    state: {
      config: {},
      page: 1,
      maxPages: 1,
      isLoading: false,
      isFinished: false,
      retryCount: 0,
      cache: new Map(),
      loadedPostIds: new Set(),
      observer: null,
      abortController: null,

      elements: {
        container: null,
        button: null,
        pagination: null,
        liveRegion: null,
        sentinel: null
      }
    },

    init: function () {
      this.state.config = this.mergeOptions(
        this.defaults,
        window.RX_LOAD_MORE_CONFIG || {}
      );

      this.state.page = this.getStartPage();
      this.state.maxPages = this.getMaxPages();

      this.cacheElements();
      this.collectExistingPostIds();
      this.createLiveRegion();
      this.createSentinel();
      this.bindEvents();
      this.setupInfiniteScroll();
      this.updateButtonState();

      this.log("RX Load More initialized", this.state.config);
      this.dispatch("rx-load-more:init", {
        page: this.state.page,
        maxPages: this.state.maxPages
      });
    },

    mergeOptions: function (defaults, options) {
      var merged = {};
      var key;

      for (key in defaults) {
        if (Object.prototype.hasOwnProperty.call(defaults, key)) {
          merged[key] = defaults[key];
        }
      }

      for (key in options) {
        if (Object.prototype.hasOwnProperty.call(options, key)) {
          if (
            typeof options[key] === "object" &&
            options[key] !== null &&
            !Array.isArray(options[key])
          ) {
            merged[key] = this.mergeOptions(merged[key] || {}, options[key]);
          } else {
            merged[key] = options[key];
          }
        }
      }

      return merged;
    },

    cacheElements: function () {
      var config = this.state.config;

      this.state.elements.container = document.querySelector(config.postsContainer);
      this.state.elements.button = document.querySelector(config.loadMoreButton);
      this.state.elements.pagination = document.querySelector(config.paginationWrapper);

      if (!this.state.elements.container) {
        this.warn("Posts container not found:", config.postsContainer);
      }

      if (!this.state.elements.button) {
        this.warn("Load more button not found:", config.loadMoreButton);
      }
    },

    bindEvents: function () {
      var self = this;
      var button = this.state.elements.button;

      if (button) {
        button.addEventListener("click", function (event) {
          event.preventDefault();
          self.loadNextPage();
        });
      }

      document.addEventListener("rx-theme:load-more", function () {
        self.loadNextPage();
      });

      window.addEventListener("beforeunload", function () {
        self.abortRequest();
      });
    },

    getStartPage: function () {
      var config = this.state.config;
      var urlPage = this.getPageFromUrl();

      if (urlPage && urlPage > 0) {
        return urlPage;
      }

      return parseInt(config.startPage, 10) || 1;
    },

    getMaxPages: function () {
      var config = this.state.config;
      var button = this.state.elements.button;
      var maxPages = parseInt(config.maxPages, 10) || 1;

      if (button && button.dataset.rxMaxPages) {
        maxPages = parseInt(button.dataset.rxMaxPages, 10) || maxPages;
      }

      return maxPages;
    },

    getPageFromUrl: function () {
      try {
        var config = this.state.config;
        var params = new URLSearchParams(window.location.search);
        var value = params.get(config.pageParam);

        return value ? parseInt(value, 10) : null;
      } catch (error) {
        return null;
      }
    },

    getNextPage: function () {
      return this.state.page + 1;
    },

    canLoadMore: function () {
      if (this.state.isLoading) {
        return false;
      }

      if (this.state.isFinished) {
        return false;
      }

      if (this.state.page >= this.state.maxPages) {
        return false;
      }

      if (!this.state.elements.container) {
        return false;
      }

      return true;
    },

    loadNextPage: function () {
      var nextPage = this.getNextPage();

      if (!this.canLoadMore()) {
        this.finishIfNeeded();
        return;
      }

      this.loadPage(nextPage);
    },

    loadPage: function (page) {
      var self = this;
      var cacheKey = this.getCacheKey(page);

      if (this.state.config.cache && this.state.cache.has(cacheKey)) {
        this.log("Serving page from cache:", page);
        this.handleSuccess(this.state.cache.get(cacheKey), page, true);
        return;
      }

      this.setLoading(true);
      this.clearError();
      this.showSkeletons();

      this.fetchPage(page)
        .then(function (response) {
          if (self.state.config.cache) {
            self.setCache(cacheKey, response);
          }

          self.handleSuccess(response, page, false);
        })
        .catch(function (error) {
          self.handleError(error, page);
        })
        .finally(function () {
          self.hideSkeletons();
          self.setLoading(false);
        });
    },

    fetchPage: function (page) {
      var config = this.state.config;

      this.abortRequest();

      if ("AbortController" in window) {
        this.state.abortController = new AbortController();
      }

      if (config.restUrl) {
        return this.fetchRest(page);
      }

      if (config.ajaxUrl) {
        return this.fetchAjax(page);
      }

      return this.fetchNextLink(page);
    },

    fetchAjax: function (page) {
      var config = this.state.config;
      var formData = new FormData();

      formData.append("action", config.action);
      formData.append("nonce", config.nonce);
      formData.append("page", String(page));
      formData.append("query_args", JSON.stringify(config.queryArgs || {}));

      return fetch(config.ajaxUrl, {
        method: "POST",
        body: formData,
        credentials: "same-origin",
        signal: this.getAbortSignal()
      }).then(this.parseResponse.bind(this));
    },

    fetchRest: function (page) {
      var config = this.state.config;
      var url = new URL(config.restUrl, window.location.origin);

      url.searchParams.set("page", String(page));

      Object.keys(config.queryArgs || {}).forEach(function (key) {
        var value = config.queryArgs[key];

        if (Array.isArray(value)) {
          value.forEach(function (item) {
            url.searchParams.append(key + "[]", item);
          });
        } else if (value !== undefined && value !== null) {
          url.searchParams.set(key, value);
        }
      });

      return fetch(url.toString(), {
        method: "GET",
        credentials: "same-origin",
        headers: {
          "X-WP-Nonce": config.nonce || "",
          "Accept": "application/json"
        },
        signal: this.getAbortSignal()
      }).then(this.parseResponse.bind(this));
    },

    fetchNextLink: function () {
      var config = this.state.config;
      var nextLink = document.querySelector(config.nextLink);

      if (!nextLink || !nextLink.href) {
        return Promise.reject(new Error("Next pagination link not found."));
      }

      return fetch(nextLink.href, {
        method: "GET",
        credentials: "same-origin",
        signal: this.getAbortSignal()
      })
        .then(function (response) {
          if (!response.ok) {
            throw new Error("Network response was not OK.");
          }

          return response.text();
        })
        .then(function (html) {
          return {
            success: true,
            html: html,
            source: "next-link"
          };
        });
    },

    parseResponse: function (response) {
      if (!response.ok) {
        throw new Error("Request failed with status " + response.status);
      }

      var contentType = response.headers.get("content-type") || "";

      if (contentType.indexOf("application/json") !== -1) {
        return response.json();
      }

      return response.text().then(function (html) {
        return {
          success: true,
          html: html
        };
      });
    },

    handleSuccess: function (response, page, fromCache) {
      var normalized = this.normalizeResponse(response);
      var html = normalized.html;
      var maxPages = normalized.maxPages;
      var foundPosts = normalized.foundPosts;

      if (maxPages) {
        this.state.maxPages = parseInt(maxPages, 10);
      }

      if (!html) {
        this.finish();
        return;
      }

      var newNodes = this.htmlToNodes(html);

      if (!newNodes.length) {
        this.finish();
        return;
      }

      var filteredNodes = this.filterDuplicatePosts(newNodes);

      if (!filteredNodes.length) {
        this.finish();
        return;
      }

      this.appendNodes(filteredNodes);

      this.state.page = page;
      this.state.retryCount = 0;

      this.collectPostIdsFromNodes(filteredNodes);
      this.updateHistory(page);
      this.updatePaginationFromResponse(normalized);
      this.updateButtonState();
      this.announce(this.state.config.liveRegionText);

      if (this.state.config.scrollAfterLoad) {
        this.scrollToFirstNode(filteredNodes[0]);
      }

      if (this.state.config.focusFirstNewPost) {
        this.focusNode(filteredNodes[0]);
      }

      this.dispatch("rx-load-more:success", {
        page: page,
        maxPages: this.state.maxPages,
        foundPosts: foundPosts,
        fromCache: !!fromCache,
        nodes: filteredNodes
      });

      this.finishIfNeeded();
    },

    normalizeResponse: function (response) {
      var data = response;

      if (typeof response === "string") {
        return {
          success: true,
          html: response
        };
      }

      if (response && response.data) {
        data = response.data;
      }

      return {
        success: response.success !== false,
        html: data.html || data.posts || data.content || "",
        maxPages: data.max_pages || data.maxPages || data.total_pages || null,
        foundPosts: data.found_posts || data.foundPosts || null,
        nextUrl: data.next_url || data.nextUrl || "",
        pagination: data.pagination || ""
      };
    },

    htmlToNodes: function (html) {
      var config = this.state.config;
      var template = document.createElement("template");
      var nodes = [];

      template.innerHTML = html.trim();

      var foundPostItems = template.content.querySelectorAll(config.postItem);

      if (foundPostItems.length) {
        nodes = Array.prototype.slice.call(foundPostItems);
      } else {
        nodes = Array.prototype.slice.call(template.content.children);
      }

      return nodes;
    },

    appendNodes: function (nodes) {
      var container = this.state.elements.container;
      var config = this.state.config;
      var fragment = document.createDocumentFragment();

      nodes.forEach(function (node) {
        node.classList.add("rx-load-more-new-item");
        fragment.appendChild(node);
      });

      if (config.appendMode === "prepend") {
        container.insertBefore(fragment, container.firstChild);
      } else if (config.appendMode === "replace") {
        container.innerHTML = "";
        container.appendChild(fragment);
      } else {
        container.appendChild(fragment);
      }

      requestAnimationFrame(function () {
        nodes.forEach(function (node) {
          node.classList.add("rx-load-more-item-visible");
        });
      });
    },

    collectExistingPostIds: function () {
      var config = this.state.config;
      var container = this.state.elements.container;

      if (!container) {
        return;
      }

      var posts = container.querySelectorAll(config.postItem);
      var self = this;

      posts.forEach(function (post) {
        var id = self.getPostId(post);

        if (id) {
          self.state.loadedPostIds.add(id);
        }
      });
    },

    collectPostIdsFromNodes: function (nodes) {
      var self = this;

      nodes.forEach(function (node) {
        var id = self.getPostId(node);

        if (id) {
          self.state.loadedPostIds.add(id);
        }
      });
    },

    getPostId: function (node) {
      if (!node || !node.dataset) {
        return "";
      }

      return (
        node.dataset.rxPostId ||
        node.dataset.postId ||
        node.getAttribute("id") ||
        ""
      );
    },

    filterDuplicatePosts: function (nodes) {
      var self = this;
      var config = this.state.config;

      if (!config.duplicateProtection) {
        return nodes;
      }

      return nodes.filter(function (node) {
        var id = self.getPostId(node);

        if (!id) {
          return true;
        }

        return !self.state.loadedPostIds.has(id);
      });
    },

    setLoading: function (isLoading) {
      var button = this.state.elements.button;
      var config = this.state.config;

      this.state.isLoading = isLoading;

      if (!button) {
        return;
      }

      if (isLoading) {
        button.classList.add(config.loadingClass);
        button.setAttribute("aria-busy", "true");
        button.setAttribute("disabled", "disabled");
        button.textContent = button.dataset.loadingText || config.buttonLoadingText;
      } else {
        button.classList.remove(config.loadingClass);
        button.removeAttribute("aria-busy");

        if (!this.state.isFinished) {
          button.removeAttribute("disabled");
          button.textContent = button.dataset.defaultText || config.buttonDefaultText;
        }
      }
    },

    updateButtonState: function () {
      var button = this.state.elements.button;
      var config = this.state.config;

      if (!button) {
        return;
      }

      if (this.state.page >= this.state.maxPages || this.state.isFinished) {
        button.classList.add(config.disabledClass);
        button.setAttribute("disabled", "disabled");
        button.setAttribute("aria-disabled", "true");
        button.textContent = button.dataset.endText || config.buttonEndText;
      } else {
        button.classList.remove(config.disabledClass);
        button.removeAttribute("disabled");
        button.setAttribute("aria-disabled", "false");
        button.textContent = button.dataset.defaultText || config.buttonDefaultText;
      }
    },

    finishIfNeeded: function () {
      if (this.state.page >= this.state.maxPages) {
        this.finish();
      }
    },

    finish: function () {
      var config = this.state.config;
      var button = this.state.elements.button;

      this.state.isFinished = true;

      if (button) {
        button.classList.add(config.disabledClass);
        button.setAttribute("disabled", "disabled");
        button.setAttribute("aria-disabled", "true");
        button.textContent = button.dataset.endText || config.buttonEndText;
      }

      if (this.state.observer) {
        this.state.observer.disconnect();
      }

      this.announce(config.endMessageText);

      this.dispatch("rx-load-more:finish", {
        page: this.state.page,
        maxPages: this.state.maxPages
      });
    },

    handleError: function (error, page) {
      var config = this.state.config;
      var button = this.state.elements.button;

      if (error && error.name === "AbortError") {
        return;
      }

      this.warn("Load more error:", error);

      if (
        config.retry &&
        this.state.retryCount < config.retryLimit
      ) {
        this.state.retryCount += 1;

        var self = this;

        window.setTimeout(function () {
          self.loadPage(page);
        }, config.retryDelay * this.state.retryCount);

        return;
      }

      if (button) {
        button.classList.add(config.errorClass);
        button.removeAttribute("disabled");
        button.textContent = button.dataset.errorText || config.buttonErrorText;
      }

      this.announce(config.errorMessageText);

      this.dispatch("rx-load-more:error", {
        page: page,
        error: error
      });
    },

    clearError: function () {
      var button = this.state.elements.button;

      if (button) {
        button.classList.remove(this.state.config.errorClass);
      }
    },

    createLiveRegion: function () {
      var liveRegion = document.createElement("div");

      liveRegion.className = "rx-load-more-live-region screen-reader-text";
      liveRegion.setAttribute("aria-live", "polite");
      liveRegion.setAttribute("aria-atomic", "true");

      document.body.appendChild(liveRegion);

      this.state.elements.liveRegion = liveRegion;
    },

    announce: function (message) {
      var liveRegion = this.state.elements.liveRegion;

      if (!liveRegion || !message) {
        return;
      }

      liveRegion.textContent = "";

      window.setTimeout(function () {
        liveRegion.textContent = message;
      }, 80);
    },

    createSentinel: function () {
      var container = this.state.elements.container;

      if (!container) {
        return;
      }

      var sentinel = document.createElement("div");

      sentinel.className = "rx-load-more-sentinel";
      sentinel.setAttribute("aria-hidden", "true");

      container.parentNode.insertBefore(sentinel, container.nextSibling);

      this.state.elements.sentinel = sentinel;
    },

    setupInfiniteScroll: function () {
      var config = this.state.config;
      var sentinel = this.state.elements.sentinel;
      var self = this;

      if (!config.infiniteScroll || !sentinel || !("IntersectionObserver" in window)) {
        return;
      }

      this.state.observer = new IntersectionObserver(
        function (entries) {
          entries.forEach(function (entry) {
            if (entry.isIntersecting) {
              self.loadNextPage();
            }
          });
        },
        {
          root: null,
          rootMargin: config.infiniteRootMargin,
          threshold: config.infiniteThreshold
        }
      );

      this.state.observer.observe(sentinel);
    },

    showSkeletons: function () {
      var config = this.state.config;
      var container = this.state.elements.container;

      if (!config.skeleton || !container) {
        return;
      }

      this.hideSkeletons();

      var wrapper = document.createElement("div");
      wrapper.className = "rx-load-more-skeleton-wrapper";
      wrapper.setAttribute("data-rx-skeleton-wrapper", "true");

      for (var i = 0; i < config.skeletonCount; i += 1) {
        var skeleton = document.createElement("div");
        skeleton.className = config.skeletonClass;
        skeleton.setAttribute("aria-hidden", "true");
        wrapper.appendChild(skeleton);
      }

      container.parentNode.insertBefore(wrapper, container.nextSibling);
    },

    hideSkeletons: function () {
      var skeletons = document.querySelectorAll("[data-rx-skeleton-wrapper]");

      skeletons.forEach(function (item) {
        item.parentNode.removeChild(item);
      });
    },

    updateHistory: function (page) {
      var config = this.state.config;

      if (!config.updateHistory || !window.history || !window.history.replaceState) {
        return;
      }

      try {
        var url = new URL(window.location.href);

        if (page > 1) {
          url.searchParams.set(config.pageParam, String(page));
        } else {
          url.searchParams.delete(config.pageParam);
        }

        window.history.replaceState(
          {
            rxLoadMorePage: page
          },
          document.title,
          url.toString()
        );
      } catch (error) {
        this.warn("History update failed:", error);
      }
    },

    updatePaginationFromResponse: function (data) {
      var pagination = this.state.elements.pagination;

      if (!pagination || !data.pagination) {
        return;
      }

      pagination.innerHTML = data.pagination;
    },

    scrollToFirstNode: function (node) {
      if (!node || !node.scrollIntoView) {
        return;
      }

      node.scrollIntoView({
        behavior: "smooth",
        block: "start"
      });
    },

    focusNode: function (node) {
      if (!node) {
        return;
      }

      if (!node.hasAttribute("tabindex")) {
        node.setAttribute("tabindex", "-1");
      }

      node.focus({
        preventScroll: true
      });
    },

    getCacheKey: function (page) {
      var config = this.state.config;

      return JSON.stringify({
        page: page,
        queryArgs: config.queryArgs || {}
      });
    },

    setCache: function (key, value) {
      var cache = this.state.cache;
      var limit = this.state.config.cacheLimit;

      cache.set(key, value);

      if (cache.size > limit) {
        var firstKey = cache.keys().next().value;
        cache.delete(firstKey);
      }
    },

    getAbortSignal: function () {
      if (this.state.abortController) {
        return this.state.abortController.signal;
      }

      return undefined;
    },

    abortRequest: function () {
      if (this.state.abortController) {
        this.state.abortController.abort();
        this.state.abortController = null;
      }
    },

    dispatch: function (name, detail) {
      var event;

      if (typeof CustomEvent === "function") {
        event = new CustomEvent(name, {
          detail: detail || {}
        });
      } else {
        event = document.createEvent("CustomEvent");
        event.initCustomEvent(name, true, true, detail || {});
      }

      document.dispatchEvent(event);
    },

    log: function () {
      if (!this.state.config.debug) {
        return;
      }

      if (window.console && console.log) {
        console.log.apply(console, arguments);
      }
    },

    warn: function () {
      if (!this.state.config.debug) {
        return;
      }

      if (window.console && console.warn) {
        console.warn.apply(console, arguments);
      }
    }
  };

  function ready(callback) {
    if (
      document.readyState === "complete" ||
      document.readyState === "interactive"
    ) {
      window.setTimeout(callback, 0);
    } else {
      document.addEventListener("DOMContentLoaded", callback);
    }
  }

  ready(function () {
    RXLoadMore.init();
  });

  window.RXThemeLoadMore = RXLoadMore;
})();

Minimum HTML structure for this file

Use this in your archive/home/category template:

<div data-rx-posts-container class="rx-post-grid">
  <article data-rx-post-id="101">
    Post content
  </article>
</div>

<button
  type="button"
  data-rx-load-more
  data-rx-max-pages="10"
>
  Load More
</button>

<nav data-rx-pagination>
  Pagination here
</nav>

Important PHP enqueue idea

Later in functions.php or inc/core/enqueue.php, you can localize the config like this:

wp_enqueue_script(
    'rx-theme-load-more',
    get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-027-load-more.js',
    array(),
    '1.0.0',
    true
);

wp_localize_script(
    'rx-theme-load-more',
    'RX_LOAD_MORE_CONFIG',
    array(
        'ajaxUrl'        => admin_url( 'admin-ajax.php' ),
        'restUrl'        => '',
        'nonce'          => wp_create_nonce( 'rx_theme_load_more_nonce' ),
        'action'         => 'rx_theme_load_more',
        'postsContainer' => '[data-rx-posts-container]',
        'loadMoreButton' => '[data-rx-load-more]',
        'paginationWrapper' => '[data-rx-pagination]',
        'postItem'       => '[data-rx-post-id]',
        'startPage'      => max( 1, get_query_var( 'paged' ) ),
        'maxPages'       => $GLOBALS['wp_query']->max_num_pages,
        'infiniteScroll' => false,
        'updateHistory'  => true,
        'cache'          => true,
        'debug'          => false,
    )
);

This JS file is now ready as an advanced RX Theme frontend chunk.

Leave a Reply

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