search.js

I’ll build this as a strong standalone inc/js/search.js for RX Theme: live search UI, debounce, keyboard support, accessibility, Ajax/REST support, caching, highlighting, history, mobile-friendly behavior, and safe fallbacks

Friend, create this file:

rx-theme/inc/js/search.js

It is an advanced RX Theme search JavaScript file with live search, AJAX/REST search, keyboard navigation, cache, highlight, accessibility, history, modal support, overlay search, fallback form submit, mobile support, and many helper functions.

/*!
 * RX Theme Advanced Search
 * File: inc/js/search.js
 * Theme: RX Theme
 * Author: RxHarun + ChatGPT
 *
 * Features:
 * - Live AJAX search
 * - WordPress REST API support
 * - admin-ajax.php fallback
 * - Debounce typing
 * - Keyboard navigation
 * - ESC close
 * - Search overlay/modal support
 * - Result highlighting
 * - Recent search history
 * - LocalStorage cache
 * - Popular searches
 * - Minimum character control
 * - Loading state
 * - Empty state
 * - Error state
 * - Accessibility roles
 * - Mobile friendly
 * - Safe fallback to normal search form
 */

(function () {
  'use strict';

  /* ==========================================================
   * 1. Default Configuration
   * ========================================================== */

  const DEFAULT_CONFIG = {
    selectors: {
      form: '.rx-search-form',
      input: '.rx-search-input',
      submit: '.rx-search-submit',
      results: '.rx-search-results',
      resultList: '.rx-search-result-list',
      overlay: '.rx-search-overlay',
      modal: '.rx-search-modal',
      openButton: '.rx-search-open',
      closeButton: '.rx-search-close',
      clearButton: '.rx-search-clear',
      recentBox: '.rx-search-recent',
      popularBox: '.rx-search-popular',
      countBox: '.rx-search-count',
      liveRegion: '.rx-search-live-region'
    },

    classes: {
      active: 'is-active',
      open: 'is-open',
      loading: 'is-loading',
      empty: 'is-empty',
      error: 'is-error',
      hidden: 'is-hidden',
      selected: 'is-selected',
      hasValue: 'has-value',
      bodySearchOpen: 'rx-search-opened'
    },

    searchParam: 's',
    restEndpoint: '/wp-json/wp/v2/search',
    ajaxAction: 'rx_live_search',
    nonceAction: 'rx_search_nonce',

    minChars: 2,
    maxResults: 10,
    debounceDelay: 350,
    cacheTTL: 5 * 60 * 1000,
    recentLimit: 8,

    enableAjax: true,
    enableRest: true,
    enableCache: true,
    enableHistory: true,
    enableHighlight: true,
    enableKeyboard: true,
    enableAutoFocus: true,
    enablePopularSearches: true,
    enableRecentSearches: true,
    enableSubmitOnEnter: true,
    enableCloseOnEscape: true,
    enableClickOutsideClose: true,
    enableBodyLock: true,
    enableAnalyticsEvents: true,

    postTypes: ['post', 'page'],
    popularSearches: [
      'Back pain',
      'Knee pain',
      'Arthritis',
      'Neck pain',
      'Diabetes',
      'Hypertension',
      'Fracture',
      'Vitamin D'
    ],

    messages: {
      loading: 'Searching...',
      typeMore: 'Please type at least 2 characters.',
      empty: 'No results found.',
      error: 'Search failed. Please try again.',
      recent: 'Recent searches',
      popular: 'Popular searches',
      resultsFound: 'results found',
      clear: 'Clear search'
    }
  };

  /* ==========================================================
   * 2. Merge WordPress Localized Config
   *
   * Optional PHP can define:
   * window.rxSearchConfig = {
   *   ajaxUrl: '...',
   *   restUrl: '...',
   *   nonce: '...',
   *   homeUrl: '...',
   *   searchUrl: '...'
   * };
   * ========================================================== */

  const WP_CONFIG = window.rxSearchConfig || {};
  const CONFIG = deepMerge(DEFAULT_CONFIG, WP_CONFIG);

  /* ==========================================================
   * 3. State
   * ========================================================== */

  const state = {
    instances: [],
    activeInstance: null,
    cache: new Map(),
    controller: null,
    lastQuery: '',
    selectedIndex: -1,
    isComposing: false
  };

  /* ==========================================================
   * 4. Utility Functions
   * ========================================================== */

  function $(selector, context = document) {
    return context.querySelector(selector);
  }

  function $$(selector, context = document) {
    return Array.prototype.slice.call(context.querySelectorAll(selector));
  }

  function isElement(value) {
    return value instanceof Element || value instanceof HTMLDocument;
  }

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

    if (!source || typeof source !== 'object') {
      return output;
    }

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

    return output;
  }

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

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

      window.clearTimeout(timer);

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

  function throttle(fn, wait) {
    let waiting = false;

    return function throttled() {
      if (waiting) return;

      waiting = true;
      const context = this;
      const args = arguments;

      fn.apply(context, args);

      window.setTimeout(function () {
        waiting = false;
      }, wait);
    };
  }

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

  function stripHTML(value) {
    const div = document.createElement('div');
    div.innerHTML = value || '';
    return div.textContent || div.innerText || '';
  }

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

  function buildURL(base, params) {
    const url = new URL(base, window.location.origin);

    Object.keys(params).forEach(function (key) {
      const value = params[key];

      if (value !== undefined && value !== null && value !== '') {
        url.searchParams.set(key, value);
      }
    });

    return url.toString();
  }

  function getSearchPageURL(query) {
    const homeUrl = CONFIG.homeUrl || window.location.origin;
    const searchUrl = CONFIG.searchUrl || homeUrl;

    return buildURL(searchUrl, {
      [CONFIG.searchParam]: query
    });
  }

  function createElement(tag, className, html) {
    const el = document.createElement(tag);

    if (className) {
      el.className = className;
    }

    if (html !== undefined) {
      el.innerHTML = html;
    }

    return el;
  }

  function setAttributes(el, attrs) {
    if (!el) return;

    Object.keys(attrs).forEach(function (key) {
      if (attrs[key] === false || attrs[key] === null || attrs[key] === undefined) {
        el.removeAttribute(key);
      } else {
        el.setAttribute(key, attrs[key]);
      }
    });
  }

  function safeJSONParse(value, fallback) {
    try {
      return JSON.parse(value);
    } catch (e) {
      return fallback;
    }
  }

  function supportsLocalStorage() {
    try {
      const key = '__rx_search_test__';
      window.localStorage.setItem(key, key);
      window.localStorage.removeItem(key);
      return true;
    } catch (e) {
      return false;
    }
  }

  const canUseStorage = supportsLocalStorage();

  /* ==========================================================
   * 5. LocalStorage Helpers
   * ========================================================== */

  function storageGet(key, fallback) {
    if (!canUseStorage) return fallback;

    const value = window.localStorage.getItem(key);

    if (!value) return fallback;

    return safeJSONParse(value, fallback);
  }

  function storageSet(key, value) {
    if (!canUseStorage) return;

    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (e) {
      // Ignore storage quota errors.
    }
  }

  function getRecentSearches() {
    return storageGet('rx_recent_searches', []);
  }

  function saveRecentSearch(query) {
    if (!CONFIG.enableRecentSearches) return;

    query = normalizeText(query);

    if (!query || query.length < CONFIG.minChars) return;

    let recent = getRecentSearches();

    recent = recent.filter(function (item) {
      return item.toLowerCase() !== query.toLowerCase();
    });

    recent.unshift(query);
    recent = recent.slice(0, CONFIG.recentLimit);

    storageSet('rx_recent_searches', recent);
  }

  function clearRecentSearches() {
    storageSet('rx_recent_searches', []);
  }

  /* ==========================================================
   * 6. Cache Helpers
   * ========================================================== */

  function getCacheKey(query) {
    return 'rx_search_' + query.toLowerCase();
  }

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

    const key = getCacheKey(query);
    const cached = state.cache.get(key);

    if (!cached) return null;

    const expired = Date.now() - cached.time > CONFIG.cacheTTL;

    if (expired) {
      state.cache.delete(key);
      return null;
    }

    return cached.data;
  }

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

    const key = getCacheKey(query);

    state.cache.set(key, {
      time: Date.now(),
      data: data
    });
  }

  /* ==========================================================
   * 7. Highlight Search Term
   * ========================================================== */

  function highlightText(text, query) {
    text = escapeHTML(stripHTML(text || ''));

    if (!CONFIG.enableHighlight || !query) {
      return text;
    }

    const words = normalizeText(query)
      .split(' ')
      .filter(Boolean)
      .map(function (word) {
        return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      });

    if (!words.length) {
      return text;
    }

    const regex = new RegExp('(' + words.join('|') + ')', 'gi');

    return text.replace(regex, '<mark>$1</mark>');
  }

  /* ==========================================================
   * 8. Analytics / Custom Events
   * ========================================================== */

  function emitEvent(name, detail) {
    if (!CONFIG.enableAnalyticsEvents) return;

    const event = new CustomEvent('rxSearch:' + name, {
      bubbles: true,
      detail: detail || {}
    });

    document.dispatchEvent(event);
  }

  /* ==========================================================
   * 9. Search Instance Constructor
   * ========================================================== */

  function RXSearch(form) {
    if (!isElement(form)) return;

    this.form = form;
    this.input = $(CONFIG.selectors.input, form);
    this.submit = $(CONFIG.selectors.submit, form);
    this.results = $(CONFIG.selectors.results, form) || findSiblingResultBox(form);
    this.resultList = this.results ? $(CONFIG.selectors.resultList, this.results) : null;
    this.overlay = closestOrGlobal(form, CONFIG.selectors.overlay);
    this.modal = closestOrGlobal(form, CONFIG.selectors.modal);
    this.clearButton = $(CONFIG.selectors.clearButton, form);
    this.countBox = this.results ? $(CONFIG.selectors.countBox, this.results) : null;
    this.liveRegion = this.results ? $(CONFIG.selectors.liveRegion, this.results) : null;

    this.query = '';
    this.resultsData = [];
    this.selectedIndex = -1;
    this.isOpen = false;

    this.handleInput = debounce(this.onInput.bind(this), CONFIG.debounceDelay);
    this.handleKeydown = this.onKeydown.bind(this);
    this.handleSubmit = this.onSubmit.bind(this);
    this.handleFocus = this.onFocus.bind(this);
    this.handleClear = this.onClear.bind(this);

    this.init();
  }

  RXSearch.prototype.init = function () {
    if (!this.input) return;

    this.prepareDOM();
    this.bindEvents();
    this.renderRecentAndPopular();
    this.updateClearButton();

    state.instances.push(this);

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

  RXSearch.prototype.prepareDOM = function () {
    const resultId = this.results && this.results.id
      ? this.results.id
      : 'rx-search-results-' + Math.random().toString(36).slice(2);

    if (this.results) {
      this.results.id = resultId;

      setAttributes(this.results, {
        role: 'region',
        'aria-live': 'polite',
        'aria-label': 'Search results'
      });

      if (!this.resultList) {
        this.resultList = createElement('div', 'rx-search-result-list');
        this.results.appendChild(this.resultList);
      }
    }

    setAttributes(this.input, {
      autocomplete: 'off',
      autocapitalize: 'off',
      spellcheck: 'false',
      role: 'combobox',
      'aria-autocomplete': 'list',
      'aria-expanded': 'false',
      'aria-controls': resultId
    });

    if (!this.liveRegion && this.results) {
      this.liveRegion = createElement('div', 'rx-search-live-region screen-reader-text');
      setAttributes(this.liveRegion, {
        'aria-live': 'polite',
        'aria-atomic': 'true'
      });
      this.results.appendChild(this.liveRegion);
    }
  };

  RXSearch.prototype.bindEvents = function () {
    this.input.addEventListener('input', this.handleInput);
    this.input.addEventListener('keydown', this.handleKeydown);
    this.input.addEventListener('focus', this.handleFocus);
    this.input.addEventListener('compositionstart', this.onCompositionStart.bind(this));
    this.input.addEventListener('compositionend', this.onCompositionEnd.bind(this));

    this.form.addEventListener('submit', this.handleSubmit);

    if (this.clearButton) {
      this.clearButton.addEventListener('click', this.handleClear);
    }

    if (CONFIG.enableClickOutsideClose) {
      document.addEventListener('click', this.onDocumentClick.bind(this));
    }
  };

  RXSearch.prototype.onCompositionStart = function () {
    state.isComposing = true;
  };

  RXSearch.prototype.onCompositionEnd = function () {
    state.isComposing = false;
    this.handleInput();
  };

  RXSearch.prototype.onInput = function () {
    if (state.isComposing) return;

    const query = normalizeText(this.input.value);

    this.query = query;
    state.lastQuery = query;
    state.activeInstance = this;

    this.updateClearButton();

    if (!query) {
      this.clearResults();
      this.renderRecentAndPopular();
      this.setExpanded(false);
      return;
    }

    if (query.length < CONFIG.minChars) {
      this.showMessage(CONFIG.messages.typeMore, 'type-more');
      this.setExpanded(true);
      return;
    }

    this.search(query);
  };

  RXSearch.prototype.onFocus = function () {
    state.activeInstance = this;

    const query = normalizeText(this.input.value);

    if (!query) {
      this.renderRecentAndPopular();
    }

    this.openResults();
  };

  RXSearch.prototype.onSubmit = function (event) {
    const query = normalizeText(this.input.value);

    if (!query) {
      event.preventDefault();
      this.input.focus();
      return;
    }

    saveRecentSearch(query);

    emitEvent('submit', {
      query: query
    });

    if (!CONFIG.enableSubmitOnEnter) {
      event.preventDefault();
      return;
    }

    if (!this.form.getAttribute('action')) {
      this.form.setAttribute('action', getSearchPageURL(''));
    }
  };

  RXSearch.prototype.onClear = function (event) {
    event.preventDefault();

    this.input.value = '';
    this.query = '';
    this.selectedIndex = -1;

    this.clearResults();
    this.renderRecentAndPopular();
    this.updateClearButton();

    this.input.focus();

    emitEvent('clear', {});
  };

  RXSearch.prototype.onDocumentClick = function (event) {
    if (
      this.form.contains(event.target) ||
      (this.results && this.results.contains(event.target)) ||
      (this.modal && this.modal.contains(event.target))
    ) {
      return;
    }

    this.closeResults();
  };

  RXSearch.prototype.onKeydown = function (event) {
    if (!CONFIG.enableKeyboard) return;

    const items = this.getResultItems();

    switch (event.key) {
      case 'ArrowDown':
        if (!items.length) return;
        event.preventDefault();
        this.moveSelection(1);
        break;

      case 'ArrowUp':
        if (!items.length) return;
        event.preventDefault();
        this.moveSelection(-1);
        break;

      case 'Enter':
        if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
          event.preventDefault();
          items[this.selectedIndex].click();
        }
        break;

      case 'Escape':
        if (CONFIG.enableCloseOnEscape) {
          event.preventDefault();
          this.closeResults();
          closeAllSearchOverlays();
        }
        break;

      default:
        break;
    }
  };

  RXSearch.prototype.moveSelection = function (direction) {
    const items = this.getResultItems();

    if (!items.length) return;

    this.selectedIndex += direction;

    if (this.selectedIndex < 0) {
      this.selectedIndex = items.length - 1;
    }

    if (this.selectedIndex >= items.length) {
      this.selectedIndex = 0;
    }

    items.forEach(function (item) {
      item.classList.remove(CONFIG.classes.selected);
      item.setAttribute('aria-selected', 'false');
    });

    const selected = items[this.selectedIndex];

    selected.classList.add(CONFIG.classes.selected);
    selected.setAttribute('aria-selected', 'true');
    selected.focus({ preventScroll: true });
    selected.scrollIntoView({
      block: 'nearest'
    });
  };

  RXSearch.prototype.getResultItems = function () {
    if (!this.resultList) return [];
    return $$('a.rx-search-result-item, button.rx-search-suggestion', this.resultList);
  };

  RXSearch.prototype.search = function (query) {
    const cached = getCachedResults(query);

    if (cached) {
      this.renderResults(cached, query);
      emitEvent('cacheHit', { query: query });
      return;
    }

    this.setLoading(true);
    this.setExpanded(true);

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

    state.controller = new AbortController();

    const request = CONFIG.enableRest
      ? this.searchByREST(query, state.controller.signal)
      : this.searchByAjax(query, state.controller.signal);

    request
      .then(function (data) {
        const results = normalizeResults(data);

        setCachedResults(query, results);
        this.renderResults(results, query);

        emitEvent('results', {
          query: query,
          count: results.length,
          results: results
        });
      }.bind(this))
      .catch(function (error) {
        if (error && error.name === 'AbortError') return;

        if (CONFIG.enableRest) {
          this.searchByAjax(query)
            .then(function (data) {
              const results = normalizeResults(data);
              setCachedResults(query, results);
              this.renderResults(results, query);
            }.bind(this))
            .catch(function () {
              this.showError();
            }.bind(this));
        } else {
          this.showError();
        }
      }.bind(this))
      .finally(function () {
        this.setLoading(false);
      }.bind(this));
  };

  RXSearch.prototype.searchByREST = function (query, signal) {
    let endpoint = CONFIG.restUrl || CONFIG.restEndpoint;

    endpoint = buildURL(endpoint, {
      search: query,
      per_page: CONFIG.maxResults,
      subtype: CONFIG.postTypes.join(',')
    });

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

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

    return fetch(endpoint, {
      method: 'GET',
      headers: headers,
      credentials: 'same-origin',
      signal: signal
    }).then(function (response) {
      if (!response.ok) {
        throw new Error('REST search failed');
      }

      return response.json();
    });
  };

  RXSearch.prototype.searchByAjax = function (query, signal) {
    const ajaxUrl = CONFIG.ajaxUrl || window.ajaxurl;

    if (!ajaxUrl) {
      return Promise.reject(new Error('No AJAX URL found'));
    }

    const formData = new FormData();

    formData.append('action', CONFIG.ajaxAction);
    formData.append('s', query);
    formData.append('query', query);
    formData.append('max_results', CONFIG.maxResults);

    if (CONFIG.nonce) {
      formData.append('nonce', CONFIG.nonce);
      formData.append('_wpnonce', CONFIG.nonce);
    }

    return fetch(ajaxUrl, {
      method: 'POST',
      body: formData,
      credentials: 'same-origin',
      signal: signal
    }).then(function (response) {
      if (!response.ok) {
        throw new Error('AJAX search failed');
      }

      return response.json();
    });
  };

  RXSearch.prototype.renderResults = function (results, query) {
    this.resultsData = results || [];
    this.selectedIndex = -1;

    if (!this.resultList) return;

    this.resultList.innerHTML = '';

    if (!results || !results.length) {
      this.showMessage(CONFIG.messages.empty, 'empty');
      this.updateCount(0);
      return;
    }

    const fragment = document.createDocumentFragment();

    results.slice(0, CONFIG.maxResults).forEach(function (item, index) {
      fragment.appendChild(this.createResultItem(item, query, index));
    }.bind(this));

    const viewAll = this.createViewAllItem(query);
    fragment.appendChild(viewAll);

    this.resultList.appendChild(fragment);

    this.updateCount(results.length);
    this.announce(results.length + ' ' + CONFIG.messages.resultsFound);
    this.openResults();
  };

  RXSearch.prototype.createResultItem = function (item, query, index) {
    const title = item.title || item.name || 'Untitled';
    const url = item.url || item.link || item.permalink || getSearchPageURL(query);
    const excerpt = item.excerpt || item.description || item.content || '';
    const type = item.type || item.subtype || item.post_type || 'result';
    const image = item.image || item.thumbnail || item.featured_image || '';

    const link = createElement('a', 'rx-search-result-item');
    link.href = url;

    setAttributes(link, {
      role: 'option',
      'aria-selected': 'false',
      'data-index': index,
      'data-type': type
    });

    let imageHTML = '';

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

    link.innerHTML =
      imageHTML +
      '<span class="rx-search-result-content">' +
        '<span class="rx-search-result-title">' + highlightText(title, query) + '</span>' +
        '<span class="rx-search-result-meta">' + escapeHTML(type) + '</span>' +
        (excerpt ? '<span class="rx-search-result-excerpt">' + highlightText(excerpt, query) + '</span>' : '') +
      '</span>';

    link.addEventListener('click', function () {
      saveRecentSearch(query);

      emitEvent('clickResult', {
        query: query,
        title: title,
        url: url,
        index: index
      });
    });

    return link;
  };

  RXSearch.prototype.createViewAllItem = function (query) {
    const link = createElement('a', 'rx-search-view-all');
    link.href = getSearchPageURL(query);
    link.innerHTML = 'View all results for <strong>' + escapeHTML(query) + '</strong>';

    link.addEventListener('click', function () {
      saveRecentSearch(query);

      emitEvent('viewAll', {
        query: query
      });
    });

    return link;
  };

  RXSearch.prototype.renderRecentAndPopular = function () {
    if (!this.resultList) return;

    const recent = CONFIG.enableRecentSearches ? getRecentSearches() : [];
    const popular = CONFIG.enablePopularSearches ? CONFIG.popularSearches : [];

    this.resultList.innerHTML = '';

    if (!recent.length && !popular.length) {
      return;
    }

    const fragment = document.createDocumentFragment();

    if (recent.length) {
      fragment.appendChild(this.createSuggestionGroup(CONFIG.messages.recent, recent, true));
    }

    if (popular.length) {
      fragment.appendChild(this.createSuggestionGroup(CONFIG.messages.popular, popular, false));
    }

    this.resultList.appendChild(fragment);
    this.openResults();
  };

  RXSearch.prototype.createSuggestionGroup = function (title, items, canClear) {
    const group = createElement('div', 'rx-search-suggestion-group');

    const heading = createElement('div', 'rx-search-suggestion-heading');
    heading.innerHTML = '<span>' + escapeHTML(title) + '</span>';

    if (canClear) {
      const clear = createElement('button', 'rx-search-clear-recent', 'Clear');
      clear.type = 'button';

      clear.addEventListener('click', function () {
        clearRecentSearches();
        this.renderRecentAndPopular();
      }.bind(this));

      heading.appendChild(clear);
    }

    group.appendChild(heading);

    items.forEach(function (item) {
      const button = createElement('button', 'rx-search-suggestion');
      button.type = 'button';
      button.textContent = item;

      setAttributes(button, {
        role: 'option',
        'aria-selected': 'false'
      });

      button.addEventListener('click', function () {
        this.input.value = item;
        this.query = item;
        this.updateClearButton();
        this.search(item);
        this.input.focus();

        emitEvent('suggestion', {
          query: item
        });
      }.bind(this));

      group.appendChild(button);
    }.bind(this));

    return group;
  };

  RXSearch.prototype.showMessage = function (message, type) {
    if (!this.resultList) return;

    this.resultList.innerHTML =
      '<div class="rx-search-message rx-search-message-' + escapeHTML(type || 'info') + '">' +
        escapeHTML(message) +
      '</div>';

    this.openResults();
    this.announce(message);
  };

  RXSearch.prototype.showError = function () {
    if (this.results) {
      this.results.classList.add(CONFIG.classes.error);
    }

    this.showMessage(CONFIG.messages.error, 'error');

    emitEvent('error', {
      query: this.query
    });
  };

  RXSearch.prototype.clearResults = function () {
    if (this.resultList) {
      this.resultList.innerHTML = '';
    }

    this.resultsData = [];
    this.selectedIndex = -1;
    this.updateCount(0);
  };

  RXSearch.prototype.openResults = function () {
    if (!this.results) return;

    this.results.classList.add(CONFIG.classes.active);
    this.results.classList.remove(CONFIG.classes.hidden);
    this.setExpanded(true);
    this.isOpen = true;
  };

  RXSearch.prototype.closeResults = function () {
    if (!this.results) return;

    this.results.classList.remove(CONFIG.classes.active);
    this.setExpanded(false);
    this.isOpen = false;
  };

  RXSearch.prototype.setExpanded = function (expanded) {
    if (this.input) {
      this.input.setAttribute('aria-expanded', expanded ? 'true' : 'false');
    }
  };

  RXSearch.prototype.setLoading = function (isLoading) {
    if (!this.results) return;

    this.results.classList.toggle(CONFIG.classes.loading, isLoading);

    if (isLoading) {
      this.showMessage(CONFIG.messages.loading, 'loading');
    }
  };

  RXSearch.prototype.updateClearButton = function () {
    const hasValue = !!normalizeText(this.input.value);

    this.form.classList.toggle(CONFIG.classes.hasValue, hasValue);

    if (this.clearButton) {
      this.clearButton.classList.toggle(CONFIG.classes.hidden, !hasValue);
      this.clearButton.setAttribute('aria-hidden', hasValue ? 'false' : 'true');
    }
  };

  RXSearch.prototype.updateCount = function (count) {
    if (this.countBox) {
      this.countBox.textContent = count ? count + ' ' + CONFIG.messages.resultsFound : '';
    }
  };

  RXSearch.prototype.announce = function (message) {
    if (this.liveRegion) {
      this.liveRegion.textContent = message;
    }
  };

  /* ==========================================================
   * 10. Normalize Different API Result Shapes
   * ========================================================== */

  function normalizeResults(data) {
    if (!data) return [];

    if (Array.isArray(data)) {
      return data.map(normalizeSingleResult);
    }

    if (data.success && data.data) {
      if (Array.isArray(data.data)) {
        return data.data.map(normalizeSingleResult);
      }

      if (Array.isArray(data.data.results)) {
        return data.data.results.map(normalizeSingleResult);
      }
    }

    if (Array.isArray(data.results)) {
      return data.results.map(normalizeSingleResult);
    }

    if (Array.isArray(data.posts)) {
      return data.posts.map(normalizeSingleResult);
    }

    return [];
  }

  function normalizeSingleResult(item) {
    const title =
      item.title && item.title.rendered
        ? item.title.rendered
        : item.title || item.name || item.post_title || 'Untitled';

    const excerpt =
      item.excerpt && item.excerpt.rendered
        ? item.excerpt.rendered
        : item.excerpt || item.description || item.post_excerpt || '';

    return {
      id: item.id || item.ID || item.object_id || '',
      title: stripHTML(title),
      excerpt: stripHTML(excerpt),
      url: item.url || item.link || item.permalink || item.guid || '#',
      type: item.subtype || item.type || item.post_type || 'post',
      image: item.image || item.thumbnail || item.featured_image || ''
    };
  }

  /* ==========================================================
   * 11. Overlay / Modal Search
   * ========================================================== */

  function openSearchOverlay(target) {
    const overlay = target || $(CONFIG.selectors.overlay) || $(CONFIG.selectors.modal);

    if (!overlay) return;

    overlay.classList.add(CONFIG.classes.open);
    overlay.classList.add(CONFIG.classes.active);

    if (CONFIG.enableBodyLock) {
      document.body.classList.add(CONFIG.classes.bodySearchOpen);
    }

    const input = $(CONFIG.selectors.input, overlay);

    if (input && CONFIG.enableAutoFocus) {
      window.setTimeout(function () {
        input.focus();
      }, 50);
    }

    emitEvent('openOverlay', {});
  }

  function closeSearchOverlay(target) {
    const overlay = target || $(CONFIG.selectors.overlay) || $(CONFIG.selectors.modal);

    if (!overlay) return;

    overlay.classList.remove(CONFIG.classes.open);
    overlay.classList.remove(CONFIG.classes.active);

    if (CONFIG.enableBodyLock) {
      document.body.classList.remove(CONFIG.classes.bodySearchOpen);
    }

    emitEvent('closeOverlay', {});
  }

  function closeAllSearchOverlays() {
    $$(CONFIG.selectors.overlay + ', ' + CONFIG.selectors.modal).forEach(function (overlay) {
      closeSearchOverlay(overlay);
    });

    state.instances.forEach(function (instance) {
      instance.closeResults();
    });
  }

  function bindOverlayButtons() {
    $$(CONFIG.selectors.openButton).forEach(function (button) {
      button.addEventListener('click', function (event) {
        event.preventDefault();

        const targetSelector = button.getAttribute('data-rx-search-target');
        const target = targetSelector ? $(targetSelector) : null;

        openSearchOverlay(target);
      });
    });

    $$(CONFIG.selectors.closeButton).forEach(function (button) {
      button.addEventListener('click', function (event) {
        event.preventDefault();

        const targetSelector = button.getAttribute('data-rx-search-target');
        const target = targetSelector ? $(targetSelector) : closestOrGlobal(button, CONFIG.selectors.overlay) || closestOrGlobal(button, CONFIG.selectors.modal);

        closeSearchOverlay(target);
      });
    });

    document.addEventListener('keydown', function (event) {
      if (event.key === 'Escape' && CONFIG.enableCloseOnEscape) {
        closeAllSearchOverlays();
      }
    });
  }

  /* ==========================================================
   * 12. Helper DOM Search
   * ========================================================== */

  function findSiblingResultBox(form) {
    const next = form.nextElementSibling;

    if (next && next.matches(CONFIG.selectors.results)) {
      return next;
    }

    const parent = form.parentElement;

    if (parent) {
      return $(CONFIG.selectors.results, parent);
    }

    return null;
  }

  function closestOrGlobal(element, selector) {
    if (!element) return $(selector);

    if (element.closest && element.closest(selector)) {
      return element.closest(selector);
    }

    return $(selector);
  }

  /* ==========================================================
   * 13. URL Query Autofill
   * ========================================================== */

  function autofillFromURL() {
    const params = new URLSearchParams(window.location.search);
    const query = params.get(CONFIG.searchParam);

    if (!query) return;

    $$(CONFIG.selectors.input).forEach(function (input) {
      if (!input.value) {
        input.value = query;
      }
    });
  }

  /* ==========================================================
   * 14. Search Shortcut
   * Ctrl + K / Cmd + K opens search overlay
   * ========================================================== */

  function bindKeyboardShortcut() {
    document.addEventListener('keydown', function (event) {
      const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
      const shortcutPressed = isMac ? event.metaKey && event.key.toLowerCase() === 'k' : event.ctrlKey && event.key.toLowerCase() === 'k';

      if (!shortcutPressed) return;

      event.preventDefault();

      const overlay = $(CONFIG.selectors.overlay) || $(CONFIG.selectors.modal);

      if (overlay) {
        openSearchOverlay(overlay);
      } else {
        const input = $(CONFIG.selectors.input);

        if (input) {
          input.focus();
        }
      }
    });
  }

  /* ==========================================================
   * 15. Lazy Init for Dynamically Added Forms
   * ========================================================== */

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

    const observer = new MutationObserver(
      throttle(function (mutations) {
        mutations.forEach(function (mutation) {
          mutation.addedNodes.forEach(function (node) {
            if (!isElement(node)) return;

            if (node.matches && node.matches(CONFIG.selectors.form)) {
              initSearchForm(node);
            }

            if (node.querySelectorAll) {
              $$(CONFIG.selectors.form, node).forEach(initSearchForm);
            }
          });
        });
      }, 500)
    );

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

  function initSearchForm(form) {
    if (!form || form.dataset.rxSearchInit === '1') return;

    form.dataset.rxSearchInit = '1';

    new RXSearch(form);
  }

  /* ==========================================================
   * 16. Public API
   * ========================================================== */

  window.RXSearch = {
    init: init,
    open: openSearchOverlay,
    close: closeSearchOverlay,
    closeAll: closeAllSearchOverlays,
    clearRecent: clearRecentSearches,
    getRecent: getRecentSearches,
    config: CONFIG,
    state: state
  };

  /* ==========================================================
   * 17. Init
   * ========================================================== */

  function init() {
    autofillFromURL();

    $$(CONFIG.selectors.form).forEach(initSearchForm);

    bindOverlayButtons();
    bindKeyboardShortcut();
    observeNewSearchForms();

    emitEvent('ready', {
      instances: state.instances.length
    });
  }

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

For this JavaScript to work beautifully, your search form should look like this:

<form class="rx-search-form" role="search" method="get" action="<?php echo esc_url( home_url( '/' ) ); ?>">
    <label class="screen-reader-text" for="rx-search-input">
        Search
    </label>

    <input
        id="rx-search-input"
        class="rx-search-input"
        type="search"
        name="s"
        placeholder="Search medical articles..."
        value="<?php echo esc_attr( get_search_query() ); ?>"
    >

    <button class="rx-search-clear is-hidden" type="button" aria-label="Clear search">
        ×
    </button>

    <button class="rx-search-submit" type="submit">
        Search
    </button>

    <div class="rx-search-results is-hidden">
        <div class="rx-search-count"></div>
        <div class="rx-search-result-list"></div>
    </div>
</form>

Add this enqueue code in your functions.php or inc/core/enqueue.php:

function rx_theme_enqueue_search_script() {
    wp_enqueue_script(
        'rx-theme-search',
        get_template_directory_uri() . '/inc/js/search.js',
        array(),
        wp_get_theme()->get( 'Version' ),
        true
    );

    wp_localize_script(
        'rx-theme-search',
        'rxSearchConfig',
        array(
            'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
            'restUrl'    => esc_url_raw( rest_url( 'wp/v2/search' ) ),
            'homeUrl'    => esc_url_raw( home_url( '/' ) ),
            'searchUrl'  => esc_url_raw( home_url( '/' ) ),
            'nonce'      => wp_create_nonce( 'wp_rest' ),
            'minChars'   => 2,
            'maxResults' => 10,
            'messages'   => array(
                'loading'      => 'Searching...',
                'typeMore'     => 'Please type at least 2 characters.',
                'empty'        => 'No results found.',
                'error'        => 'Search failed. Please try again.',
                'recent'       => 'Recent searches',
                'popular'      => 'Popular searches',
                'resultsFound' => 'results found',
                'clear'        => 'Clear search',
            ),
        )
    );
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_search_script' );

Optional CSS class idea:

.rx-search-results {
    display: none;
}

.rx-search-results.is-active {
    display: block;
}

.rx-search-result-item,
.rx-search-view-all,
.rx-search-suggestion {
    display: block;
    width: 100%;
}

.rx-search-result-item.is-selected {
    outline: 2px solid currentColor;
}

.rx-search-results.is-loading {
    opacity: 0.7;
}

.rx-search-opened {
    overflow: hidden;
}

.rx-search-result-title mark,
.rx-search-result-excerpt mark {
    font-weight: 700;
}

This file is safe for your RX Theme because it does not depend on jQuery and works with normal WordPress search even if AJAX fails.

Leave a Reply

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