ajax-load-more.js

I’ll build this as a strong reusable ajax-load-more.js for RX Theme, with button loading, infinite scroll, filters, search, pagination states, accessibility, error handling, and WordPress AJAX/REST compatibility.

My dear friend, create this file:

rx-theme/inc/js/ajax-load-more.js

Paste the full code below.

/*!
 * RX Theme - Advanced Ajax Load More
 * File: inc/js/ajax-load-more.js
 *
 * Features:
 * - Load more button
 * - Infinite scroll
 * - Auto pagination
 * - Category / tag / taxonomy filter support
 * - Search support
 * - Author / post type / order support
 * - Skeleton loading
 * - Spinner loading
 * - Duplicate post prevention
 * - Accessibility messages
 * - Browser URL update
 * - Back/forward state support
 * - Retry on network error
 * - Abort old request
 * - REST API or admin-ajax.php support
 * - Works with multiple load-more sections
 */

(function () {
  'use strict';

  /**
   * Default settings.
   * These can be overridden by window.rxAjaxLoadMore.
   */
  var defaults = {
    ajaxUrl: '',
    restUrl: '',
    nonce: '',
    action: 'rx_load_more_posts',

    containerSelector: '[data-rx-post-container]',
    itemSelector: '[data-rx-post-item]',
    buttonSelector: '[data-rx-load-more]',
    statusSelector: '[data-rx-load-status]',
    filterSelector: '[data-rx-filter]',
    searchSelector: '[data-rx-search]',
    formSelector: '[data-rx-filter-form]',

    mode: 'button', // button, scroll, mixed
    method: 'POST', // POST or GET
    apiType: 'ajax', // ajax or rest

    page: 1,
    maxPages: 1,
    postsPerPage: 10,
    postType: 'post',
    order: 'DESC',
    orderby: 'date',

    category: '',
    tag: '',
    taxonomy: '',
    term: '',
    author: '',
    search: '',
    exclude: [],
    include: [],

    updateUrl: true,
    pushState: true,
    preventDuplicates: true,
    autoInit: true,

    scrollOffset: 400,
    debounceDelay: 350,
    retryLimit: 2,
    timeout: 20000,

    loadingClass: 'rx-is-loading',
    loadedClass: 'rx-is-loaded',
    errorClass: 'rx-has-error',
    hiddenClass: 'rx-hidden',
    disabledClass: 'rx-is-disabled',
    activeClass: 'rx-is-active',

    loadingText: 'Loading...',
    loadMoreText: 'Load More',
    noMoreText: 'No more posts',
    errorText: 'Something went wrong. Please try again.',
    emptyText: 'No posts found.',
    retryText: 'Retry',

    enableSkeleton: true,
    skeletonCount: 3,
    skeletonClass: 'rx-post-skeleton',

    enableDebug: false
  };

  var config = extend(defaults, window.rxAjaxLoadMore || {});
  var instances = [];

  /**
   * Small helper: object merge.
   */
  function extend(target) {
    var output = {};
    var i;
    var key;

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

    for (i = 1; i < arguments.length; i++) {
      if (!arguments[i]) {
        continue;
      }

      for (key in arguments[i]) {
        if (Object.prototype.hasOwnProperty.call(arguments[i], key)) {
          output[key] = arguments[i][key];
        }
      }
    }

    return output;
  }

  /**
   * Debug logger.
   */
  function debug() {
    if (!config.enableDebug || !window.console) {
      return;
    }

    console.log.apply(console, ['RX Ajax Load More:'].concat(Array.prototype.slice.call(arguments)));
  }

  /**
   * Debounce helper.
   */
  function debounce(fn, delay) {
    var timer = null;

    return function () {
      var context = this;
      var args = arguments;

      clearTimeout(timer);

      timer = setTimeout(function () {
        fn.apply(context, args);
      }, delay);
    };
  }

  /**
   * Convert NodeList to array.
   */
  function toArray(list) {
    return Array.prototype.slice.call(list || []);
  }

  /**
   * Get closest parent.
   */
  function closest(el, selector) {
    if (!el) {
      return null;
    }

    if (el.closest) {
      return el.closest(selector);
    }

    while (el && el.nodeType === 1) {
      if (matches(el, selector)) {
        return el;
      }

      el = el.parentNode;
    }

    return null;
  }

  /**
   * Matches helper.
   */
  function matches(el, selector) {
    var proto = Element.prototype;
    var fn =
      proto.matches ||
      proto.webkitMatchesSelector ||
      proto.mozMatchesSelector ||
      proto.msMatchesSelector ||
      proto.oMatchesSelector;

    if (!fn) {
      return false;
    }

    return fn.call(el, selector);
  }

  /**
   * Serialize form data.
   */
  function serializeForm(form) {
    var data = {};

    if (!form) {
      return data;
    }

    var fields = form.querySelectorAll('input, select, textarea');

    toArray(fields).forEach(function (field) {
      if (!field.name || field.disabled) {
        return;
      }

      if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) {
        return;
      }

      if (field.type === 'checkbox') {
        if (!data[field.name]) {
          data[field.name] = [];
        }

        data[field.name].push(field.value);
        return;
      }

      data[field.name] = field.value;
    });

    return data;
  }

  /**
   * Build query string.
   */
  function buildQuery(data) {
    var parts = [];

    Object.keys(data).forEach(function (key) {
      var value = data[key];

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

      if (Array.isArray(value)) {
        value.forEach(function (item) {
          parts.push(encodeURIComponent(key + '[]') + '=' + encodeURIComponent(item));
        });
      } else {
        parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
      }
    });

    return parts.join('&');
  }

  /**
   * Parse HTML safely.
   */
  function parseHTML(html) {
    var template = document.createElement('template');
    template.innerHTML = html.trim();
    return template.content;
  }

  /**
   * Create live region.
   */
  function createLiveRegion() {
    var region = document.getElementById('rx-ajax-live-region');

    if (region) {
      return region;
    }

    region = document.createElement('div');
    region.id = 'rx-ajax-live-region';
    region.setAttribute('aria-live', 'polite');
    region.setAttribute('aria-atomic', 'true');
    region.style.position = 'absolute';
    region.style.width = '1px';
    region.style.height = '1px';
    region.style.padding = '0';
    region.style.margin = '-1px';
    region.style.overflow = 'hidden';
    region.style.clip = 'rect(0, 0, 0, 0)';
    region.style.whiteSpace = 'nowrap';
    region.style.border = '0';

    document.body.appendChild(region);

    return region;
  }

  /**
   * RX Ajax Load More class.
   */
  function RXAjaxLoadMore(root, options) {
    this.root = root;
    this.options = extend(config, options || {}, this.getDataOptions(root));

    this.container = root.querySelector(this.options.containerSelector) || root;
    this.button = root.querySelector(this.options.buttonSelector);
    this.status = root.querySelector(this.options.statusSelector);
    this.form = root.querySelector(this.options.formSelector);

    this.page = parseInt(this.options.page, 10) || 1;
    this.maxPages = parseInt(this.options.maxPages, 10) || 1;
    this.loading = false;
    this.finished = this.page >= this.maxPages;
    this.retryCount = 0;
    this.controller = null;
    this.loadedIds = [];

    this.liveRegion = createLiveRegion();

    this.handleButtonClick = this.handleButtonClick.bind(this);
    this.handleScroll = debounce(this.handleScroll.bind(this), 100);
    this.handleFilterChange = debounce(this.handleFilterChange.bind(this), this.options.debounceDelay);
    this.handleFormSubmit = this.handleFormSubmit.bind(this);
    this.handlePopState = this.handlePopState.bind(this);

    this.init();
  }

  /**
   * Read data attributes from wrapper.
   */
  RXAjaxLoadMore.prototype.getDataOptions = function (root) {
    var data = root.dataset || {};
    var options = {};

    if (data.rxMode) {
      options.mode = data.rxMode;
    }

    if (data.rxPage) {
      options.page = parseInt(data.rxPage, 10);
    }

    if (data.rxMaxPages) {
      options.maxPages = parseInt(data.rxMaxPages, 10);
    }

    if (data.rxPostType) {
      options.postType = data.rxPostType;
    }

    if (data.rxPostsPerPage) {
      options.postsPerPage = parseInt(data.rxPostsPerPage, 10);
    }

    if (data.rxCategory) {
      options.category = data.rxCategory;
    }

    if (data.rxTag) {
      options.tag = data.rxTag;
    }

    if (data.rxTaxonomy) {
      options.taxonomy = data.rxTaxonomy;
    }

    if (data.rxTerm) {
      options.term = data.rxTerm;
    }

    if (data.rxAuthor) {
      options.author = data.rxAuthor;
    }

    if (data.rxOrder) {
      options.order = data.rxOrder;
    }

    if (data.rxOrderby) {
      options.orderby = data.rxOrderby;
    }

    if (data.rxSearch) {
      options.search = data.rxSearch;
    }

    return options;
  };

  /**
   * Init.
   */
  RXAjaxLoadMore.prototype.init = function () {
    this.collectLoadedIds();
    this.bindEvents();
    this.updateUI();

    debug('Instance initialized', this.root);
  };

  /**
   * Bind events.
   */
  RXAjaxLoadMore.prototype.bindEvents = function () {
    var self = this;

    if (this.button) {
      this.button.addEventListener('click', this.handleButtonClick);
    }

    if (this.options.mode === 'scroll' || this.options.mode === 'mixed') {
      window.addEventListener('scroll', this.handleScroll, { passive: true });
      window.addEventListener('resize', this.handleScroll, { passive: true });
    }

    if (this.form) {
      this.form.addEventListener('submit', this.handleFormSubmit);

      toArray(this.form.querySelectorAll('input, select, textarea')).forEach(function (field) {
        field.addEventListener('change', self.handleFilterChange);

        if (field.type === 'search' || field.type === 'text') {
          field.addEventListener('input', self.handleFilterChange);
        }
      });
    }

    toArray(this.root.querySelectorAll(this.options.filterSelector)).forEach(function (filter) {
      filter.addEventListener('click', function (event) {
        event.preventDefault();

        self.handleFilterButton(filter);
      });
    });

    if (this.options.pushState) {
      window.addEventListener('popstate', this.handlePopState);
    }
  };

  /**
   * Destroy instance.
   */
  RXAjaxLoadMore.prototype.destroy = function () {
    if (this.button) {
      this.button.removeEventListener('click', this.handleButtonClick);
    }

    window.removeEventListener('scroll', this.handleScroll);
    window.removeEventListener('resize', this.handleScroll);
    window.removeEventListener('popstate', this.handlePopState);

    if (this.controller) {
      this.controller.abort();
    }
  };

  /**
   * Button click.
   */
  RXAjaxLoadMore.prototype.handleButtonClick = function (event) {
    event.preventDefault();

    if (this.finished) {
      return;
    }

    this.loadNext();
  };

  /**
   * Infinite scroll.
   */
  RXAjaxLoadMore.prototype.handleScroll = function () {
    if (this.loading || this.finished) {
      return;
    }

    var rect = this.root.getBoundingClientRect();
    var viewportHeight = window.innerHeight || document.documentElement.clientHeight;

    if (rect.bottom - viewportHeight <= this.options.scrollOffset) {
      this.loadNext();
    }
  };

  /**
   * Form submit.
   */
  RXAjaxLoadMore.prototype.handleFormSubmit = function (event) {
    event.preventDefault();
    this.resetAndLoad();
  };

  /**
   * Filter change.
   */
  RXAjaxLoadMore.prototype.handleFilterChange = function () {
    this.resetAndLoad();
  };

  /**
   * Filter button.
   */
  RXAjaxLoadMore.prototype.handleFilterButton = function (filter) {
    var group = filter.getAttribute('data-rx-filter-group') || 'default';
    var value = filter.getAttribute('data-rx-filter-value') || '';
    var key = filter.getAttribute('data-rx-filter-key') || 'category';

    toArray(this.root.querySelectorAll('[data-rx-filter-group="' + group + '"]')).forEach(
      function (el) {
        el.classList.remove(config.activeClass);
        el.setAttribute('aria-pressed', 'false');
      }
    );

    filter.classList.add(this.options.activeClass);
    filter.setAttribute('aria-pressed', 'true');

    this.options[key] = value;

    this.resetAndLoad();
  };

  /**
   * Browser back/forward support.
   */
  RXAjaxLoadMore.prototype.handlePopState = function (event) {
    if (!event.state || !event.state.rxAjaxLoadMore) {
      return;
    }

    this.page = event.state.page || 1;
    this.options.search = event.state.search || '';
    this.options.category = event.state.category || '';
    this.options.tag = event.state.tag || '';
    this.options.term = event.state.term || '';

    this.resetAndLoad(false);
  };

  /**
   * Load next page.
   */
  RXAjaxLoadMore.prototype.loadNext = function () {
    if (this.loading || this.finished) {
      return;
    }

    this.page += 1;
    this.load(false);
  };

  /**
   * Reset then load.
   */
  RXAjaxLoadMore.prototype.resetAndLoad = function (updateUrl) {
    if (typeof updateUrl === 'undefined') {
      updateUrl = true;
    }

    this.page = 1;
    this.finished = false;
    this.retryCount = 0;
    this.loadedIds = [];

    this.container.innerHTML = '';

    this.load(true, updateUrl);
  };

  /**
   * Main load method.
   */
  RXAjaxLoadMore.prototype.load = function (replace, updateUrl) {
    var self = this;

    if (this.loading) {
      return;
    }

    if (typeof updateUrl === 'undefined') {
      updateUrl = true;
    }

    this.loading = true;
    this.root.classList.add(this.options.loadingClass);
    this.root.classList.remove(this.options.errorClass);

    this.setStatus(this.options.loadingText);
    this.setButtonLoading(true);
    this.addSkeletons();

    if (this.controller) {
      this.controller.abort();
    }

    this.controller = new AbortController();

    var payload = this.getPayload();

    this.request(payload)
      .then(function (response) {
        self.removeSkeletons();
        self.handleResponse(response, replace);

        if (updateUrl && self.options.updateUrl) {
          self.updateBrowserUrl();
        }

        self.retryCount = 0;
      })
      .catch(function (error) {
        self.removeSkeletons();

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

        self.handleError(error, replace);
      })
      .finally(function () {
        self.loading = false;
        self.root.classList.remove(self.options.loadingClass);
        self.setButtonLoading(false);
        self.updateUI();
      });
  };

  /**
   * Build payload.
   */
  RXAjaxLoadMore.prototype.getPayload = function () {
    var formData = serializeForm(this.form);

    var payload = extend(
      {
        action: this.options.action,
        nonce: this.options.nonce,
        page: this.page,
        posts_per_page: this.options.postsPerPage,
        post_type: this.options.postType,
        order: this.options.order,
        orderby: this.options.orderby,
        category: this.options.category,
        tag: this.options.tag,
        taxonomy: this.options.taxonomy,
        term: this.options.term,
        author: this.options.author,
        search: this.options.search,
        exclude: this.options.exclude,
        include: this.options.include,
        loaded_ids: this.loadedIds
      },
      formData
    );

    var searchField = this.root.querySelector(this.options.searchSelector);

    if (searchField && searchField.value) {
      payload.search = searchField.value;
    }

    return payload;
  };

  /**
   * AJAX request.
   */
  RXAjaxLoadMore.prototype.request = function (payload) {
    var self = this;
    var url = this.options.apiType === 'rest' ? this.options.restUrl : this.options.ajaxUrl;

    if (!url) {
      return Promise.reject(new Error('AJAX URL is missing.'));
    }

    var fetchOptions = {
      method: this.options.method,
      credentials: 'same-origin',
      signal: this.controller.signal,
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    };

    if (this.options.apiType === 'rest') {
      fetchOptions.headers['X-WP-Nonce'] = this.options.nonce || '';
    }

    if (this.options.method.toUpperCase() === 'GET') {
      url += (url.indexOf('?') === -1 ? '?' : '&') + buildQuery(payload);
    } else {
      fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
      fetchOptions.body = buildQuery(payload);
    }

    return this.fetchWithTimeout(url, fetchOptions, this.options.timeout).then(function (res) {
      if (!res.ok) {
        throw new Error('Request failed with status ' + res.status);
      }

      return res.json().then(function (json) {
        return self.normalizeResponse(json);
      });
    });
  };

  /**
   * Fetch timeout.
   */
  RXAjaxLoadMore.prototype.fetchWithTimeout = function (url, options, timeout) {
    return new Promise(function (resolve, reject) {
      var timer = setTimeout(function () {
        reject(new Error('Request timeout.'));
      }, timeout);

      fetch(url, options)
        .then(function (response) {
          clearTimeout(timer);
          resolve(response);
        })
        .catch(function (error) {
          clearTimeout(timer);
          reject(error);
        });
    });
  };

  /**
   * Normalize WordPress admin-ajax or REST response.
   */
  RXAjaxLoadMore.prototype.normalizeResponse = function (json) {
    if (json && json.success && json.data) {
      return json.data;
    }

    return json || {};
  };

  /**
   * Handle response.
   */
  RXAjaxLoadMore.prototype.handleResponse = function (response, replace) {
    var html = response.html || response.posts || '';
    var count = parseInt(response.count, 10) || 0;
    var maxPages = parseInt(response.max_pages, 10) || parseInt(response.maxPages, 10) || this.maxPages;
    var currentPage = parseInt(response.page, 10) || this.page;

    this.maxPages = maxPages;
    this.page = currentPage;

    if (response.found_posts !== undefined) {
      this.root.setAttribute('data-rx-found-posts', response.found_posts);
    }

    if (response.query_id !== undefined) {
      this.root.setAttribute('data-rx-query-id', response.query_id);
    }

    if (!html || count === 0) {
      if (replace) {
        this.container.innerHTML = this.getEmptyMarkup();
      }

      this.finished = true;
      this.setStatus(this.options.emptyText);
      this.announce(this.options.emptyText);
      return;
    }

    this.insertPosts(html, replace);
    this.collectLoadedIds();

    this.finished = this.page >= this.maxPages || response.has_more === false;

    if (this.finished) {
      this.setStatus(this.options.noMoreText);
      this.announce(this.options.noMoreText);
    } else {
      this.setStatus(count + ' posts loaded.');
      this.announce(count + ' posts loaded.');
    }

    this.dispatchEvent('rx:ajax-loaded', {
      response: response,
      page: this.page,
      maxPages: this.maxPages,
      finished: this.finished
    });
  };

  /**
   * Insert posts.
   */
  RXAjaxLoadMore.prototype.insertPosts = function (html, replace) {
    var fragment = parseHTML(html);
    var nodes = toArray(fragment.children);

    if (this.options.preventDuplicates) {
      nodes = this.filterDuplicates(nodes);
    }

    if (replace) {
      this.container.innerHTML = '';
    }

    nodes.forEach(function (node) {
      this.container.appendChild(node);
    }, this);

    this.animateNewItems(nodes);
  };

  /**
   * Prevent duplicate posts.
   */
  RXAjaxLoadMore.prototype.filterDuplicates = function (nodes) {
    var self = this;

    return nodes.filter(function (node) {
      var id =
        node.getAttribute('data-post-id') ||
        node.getAttribute('data-id') ||
        node.id ||
        '';

      if (!id) {
        return true;
      }

      if (self.loadedIds.indexOf(id) !== -1) {
        return false;
      }

      self.loadedIds.push(id);
      return true;
    });
  };

  /**
   * Collect currently loaded post IDs.
   */
  RXAjaxLoadMore.prototype.collectLoadedIds = function () {
    var self = this;
    var items = this.container.querySelectorAll(this.options.itemSelector + ', [data-post-id]');

    toArray(items).forEach(function (item) {
      var id =
        item.getAttribute('data-post-id') ||
        item.getAttribute('data-id') ||
        item.id ||
        '';

      if (id && self.loadedIds.indexOf(id) === -1) {
        self.loadedIds.push(id);
      }
    });
  };

  /**
   * New item animation.
   */
  RXAjaxLoadMore.prototype.animateNewItems = function (nodes) {
    nodes.forEach(function (node) {
      node.classList.add('rx-ajax-new-item');

      requestAnimationFrame(function () {
        node.classList.add('rx-ajax-visible');
      });

      setTimeout(function () {
        node.classList.remove('rx-ajax-new-item');
        node.classList.remove('rx-ajax-visible');
      }, 600);
    });
  };

  /**
   * Error handler.
   */
  RXAjaxLoadMore.prototype.handleError = function (error, replace) {
    debug('Error:', error);

    this.root.classList.add(this.options.errorClass);

    if (this.retryCount < this.options.retryLimit) {
      this.retryCount += 1;
      this.page = Math.max(1, this.page - 1);

      this.setStatus(this.options.errorText + ' Retrying...');
      this.announce(this.options.errorText + ' Retrying.');

      this.load(replace);
      return;
    }

    this.page = Math.max(1, this.page - 1);
    this.setStatus(this.options.errorText);
    this.announce(this.options.errorText);

    if (this.button) {
      this.button.textContent = this.options.retryText;
      this.button.removeAttribute('disabled');
      this.button.classList.remove(this.options.disabledClass);
    }

    this.dispatchEvent('rx:ajax-error', {
      error: error
    });
  };

  /**
   * Update UI.
   */
  RXAjaxLoadMore.prototype.updateUI = function () {
    if (!this.button) {
      return;
    }

    if (this.finished) {
      this.button.textContent = this.options.noMoreText;
      this.button.setAttribute('disabled', 'disabled');
      this.button.classList.add(this.options.disabledClass);
      this.button.setAttribute('aria-disabled', 'true');

      if (this.options.mode === 'scroll') {
        this.button.classList.add(this.options.hiddenClass);
      }

      return;
    }

    this.button.textContent = this.options.loadMoreText;
    this.button.removeAttribute('disabled');
    this.button.classList.remove(this.options.disabledClass);
    this.button.setAttribute('aria-disabled', 'false');
  };

  /**
   * Set button loading state.
   */
  RXAjaxLoadMore.prototype.setButtonLoading = function (state) {
    if (!this.button) {
      return;
    }

    if (state) {
      this.button.textContent = this.options.loadingText;
      this.button.setAttribute('disabled', 'disabled');
      this.button.classList.add(this.options.disabledClass);
      this.button.setAttribute('aria-busy', 'true');
    } else {
      this.button.removeAttribute('aria-busy');
    }
  };

  /**
   * Set status text.
   */
  RXAjaxLoadMore.prototype.setStatus = function (message) {
    if (!this.status) {
      return;
    }

    this.status.textContent = message || '';
  };

  /**
   * Accessibility announce.
   */
  RXAjaxLoadMore.prototype.announce = function (message) {
    if (!this.liveRegion) {
      return;
    }

    this.liveRegion.textContent = '';

    var region = this.liveRegion;

    setTimeout(function () {
      region.textContent = message;
    }, 50);
  };

  /**
   * Skeleton loading.
   */
  RXAjaxLoadMore.prototype.addSkeletons = function () {
    if (!this.options.enableSkeleton) {
      return;
    }

    this.removeSkeletons();

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

    for (var i = 0; i < this.options.skeletonCount; i++) {
      var skeleton = document.createElement('div');
      skeleton.className = this.options.skeletonClass;
      skeleton.innerHTML =
        '<div class="rx-skeleton-thumb"></div>' +
        '<div class="rx-skeleton-content">' +
        '<div class="rx-skeleton-line rx-skeleton-line-lg"></div>' +
        '<div class="rx-skeleton-line"></div>' +
        '<div class="rx-skeleton-line rx-skeleton-line-sm"></div>' +
        '</div>';

      wrapper.appendChild(skeleton);
    }

    this.container.appendChild(wrapper);
  };

  /**
   * Remove skeletons.
   */
  RXAjaxLoadMore.prototype.removeSkeletons = function () {
    var skeletons = this.container.querySelectorAll('[data-rx-skeleton-wrapper]');

    toArray(skeletons).forEach(function (skeleton) {
      skeleton.parentNode.removeChild(skeleton);
    });
  };

  /**
   * Empty markup.
   */
  RXAjaxLoadMore.prototype.getEmptyMarkup = function () {
    return (
      '<div class="rx-ajax-empty" role="status">' +
      '<p>' +
      this.escapeHTML(this.options.emptyText) +
      '</p>' +
      '</div>'
    );
  };

  /**
   * Escape HTML.
   */
  RXAjaxLoadMore.prototype.escapeHTML = function (str) {
    return String(str).replace(/[&<>"']/g, function (match) {
      var map = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#039;'
      };

      return map[match];
    });
  };

  /**
   * Update browser URL.
   */
  RXAjaxLoadMore.prototype.updateBrowserUrl = function () {
    if (!window.history || !window.history.pushState) {
      return;
    }

    var params = new URLSearchParams(window.location.search);

    if (this.page > 1) {
      params.set('rxpage', this.page);
    } else {
      params.delete('rxpage');
    }

    var payload = this.getPayload();

    if (payload.search) {
      params.set('s', payload.search);
    } else {
      params.delete('s');
    }

    if (payload.category) {
      params.set('category', payload.category);
    } else {
      params.delete('category');
    }

    if (payload.tag) {
      params.set('tag', payload.tag);
    } else {
      params.delete('tag');
    }

    if (payload.term) {
      params.set('term', payload.term);
    } else {
      params.delete('term');
    }

    var newUrl =
      window.location.pathname +
      (params.toString() ? '?' + params.toString() : '') +
      window.location.hash;

    var state = {
      rxAjaxLoadMore: true,
      page: this.page,
      search: payload.search || '',
      category: payload.category || '',
      tag: payload.tag || '',
      term: payload.term || ''
    };

    window.history.pushState(state, document.title, newUrl);
  };

  /**
   * Dispatch custom event.
   */
  RXAjaxLoadMore.prototype.dispatchEvent = function (name, detail) {
    var event;

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

    this.root.dispatchEvent(event);
  };

  /**
   * Public reload method.
   */
  RXAjaxLoadMore.prototype.reload = function () {
    this.resetAndLoad();
  };

  /**
   * Public load more method.
   */
  RXAjaxLoadMore.prototype.loadMore = function () {
    this.loadNext();
  };

  /**
   * Public set option method.
   */
  RXAjaxLoadMore.prototype.setOption = function (key, value) {
    this.options[key] = value;
  };

  /**
   * Public get option method.
   */
  RXAjaxLoadMore.prototype.getOption = function (key) {
    return this.options[key];
  };

  /**
   * Init all wrappers.
   */
  function initRXAjaxLoadMore() {
    var roots = document.querySelectorAll('[data-rx-ajax-load-more]');

    toArray(roots).forEach(function (root) {
      if (root.rxAjaxLoadMoreInstance) {
        return;
      }

      var instance = new RXAjaxLoadMore(root);
      root.rxAjaxLoadMoreInstance = instance;
      instances.push(instance);
    });
  }

  /**
   * Public API.
   */
  window.RXAjaxLoadMore = {
    init: initRXAjaxLoadMore,
    instances: instances,
    create: function (root, options) {
      var instance = new RXAjaxLoadMore(root, options);
      root.rxAjaxLoadMoreInstance = instance;
      instances.push(instance);
      return instance;
    },
    reloadAll: function () {
      instances.forEach(function (instance) {
        instance.reload();
      });
    },
    destroyAll: function () {
      instances.forEach(function (instance) {
        instance.destroy();
      });

      instances.length = 0;
    }
  };

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

Also add this small CSS to your theme CSS for loading effect:

.rx-is-loading {
  opacity: 0.88;
}

.rx-is-disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.rx-hidden {
  display: none !important;
}

.rx-ajax-new-item {
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.35s ease, transform 0.35s ease;
}

.rx-ajax-new-item.rx-ajax-visible {
  opacity: 1;
  transform: translateY(0);
}

.rx-skeleton-wrapper {
  display: grid;
  gap: 20px;
  margin-top: 20px;
}

.rx-post-skeleton {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 16px;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
}

.rx-skeleton-thumb,
.rx-skeleton-line {
  background: linear-gradient(90deg, #eeeeee 25%, #f7f7f7 50%, #eeeeee 75%);
  background-size: 200% 100%;
  animation: rxSkeletonMove 1.3s infinite;
  border-radius: 8px;
}

.rx-skeleton-thumb {
  width: 120px;
  height: 90px;
}

.rx-skeleton-content {
  display: grid;
  gap: 10px;
  align-content: center;
}

.rx-skeleton-line {
  height: 14px;
}

.rx-skeleton-line-lg {
  width: 80%;
}

.rx-skeleton-line-sm {
  width: 45%;
}

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

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

.rx-ajax-empty {
  padding: 24px;
  text-align: center;
  border: 1px dashed #d1d5db;
  border-radius: 12px;
}

Use this HTML structure in your theme template:

<div
  class="rx-blog-posts-area"
  data-rx-ajax-load-more
  data-rx-mode="mixed"
  data-rx-page="1"
  data-rx-max-pages="<?php echo esc_attr( $wp_query->max_num_pages ); ?>"
  data-rx-post-type="post"
  data-rx-posts-per-page="<?php echo esc_attr( get_option( 'posts_per_page' ) ); ?>"
>
  <div class="rx-post-grid" data-rx-post-container>
    <?php
    if ( have_posts() ) :
      while ( have_posts() ) :
        the_post();
        ?>
        <article id="post-<?php the_ID(); ?>" data-rx-post-item data-post-id="<?php the_ID(); ?>">
          <h2>
            <a href="<?php the_permalink(); ?>">
              <?php the_title(); ?>
            </a>
          </h2>
          <?php the_excerpt(); ?>
        </article>
        <?php
      endwhile;
    endif;
    ?>
  </div>

  <p data-rx-load-status aria-live="polite"></p>

  <button type="button" class="rx-load-more-button" data-rx-load-more>
    Load More
  </button>
</div>

And enqueue/localize it in functions.php or your enqueue file:

function rx_theme_enqueue_ajax_load_more() {
    wp_enqueue_script(
        'rx-ajax-load-more',
        get_template_directory_uri() . '/inc/js/ajax-load-more.js',
        array(),
        '1.0.0',
        true
    );

    wp_localize_script(
        'rx-ajax-load-more',
        'rxAjaxLoadMore',
        array(
            'ajaxUrl'       => admin_url( 'admin-ajax.php' ),
            'restUrl'       => esc_url_raw( rest_url( 'rx-theme/v1/load-more' ) ),
            'nonce'         => wp_create_nonce( 'rx_ajax_load_more_nonce' ),
            'action'        => 'rx_load_more_posts',
            'apiType'       => 'ajax',
            'mode'          => 'mixed',
            'postsPerPage'  => get_option( 'posts_per_page' ),
            'loadingText'   => esc_html__( 'Loading...', 'rx-theme' ),
            'loadMoreText'  => esc_html__( 'Load More', 'rx-theme' ),
            'noMoreText'    => esc_html__( 'No more posts', 'rx-theme' ),
            'errorText'     => esc_html__( 'Something went wrong. Please try again.', 'rx-theme' ),
            'emptyText'     => esc_html__( 'No posts found.', 'rx-theme' ),
            'enableDebug'   => false,
        )
    );
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_ajax_load_more' );

You will also need the PHP AJAX callback. Add this in functions.php or better in:

inc/ajax/ajax-load-more.php

<?php
/**
 * RX Theme Ajax Load More Callback
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

function rx_theme_ajax_load_more_posts() {
    check_ajax_referer( 'rx_ajax_load_more_nonce', 'nonce' );

    $page           = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
    $posts_per_page = isset( $_POST['posts_per_page'] ) ? absint( $_POST['posts_per_page'] ) : get_option( 'posts_per_page' );
    $post_type      = isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : 'post';
    $order          = isset( $_POST['order'] ) ? sanitize_key( wp_unslash( $_POST['order'] ) ) : 'DESC';
    $orderby        = isset( $_POST['orderby'] ) ? sanitize_key( wp_unslash( $_POST['orderby'] ) ) : 'date';
    $search         = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    $category       = isset( $_POST['category'] ) ? sanitize_text_field( wp_unslash( $_POST['category'] ) ) : '';
    $tag            = isset( $_POST['tag'] ) ? sanitize_text_field( wp_unslash( $_POST['tag'] ) ) : '';
    $taxonomy       = isset( $_POST['taxonomy'] ) ? sanitize_key( wp_unslash( $_POST['taxonomy'] ) ) : '';
    $term           = isset( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : '';
    $author         = isset( $_POST['author'] ) ? absint( $_POST['author'] ) : 0;

    $loaded_ids = array();

    if ( isset( $_POST['loaded_ids'] ) && is_array( $_POST['loaded_ids'] ) ) {
        $loaded_ids = array_map( 'absint', wp_unslash( $_POST['loaded_ids'] ) );
        $loaded_ids = array_filter( $loaded_ids );
    }

    $args = array(
        'post_type'           => $post_type,
        'post_status'         => 'publish',
        'posts_per_page'      => $posts_per_page,
        'paged'               => $page,
        'order'               => $order,
        'orderby'             => $orderby,
        'ignore_sticky_posts' => true,
    );

    if ( ! empty( $search ) ) {
        $args['s'] = $search;
    }

    if ( ! empty( $category ) ) {
        if ( is_numeric( $category ) ) {
            $args['cat'] = absint( $category );
        } else {
            $args['category_name'] = sanitize_title( $category );
        }
    }

    if ( ! empty( $tag ) ) {
        $args['tag'] = sanitize_title( $tag );
    }

    if ( ! empty( $author ) ) {
        $args['author'] = $author;
    }

    if ( ! empty( $loaded_ids ) ) {
        $args['post__not_in'] = $loaded_ids;
    }

    if ( ! empty( $taxonomy ) && ! empty( $term ) ) {
        $args['tax_query'] = array(
            array(
                'taxonomy' => $taxonomy,
                'field'    => is_numeric( $term ) ? 'term_id' : 'slug',
                'terms'    => is_numeric( $term ) ? absint( $term ) : sanitize_title( $term ),
            ),
        );
    }

    $query = new WP_Query( $args );

    ob_start();

    if ( $query->have_posts() ) {
        while ( $query->have_posts() ) {
            $query->the_post();
            ?>
            <article id="post-<?php the_ID(); ?>" <?php post_class( 'rx-post-card' ); ?> data-rx-post-item data-post-id="<?php the_ID(); ?>">
                <?php if ( has_post_thumbnail() ) : ?>
                    <a class="rx-post-card__thumbnail" href="<?php the_permalink(); ?>">
                        <?php the_post_thumbnail( 'medium_large' ); ?>
                    </a>
                <?php endif; ?>

                <div class="rx-post-card__content">
                    <h2 class="rx-post-card__title">
                        <a href="<?php the_permalink(); ?>">
                            <?php the_title(); ?>
                        </a>
                    </h2>

                    <div class="rx-post-card__meta">
                        <span><?php echo esc_html( get_the_date() ); ?></span>
                        <span><?php echo esc_html( get_the_author() ); ?></span>
                    </div>

                    <div class="rx-post-card__excerpt">
                        <?php the_excerpt(); ?>
                    </div>

                    <a class="rx-post-card__read-more" href="<?php the_permalink(); ?>">
                        <?php esc_html_e( 'Read More', 'rx-theme' ); ?>
                    </a>
                </div>
            </article>
            <?php
        }
    }

    $html = ob_get_clean();

    wp_reset_postdata();

    wp_send_json_success(
        array(
            'html'        => $html,
            'count'       => $query->post_count,
            'found_posts' => $query->found_posts,
            'page'        => $page,
            'max_pages'   => $query->max_num_pages,
            'has_more'    => $page < $query->max_num_pages,
        )
    );
}

add_action( 'wp_ajax_rx_load_more_posts', 'rx_theme_ajax_load_more_posts' );
add_action( 'wp_ajax_nopriv_rx_load_more_posts', 'rx_theme_ajax_load_more_posts' );

Then include this PHP file from functions.php:

require_once get_template_directory() . '/inc/ajax/ajax-load-more.php';

This gives your RX Theme a powerful modern Ajax load-more system. Button mode, scroll mode, and mixed mode will all work.

Leave a Reply

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