Skip to content

Instantly share code, notes, and snippets.

@ajaydsouza
Last active July 30, 2025 19:03
Show Gist options
  • Save ajaydsouza/1762f0c499c9fd593a57e79a1d408afc to your computer and use it in GitHub Desktop.
Save ajaydsouza/1762f0c499c9fd593a57e79a1d408afc to your computer and use it in GitHub Desktop.
Better Search Live Seach
/**
* Manages autocomplete search functionality for forms
*/
class SearchAutocomplete {
static SELECTOR = '.search-form, form[role="search"]';
static DEBOUNCE_DELAY = 300;
static CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes.
constructor(form) {
this.form = form;
this.searchInput = form.querySelector('input[name="s"]');
this.submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
this.selectedIndex = -1;
this.debounceTimer = null;
this.cache = new Map();
this.observer = null;
if (!this.searchInput) return;
// Add class to identify forms with Better Search functionality
this.form.classList.add('bsearch-enabled');
this.initializeElements();
this.bindEvents();
}
/**
* Initializes DOM elements and sets up ARIA attributes
*/
initializeElements() {
// Create announcement region
this.announceRegion = this.createAnnounceRegion();
this.form.insertBefore(this.announceRegion, this.form.firstChild);
// Create results container
this.resultsContainer = this.createResultsContainer();
this.insertResultsContainer();
// Configure search input
this.configureSearchInput();
}
/**
* Creates announcement region for screen readers
* @returns {HTMLDivElement}
*/
createAnnounceRegion() {
const region = document.createElement('div');
region.className = 'bsearch-visually-hidden';
region.setAttribute('aria-live', 'assertive');
region.id = `announce-${this.generateId()}`;
return region;
}
/**
* Creates results container
* @returns {HTMLDivElement}
*/
createResultsContainer() {
const container = document.createElement('div');
container.className = 'bsearch-autocomplete-results';
container.setAttribute('role', 'listbox');
container.id = `search-suggestions-${this.generateId()}`;
return container;
}
/**
* Generates random ID for elements
* @returns {string}
*/
generateId() {
return Math.random().toString(36).substring(2, 9);
}
/**
* Inserts results container after submit button or input
*/
insertResultsContainer() {
const insertAfter = this.submitButton || this.searchInput;
insertAfter.parentNode.insertBefore(this.resultsContainer, insertAfter.nextSibling);
}
/**
* Configures search input attributes
*/
configureSearchInput() {
Object.entries({
autocomplete: 'off',
'aria-autocomplete': 'list',
'aria-controls': this.resultsContainer.id,
autocapitalize: 'off',
spellcheck: 'false'
}).forEach(([key, value]) => {
this.searchInput.setAttribute(key, value);
});
}
/**
* Binds all event listeners
*/
bindEvents() {
this.form.addEventListener('submit', () => this.clearCache());
this.searchInput.addEventListener('input', this.handleInput.bind(this));
this.searchInput.addEventListener('keydown', this.handleInputKeydown.bind(this));
this.searchInput.addEventListener('focus', this.handleInputFocus.bind(this));
this.searchInput.addEventListener('blur', this.handleInputBlur.bind(this));
if (this.submitButton) {
this.submitButton.addEventListener('keydown', this.handleSubmitKeydown.bind(this));
}
this.resultsContainer.addEventListener('keydown', this.handleResultsKeydown.bind(this));
// Set up MutationObserver to watch for changes in the results container
this.setupMutationObserver();
document.addEventListener('click', this.handleDocumentClick.bind(this));
}
/**
* Handles input changes with debouncing
*/
handleInput() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
const searchTerm = this.searchInput.value.trim();
if (searchTerm.length > 2) {
this.announce(bsearch_live_search.strings.searching);
this.fetchResults(searchTerm);
} else {
this.announce(searchTerm.length === 0 ? '' : bsearch_live_search.strings.min_chars);
this.clearResults();
}
}, SearchAutocomplete.DEBOUNCE_DELAY);
}
/**
* Handles keyboard navigation in input
* @param {KeyboardEvent} event
*/
handleInputKeydown(event) {
const items = this.resultsContainer.querySelectorAll('li');
switch (event.key) {
case 'Escape':
event.preventDefault();
this.clearResults();
this.announce(bsearch_live_search.strings.suggestions_closed);
break;
case 'ArrowDown':
event.preventDefault();
this.handleArrowDown(items);
break;
case 'ArrowUp':
event.preventDefault();
this.handleArrowUp(items);
break;
case 'Enter':
this.handleEnter(items, event);
break;
}
}
/**
* Handles ArrowDown navigation
* @param {NodeList} items
*/
handleArrowDown(items) {
if (!items.length && this.searchInput.value.length > 2) {
this.fetchResults(this.searchInput.value);
return;
}
this.selectedIndex = items.length ?
Math.min(this.selectedIndex + 1, items.length - 1) : 0;
this.updateSelection(items);
}
/**
* Handles ArrowUp navigation
* @param {NodeList} items
*/
handleArrowUp(items) {
if (!items.length) return;
this.selectedIndex = this.selectedIndex > 0 ?
this.selectedIndex - 1 : items.length - 1;
this.updateSelection(items);
}
/**
* Handles Enter key
* @param {NodeList} items
* @param {KeyboardEvent} event
*/
handleEnter(items, event) {
if (items.length && this.selectedIndex >= 0) {
event.preventDefault();
const selectedItem = items[this.selectedIndex].querySelector('a');
if (selectedItem?.href) {
this.announce(bsearch_live_search.strings.navigating_to.replace('%s', selectedItem.textContent));
window.location.href = selectedItem.href;
}
} else {
this.announce(bsearch_live_search.strings.submitting_search);
this.form.submit();
}
}
/**
* Handles submit button keyboard events
* @param {KeyboardEvent} event
*/
handleSubmitKeydown(event) {
const items = this.resultsContainer.querySelectorAll('li');
switch (event.key) {
case 'Escape':
event.preventDefault();
this.clearResults();
this.searchInput.focus();
this.announce(bsearch_live_search.strings.suggestions_closed);
break;
case 'ArrowDown':
if (!items.length) return;
event.preventDefault();
this.selectedIndex = 0;
this.updateSelection(items);
break;
case 'ArrowUp':
event.preventDefault();
this.searchInput.focus();
this.announce(bsearch_live_search.strings.back_to_input);
break;
}
}
/**
* Handles results container keyboard events
* @param {KeyboardEvent} event
*/
handleResultsKeydown(event) {
const items = this.resultsContainer.querySelectorAll('li');
if (!items.length) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
this.updateSelection(items);
break;
case 'ArrowUp':
event.preventDefault();
this.handleResultsArrowUp(items);
break;
case 'Escape':
event.preventDefault();
this.clearResults();
this.searchInput.focus();
this.announce(bsearch_live_search.strings.suggestions_closed);
break;
case 'Enter':
event.preventDefault();
this.handleResultsEnter(items);
break;
}
}
/**
* Handles ArrowUp in results
* @param {NodeList} items
*/
handleResultsArrowUp(items) {
if (this.selectedIndex === 0) {
(this.submitButton || this.searchInput).focus();
this.selectedIndex = -1;
this.announce(bsearch_live_search.strings.back_to_search);
} else {
this.selectedIndex--;
this.updateSelection(items);
}
}
/**
* Handles Enter in results
* @param {NodeList} items
*/
handleResultsEnter(items) {
if (this.selectedIndex >= 0 && this.selectedIndex < items.length) {
const selectedItem = items[this.selectedIndex].querySelector('a');
if (selectedItem?.href) {
this.announce(bsearch_live_search.strings.navigating_to.replace('%s', selectedItem.textContent));
window.location.href = selectedItem.href;
} else {
this.searchInput.value = items[this.selectedIndex].textContent;
this.form.submit();
}
}
}
/**
* Sets up MutationObserver to watch for changes in the results container
*/
setupMutationObserver() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.tagName === 'A') {
node.removeAttribute('tabindex');
} else if (node.querySelectorAll) {
node.querySelectorAll('a').forEach(a => a.removeAttribute('tabindex'));
}
});
}
});
});
this.observer.observe(this.resultsContainer, { childList: true, subtree: true });
}
/**
* Handles document clicks for closing suggestions
* @param {MouseEvent} event
*/
handleDocumentClick(event) {
if (!this.form.contains(event.target) && !this.resultsContainer.contains(event.target)) {
this.clearResults();
}
}
/**
* Handles input focus
*/
handleInputFocus() {
if (this.resultsContainer.innerHTML.trim() && this.searchInput.value.length > 2) {
this.resultsContainer.style.display = 'block';
}
}
/**
* Handles input blur
*/
handleInputBlur() {
setTimeout(() => {
this.clearResults();
this.announce('Search suggestions closed');
}, 100);
}
/**
* Updates screen reader announcements
* @param {string} message
*/
announce(message) {
this.announceRegion.textContent = message;
console.log(`Announced: ${message}`);
}
/**
* Clears search results
*/
clearResults() {
if (this.observer) {
this.observer.disconnect();
this.observer.observe(this.resultsContainer, { childList: true, subtree: true });
}
this.resultsContainer.innerHTML = '';
this.resultsContainer.style.display = 'none';
this.selectedIndex = -1;
this.searchInput.removeAttribute('aria-activedescendant');
this.announceRegion.textContent = '';
}
/**
* Updates selection state
* @param {NodeList} items
*/
updateSelection(items) {
items.forEach(item => {
item.classList.remove('bsearch-selected');
item.setAttribute('aria-selected', 'false');
});
const selectedItem = items[this.selectedIndex];
if (selectedItem) {
selectedItem.classList.add('bsearch-selected');
selectedItem.setAttribute('aria-selected', 'true');
selectedItem.scrollIntoView({ block: 'nearest' });
this.searchInput.setAttribute('aria-activedescendant', selectedItem.id);
this.announce(selectedItem.textContent);
}
}
/**
* Gets cached results if available and not expired
* @param {string} searchTerm
* @returns {Array|null}
*/
getCachedResults(searchTerm) {
const cached = this.cache.get(searchTerm);
if (!cached) return null;
const now = Date.now();
if (now - cached.timestamp > SearchAutocomplete.CACHE_TIMEOUT) {
this.cache.delete(searchTerm);
return null;
}
return cached.results;
}
/**
* Caches search results
* @param {string} searchTerm
* @param {Array} results
*/
cacheResults(searchTerm, results) {
// Limit cache size to prevent memory issues
if (this.cache.size > 50) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(searchTerm, {
results,
timestamp: Date.now()
});
}
/**
* Clears the results cache
*/
clearCache() {
this.cache.clear();
}
/**
* Fetches search results
* @param {string} searchTerm
*/
async fetchResults(searchTerm) {
try {
// Check cache first
const cachedResults = this.getCachedResults(searchTerm);
if (cachedResults) {
this.displayResults(cachedResults);
return;
}
const response = await fetch(bsearch_live_search.ajax_url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache'
},
body: new URLSearchParams({
action: 'bsearch_live_search',
s: searchTerm
}).toString()
});
const results = await response.json();
this.cacheResults(searchTerm, results);
this.displayResults(results);
} catch (error) {
console.error('Error:', error);
this.clearResults();
this.announce(bsearch_live_search.strings.error_loading);
}
}
/**
* Displays search results
* @param {Array} results
*/
displayResults(results) {
this.resultsContainer.innerHTML = '';
if (!results.length) {
this.announce(bsearch_live_search.strings.no_suggestions);
return;
}
const ul = document.createElement('ul');
ul.setAttribute('role', 'listbox');
results.forEach((result, index) => {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.id = `search-suggestion-${index}`;
const a = document.createElement('a');
a.href = result.link;
a.textContent = result.title;
li.appendChild(a);
ul.appendChild(li);
});
this.resultsContainer.appendChild(ul);
this.resultsContainer.style.display = 'block';
// Force proper rendering of the container
this.resultsContainer.style.height = 'auto';
this.resultsContainer.style.minHeight = '50px';
this.announce(bsearch_live_search.strings.suggestions_found.replace('%d', results.length));
}
}
/**
* Initializes search autocomplete for all matching forms
*/
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll(SearchAutocomplete.SELECTOR)
.forEach(form => new SearchAutocomplete(form));
});
/* CSS Variables for customizable colors */
:root {
--bsearch-dropdown-bg: #fefefe;
--bsearch-dropdown-border: #ccc;
--bsearch-dropdown-shadow: rgba(0, 0, 0, 0.1);
--bsearch-item-border: #eee;
--bsearch-item-hover-bg: #f0f0f0;
--bsearch-item-focus-outline: #2271b1;
--bsearch-link-color: #333;
--bsearch-link-focus-bg: #e8f0fe;
--bsearch-scrollbar-track: #f0f0f0;
--bsearch-scrollbar-thumb: #2271b1;
}
/* Only apply positioning to forms that have Better Search autocomplete - exclude Max Mega Menu */
.search-form:has(.bsearch-autocomplete-results),
form[role="search"]:has(.bsearch-autocomplete-results),
.search-form.bsearch-enabled,
form[role="search"].bsearch-enabled {
position: relative;
}
/* Visually hidden element for screen readers */
.bsearch-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
div.bsearch-autocomplete-results {
display: none;
position: absolute;
z-index: 1000;
background: var(--bsearch-dropdown-bg);
border: 1px solid var(--bsearch-dropdown-border);
border-radius: 4px;
box-shadow: 0 4px 6px var(--bsearch-dropdown-shadow);
max-height: 200px;
overflow-y: auto;
width: 100%;
top: 100%;
left: 0;
margin-top: 4px;
}
div.bsearch-autocomplete-results ul,
div.bsearch-autocomplete-results > ul,
div.bsearch-autocomplete-results ul.bsearch-results-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
position: static !important;
width: 100% !important;
}
div.bsearch-autocomplete-results ul li,
div.bsearch-autocomplete-results > ul > li,
div.bsearch-autocomplete-results ul.bsearch-results-list li {
display: flex;
margin: 0 !important;
align-items: center;
padding: 0;
border-bottom: 1px solid var(--bsearch-item-border);
cursor: pointer;
position: static !important;
width: 100% !important;
}
div.bsearch-autocomplete-results ul li:last-child,
div.bsearch-autocomplete-results > ul > li:last-child,
div.bsearch-autocomplete-results ul.bsearch-results-list li:last-child {
border-bottom: none;
}
div.bsearch-autocomplete-results ul li.selected,
div.bsearch-autocomplete-results ul li:hover,
div.bsearch-autocomplete-results ul li.bsearch-selected,
div.bsearch-autocomplete-results > ul > li:hover,
div.bsearch-autocomplete-results ul.bsearch-results-list li:hover {
background-color: var(--bsearch-item-hover-bg);
}
div.bsearch-autocomplete-results ul li:focus-within,
div.bsearch-autocomplete-results ul li.bsearch-selected,
div.bsearch-autocomplete-results > ul > li:focus-within,
div.bsearch-autocomplete-results ul.bsearch-results-list li:focus-within {
outline: 2px solid var(--bsearch-item-focus-outline);
outline-offset: -2px;
}
html body div.bsearch-autocomplete-results ul li a,
html body div.bsearch-autocomplete-results > ul > li > a,
div.bsearch-autocomplete-results ul.bsearch-results-list li a,
div.bsearch-autocomplete-results ul li.bsearch-result a,
div.bsearch-autocomplete-results ul li a.bsearch-result-link {
display: block;
width: 100%;
padding: 5px 10px;
text-align: left;
text-decoration: none;
color: var(--bsearch-link-color);
line-height: 1.4;
transition: all 0.2s ease;
position: static;
}
/* Focus styles for input */
.search-form input[type="search"]:focus,
form[role="search"] input[type="search"]:focus {
outline: none;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2271b1;
border-color: #2271b1;
}
/* Focus styles for submit button */
.search-form input[type="submit"]:focus,
.search-form button[type="submit"]:focus,
form[role="search"] input[type="submit"]:focus,
form[role="search"] button[type="submit"]:focus {
outline: none;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2271b1;
border-color: #2271b1;
}
/* Focus styles for result links */
html body div.bsearch-autocomplete-results ul li a:focus,
html body div.bsearch-autocomplete-results > ul > li > a:focus,
html body div.bsearch-autocomplete-results ul.bsearch-results-list li a:focus {
outline: none;
background-color: var(--bsearch-link-focus-bg);
box-shadow: inset 0 0 0 2px var(--bsearch-item-focus-outline);
color: var(--bsearch-link-color);
}
/* Selected state combined with focus */
html body div.bsearch-autocomplete-results ul li.selected a:focus,
html body div.bsearch-autocomplete-results > ul > li.selected > a:focus,
html body div.bsearch-autocomplete-results ul.bsearch-results-list li.selected a:focus {
background-color: var(--bsearch-link-focus-bg);
box-shadow: inset 0 0 0 2px var(--bsearch-item-focus-outline);
}
/* High contrast mode support */
@media (forced-colors: active) {
div.bsearch-autocomplete-results ul li.selected,
div.bsearch-autocomplete-results ul li a:focus,
div.bsearch-autocomplete-results > ul > li > a:focus,
div.bsearch-autocomplete-results ul.bsearch-results-list li a:focus {
outline: 2px solid CanvasText;
outline-offset: -2px;
}
}
/* Ensure scrollbar is visible and styled */
div.bsearch-autocomplete-results {
scrollbar-width: thin;
scrollbar-color: #2271b1 #f0f0f0;
}
div.bsearch-autocomplete-results::-webkit-scrollbar {
width: 8px;
}
div.bsearch-autocomplete-results::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 4px;
}
div.bsearch-autocomplete-results::-webkit-scrollbar-thumb {
background-color: #2271b1;
border-radius: 4px;
border: 2px solid #f0f0f0;
}
@media (max-width: 600px) {
div.bsearch-autocomplete-results {
width: 100%;
max-height: 150px;
margin-top: 2px;
}
html body div.bsearch-autocomplete-results ul li a,
html body div.bsearch-autocomplete-results > ul > li > a,
html body div.bsearch-autocomplete-results ul.bsearch-results-list li a {
padding: 10px 12px;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment