332 lines
11 KiB
JavaScript
332 lines
11 KiB
JavaScript
/**
|
|
* 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 };
|
|
})();
|