Last active
July 30, 2025 19:03
-
-
Save ajaydsouza/1762f0c499c9fd593a57e79a1d408afc to your computer and use it in GitHub Desktop.
Better Search Live Seach
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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)); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* 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