/** * XShop AJAX Search — vanilla, debounce 250ms, AbortController, stale protection, a11y keyboard. * Expects markup from template-parts/components/ajax-search.php */ (function () { 'use strict'; var DEBOUNCE_MS = 250; var MIN_LENGTH = 2; function debounce(fn, ms) { var t; return function () { var args = arguments; var ctx = this; clearTimeout(t); t = setTimeout(function () { fn.apply(ctx, args); }, ms); }; } function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function initSearch(root) { if (!root || root.dataset.xshopSearchInit) return; root.dataset.xshopSearchInit = '1'; var input = root.querySelector('[data-xshop-search-input]'); var form = root.querySelector('[data-xshop-search-form]'); var dropdown = root.querySelector('[data-xshop-search-dropdown]'); var resultsEl = root.querySelector('[data-xshop-search-results]'); var loadingEl = root.querySelector('[data-xshop-search-loading]'); var emptyEl = root.querySelector('[data-xshop-search-empty]'); var errorEl = root.querySelector('[data-xshop-search-error]'); var viewAllEl = root.querySelector('[data-xshop-search-viewall]'); var statusEl = root.querySelector('[data-xshop-search-status]'); if (!input || !form || !dropdown || !resultsEl) return; var limit = parseInt(input.getAttribute('data-limit') || '6', 10); var minLen = parseInt(input.getAttribute('data-min-length') || String(MIN_LENGTH), 10); var abortCtrl = null; var lastQuery = ''; var seq = 0; var activeIndex = -1; var items = []; var restUrl = (window.xshopData && window.xshopData.restUrl) ? window.xshopData.restUrl : '/wp-json/'; // Ensure trailing slash handling: restUrl is like http://.../wp-json/xshop/v1/ or /wp-json/ // We need xshop/v1/search var endpoint = restUrl.replace(/\/$/, '') + '/search'; // If restUrl already contains xshop/v1/, don't duplicate if (restUrl.indexOf('xshop/v1') !== -1) { endpoint = restUrl.replace(/\/$/, '') + '/search'; // restUrl is .../xshop/v1/ -> endpoint is .../xshop/v1/search // If restUrl is .../wp-json/ -> endpoint becomes .../wp-json/search (wrong), fix if (restUrl.indexOf('xshop/v1') === -1) { endpoint = restUrl.replace(/\/$/, '') + '/xshop/v1/search'; } } // Safer: build from origin if (endpoint.indexOf('xshop') === -1) { endpoint = (window.location.origin || '') + '/wp-json/xshop/v1/search'; if (window.xshopData && window.xshopData.restUrl) { var base = window.xshopData.restUrl; // base like http://localhost/.../wp-json/ -> append xshop/v1/search if (base.indexOf('/wp-json/') !== -1) { endpoint = base.split('/wp-json/')[0] + '/wp-json/xshop/v1/search'; // Preserve subpath like /xshop-test/wordpress/wp-json/ // base is http://localhost/xshop-test/wordpress/wp-json/ or .../xshop/v1/ if (base.indexOf('xshop/v1') !== -1) { endpoint = base.replace(/\/$/, '') + '/search'; } else { endpoint = base.replace(/\/$/, '') + '/xshop/v1/search'; // if base already is wp-json/, this works; if base is wp-json/xshop/v1/ this duplicates if (endpoint.indexOf('/xshop/v1/xshop/v1/') !== -1) { endpoint = endpoint.replace('/xshop/v1/xshop/v1/', '/xshop/v1/'); } } } } } function setExpanded(expanded) { input.setAttribute('aria-expanded', expanded ? 'true' : 'false'); dropdown.hidden = !expanded; } function setLoading(loading) { if (loadingEl) loadingEl.hidden = !loading; } function showEmpty(show) { if (emptyEl) emptyEl.hidden = !show; } function showError(show) { if (errorEl) errorEl.hidden = !show; } function updateViewAll(query) { if (!viewAllEl) return; if (!query || query.length < minLen) { viewAllEl.hidden = true; return; } var url = form.getAttribute('action') || '/'; var q = encodeURIComponent(query); viewAllEl.href = url + (url.indexOf('?') === -1 ? '?' : '&') + 's=' + q + '&post_type=product'; viewAllEl.hidden = false; } function clearResults() { resultsEl.innerHTML = ''; items = []; activeIndex = -1; input.removeAttribute('aria-activedescendant'); } function render(itemsData, query) { clearResults(); showEmpty(false); showError(false); if (!itemsData.length) { showEmpty(true); if (statusEl) statusEl.textContent = (window.xshopData && window.xshopData.i18n && window.xshopData.i18n.noResults) ? window.xshopData.i18n.noResults : 'No results'; updateViewAll(query); return; } if (statusEl) statusEl.textContent = itemsData.length + ' results'; items = itemsData; itemsData.forEach(function (it, idx) { var li = document.createElement('li'); li.setAttribute('role', 'option'); li.id = input.id + '-option-' + idx; li.className = 'xshop-search__item'; if (idx === activeIndex) li.setAttribute('aria-selected', 'true'); // Build via DOM to avoid innerHTML XSS for price_html (Woo price_html is trusted but we still sanitize via allowed) var a = document.createElement('a'); a.href = it.url; a.className = 'xshop-search__link'; // image if (it.image) { var img = document.createElement('img'); img.src = it.image; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.className = 'xshop-search__thumb'; a.appendChild(img); } var body = document.createElement('span'); body.className = 'xshop-search__body'; var title = document.createElement('span'); title.className = 'xshop-search__title'; title.textContent = it.title; body.appendChild(title); if (it.price_html) { var price = document.createElement('span'); price.className = 'xshop-search__price'; // price_html from Woo is HTML; we allow it but strip scripts via text? Use innerHTML with sanitization: only allow Woo price spans // Since price_html is from trusted server (Woo), we can use innerHTML after ensuring no script price.innerHTML = it.price_html; // trusted body.appendChild(price); } if (!it.in_stock) { var stock = document.createElement('span'); stock.className = 'xshop-search__stock'; stock.textContent = 'Out of stock'; body.appendChild(stock); } a.appendChild(body); li.appendChild(a); // Click closes li.addEventListener('mousedown', function (e) { // prevent input blur before click e.preventDefault(); }); resultsEl.appendChild(li); }); updateViewAll(query); } function setActive(idx) { var lis = resultsEl.querySelectorAll('[role="option"]'); lis.forEach(function (li, i) { if (i === idx) { li.classList.add('is-active'); li.setAttribute('aria-selected', 'true'); input.setAttribute('aria-activedescendant', li.id); } else { li.classList.remove('is-active'); li.setAttribute('aria-selected', 'false'); } }); activeIndex = idx; } function closeDropdown() { setExpanded(false); setLoading(false); showError(false); // keep results for next open? Clear on next input input.removeAttribute('aria-activedescendant'); activeIndex = -1; } function openDropdown() { setExpanded(true); } var doSearch = debounce(function () { var q = input.value.trim(); if (q.length < minLen) { if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } clearResults(); showEmpty(false); showError(false); setLoading(false); setExpanded(false); updateViewAll(''); if (statusEl) statusEl.textContent = ''; return; } // dedupe identical query if (q === lastQuery && items.length) { openDropdown(); return; } lastQuery = q; var mySeq = ++seq; if (abortCtrl) abortCtrl.abort(); abortCtrl = new AbortController(); setLoading(true); showError(false); showEmpty(false); openDropdown(); if (statusEl) statusEl.textContent = 'Searching'; var url = endpoint + '?search=' + encodeURIComponent(q) + '&limit=' + encodeURIComponent(String(limit)); fetch(url, { signal: abortCtrl.signal, headers: { 'Accept': 'application/json' } }) .then(function (res) { if (!res.ok) throw new Error('http ' + res.status); return res.json(); }) .then(function (data) { if (mySeq !== seq) return; // stale setLoading(false); var list = (data && data.items) ? data.items : []; render(list, q); }) .catch(function (err) { if (err && err.name === 'AbortError') return; if (mySeq !== seq) return; setLoading(false); showError(true); if (statusEl) statusEl.textContent = 'Error'; }); }, DEBOUNCE_MS); input.addEventListener('input', doSearch); input.addEventListener('focus', function () { if (input.value.trim().length >= minLen && resultsEl.children.length) openDropdown(); }); input.addEventListener('keydown', function (e) { var lis = resultsEl.querySelectorAll('[role="option"]'); if (!lis.length) { if (e.key === 'Escape') closeDropdown(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); var next = activeIndex + 1; if (next >= lis.length) next = 0; setActive(next); } else if (e.key === 'ArrowUp') { e.preventDefault(); var prev = activeIndex - 1; if (prev < 0) prev = lis.length - 1; setActive(prev); } else if (e.key === 'Enter') { if (activeIndex >= 0 && lis[activeIndex]) { e.preventDefault(); var link = lis[activeIndex].querySelector('a'); if (link) window.location.href = link.href; } else { // let form submit to canonical search page closeDropdown(); } } else if (e.key === 'Escape') { e.preventDefault(); closeDropdown(); input.focus(); } }); // Click outside to close document.addEventListener('click', function (e) { if (!root.contains(e.target)) closeDropdown(); }); // Form submit: let it go to ?s=...&post_type=product (canonical). But ensure we close dropdown. form.addEventListener('submit', function () { closeDropdown(); }); // View all link if (viewAllEl) { viewAllEl.addEventListener('click', function () { closeDropdown(); }); } } function initAll() { document.querySelectorAll('[data-xshop-search]').forEach(initSearch); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAll); } else { initAll(); } // Expose for testing window.xshopSearch = { init: initSearch, debounceMs: DEBOUNCE_MS }; })();