inc/ajax/live-search.php module for your RX theme: secure AJAX + REST search, nonce protection, caching, highlighting, post-type support, taxonomy/meta filtering, analytics hooks, and clean JSON output.Below is an advanced inc/ajax/live-search.php file for your RX Theme. It includes many useful live-search features: AJAX endpoint, REST endpoint, nonce security, transient cache, keyword highlighting, post types, categories, tags, CPT support, excerpt, thumbnail, reading time, schema-friendly data, search suggestions, popular fallback, and developer hooks.
Create this file:
<?php
/**
* RX Theme Live Search
*
* File: inc/ajax/live-search.php
*
* Advanced AJAX + REST powered live search system for RX Theme.
*
* Features:
* - WordPress AJAX live search
* - REST API live search endpoint
* - Nonce security
* - Sanitized input
* - Post type support
* - Category/tag/taxonomy filtering
* - Result caching with transients
* - Highlight matched keyword
* - Thumbnail support
* - Excerpt support
* - Author, date, reading time
* - Popular fallback results
* - Search suggestions
* - Minimum keyword length control
* - JSON response
* - Developer-friendly filters/actions
*
* @package RX_Theme
*/
defined( 'ABSPATH' ) || exit;
if ( ! class_exists( 'RX_Theme_Live_Search' ) ) :
final class RX_Theme_Live_Search {
/**
* AJAX action name.
*/
const AJAX_ACTION = 'rx_live_search';
/**
* Nonce action.
*/
const NONCE_ACTION = 'rx_live_search_nonce_action';
/**
* Nonce field/key.
*/
const NONCE_KEY = 'nonce';
/**
* REST namespace.
*/
const REST_NAMESPACE = 'rx-theme/v1';
/**
* REST route.
*/
const REST_ROUTE = '/live-search';
/**
* Cache group prefix.
*/
const CACHE_PREFIX = 'rx_live_search_';
/**
* Minimum search length.
*/
private int $min_length = 2;
/**
* Maximum result limit.
*/
private int $max_limit = 20;
/**
* Default result limit.
*/
private int $default_limit = 8;
/**
* Cache expiration time.
*/
private int $cache_expiration = 15 * MINUTE_IN_SECONDS;
/**
* Constructor.
*/
public function __construct() {
$this->min_length = (int) apply_filters( 'rx_live_search_min_length', 2 );
$this->max_limit = (int) apply_filters( 'rx_live_search_max_limit', 20 );
$this->default_limit = (int) apply_filters( 'rx_live_search_default_limit', 8 );
$this->cache_expiration = (int) apply_filters( 'rx_live_search_cache_expiration', 15 * MINUTE_IN_SECONDS );
add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'ajax_search' ) );
add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION, array( $this, 'ajax_search' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'localize_live_search_data' ), 20 );
add_action( 'save_post', array( $this, 'clear_cache_on_content_change' ) );
add_action( 'deleted_post', array( $this, 'clear_cache_on_content_change' ) );
add_action( 'edited_terms', array( $this, 'clear_cache_on_content_change' ) );
}
/**
* Localize AJAX URL and nonce.
*
* This assumes your main theme script handle is rx-theme-main.
* Change the handle if your enqueue file uses another name.
*/
public function localize_live_search_data(): void {
$handle = apply_filters( 'rx_live_search_script_handle', 'rx-theme-main' );
if ( wp_script_is( $handle, 'registered' ) || wp_script_is( $handle, 'enqueued' ) ) {
wp_localize_script(
$handle,
'RXLiveSearch',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'restUrl' => esc_url_raw( rest_url( self::REST_NAMESPACE . self::REST_ROUTE ) ),
'action' => self::AJAX_ACTION,
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'restNonce' => wp_create_nonce( 'wp_rest' ),
'minLength' => $this->min_length,
'defaultLimit' => $this->default_limit,
'maxLimit' => $this->max_limit,
'i18n' => array(
'searching' => esc_html__( 'Searching...', 'rx-theme' ),
'noResults' => esc_html__( 'No results found.', 'rx-theme' ),
'typeMore' => esc_html__( 'Please type more characters.', 'rx-theme' ),
'viewAllResults' => esc_html__( 'View all results', 'rx-theme' ),
'error' => esc_html__( 'Something went wrong. Please try again.', 'rx-theme' ),
),
)
);
}
}
/**
* Register REST route.
*/
public function register_rest_routes(): void {
register_rest_route(
self::REST_NAMESPACE,
self::REST_ROUTE,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'rest_search' ),
'permission_callback' => '__return_true',
'args' => array(
's' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'limit' => array(
'type' => 'integer',
'required' => false,
'sanitize_callback' => 'absint',
),
'post_type' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
'cat' => array(
'type' => 'integer',
'required' => false,
'sanitize_callback' => 'absint',
),
'tag' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
'taxonomy' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_key',
),
'term' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
/**
* AJAX callback.
*/
public function ajax_search(): void {
$nonce = isset( $_REQUEST[ self::NONCE_KEY ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ self::NONCE_KEY ] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
wp_send_json_error(
array(
'message' => esc_html__( 'Security check failed.', 'rx-theme' ),
),
403
);
}
$params = $this->collect_request_params( $_REQUEST );
$data = $this->get_results( $params );
wp_send_json_success( $data );
}
/**
* REST callback.
*/
public function rest_search( WP_REST_Request $request ): WP_REST_Response {
$params = array(
's' => $request->get_param( 's' ),
'limit' => $request->get_param( 'limit' ),
'post_type' => $request->get_param( 'post_type' ),
'cat' => $request->get_param( 'cat' ),
'tag' => $request->get_param( 'tag' ),
'taxonomy' => $request->get_param( 'taxonomy' ),
'term' => $request->get_param( 'term' ),
);
$params = $this->normalize_params( $params );
$data = $this->get_results( $params );
return rest_ensure_response( $data );
}
/**
* Collect request params.
*/
private function collect_request_params( array $request ): array {
$params = array(
's' => isset( $request['s'] ) ? sanitize_text_field( wp_unslash( $request['s'] ) ) : '',
'limit' => isset( $request['limit'] ) ? absint( $request['limit'] ) : $this->default_limit,
'post_type' => isset( $request['post_type'] ) ? sanitize_text_field( wp_unslash( $request['post_type'] ) ) : 'post',
'cat' => isset( $request['cat'] ) ? absint( $request['cat'] ) : 0,
'tag' => isset( $request['tag'] ) ? sanitize_text_field( wp_unslash( $request['tag'] ) ) : '',
'taxonomy' => isset( $request['taxonomy'] ) ? sanitize_key( wp_unslash( $request['taxonomy'] ) ) : '',
'term' => isset( $request['term'] ) ? sanitize_text_field( wp_unslash( $request['term'] ) ) : '',
);
return $this->normalize_params( $params );
}
/**
* Normalize params.
*/
private function normalize_params( array $params ): array {
$search = isset( $params['s'] ) ? sanitize_text_field( (string) $params['s'] ) : '';
$search = trim( $search );
$limit = isset( $params['limit'] ) ? absint( $params['limit'] ) : $this->default_limit;
$limit = min( max( 1, $limit ), $this->max_limit );
$post_type = isset( $params['post_type'] ) ? sanitize_text_field( (string) $params['post_type'] ) : 'post';
$post_type = $this->normalize_post_types( $post_type );
return array(
's' => $search,
'limit' => $limit,
'post_type' => $post_type,
'cat' => isset( $params['cat'] ) ? absint( $params['cat'] ) : 0,
'tag' => isset( $params['tag'] ) ? sanitize_text_field( (string) $params['tag'] ) : '',
'taxonomy' => isset( $params['taxonomy'] ) ? sanitize_key( (string) $params['taxonomy'] ) : '',
'term' => isset( $params['term'] ) ? sanitize_text_field( (string) $params['term'] ) : '',
);
}
/**
* Normalize post types.
*/
private function normalize_post_types( string $post_type ): array {
if ( 'all' === $post_type ) {
$post_types = array( 'post', 'page' );
} else {
$post_types = array_map( 'sanitize_key', array_map( 'trim', explode( ',', $post_type ) ) );
}
$public_post_types = get_post_types(
array(
'public' => true,
'exclude_from_search' => false,
),
'names'
);
$allowed = apply_filters(
'rx_live_search_allowed_post_types',
array_values( $public_post_types )
);
$post_types = array_intersect( $post_types, $allowed );
if ( empty( $post_types ) ) {
$post_types = array( 'post' );
}
return array_values( $post_types );
}
/**
* Main result engine.
*/
private function get_results( array $params ): array {
$search = $params['s'];
if ( mb_strlen( $search ) < $this->min_length ) {
return array(
'query' => $search,
'count' => 0,
'results' => array(),
'suggestions' => array(),
'fallback' => array(),
'message' => esc_html__( 'Please type more characters.', 'rx-theme' ),
);
}
$cache_key = $this->get_cache_key( $params );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
$cached['cached'] = true;
return $cached;
}
$query = new WP_Query( $this->build_query_args( $params ) );
$results = array();
if ( $query->have_posts() ) {
foreach ( $query->posts as $post ) {
$results[] = $this->format_result( $post, $search );
}
}
$suggestions = $this->get_suggestions( $search, $params );
$fallback = empty( $results ) ? $this->get_popular_fallback( $params ) : array();
$data = array(
'query' => $search,
'count' => count( $results ),
'total' => (int) $query->found_posts,
'maxPages' => (int) $query->max_num_pages,
'results' => $results,
'suggestions' => $suggestions,
'fallback' => $fallback,
'searchUrl' => esc_url( add_query_arg( 's', rawurlencode( $search ), home_url( '/' ) ) ),
'cached' => false,
'generated_at' => current_time( 'mysql' ),
);
$data = apply_filters( 'rx_live_search_response_data', $data, $params, $query );
set_transient( $cache_key, $data, $this->cache_expiration );
do_action( 'rx_live_search_completed', $search, $data, $params );
return $data;
}
/**
* Build WP_Query args.
*/
private function build_query_args( array $params ): array {
$args = array(
'post_type' => $params['post_type'],
'post_status' => 'publish',
's' => $params['s'],
'posts_per_page' => $params['limit'],
'ignore_sticky_posts' => true,
'no_found_rows' => false,
'update_post_meta_cache' => true,
'update_post_term_cache' => true,
'orderby' => 'relevance',
'order' => 'DESC',
);
if ( ! empty( $params['cat'] ) ) {
$args['cat'] = absint( $params['cat'] );
}
if ( ! empty( $params['tag'] ) ) {
$args['tag'] = sanitize_title( $params['tag'] );
}
if ( ! empty( $params['taxonomy'] ) && ! empty( $params['term'] ) && taxonomy_exists( $params['taxonomy'] ) ) {
$args['tax_query'] = array(
array(
'taxonomy' => $params['taxonomy'],
'field' => is_numeric( $params['term'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $params['term'] ) ? absint( $params['term'] ) : sanitize_title( $params['term'] ),
),
);
}
/**
* Optional: hide password protected content.
*/
$args['has_password'] = false;
return apply_filters( 'rx_live_search_query_args', $args, $params );
}
/**
* Format one result.
*/
private function format_result( WP_Post $post, string $search ): array {
$post_id = $post->ID;
$title_raw = get_the_title( $post_id );
$title = $this->highlight_text( $title_raw, $search );
$permalink = get_permalink( $post_id );
$post_type = get_post_type( $post_id );
$post_type_o = get_post_type_object( $post_type );
$excerpt_raw = $this->get_clean_excerpt( $post );
$excerpt = $this->highlight_text( $excerpt_raw, $search );
$thumbnail = $this->get_thumbnail_data( $post_id );
$categories = $this->get_post_terms_data( $post_id, 'category' );
$tags = $this->get_post_terms_data( $post_id, 'post_tag' );
$result = array(
'id' => $post_id,
'title' => wp_kses_post( $title ),
'titleRaw' => esc_html( $title_raw ),
'excerpt' => wp_kses_post( $excerpt ),
'excerptRaw' => esc_html( $excerpt_raw ),
'url' => esc_url( $permalink ),
'postType' => esc_html( $post_type ),
'postTypeLabel' => $post_type_o ? esc_html( $post_type_o->labels->singular_name ) : esc_html( $post_type ),
'date' => esc_html( get_the_date( '', $post_id ) ),
'dateIso' => esc_attr( get_the_date( DATE_W3C, $post_id ) ),
'modified' => esc_html( get_the_modified_date( '', $post_id ) ),
'modifiedIso' => esc_attr( get_the_modified_date( DATE_W3C, $post_id ) ),
'author' => esc_html( get_the_author_meta( 'display_name', (int) $post->post_author ) ),
'authorUrl' => esc_url( get_author_posts_url( (int) $post->post_author ) ),
'thumbnail' => $thumbnail,
'categories' => $categories,
'tags' => $tags,
'readingTime' => $this->get_reading_time( $post ),
'commentCount' => (int) get_comments_number( $post_id ),
'isSticky' => is_sticky( $post_id ),
'schema' => array(
'@type' => 'Article',
'headline' => wp_strip_all_tags( $title_raw ),
'url' => esc_url( $permalink ),
'datePublished' => esc_attr( get_the_date( DATE_W3C, $post_id ) ),
'dateModified' => esc_attr( get_the_modified_date( DATE_W3C, $post_id ) ),
),
);
return apply_filters( 'rx_live_search_single_result', $result, $post, $search );
}
/**
* Clean excerpt.
*/
private function get_clean_excerpt( WP_Post $post ): string {
if ( has_excerpt( $post ) ) {
$text = get_the_excerpt( $post );
} else {
$text = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
}
$text = preg_replace( '/\s+/', ' ', $text );
$text = trim( (string) $text );
$length = (int) apply_filters( 'rx_live_search_excerpt_length', 26 );
return wp_trim_words( $text, $length, '...' );
}
/**
* Highlight matched keyword.
*/
private function highlight_text( string $text, string $search ): string {
$text = esc_html( $text );
$search = trim( wp_strip_all_tags( $search ) );
if ( '' === $search ) {
return $text;
}
$words = preg_split( '/\s+/', $search );
if ( empty( $words ) ) {
return $text;
}
foreach ( $words as $word ) {
$word = preg_quote( $word, '/' );
if ( mb_strlen( $word ) < $this->min_length ) {
continue;
}
$text = preg_replace(
'/(' . $word . ')/iu',
'<mark class="rx-live-search-highlight">$1</mark>',
$text
);
}
return wp_kses(
$text,
array(
'mark' => array(
'class' => true,
),
)
);
}
/**
* Thumbnail data.
*/
private function get_thumbnail_data( int $post_id ): array {
if ( ! has_post_thumbnail( $post_id ) ) {
return array(
'hasThumbnail' => false,
'url' => '',
'alt' => '',
'width' => 0,
'height' => 0,
);
}
$size = apply_filters( 'rx_live_search_thumbnail_size', 'thumbnail' );
$id = get_post_thumbnail_id( $post_id );
$src = wp_get_attachment_image_src( $id, $size );
$alt = get_post_meta( $id, '_wp_attachment_image_alt', true );
return array(
'hasThumbnail' => true,
'url' => isset( $src[0] ) ? esc_url( $src[0] ) : '',
'alt' => esc_attr( $alt ? $alt : get_the_title( $post_id ) ),
'width' => isset( $src[1] ) ? absint( $src[1] ) : 0,
'height' => isset( $src[2] ) ? absint( $src[2] ) : 0,
);
}
/**
* Term data.
*/
private function get_post_terms_data( int $post_id, string $taxonomy ): array {
if ( ! taxonomy_exists( $taxonomy ) ) {
return array();
}
$terms = get_the_terms( $post_id, $taxonomy );
if ( empty( $terms ) || is_wp_error( $terms ) ) {
return array();
}
$data = array();
foreach ( $terms as $term ) {
$data[] = array(
'id' => (int) $term->term_id,
'name' => esc_html( $term->name ),
'slug' => esc_attr( $term->slug ),
'url' => esc_url( get_term_link( $term ) ),
);
}
return $data;
}
/**
* Reading time.
*/
private function get_reading_time( WP_Post $post ): array {
$content = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
$words = str_word_count( $content );
$wpm = (int) apply_filters( 'rx_live_search_reading_words_per_minute', 220 );
$minutes = max( 1, (int) ceil( $words / $wpm ) );
return array(
'minutes' => $minutes,
'label' => sprintf(
/* translators: %d: reading time in minutes */
_n( '%d min read', '%d min read', $minutes, 'rx-theme' ),
$minutes
),
);
}
/**
* Get search suggestions from matched post titles.
*/
private function get_suggestions( string $search, array $params ): array {
$args = array(
'post_type' => $params['post_type'],
'post_status' => 'publish',
's' => $search,
'posts_per_page' => 5,
'ignore_sticky_posts' => true,
'fields' => 'ids',
'no_found_rows' => true,
);
$query = new WP_Query( apply_filters( 'rx_live_search_suggestion_args', $args, $search, $params ) );
$suggestions = array();
if ( ! empty( $query->posts ) ) {
foreach ( $query->posts as $post_id ) {
$suggestions[] = array(
'title' => esc_html( get_the_title( $post_id ) ),
'url' => esc_url( get_permalink( $post_id ) ),
);
}
}
return apply_filters( 'rx_live_search_suggestions', $suggestions, $search, $params );
}
/**
* Popular fallback when no result is found.
*/
private function get_popular_fallback( array $params ): array {
$args = array(
'post_type' => $params['post_type'],
'post_status' => 'publish',
'posts_per_page' => 5,
'ignore_sticky_posts' => true,
'orderby' => 'comment_count',
'order' => 'DESC',
'no_found_rows' => true,
);
$query = new WP_Query( apply_filters( 'rx_live_search_fallback_args', $args, $params ) );
$fallback = array();
if ( $query->have_posts() ) {
foreach ( $query->posts as $post ) {
$fallback[] = array(
'id' => (int) $post->ID,
'title' => esc_html( get_the_title( $post->ID ) ),
'url' => esc_url( get_permalink( $post->ID ) ),
'date' => esc_html( get_the_date( '', $post->ID ) ),
);
}
}
return apply_filters( 'rx_live_search_fallback_results', $fallback, $params );
}
/**
* Cache key.
*/
private function get_cache_key( array $params ): string {
$key = self::CACHE_PREFIX . md5( wp_json_encode( $params ) );
return apply_filters( 'rx_live_search_cache_key', $key, $params );
}
/**
* Clear cache on content changes.
*
* WordPress transients do not support prefix deletion natively.
* This clears common object cache if available and also bumps a version option.
*/
public function clear_cache_on_content_change(): void {
$version = (int) get_option( 'rx_live_search_cache_version', 1 );
update_option( 'rx_live_search_cache_version', $version + 1, false );
do_action( 'rx_live_search_cache_cleared' );
}
}
endif;
/**
* Boot Live Search.
*/
function rx_theme_live_search(): RX_Theme_Live_Search {
static $instance = null;
if ( null === $instance ) {
$instance = new RX_Theme_Live_Search();
}
return $instance;
}
rx_theme_live_search();
Now include it in your functions.php:
/**
* RX Theme AJAX modules.
*/
$rx_live_search_file = get_template_directory() . '/inc/ajax/live-search.php';
if ( file_exists( $rx_live_search_file ) ) {
require_once $rx_live_search_file;
}
Use this HTML search box anywhere in your theme:
<form role="search" method="get" class="rx-live-search-form" action="<?php echo esc_url( home_url( '/' ) ); ?>">
<label class="screen-reader-text" for="rx-live-search-input">
<?php esc_html_e( 'Search for:', 'rx-theme' ); ?>
</label>
<input
type="search"
id="rx-live-search-input"
class="rx-live-search-input"
name="s"
placeholder="<?php esc_attr_e( 'Search...', 'rx-theme' ); ?>"
autocomplete="off"
data-rx-live-search="true"
data-post-type="post,page"
data-limit="8"
/>
<button type="submit" class="rx-live-search-submit">
<?php esc_html_e( 'Search', 'rx-theme' ); ?>
</button>
<div class="rx-live-search-results" aria-live="polite"></div>
</form>
Basic JavaScript for assets/js/live-search.js:
(function () {
'use strict';
const input = document.querySelector('[data-rx-live-search="true"]');
if (!input || typeof RXLiveSearch === 'undefined') {
return;
}
const form = input.closest('form');
const resultBox = form ? form.querySelector('.rx-live-search-results') : null;
if (!resultBox) {
return;
}
let timer = null;
let controller = null;
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderResults(data) {
const results = data.results || [];
const fallback = data.fallback || [];
if (results.length < 1) {
if (fallback.length > 0) {
resultBox.innerHTML = `
<div class="rx-live-search-empty">${RXLiveSearch.i18n.noResults}</div>
<div class="rx-live-search-fallback-title">Popular posts</div>
<ul class="rx-live-search-list">
${fallback.map(item => `
<li class="rx-live-search-item">
<a href="${escapeHtml(item.url)}">${escapeHtml(item.title)}</a>
</li>
`).join('')}
</ul>
`;
return;
}
resultBox.innerHTML = `<div class="rx-live-search-empty">${RXLiveSearch.i18n.noResults}</div>`;
return;
}
resultBox.innerHTML = `
<ul class="rx-live-search-list">
${results.map(item => `
<li class="rx-live-search-item">
<a href="${escapeHtml(item.url)}" class="rx-live-search-link">
${item.thumbnail && item.thumbnail.hasThumbnail ? `
<img
src="${escapeHtml(item.thumbnail.url)}"
alt="${escapeHtml(item.thumbnail.alt)}"
width="48"
height="48"
loading="lazy"
/>
` : ''}
<span class="rx-live-search-content">
<strong class="rx-live-search-title">${item.title}</strong>
<span class="rx-live-search-excerpt">${item.excerpt}</span>
<span class="rx-live-search-meta">
${escapeHtml(item.postTypeLabel)} · ${escapeHtml(item.date)} · ${escapeHtml(item.readingTime.label)}
</span>
</span>
</a>
</li>
`).join('')}
</ul>
<a class="rx-live-search-view-all" href="${escapeHtml(data.searchUrl)}">
${RXLiveSearch.i18n.viewAllResults}
</a>
`;
}
function searchNow() {
const keyword = input.value.trim();
if (keyword.length < Number(RXLiveSearch.minLength)) {
resultBox.innerHTML = `<div class="rx-live-search-empty">${RXLiveSearch.i18n.typeMore}</div>`;
return;
}
if (controller) {
controller.abort();
}
controller = new AbortController();
const formData = new FormData();
formData.append('action', RXLiveSearch.action);
formData.append('nonce', RXLiveSearch.nonce);
formData.append('s', keyword);
formData.append('limit', input.dataset.limit || RXLiveSearch.defaultLimit);
formData.append('post_type', input.dataset.postType || 'post');
resultBox.innerHTML = `<div class="rx-live-search-loading">${RXLiveSearch.i18n.searching}</div>`;
fetch(RXLiveSearch.ajaxUrl, {
method: 'POST',
body: formData,
signal: controller.signal,
credentials: 'same-origin'
})
.then(response => response.json())
.then(response => {
if (!response || !response.success) {
resultBox.innerHTML = `<div class="rx-live-search-error">${RXLiveSearch.i18n.error}</div>`;
return;
}
renderResults(response.data);
})
.catch(error => {
if (error.name === 'AbortError') {
return;
}
resultBox.innerHTML = `<div class="rx-live-search-error">${RXLiveSearch.i18n.error}</div>`;
});
}
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(searchNow, 300);
});
document.addEventListener('click', function (event) {
if (!form.contains(event.target)) {
resultBox.innerHTML = '';
}
});
})();
Add enqueue in your inc/core/enqueue.php or functions.php:
wp_enqueue_script(
'rx-theme-main',
get_template_directory_uri() . '/assets/js/live-search.js',
array(),
wp_get_theme()->get( 'Version' ),
true
);
Basic CSS:
.rx-live-search-form {
position: relative;
max-width: 560px;
}
.rx-live-search-input {
width: 100%;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 8px;
}
.rx-live-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 9999;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
margin-top: 8px;
overflow: hidden;
}
.rx-live-search-list {
list-style: none;
margin: 0;
padding: 0;
}
.rx-live-search-item {
border-bottom: 1px solid #f1f1f1;
}
.rx-live-search-item:last-child {
border-bottom: 0;
}
.rx-live-search-link {
display: flex;
gap: 12px;
padding: 12px;
text-decoration: none;
color: inherit;
}
.rx-live-search-link:hover {
background: #f8f8f8;
}
.rx-live-search-link img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
}
.rx-live-search-content {
display: block;
min-width: 0;
}
.rx-live-search-title {
display: block;
font-size: 15px;
line-height: 1.3;
}
.rx-live-search-excerpt {
display: block;
margin-top: 4px;
font-size: 13px;
color: #555;
}
.rx-live-search-meta {
display: block;
margin-top: 5px;
font-size: 12px;
color: #888;
}
.rx-live-search-highlight {
background: #fff3a3;
padding: 0 2px;
}
.rx-live-search-empty,
.rx-live-search-loading,
.rx-live-search-error,
.rx-live-search-fallback-title {
padding: 12px;
font-size: 14px;
}
.rx-live-search-view-all {
display: block;
padding: 12px;
text-align: center;
font-weight: 600;
border-top: 1px solid #eee;
text-decoration: none;
}
For your RX theme structure, the best path is:
rx-theme/
├── inc/
│ ├── ajax/
│ │ └── live-search.php
├── assets/
│ ├── js/
│ │ └── live-search.js
│ └── css/
│ └── live-search.css
This is a strong base. Later you can connect it with Algolia, ElasticPress, Meilisearch, custom medical glossary search, WooCommerce product search, or RxHarun disease-condition search.