chunk-045-ajax-search.js

I’ll build this as a standalone advanced AJAX search chunk for RX Theme, with debounce, caching, keyboard navigation, accessibility, loading states, error handling, and WordPress REST/AJAX compatibility.

Below is an advanced full code for:

assets/static-js/static-chunks-js/chunk-045-ajax-search.js

It is written as a theme chunk file, safe for RX Theme, and includes: debounce, abort controller, local cache, keyboard navigation, ARIA accessibility, recent search memory, popular search support, REST API/AJAX fallback, mobile overlay support, close on Escape/outside click, and extensible custom events.

/*!
 * RX Theme - Advanced AJAX Search Chunk
 * File: assets/static-js/static-chunks-js/chunk-045-ajax-search.js
 * Version: 1.0.0
 *
 * Expected HTML example:
 *
 * <form class="rx-ajax-search" role="search">
 *   <label class="screen-reader-text" for="rx-search-input">Search</label>
 *   <input
 *     id="rx-search-input"
 *     class="rx-ajax-search__input"
 *     type="search"
 *     name="s"
 *     placeholder="Search diseases, symptoms, treatments..."
 *     autocomplete="off"
 *     data-rx-search-input
 *   >
 *   <button type="submit">Search</button>
 *   <div class="rx-ajax-search__results" data-rx-search-results></div>
 * </form>
 *
 * Optional global config from WordPress:
 *
 * wp_localize_script(
 *   'rx-chunk-045-ajax-search',
 *   'RX_AJAX_SEARCH',
 *   [
 *     'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
 *     'restUrl'      => esc_url_raw( rest_url( 'wp/v2/search' ) ),
 *     'homeUrl'      => home_url( '/' ),
 *     'searchUrl'    => home_url( '/' ),
 *     'nonce'        => wp_create_nonce( 'rx_ajax_search_nonce' ),
 *     'restNonce'    => wp_create_nonce( 'wp_rest' ),
 *     'action'       => 'rx_ajax_search',
 *     'minChars'     => 2,
 *     'limit'        => 8,
 *     'debounce'     => 280,
 *     'cacheTTL'     => 300000,
 *     'enableCache'  => true,
 *     'enableRecent' => true,
 *   ]
 * );
 */

(function RXAjaxSearchChunk(window, document) {
  'use strict';

  if (!window || !document) {
    return;
  }

  var ROOT = document.documentElement;

  var DEFAULTS = {
    selectors: {
      form: '.rx-ajax-search',
      input: '[data-rx-search-input], .rx-ajax-search__input',
      results: '[data-rx-search-results], .rx-ajax-search__results',
      submit: '[data-rx-search-submit], .rx-ajax-search__submit',
      clear: '[data-rx-search-clear], .rx-ajax-search__clear',
      overlay: '[data-rx-search-overlay]',
      openButton: '[data-rx-search-open]',
      closeButton: '[data-rx-search-close]'
    },

    classes: {
      active: 'is-active',
      loading: 'is-loading',
      open: 'is-open',
      empty: 'is-empty',
      error: 'has-error',
      selected: 'is-selected',
      hidden: 'is-hidden',
      bodyLocked: 'rx-search-open'
    },

    attributes: {
      expanded: 'aria-expanded',
      controls: 'aria-controls',
      selected: 'aria-selected',
      activedescendant: 'aria-activedescendant',
      busy: 'aria-busy',
      live: 'aria-live'
    },

    minChars: 2,
    limit: 8,
    debounce: 280,
    cacheTTL: 5 * 60 * 1000,
    enableCache: true,
    enableRecent: true,
    maxRecent: 8,
    enablePopular: true,
    requestMode: 'auto', // auto | rest | ajax
    highlight: true,
    submitOnEnterWithoutSelection: true,
    closeOnOutsideClick: true,
    closeOnEscape: true,
    focusFirstResultOnArrowDown: true,

    messages: {
      start: 'Start typing to search.',
      loading: 'Searching...',
      empty: 'No results found.',
      error: 'Search is not available right now.',
      tooShort: 'Please type at least {min} characters.',
      recentTitle: 'Recent searches',
      popularTitle: 'Popular searches',
      clearRecent: 'Clear recent searches',
      viewAll: 'View all results',
      resultSingular: 'result found',
      resultPlural: 'results found'
    },

    popular: [
      'Back pain',
      'Diabetes',
      'High blood pressure',
      'Anemia',
      'Neutropenia',
      'Disc displacement'
    ]
  };

  var WP_CONFIG = window.RX_AJAX_SEARCH || {};
  var CONFIG = deepMerge(DEFAULTS, normalizeConfig(WP_CONFIG));

  var memoryCache = new Map();
  var activeController = null;
  var instanceCounter = 0;

  function normalizeConfig(config) {
    return {
      ajaxUrl: config.ajaxUrl || config.ajax_url || '',
      restUrl: config.restUrl || config.rest_url || '',
      homeUrl: config.homeUrl || config.home_url || '/',
      searchUrl: config.searchUrl || config.search_url || '/',
      nonce: config.nonce || '',
      restNonce: config.restNonce || config.rest_nonce || '',
      action: config.action || 'rx_ajax_search',

      minChars: toInt(config.minChars, DEFAULTS.minChars),
      limit: toInt(config.limit, DEFAULTS.limit),
      debounce: toInt(config.debounce, DEFAULTS.debounce),
      cacheTTL: toInt(config.cacheTTL, DEFAULTS.cacheTTL),

      enableCache: toBool(config.enableCache, DEFAULTS.enableCache),
      enableRecent: toBool(config.enableRecent, DEFAULTS.enableRecent),
      enablePopular: toBool(config.enablePopular, DEFAULTS.enablePopular),

      requestMode: config.requestMode || DEFAULTS.requestMode,

      popular: Array.isArray(config.popular) ? config.popular : DEFAULTS.popular
    };
  }

  function deepMerge(target, source) {
    var output = Object.assign({}, target);

    Object.keys(source || {}).forEach(function mergeKey(key) {
      if (
        source[key] &&
        typeof source[key] === 'object' &&
        !Array.isArray(source[key]) &&
        target[key] &&
        typeof target[key] === 'object'
      ) {
        output[key] = deepMerge(target[key], source[key]);
      } else {
        output[key] = source[key];
      }
    });

    return output;
  }

  function toInt(value, fallback) {
    var parsed = parseInt(value, 10);
    return Number.isFinite(parsed) ? parsed : fallback;
  }

  function toBool(value, fallback) {
    if (typeof value === 'boolean') {
      return value;
    }

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

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

    return fallback;
  }

  function debounce(fn, delay) {
    var timer = null;

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

      window.clearTimeout(timer);

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

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

  function normalizeQuery(value) {
    return String(value || '')
      .trim()
      .replace(/\s+/g, ' ')
      .slice(0, 120);
  }

  function buildSearchUrl(query) {
    var base = CONFIG.searchUrl || CONFIG.homeUrl || '/';
    var url;

    try {
      url = new URL(base, window.location.origin);
      url.searchParams.set('s', query);
      return url.toString();
    } catch (error) {
      return '/?s=' + encodeURIComponent(query);
    }
  }

  function emit(name, detail) {
    document.dispatchEvent(
      new CustomEvent('rx:ajax-search:' + name, {
        bubbles: true,
        detail: detail || {}
      })
    );
  }

  function getCacheKey(query) {
    return normalizeQuery(query).toLowerCase();
  }

  function getFromCache(query) {
    if (!CONFIG.enableCache) {
      return null;
    }

    var key = getCacheKey(query);
    var item = memoryCache.get(key);

    if (!item) {
      return null;
    }

    if (Date.now() - item.time > CONFIG.cacheTTL) {
      memoryCache.delete(key);
      return null;
    }

    return item.data;
  }

  function setToCache(query, data) {
    if (!CONFIG.enableCache) {
      return;
    }

    memoryCache.set(getCacheKey(query), {
      time: Date.now(),
      data: data
    });

    if (memoryCache.size > 60) {
      var firstKey = memoryCache.keys().next().value;
      memoryCache.delete(firstKey);
    }
  }

  function storageAvailable() {
    try {
      var key = '__rx_storage_test__';
      window.localStorage.setItem(key, key);
      window.localStorage.removeItem(key);
      return true;
    } catch (error) {
      return false;
    }
  }

  var canUseStorage = storageAvailable();
  var RECENT_KEY = 'rx_theme_recent_searches';

  function getRecentSearches() {
    if (!CONFIG.enableRecent || !canUseStorage) {
      return [];
    }

    try {
      var raw = window.localStorage.getItem(RECENT_KEY);
      var parsed = JSON.parse(raw || '[]');

      if (!Array.isArray(parsed)) {
        return [];
      }

      return parsed
        .filter(Boolean)
        .map(normalizeQuery)
        .filter(Boolean)
        .slice(0, CONFIG.maxRecent);
    } catch (error) {
      return [];
    }
  }

  function saveRecentSearch(query) {
    if (!CONFIG.enableRecent || !canUseStorage) {
      return;
    }

    var clean = normalizeQuery(query);

    if (!clean || clean.length < CONFIG.minChars) {
      return;
    }

    var existing = getRecentSearches().filter(function removeDuplicate(item) {
      return item.toLowerCase() !== clean.toLowerCase();
    });

    existing.unshift(clean);

    try {
      window.localStorage.setItem(
        RECENT_KEY,
        JSON.stringify(existing.slice(0, CONFIG.maxRecent))
      );
    } catch (error) {
      // Ignore localStorage write errors.
    }
  }

  function clearRecentSearches() {
    if (!canUseStorage) {
      return;
    }

    try {
      window.localStorage.removeItem(RECENT_KEY);
    } catch (error) {
      // Ignore localStorage errors.
    }
  }

  function highlightText(text, query) {
    var safeText = escapeHTML(text);

    if (!CONFIG.highlight || !query) {
      return safeText;
    }

    var cleanQuery = normalizeQuery(query);

    if (!cleanQuery) {
      return safeText;
    }

    var terms = cleanQuery
      .split(' ')
      .filter(function validTerm(term) {
        return term.length > 1;
      })
      .map(function escapeRegExp(term) {
        return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      });

    if (!terms.length) {
      return safeText;
    }

    try {
      var regex = new RegExp('(' + terms.join('|') + ')', 'gi');
      return safeText.replace(regex, '<mark>$1</mark>');
    } catch (error) {
      return safeText;
    }
  }

  function normalizeResult(item) {
    var title =
      item.title ||
      item.post_title ||
      item.name ||
      item.label ||
      '';

    if (typeof title === 'object' && title.rendered) {
      title = title.rendered;
    }

    var url =
      item.url ||
      item.link ||
      item.permalink ||
      item.guid ||
      '#';

    var excerpt =
      item.excerpt ||
      item.post_excerpt ||
      item.description ||
      item.summary ||
      '';

    if (typeof excerpt === 'object' && excerpt.rendered) {
      excerpt = excerpt.rendered;
    }

    var type =
      item.type ||
      item.subtype ||
      item.post_type ||
      'post';

    var image =
      item.image ||
      item.thumbnail ||
      item.featured_image ||
      item.featuredImage ||
      '';

    return {
      id: item.id || item.ID || url || title,
      title: stripTags(title),
      url: url,
      excerpt: stripTags(excerpt),
      type: type,
      image: image
    };
  }

  function stripTags(value) {
    var div = document.createElement('div');
    div.innerHTML = String(value || '');
    return div.textContent || div.innerText || '';
  }

  async function fetchResults(query) {
    var cached = getFromCache(query);

    if (cached) {
      emit('cache-hit', {
        query: query,
        results: cached
      });

      return cached;
    }

    if (activeController) {
      activeController.abort();
    }

    activeController = new AbortController();

    var mode = CONFIG.requestMode;

    if (mode === 'rest') {
      return fetchRestResults(query, activeController.signal);
    }

    if (mode === 'ajax') {
      return fetchAjaxResults(query, activeController.signal);
    }

    try {
      if (CONFIG.restUrl) {
        return await fetchRestResults(query, activeController.signal);
      }

      return await fetchAjaxResults(query, activeController.signal);
    } catch (error) {
      if (error && error.name === 'AbortError') {
        throw error;
      }

      if (CONFIG.ajaxUrl && CONFIG.restUrl) {
        return fetchAjaxResults(query, activeController.signal);
      }

      throw error;
    }
  }

  async function fetchRestResults(query, signal) {
    if (!CONFIG.restUrl) {
      throw new Error('REST URL is missing.');
    }

    var url = new URL(CONFIG.restUrl, window.location.origin);
    url.searchParams.set('search', query);
    url.searchParams.set('per_page', CONFIG.limit);

    var headers = {
      Accept: 'application/json'
    };

    if (CONFIG.restNonce) {
      headers['X-WP-Nonce'] = CONFIG.restNonce;
    }

    emit('request-start', {
      query: query,
      mode: 'rest'
    });

    var response = await window.fetch(url.toString(), {
      method: 'GET',
      credentials: 'same-origin',
      headers: headers,
      signal: signal
    });

    if (!response.ok) {
      throw new Error('REST search failed with status ' + response.status);
    }

    var json = await response.json();
    var results = Array.isArray(json) ? json.map(normalizeResult) : [];

    setToCache(query, results);

    emit('request-success', {
      query: query,
      mode: 'rest',
      results: results
    });

    return results;
  }

  async function fetchAjaxResults(query, signal) {
    if (!CONFIG.ajaxUrl) {
      throw new Error('AJAX URL is missing.');
    }

    var formData = new FormData();
    formData.append('action', CONFIG.action);
    formData.append('nonce', CONFIG.nonce);
    formData.append('search', query);
    formData.append('s', query);
    formData.append('limit', CONFIG.limit);

    emit('request-start', {
      query: query,
      mode: 'ajax'
    });

    var response = await window.fetch(CONFIG.ajaxUrl, {
      method: 'POST',
      credentials: 'same-origin',
      body: formData,
      signal: signal
    });

    if (!response.ok) {
      throw new Error('AJAX search failed with status ' + response.status);
    }

    var json = await response.json();

    var rawResults = [];

    if (Array.isArray(json)) {
      rawResults = json;
    } else if (json && Array.isArray(json.data)) {
      rawResults = json.data;
    } else if (json && json.data && Array.isArray(json.data.results)) {
      rawResults = json.data.results;
    } else if (json && Array.isArray(json.results)) {
      rawResults = json.results;
    }

    var results = rawResults.map(normalizeResult);

    setToCache(query, results);

    emit('request-success', {
      query: query,
      mode: 'ajax',
      results: results
    });

    return results;
  }

  function RXAjaxSearch(form) {
    this.form = form;
    this.id = 'rx-ajax-search-' + ++instanceCounter;
    this.input = form.querySelector(CONFIG.selectors.input);
    this.results = form.querySelector(CONFIG.selectors.results);
    this.submit = form.querySelector(CONFIG.selectors.submit);
    this.clear = form.querySelector(CONFIG.selectors.clear);
    this.selectedIndex = -1;
    this.currentResults = [];
    this.isOpen = false;
    this.lastQuery = '';

    if (!this.input) {
      return;
    }

    if (!this.results) {
      this.results = document.createElement('div');
      this.results.className = 'rx-ajax-search__results';
      this.results.setAttribute('data-rx-search-results', '');
      this.form.appendChild(this.results);
    }

    this.init();
  }

  RXAjaxSearch.prototype.init = function init() {
    var self = this;

    this.setupAccessibility();

    this.handleInput = debounce(function handleDebouncedInput() {
      self.onInput();
    }, CONFIG.debounce);

    this.input.addEventListener('input', this.handleInput);
    this.input.addEventListener('focus', function onFocus() {
      self.onFocus();
    });

    this.input.addEventListener('keydown', function onKeydown(event) {
      self.onKeydown(event);
    });

    this.form.addEventListener('submit', function onSubmit(event) {
      self.onSubmit(event);
    });

    if (this.clear) {
      this.clear.addEventListener('click', function onClear(event) {
        event.preventDefault();
        self.clearSearch();
      });
    }

    this.results.addEventListener('mousedown', function preventBlur(event) {
      event.preventDefault();
    });

    this.results.addEventListener('click', function onResultClick(event) {
      self.onResultsClick(event);
    });

    if (CONFIG.closeOnOutsideClick) {
      document.addEventListener('click', function onDocumentClick(event) {
        if (!self.form.contains(event.target)) {
          self.close();
        }
      });
    }

    this.renderInitial();

    emit('init', {
      id: this.id,
      form: this.form
    });
  };

  RXAjaxSearch.prototype.setupAccessibility = function setupAccessibility() {
    this.results.id = this.results.id || this.id + '-results';

    this.input.setAttribute('role', 'combobox');
    this.input.setAttribute('autocomplete', 'off');
    this.input.setAttribute('aria-autocomplete', 'list');
    this.input.setAttribute(CONFIG.attributes.expanded, 'false');
    this.input.setAttribute(CONFIG.attributes.controls, this.results.id);

    this.results.setAttribute('role', 'listbox');
    this.results.setAttribute(CONFIG.attributes.live, 'polite');
    this.results.setAttribute(CONFIG.attributes.busy, 'false');
  };

  RXAjaxSearch.prototype.setLoading = function setLoading(isLoading) {
    this.form.classList.toggle(CONFIG.classes.loading, !!isLoading);
    this.results.setAttribute(CONFIG.attributes.busy, isLoading ? 'true' : 'false');
  };

  RXAjaxSearch.prototype.open = function open() {
    this.isOpen = true;
    this.form.classList.add(CONFIG.classes.active);
    this.results.classList.add(CONFIG.classes.open);
    this.input.setAttribute(CONFIG.attributes.expanded, 'true');
  };

  RXAjaxSearch.prototype.close = function close() {
    this.isOpen = false;
    this.selectedIndex = -1;
    this.form.classList.remove(CONFIG.classes.active);
    this.results.classList.remove(CONFIG.classes.open);
    this.input.setAttribute(CONFIG.attributes.expanded, 'false');
    this.input.removeAttribute(CONFIG.attributes.activedescendant);
    this.updateSelection();
  };

  RXAjaxSearch.prototype.onFocus = function onFocus() {
    var query = normalizeQuery(this.input.value);

    if (query.length >= CONFIG.minChars && this.currentResults.length) {
      this.open();
      return;
    }

    this.renderInitial();
    this.open();
  };

  RXAjaxSearch.prototype.onInput = async function onInput() {
    var query = normalizeQuery(this.input.value);
    this.lastQuery = query;
    this.selectedIndex = -1;

    if (!query) {
      this.renderInitial();
      this.open();
      return;
    }

    if (query.length < CONFIG.minChars) {
      this.renderMessage(
        CONFIG.messages.tooShort.replace('{min}', CONFIG.minChars),
        'too-short'
      );
      this.open();
      return;
    }

    this.setLoading(true);
    this.renderMessage(CONFIG.messages.loading, 'loading');
    this.open();

    try {
      var results = await fetchResults(query);

      if (this.lastQuery !== query) {
        return;
      }

      this.currentResults = results;

      if (!results.length) {
        this.renderMessage(CONFIG.messages.empty, 'empty');
      } else {
        this.renderResults(results, query);
      }

      emit('render', {
        query: query,
        results: results
      });
    } catch (error) {
      if (error && error.name === 'AbortError') {
        return;
      }

      this.currentResults = [];
      this.renderMessage(CONFIG.messages.error, 'error');

      emit('error', {
        query: query,
        error: error
      });
    } finally {
      this.setLoading(false);
    }
  };

  RXAjaxSearch.prototype.onKeydown = function onKeydown(event) {
    var key = event.key;

    if (key === 'Escape' && CONFIG.closeOnEscape) {
      this.close();
      return;
    }

    if (!this.isOpen && (key === 'ArrowDown' || key === 'ArrowUp')) {
      this.open();
    }

    var items = this.getSelectableItems();

    if (!items.length) {
      return;
    }

    if (key === 'ArrowDown') {
      event.preventDefault();

      if (
        this.selectedIndex === -1 &&
        CONFIG.focusFirstResultOnArrowDown
      ) {
        this.selectedIndex = 0;
      } else {
        this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
      }

      this.updateSelection();
    }

    if (key === 'ArrowUp') {
      event.preventDefault();
      this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
      this.updateSelection();
    }

    if (key === 'Enter') {
      if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
        event.preventDefault();
        this.followItem(items[this.selectedIndex]);
      }
    }

    if (key === 'Tab') {
      this.close();
    }
  };

  RXAjaxSearch.prototype.onSubmit = function onSubmit(event) {
    var query = normalizeQuery(this.input.value);
    var items = this.getSelectableItems();

    if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
      event.preventDefault();
      this.followItem(items[this.selectedIndex]);
      return;
    }

    if (!query || query.length < CONFIG.minChars) {
      event.preventDefault();
      this.renderMessage(
        CONFIG.messages.tooShort.replace('{min}', CONFIG.minChars),
        'too-short'
      );
      this.open();
      return;
    }

    saveRecentSearch(query);

    if (CONFIG.submitOnEnterWithoutSelection) {
      event.preventDefault();
      window.location.href = buildSearchUrl(query);
    }
  };

  RXAjaxSearch.prototype.onResultsClick = function onResultsClick(event) {
    var recentButton = event.target.closest('[data-rx-recent-query]');
    var clearRecentButton = event.target.closest('[data-rx-clear-recent]');
    var popularButton = event.target.closest('[data-rx-popular-query]');
    var viewAll = event.target.closest('[data-rx-view-all]');

    if (clearRecentButton) {
      event.preventDefault();
      clearRecentSearches();
      this.renderInitial();
      return;
    }

    if (recentButton) {
      event.preventDefault();
      this.input.value = recentButton.getAttribute('data-rx-recent-query') || '';
      this.input.focus();
      this.onInput();
      return;
    }

    if (popularButton) {
      event.preventDefault();
      this.input.value = popularButton.getAttribute('data-rx-popular-query') || '';
      this.input.focus();
      this.onInput();
      return;
    }

    if (viewAll) {
      var query = normalizeQuery(this.input.value);

      if (query) {
        saveRecentSearch(query);
      }
    }
  };

  RXAjaxSearch.prototype.followItem = function followItem(item) {
    var link = item.querySelector('a[href]');

    if (!link) {
      return;
    }

    var query = normalizeQuery(this.input.value);

    if (query) {
      saveRecentSearch(query);
    }

    window.location.href = link.href;
  };

  RXAjaxSearch.prototype.getSelectableItems = function getSelectableItems() {
    return Array.prototype.slice.call(
      this.results.querySelectorAll('[data-rx-search-item]')
    );
  };

  RXAjaxSearch.prototype.updateSelection = function updateSelection() {
    var self = this;
    var items = this.getSelectableItems();

    items.forEach(function updateItem(item, index) {
      var isSelected = index === self.selectedIndex;

      item.classList.toggle(CONFIG.classes.selected, isSelected);
      item.setAttribute(CONFIG.attributes.selected, isSelected ? 'true' : 'false');

      if (isSelected) {
        self.input.setAttribute(CONFIG.attributes.activedescendant, item.id);
        item.scrollIntoView({
          block: 'nearest'
        });
      }
    });

    if (this.selectedIndex < 0) {
      this.input.removeAttribute(CONFIG.attributes.activedescendant);
    }
  };

  RXAjaxSearch.prototype.renderInitial = function renderInitial() {
    var html = '';
    var recent = getRecentSearches();

    this.currentResults = [];

    if (recent.length) {
      html += '<div class="rx-search-panel rx-search-panel--recent">';
      html += '<div class="rx-search-panel__header">';
      html += '<strong>' + escapeHTML(CONFIG.messages.recentTitle) + '</strong>';
      html += '<button type="button" class="rx-search-panel__clear" data-rx-clear-recent>';
      html += escapeHTML(CONFIG.messages.clearRecent);
      html += '</button>';
      html += '</div>';
      html += '<div class="rx-search-suggestions">';

      recent.forEach(function renderRecent(query) {
        html += '<button type="button" class="rx-search-suggestion" data-rx-recent-query="' + escapeHTML(query) + '">';
        html += escapeHTML(query);
        html += '</button>';
      });

      html += '</div>';
      html += '</div>';
    }

    if (CONFIG.enablePopular && CONFIG.popular && CONFIG.popular.length) {
      html += '<div class="rx-search-panel rx-search-panel--popular">';
      html += '<div class="rx-search-panel__header">';
      html += '<strong>' + escapeHTML(CONFIG.messages.popularTitle) + '</strong>';
      html += '</div>';
      html += '<div class="rx-search-suggestions">';

      CONFIG.popular.slice(0, 10).forEach(function renderPopular(query) {
        html += '<button type="button" class="rx-search-suggestion" data-rx-popular-query="' + escapeHTML(query) + '">';
        html += escapeHTML(query);
        html += '</button>';
      });

      html += '</div>';
      html += '</div>';
    }

    if (!html) {
      html = this.getMessageHTML(CONFIG.messages.start, 'start');
    }

    this.results.innerHTML = html;
  };

  RXAjaxSearch.prototype.renderMessage = function renderMessage(message, type) {
    this.currentResults = [];
    this.results.innerHTML = this.getMessageHTML(message, type);

    this.form.classList.toggle(CONFIG.classes.empty, type === 'empty');
    this.form.classList.toggle(CONFIG.classes.error, type === 'error');
  };

  RXAjaxSearch.prototype.getMessageHTML = function getMessageHTML(message, type) {
    return (
      '<div class="rx-search-message rx-search-message--' + escapeHTML(type || 'info') + '">' +
      escapeHTML(message) +
      '</div>'
    );
  };

  RXAjaxSearch.prototype.renderResults = function renderResults(results, query) {
    var self = this;
    var totalText =
      results.length === 1
        ? CONFIG.messages.resultSingular
        : CONFIG.messages.resultPlural;

    var html = '';

    html += '<div class="rx-search-results-wrap">';
    html += '<div class="rx-search-results-count">';
    html += escapeHTML(results.length + ' ' + totalText);
    html += '</div>';
    html += '<ul class="rx-search-results-list" role="presentation">';

    results.forEach(function renderResult(result, index) {
      var itemId = self.id + '-item-' + index;
      var typeLabel = result.type ? escapeHTML(result.type) : '';

      html += '<li';
      html += ' id="' + escapeHTML(itemId) + '"';
      html += ' class="rx-search-result"';
      html += ' role="option"';
      html += ' aria-selected="false"';
      html += ' data-rx-search-item';
      html += '>';

      html += '<a class="rx-search-result__link" href="' + escapeHTML(result.url) + '">';

      if (result.image) {
        html += '<span class="rx-search-result__media">';
        html += '<img src="' + escapeHTML(result.image) + '" alt="" loading="lazy" decoding="async">';
        html += '</span>';
      }

      html += '<span class="rx-search-result__content">';
      html += '<span class="rx-search-result__title">';
      html += highlightText(result.title, query);
      html += '</span>';

      if (result.excerpt) {
        html += '<span class="rx-search-result__excerpt">';
        html += highlightText(result.excerpt.slice(0, 150), query);
        html += '</span>';
      }

      if (typeLabel) {
        html += '<span class="rx-search-result__type">';
        html += typeLabel;
        html += '</span>';
      }

      html += '</span>';
      html += '</a>';
      html += '</li>';
    });

    html += '</ul>';

    html += '<a class="rx-search-view-all" href="' + escapeHTML(buildSearchUrl(query)) + '" data-rx-view-all>';
    html += escapeHTML(CONFIG.messages.viewAll);
    html += '</a>';

    html += '</div>';

    this.results.innerHTML = html;
    this.form.classList.remove(CONFIG.classes.empty, CONFIG.classes.error);
  };

  RXAjaxSearch.prototype.clearSearch = function clearSearch() {
    this.input.value = '';
    this.selectedIndex = -1;
    this.currentResults = [];
    this.renderInitial();
    this.input.focus();
    this.open();

    emit('clear', {
      id: this.id
    });
  };

  function initForms(context) {
    var scope = context || document;
    var forms = scope.querySelectorAll(CONFIG.selectors.form);

    Array.prototype.forEach.call(forms, function setupForm(form) {
      if (form.__rxAjaxSearchReady) {
        return;
      }

      form.__rxAjaxSearchReady = true;
      form.__rxAjaxSearchInstance = new RXAjaxSearch(form);
    });
  }

  function initOverlayControls() {
    var openButtons = document.querySelectorAll(CONFIG.selectors.openButton);
    var closeButtons = document.querySelectorAll(CONFIG.selectors.closeButton);
    var overlay = document.querySelector(CONFIG.selectors.overlay);

    if (!overlay) {
      return;
    }

    function openOverlay() {
      overlay.classList.add(CONFIG.classes.open);
      ROOT.classList.add(CONFIG.classes.bodyLocked);

      var input = overlay.querySelector(CONFIG.selectors.input);

      window.setTimeout(function focusInput() {
        if (input) {
          input.focus();
        }
      }, 30);

      emit('overlay-open', {
        overlay: overlay
      });
    }

    function closeOverlay() {
      overlay.classList.remove(CONFIG.classes.open);
      ROOT.classList.remove(CONFIG.classes.bodyLocked);

      emit('overlay-close', {
        overlay: overlay
      });
    }

    Array.prototype.forEach.call(openButtons, function bindOpen(button) {
      button.addEventListener('click', function onOpenClick(event) {
        event.preventDefault();
        openOverlay();
      });
    });

    Array.prototype.forEach.call(closeButtons, function bindClose(button) {
      button.addEventListener('click', function onCloseClick(event) {
        event.preventDefault();
        closeOverlay();
      });
    });

    if (CONFIG.closeOnEscape) {
      document.addEventListener('keydown', function closeOnEsc(event) {
        if (event.key === 'Escape' && overlay.classList.contains(CONFIG.classes.open)) {
          closeOverlay();
        }
      });
    }

    overlay.addEventListener('click', function closeOnBackdrop(event) {
      if (event.target === overlay) {
        closeOverlay();
      }
    });
  }

  function initMutationObserver() {
    if (!('MutationObserver' in window)) {
      return;
    }

    var observer = new MutationObserver(function onMutation(mutations) {
      mutations.forEach(function eachMutation(mutation) {
        Array.prototype.forEach.call(mutation.addedNodes, function eachNode(node) {
          if (!node || node.nodeType !== 1) {
            return;
          }

          if (node.matches && node.matches(CONFIG.selectors.form)) {
            initForms(node.parentNode || document);
          } else if (node.querySelector && node.querySelector(CONFIG.selectors.form)) {
            initForms(node);
          }
        });
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  }

  function ready(callback) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', callback);
    } else {
      callback();
    }
  }

  ready(function onReady() {
    initForms(document);
    initOverlayControls();
    initMutationObserver();

    emit('ready', {
      config: CONFIG
    });
  });

  window.RXAjaxSearch = {
    init: initForms,
    clearCache: function clearCache() {
      memoryCache.clear();
    },
    getRecent: getRecentSearches,
    clearRecent: clearRecentSearches,
    config: CONFIG
  };
})(window, document);

For WordPress backend support, you need an AJAX handler like this in your theme PHP file, preferably inside:

inc/ajax/ajax-search.php
<?php
/**
 * RX Theme AJAX Search Handler
 */

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

add_action( 'wp_ajax_rx_ajax_search', 'rx_theme_ajax_search' );
add_action( 'wp_ajax_nopriv_rx_ajax_search', 'rx_theme_ajax_search' );

function rx_theme_ajax_search() {
	if ( isset( $_POST['nonce'] ) ) {
		check_ajax_referer( 'rx_ajax_search_nonce', 'nonce' );
	}

	$search = isset( $_POST['search'] )
		? sanitize_text_field( wp_unslash( $_POST['search'] ) )
		: '';

	$limit = isset( $_POST['limit'] )
		? absint( $_POST['limit'] )
		: 8;

	if ( strlen( $search ) < 2 ) {
		wp_send_json_success( array() );
	}

	$query = new WP_Query(
		array(
			's'                   => $search,
			'post_type'           => array( 'post', 'page' ),
			'post_status'         => 'publish',
			'posts_per_page'      => min( $limit, 20 ),
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
		)
	);

	$results = array();

	if ( $query->have_posts() ) {
		while ( $query->have_posts() ) {
			$query->the_post();

			$results[] = array(
				'id'        => get_the_ID(),
				'title'     => get_the_title(),
				'url'       => get_permalink(),
				'excerpt'   => wp_trim_words( get_the_excerpt(), 22 ),
				'type'      => get_post_type(),
				'thumbnail' => get_the_post_thumbnail_url( get_the_ID(), 'thumbnail' ),
			);
		}

		wp_reset_postdata();
	}

	wp_send_json_success( $results );
}

And enqueue/localize it like this:

wp_enqueue_script(
	'rx-chunk-045-ajax-search',
	get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-045-ajax-search.js',
	array(),
	wp_get_theme()->get( 'Version' ),
	true
);

wp_localize_script(
	'rx-chunk-045-ajax-search',
	'RX_AJAX_SEARCH',
	array(
		'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
		'restUrl'      => esc_url_raw( rest_url( 'wp/v2/search' ) ),
		'homeUrl'      => home_url( '/' ),
		'searchUrl'    => home_url( '/' ),
		'nonce'        => wp_create_nonce( 'rx_ajax_search_nonce' ),
		'restNonce'    => wp_create_nonce( 'wp_rest' ),
		'action'       => 'rx_ajax_search',
		'minChars'     => 2,
		'limit'        => 8,
		'debounce'     => 280,
		'cacheTTL'     => 300000,
		'enableCache'  => true,
		'enableRecent' => true,
		'enablePopular'=> true,
		'popular'      => array(
			'Back pain',
			'Diabetes',
			'High blood pressure',
			'Anemia',
			'Neutropenia',
			'Disc displacement',
		),
	)
);

Best RX Theme folder placement:

rx-theme/
├── assets/
│   └── static-js/
│       └── static-chunks-js/
│           └── chunk-045-ajax-search.js
│
├── inc/
│   └── ajax/
│       └── ajax-search.php
│
└── functions.php

In functions.php, include the AJAX PHP file:

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

This file is now suitable for an advanced RX Theme search system.

Leave a Reply

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