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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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.