live-search.php

I’ll build this as a strong 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, '&amp;')
			.replace(/</g, '&lt;')
			.replace(/>/g, '&gt;')
			.replace(/"/g, '&quot;')
			.replace(/'/g, '&#039;');
	}

	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.

Leave a Reply

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