chunk-029-pagination.js

I’ll build this as a strong standalone pagination chunk for RX Theme: classic links, AJAX loading, infinite scroll, load-more button, accessibility, history URL updates, caching, and safe WordPress-friendly behavior.

Dear friend, create this file:

assets/static-js/static-chunks-js/chunk-029-pagination.js

Then paste this full advanced pagination code.

/*!
 * RX Theme - Chunk 029 Pagination
 * File: assets/static-js/static-chunks-js/chunk-029-pagination.js
 * Purpose:
 * - AJAX pagination
 * - Load More button
 * - Infinite scroll
 * - History URL update
 * - Browser back/forward support
 * - Accessibility focus management
 * - Skeleton loading
 * - Error handling
 * - Small in-memory cache
 * - WordPress archive/search/category/tag support
 */

(function () {
  'use strict';

  /**
   * RX Pagination Namespace
   */
  const RXPagination = {
    version: '1.0.0',

    config: {
      contentSelector: '[data-rx-posts], .rx-posts, .site-main, main',
      paginationSelector: '[data-rx-pagination], .rx-pagination, .navigation.pagination, nav.pagination',
      nextSelector: 'a.next, .next.page-numbers, [rel="next"], [data-rx-next]',
      prevSelector: 'a.prev, .prev.page-numbers, [rel="prev"], [data-rx-prev]',
      pageLinkSelector: '.page-numbers a, a.page-numbers, [data-rx-page-link]',
      loadMoreSelector: '[data-rx-load-more], .rx-load-more',
      infiniteRootSelector: '[data-rx-infinite-pagination]',
      ajaxContainerSelector: '[data-rx-ajax-container]',
      itemSelector: '[data-rx-post-item], article, .post, .rx-card',
      skeletonClass: 'rx-pagination-skeleton',
      loadingClass: 'rx-pagination-is-loading',
      disabledClass: 'rx-pagination-is-disabled',
      activeClass: 'rx-pagination-is-active',
      errorClass: 'rx-pagination-has-error',
      cacheLimit: 20,
      scrollOffset: 90,
      infiniteScrollOffset: 550,
      requestTimeout: 20000,
      useHistory: true,
      useCache: true,
      usePrefetch: true,
      useSkeleton: true,
      enableAjaxLinks: true,
      enableLoadMore: true,
      enableInfiniteScroll: true,
      debug: false
    },

    state: {
      isLoading: false,
      currentUrl: window.location.href,
      lastUrl: '',
      currentPage: 1,
      nextUrl: '',
      prevUrl: '',
      observer: null,
      controller: null,
      cache: new Map(),
      initialized: false
    },

    init() {
      if (this.state.initialized) return;

      this.readConfigFromBody();
      this.detectCurrentPage();
      this.updatePaginationState();

      this.bindPaginationLinks();
      this.bindLoadMore();
      this.bindInfiniteScroll();
      this.bindPopState();
      this.prefetchNextPage();

      this.state.initialized = true;

      this.log('RX Pagination initialized');
    },

    /**
     * Optional body config:
     * <body data-rx-pagination-history="false" data-rx-pagination-infinite="true">
     */
    readConfigFromBody() {
      const body = document.body;
      if (!body) return;

      if (body.dataset.rxPaginationHistory === 'false') {
        this.config.useHistory = false;
      }

      if (body.dataset.rxPaginationCache === 'false') {
        this.config.useCache = false;
      }

      if (body.dataset.rxPaginationPrefetch === 'false') {
        this.config.usePrefetch = false;
      }

      if (body.dataset.rxPaginationInfinite === 'false') {
        this.config.enableInfiniteScroll = false;
      }

      if (body.dataset.rxPaginationAjaxLinks === 'false') {
        this.config.enableAjaxLinks = false;
      }

      if (body.dataset.rxPaginationDebug === 'true') {
        this.config.debug = true;
      }
    },

    log(...args) {
      if (this.config.debug) {
        console.log('[RX Pagination]', ...args);
      }
    },

    warn(...args) {
      if (this.config.debug) {
        console.warn('[RX Pagination]', ...args);
      }
    },

    getContentContainer() {
      return (
        document.querySelector(this.config.ajaxContainerSelector) ||
        document.querySelector(this.config.contentSelector)
      );
    },

    getPaginationContainer() {
      return document.querySelector(this.config.paginationSelector);
    },

    updatePaginationState() {
      const pagination = this.getPaginationContainer();

      if (!pagination) {
        this.state.nextUrl = '';
        this.state.prevUrl = '';
        return;
      }

      const next = pagination.querySelector(this.config.nextSelector);
      const prev = pagination.querySelector(this.config.prevSelector);

      this.state.nextUrl = next ? next.href : '';
      this.state.prevUrl = prev ? prev.href : '';
    },

    detectCurrentPage() {
      const url = new URL(window.location.href);
      let page = 1;

      const pagedParam = url.searchParams.get('paged');
      if (pagedParam && !Number.isNaN(parseInt(pagedParam, 10))) {
        page = parseInt(pagedParam, 10);
      }

      const pathMatch = url.pathname.match(/\/page\/([0-9]+)\/?/i);
      if (pathMatch && pathMatch[1]) {
        page = parseInt(pathMatch[1], 10);
      }

      this.state.currentPage = page || 1;
    },

    bindPaginationLinks() {
      if (!this.config.enableAjaxLinks) return;

      document.addEventListener('click', (event) => {
        const link = event.target.closest(this.config.pageLinkSelector);

        if (!link) return;
        if (!this.isValidPaginationLink(link)) return;

        event.preventDefault();

        this.loadPage(link.href, {
          mode: 'replace',
          pushState: true,
          scroll: true,
          focus: true
        });
      });
    },

    bindLoadMore() {
      if (!this.config.enableLoadMore) return;

      document.addEventListener('click', (event) => {
        const button = event.target.closest(this.config.loadMoreSelector);

        if (!button) return;

        event.preventDefault();

        const nextUrl = button.dataset.rxNext || this.state.nextUrl;

        if (!nextUrl || this.state.isLoading) return;

        this.loadPage(nextUrl, {
          mode: 'append',
          pushState: true,
          scroll: false,
          focus: false,
          trigger: button
        });
      });
    },

    bindInfiniteScroll() {
      if (!this.config.enableInfiniteScroll) return;

      const infiniteRoot = document.querySelector(this.config.infiniteRootSelector);
      const pagination = this.getPaginationContainer();

      if (!infiniteRoot && !pagination) return;

      const sentinel = document.createElement('div');
      sentinel.className = 'rx-pagination-sentinel';
      sentinel.setAttribute('aria-hidden', 'true');

      const target = infiniteRoot || pagination;
      target.insertAdjacentElement('afterend', sentinel);

      this.state.observer = new IntersectionObserver(
        (entries) => {
          const entry = entries[0];

          if (!entry || !entry.isIntersecting) return;
          if (this.state.isLoading) return;
          if (!this.state.nextUrl) return;

          const root = document.querySelector(this.config.infiniteRootSelector);

          if (!root && !document.body.dataset.rxAutoInfinite) {
            return;
          }

          this.loadPage(this.state.nextUrl, {
            mode: 'append',
            pushState: true,
            scroll: false,
            focus: false
          });
        },
        {
          root: null,
          rootMargin: `${this.config.infiniteScrollOffset}px 0px`,
          threshold: 0
        }
      );

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

    bindPopState() {
      window.addEventListener('popstate', () => {
        this.loadPage(window.location.href, {
          mode: 'replace',
          pushState: false,
          scroll: true,
          focus: true
        });
      });
    },

    isValidPaginationLink(link) {
      if (!link || !link.href) return false;
      if (link.getAttribute('aria-disabled') === 'true') return false;
      if (link.classList.contains('current')) return false;
      if (link.classList.contains(this.config.disabledClass)) return false;
      if (link.target && link.target !== '_self') return false;

      const url = new URL(link.href, window.location.origin);

      if (url.origin !== window.location.origin) return false;

      return true;
    },

    async loadPage(url, options = {}) {
      const settings = Object.assign(
        {
          mode: 'replace',
          pushState: true,
          scroll: true,
          focus: false,
          trigger: null
        },
        options
      );

      if (!url || this.state.isLoading) return;

      const container = this.getContentContainer();

      if (!container) {
        this.warn('Content container not found.');
        window.location.href = url;
        return;
      }

      this.state.isLoading = true;
      this.state.lastUrl = this.state.currentUrl;

      this.setLoading(true, settings.trigger);

      try {
        let html;

        if (this.config.useCache && this.state.cache.has(url)) {
          html = this.state.cache.get(url);
          this.log('Loaded from cache:', url);
        } else {
          html = await this.fetchPage(url);
          this.addToCache(url, html);
        }

        const doc = this.parseHTML(html);

        this.renderPage(doc, url, settings);

        this.state.currentUrl = url;
        this.detectCurrentPageFromUrl(url);
        this.updatePaginationState();

        if (settings.pushState && this.config.useHistory) {
          history.pushState(
            {
              rxPagination: true,
              url
            },
            '',
            url
          );
        }

        if (settings.scroll) {
          this.scrollToContainer();
        }

        if (settings.focus) {
          this.focusContainer();
        }

        this.prefetchNextPage();

        this.dispatchEvent('rx:pagination:loaded', {
          url,
          mode: settings.mode,
          page: this.state.currentPage
        });
      } catch (error) {
        this.handleError(error, url);
      } finally {
        this.state.isLoading = false;
        this.setLoading(false, settings.trigger);
      }
    },

    fetchPage(url) {
      if (this.state.controller) {
        this.state.controller.abort();
      }

      this.state.controller = new AbortController();

      const timeout = setTimeout(() => {
        this.state.controller.abort();
      }, this.config.requestTimeout);

      return fetch(url, {
        method: 'GET',
        credentials: 'same-origin',
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          Accept: 'text/html,application/xhtml+xml'
        },
        signal: this.state.controller.signal
      })
        .then((response) => {
          clearTimeout(timeout);

          if (!response.ok) {
            throw new Error(`Request failed: ${response.status}`);
          }

          return response.text();
        })
        .catch((error) => {
          clearTimeout(timeout);
          throw error;
        });
    },

    parseHTML(html) {
      const parser = new DOMParser();
      return parser.parseFromString(html, 'text/html');
    },

    renderPage(doc, url, settings) {
      const currentContainer = this.getContentContainer();
      const newContainer =
        doc.querySelector(this.config.ajaxContainerSelector) ||
        doc.querySelector(this.config.contentSelector);

      const currentPagination = this.getPaginationContainer();
      const newPagination = doc.querySelector(this.config.paginationSelector);

      if (!currentContainer || !newContainer) {
        throw new Error('Pagination render container missing.');
      }

      if (settings.mode === 'append') {
        this.appendItems(currentContainer, newContainer);
      } else {
        currentContainer.innerHTML = newContainer.innerHTML;
      }

      if (currentPagination && newPagination) {
        currentPagination.innerHTML = newPagination.innerHTML;
      } else if (currentPagination && !newPagination) {
        currentPagination.remove();
      } else if (!currentPagination && newPagination) {
        currentContainer.insertAdjacentElement('afterend', newPagination.cloneNode(true));
      }

      this.updateDocumentMeta(doc);
      this.markActivePagination(url);
      this.lazyRefresh();
    },

    appendItems(currentContainer, newContainer) {
      const newItems = newContainer.querySelectorAll(this.config.itemSelector);

      if (!newItems.length) {
        currentContainer.insertAdjacentHTML('beforeend', newContainer.innerHTML);
        return;
      }

      const fragment = document.createDocumentFragment();

      newItems.forEach((item) => {
        const clone = item.cloneNode(true);
        clone.classList.add('rx-pagination-new-item');
        fragment.appendChild(clone);
      });

      currentContainer.appendChild(fragment);

      requestAnimationFrame(() => {
        currentContainer
          .querySelectorAll('.rx-pagination-new-item')
          .forEach((item) => {
            item.classList.remove('rx-pagination-new-item');
          });
      });
    },

    updateDocumentMeta(doc) {
      const newTitle = doc.querySelector('title');

      if (newTitle && newTitle.textContent) {
        document.title = newTitle.textContent;
      }

      this.replaceMetaTag(doc, 'meta[name="description"]');
      this.replaceMetaTag(doc, 'link[rel="canonical"]');
      this.replaceMetaTag(doc, 'link[rel="prev"]');
      this.replaceMetaTag(doc, 'link[rel="next"]');
    },

    replaceMetaTag(doc, selector) {
      const oldTag = document.head.querySelector(selector);
      const newTag = doc.head.querySelector(selector);

      if (oldTag && newTag) {
        oldTag.replaceWith(newTag.cloneNode(true));
      } else if (!oldTag && newTag) {
        document.head.appendChild(newTag.cloneNode(true));
      } else if (oldTag && !newTag) {
        oldTag.remove();
      }
    },

    markActivePagination(url) {
      const pagination = this.getPaginationContainer();
      if (!pagination) return;

      pagination.querySelectorAll('a').forEach((link) => {
        link.classList.remove(this.config.activeClass);
        link.removeAttribute('aria-current');

        if (link.href === url) {
          link.classList.add(this.config.activeClass);
          link.setAttribute('aria-current', 'page');
        }
      });
    },

    setLoading(isLoading, trigger) {
      const body = document.body;
      const container = this.getContentContainer();
      const pagination = this.getPaginationContainer();

      body.classList.toggle(this.config.loadingClass, isLoading);

      if (container) {
        container.classList.toggle(this.config.loadingClass, isLoading);
        container.setAttribute('aria-busy', isLoading ? 'true' : 'false');
      }

      if (pagination) {
        pagination.classList.toggle(this.config.loadingClass, isLoading);
      }

      if (trigger) {
        trigger.classList.toggle(this.config.loadingClass, isLoading);
        trigger.disabled = isLoading;
        trigger.setAttribute('aria-busy', isLoading ? 'true' : 'false');

        const loadingText = trigger.dataset.rxLoadingText;
        const defaultText = trigger.dataset.rxDefaultText || trigger.textContent;

        if (!trigger.dataset.rxDefaultText) {
          trigger.dataset.rxDefaultText = defaultText;
        }

        if (isLoading && loadingText) {
          trigger.textContent = loadingText;
        }

        if (!isLoading) {
          trigger.textContent = trigger.dataset.rxDefaultText;
        }
      }

      if (this.config.useSkeleton) {
        this.toggleSkeleton(isLoading);
      }
    },

    toggleSkeleton(show) {
      const container = this.getContentContainer();
      if (!container) return;

      let skeleton = document.querySelector(`.${this.config.skeletonClass}`);

      if (show) {
        if (skeleton) return;

        skeleton = document.createElement('div');
        skeleton.className = this.config.skeletonClass;
        skeleton.setAttribute('aria-hidden', 'true');

        skeleton.innerHTML = `
          <div class="rx-skeleton-card"></div>
          <div class="rx-skeleton-card"></div>
          <div class="rx-skeleton-card"></div>
        `;

        container.insertAdjacentElement('beforebegin', skeleton);
      } else if (skeleton) {
        skeleton.remove();
      }
    },

    scrollToContainer() {
      const container = this.getContentContainer();
      if (!container) return;

      const top =
        container.getBoundingClientRect().top +
        window.pageYOffset -
        this.config.scrollOffset;

      window.scrollTo({
        top: Math.max(top, 0),
        behavior: 'smooth'
      });
    },

    focusContainer() {
      const container = this.getContentContainer();
      if (!container) return;

      if (!container.hasAttribute('tabindex')) {
        container.setAttribute('tabindex', '-1');
      }

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

    detectCurrentPageFromUrl(url) {
      try {
        const parsed = new URL(url, window.location.origin);
        let page = 1;

        const param = parsed.searchParams.get('paged');

        if (param) {
          page = parseInt(param, 10);
        }

        const pathMatch = parsed.pathname.match(/\/page\/([0-9]+)\/?/i);

        if (pathMatch && pathMatch[1]) {
          page = parseInt(pathMatch[1], 10);
        }

        this.state.currentPage = page || 1;
      } catch (error) {
        this.state.currentPage = 1;
      }
    },

    addToCache(url, html) {
      if (!this.config.useCache) return;

      if (this.state.cache.has(url)) {
        this.state.cache.delete(url);
      }

      this.state.cache.set(url, html);

      while (this.state.cache.size > this.config.cacheLimit) {
        const firstKey = this.state.cache.keys().next().value;
        this.state.cache.delete(firstKey);
      }
    },

    prefetchNextPage() {
      if (!this.config.usePrefetch) return;
      if (!this.state.nextUrl) return;
      if (this.state.cache.has(this.state.nextUrl)) return;

      const nextUrl = this.state.nextUrl;

      if ('requestIdleCallback' in window) {
        requestIdleCallback(() => {
          this.prefetchUrl(nextUrl);
        });
      } else {
        setTimeout(() => {
          this.prefetchUrl(nextUrl);
        }, 1200);
      }
    },

    async prefetchUrl(url) {
      if (!url || this.state.cache.has(url)) return;

      try {
        const html = await fetch(url, {
          method: 'GET',
          credentials: 'same-origin',
          headers: {
            'X-Requested-With': 'XMLHttpRequest',
            Accept: 'text/html'
          }
        }).then((response) => {
          if (!response.ok) throw new Error('Prefetch failed');
          return response.text();
        });

        this.addToCache(url, html);
        this.log('Prefetched:', url);
      } catch (error) {
        this.warn('Prefetch error:', error);
      }
    },

    lazyRefresh() {
      /**
       * Refresh native lazy loading, third-party lazy libraries,
       * masonry/grid plugins, ads, analytics hooks if available.
       */

      document.querySelectorAll('img[data-src]').forEach((img) => {
        if (!img.getAttribute('src')) {
          img.setAttribute('src', img.dataset.src);
        }
      });

      if (window.lazySizes && typeof window.lazySizes.autoSizer === 'object') {
        window.lazySizes.autoSizer.checkElems();
      }

      if (window.Masonry && document.querySelector('.rx-masonry')) {
        document.querySelectorAll('.rx-masonry').forEach((grid) => {
          try {
            const masonry = Masonry.data(grid);
            if (masonry) masonry.reloadItems();
          } catch (error) {
            this.warn('Masonry refresh failed:', error);
          }
        });
      }

      if (window.rxTheme && typeof window.rxTheme.refresh === 'function') {
        window.rxTheme.refresh();
      }

      this.dispatchEvent('rx:pagination:refresh');
    },

    handleError(error, url) {
      this.warn('Pagination error:', error);

      const body = document.body;
      const pagination = this.getPaginationContainer();

      body.classList.add(this.config.errorClass);

      if (pagination) {
        pagination.classList.add(this.config.errorClass);
      }

      this.showErrorMessage(url);

      this.dispatchEvent('rx:pagination:error', {
        error,
        url
      });
    },

    showErrorMessage(url) {
      const pagination = this.getPaginationContainer();
      if (!pagination) return;

      let errorBox = pagination.querySelector('.rx-pagination-error-message');

      if (!errorBox) {
        errorBox = document.createElement('div');
        errorBox.className = 'rx-pagination-error-message';
        errorBox.setAttribute('role', 'alert');
        pagination.appendChild(errorBox);
      }

      errorBox.innerHTML = `
        <p>Pagination loading failed. Please try again.</p>
        <a href="${this.escapeAttr(url)}">Open this page normally</a>
      `;
    },

    escapeAttr(value) {
      return String(value)
        .replace(/&/g, '&amp;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
    },

    dispatchEvent(name, detail = {}) {
      document.dispatchEvent(
        new CustomEvent(name, {
          bubbles: true,
          detail
        })
      );
    },

    destroy() {
      if (this.state.observer) {
        this.state.observer.disconnect();
        this.state.observer = null;
      }

      if (this.state.controller) {
        this.state.controller.abort();
        this.state.controller = null;
      }

      this.state.cache.clear();
      this.state.initialized = false;
    }
  };

  /**
   * Public API
   */
  window.RXThemePagination = RXPagination;

  /**
   * Auto init
   */
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => RXPagination.init());
  } else {
    RXPagination.init();
  }
})();

Add this small CSS in your theme CSS file, for example:

assets/css/components/pagination.css

.rx-pagination-is-loading {
  pointer-events: none;
  opacity: 0.75;
}

.rx-pagination-skeleton {
  display: grid;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.rx-skeleton-card {
  min-height: 160px;
  border-radius: 14px;
  background: linear-gradient(
    90deg,
    rgba(0, 0, 0, 0.06),
    rgba(0, 0, 0, 0.11),
    rgba(0, 0, 0, 0.06)
  );
  background-size: 200% 100%;
  animation: rxSkeletonLoading 1.2s linear infinite;
}

.rx-pagination-new-item {
  animation: rxPaginationFadeIn 0.35s ease both;
}

.rx-pagination-error-message {
  margin-top: 1rem;
  padding: 1rem;
  border-radius: 12px;
  background: #fff4f4;
  border: 1px solid #ffd0d0;
}

.rx-pagination-error-message p {
  margin: 0 0 0.5rem;
}

.rx-pagination-is-active,
.page-numbers.current {
  font-weight: 700;
}

.rx-pagination-sentinel {
  width: 100%;
  height: 1px;
}

@keyframes rxSkeletonLoading {
  from {
    background-position: 200% 0;
  }

  to {
    background-position: -200% 0;
  }
}

@keyframes rxPaginationFadeIn {
  from {
    opacity: 0;
    transform: translateY(12px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Use this HTML structure in archive/category/search templates:

<main id="primary" class="site-main rx-site-main">
  <div class="rx-posts" data-rx-ajax-container data-rx-posts>
    <?php if ( have_posts() ) : ?>
      <?php while ( have_posts() ) : the_post(); ?>
        <article <?php post_class('rx-card'); ?> data-rx-post-item>
          <h2>
            <a href="<?php the_permalink(); ?>">
              <?php the_title(); ?>
            </a>
          </h2>

          <div class="rx-excerpt">
            <?php the_excerpt(); ?>
          </div>
        </article>
      <?php endwhile; ?>
    <?php endif; ?>
  </div>

  <nav class="rx-pagination" data-rx-pagination aria-label="Posts pagination">
    <?php
    the_posts_pagination(array(
      'mid_size'  => 2,
      'prev_text' => esc_html__('Previous', 'rx-theme'),
      'next_text' => esc_html__('Next', 'rx-theme'),
    ));
    ?>
  </nav>

  <button
    class="rx-load-more"
    data-rx-load-more
    data-rx-loading-text="Loading..."
    type="button"
  >
    Load More
  </button>
</main>

Enqueue it in functions.php or your enqueue file:

function rx_theme_enqueue_pagination_chunk() {
    wp_enqueue_script(
        'rx-chunk-029-pagination',
        get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-029-pagination.js',
        array(),
        wp_get_theme()->get('Version'),
        true
    );
}
add_action('wp_enqueue_scripts', 'rx_theme_enqueue_pagination_chunk');

For automatic infinite scroll, add this attribute to body or wrapper:

<div data-rx-infinite-pagination>

or add this in body:

<body data-rx-auto-infinite="true">

Best practical setup for RX Theme:

<div class="rx-posts" data-rx-ajax-container data-rx-posts>

Each post/card:

<article data-rx-post-item>

Pagination wrapper:

<nav data-rx-pagination>

Load more button:

<button data-rx-load-more data-rx-loading-text="Loading...">Load More</button>

Leave a Reply

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