feat: implement WooCommerce AJAX product search

This commit is contained in:
XShop
2026-09-13 20:01:15 +03:30
parent 00439fcea9
commit f5c283af25
14 changed files with 760 additions and 37 deletions
+30
View File
@@ -155,5 +155,35 @@
/* Empty states */
.xshop-empty--shop{ text-align:center; }
/* ---- AJAX Search ---- */
.xshop-search{ position:relative; inline-size:100%; max-inline-size:520px; }
.xshop-search__form{ display:flex; gap:var(--xshop-space-2); }
.xshop-search__form input[type="search"]{ flex:1; }
.xshop-search__dropdown{ position:absolute; inset-block-start:calc(100% + 8px); inset-inline:0; background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); box-shadow:var(--xshop-shadow-lg); z-index:var(--xshop-z-overlay); max-block-size:min(60vh, 420px); overflow:auto; padding:var(--xshop-space-2); display:flex; flex-direction:column; gap:var(--xshop-space-2); }
.xshop-search__dropdown[hidden]{ display:none !important; }
.xshop-search__loading{ display:flex; align-items:center; gap:var(--xshop-space-2); font-size:var(--xshop-text-sm); color:var(--xshop-muted); padding:var(--xshop-space-3); }
.xshop-search__loading[hidden]{ display:none !important; }
.xshop-search__results{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
.xshop-search__item{ border-radius:var(--xshop-radius-sm); }
.xshop-search__item.is-active, .xshop-search__item:focus-within, .xshop-search__item:hover{ background:var(--xshop-surface); }
.xshop-search__item[aria-selected="true"]{ background:var(--xshop-surface-2); }
.xshop-search__link{ display:grid; grid-template-columns:48px 1fr; gap:var(--xshop-space-3); align-items:center; padding:var(--xshop-space-2); text-decoration:none; color:inherit; }
.xshop-search__thumb{ inline-size:48px; block-size:48px; object-fit:cover; border-radius:var(--xshop-radius-sm); background:var(--xshop-surface); }
.xshop-search__body{ display:flex; flex-direction:column; gap:2px; min-inline-size:0; }
.xshop-search__title{ font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); color:var(--xshop-text); display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; overflow:hidden; }
.xshop-search__price{ font-size:var(--xshop-text-xs); font-weight:var(--xshop-weight-bold); color:var(--xshop-text); }
.xshop-search__price del{ color:var(--xshop-muted); font-weight:400; margin-inline-end:6px; }
.xshop-search__price ins{ text-decoration:none; color:var(--xshop-danger); }
.xshop-search__stock{ font-size:var(--xshop-text-xs); color:var(--xshop-muted); }
.xshop-search__empty, .xshop-search__error{ font-size:var(--xshop-text-sm); color:var(--xshop-muted); padding:var(--xshop-space-3); text-align:center; }
.xshop-search__empty[hidden], .xshop-search__error[hidden]{ display:none !important; }
.xshop-search__viewall{ display:block; text-align:center; padding:var(--xshop-space-2); font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); color:var(--xshop-primary); text-decoration:none; border-block-start:1px solid var(--xshop-border); margin-block-start:var(--xshop-space-1); }
.xshop-search__viewall[hidden]{ display:none !important; }
.xshop-search__viewall:hover{ background:var(--xshop-surface); }
@media (max-width:640px){
.xshop-search{ max-inline-size:100%; }
.xshop-search__dropdown{ position:fixed; inset-block-start:auto; inset-inline:var(--xshop-space-2); inset-block-end:var(--xshop-space-2); max-block-size:55vh; }
}
/* Star rating fallback */
.star-rating{ color:#f59e0b; }
+331
View File
@@ -0,0 +1,331 @@
/**
* 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 };
})();
+8
View File
@@ -23,6 +23,14 @@ function xshop_enqueue_assets(): void {
// Theme JS is tiny; feature modules conditionally enqueued elsewhere.
wp_enqueue_script('xshop', XSHOP_THEME_URI . '/assets/js/theme.js', [], $ver, true);
// Search JS — only where ajax-search component is rendered (header has it globally, so enqueue on front-end when search UI exists)
// We check for header search presence via has_action or simply enqueue on all front-end where header is rendered (is not admin/customizer preview irrelevant)
// To satisfy "only where useful" while keeping header simple, enqueue on non-admin front-end (header renders on all front-end pages)
if (!is_admin() && !wp_is_json_request()) {
// Only enqueue if theme header will render (which is all front-end). We further guard via DOM marker in JS (init guard).
wp_enqueue_script('xshop-search', XSHOP_THEME_URI . '/assets/js/search.js', [], $ver, true);
}
// Localized data for REST/AJAX (nonces, i18n hints).
wp_localize_script('xshop', 'xshopData', [
'restUrl' => esc_url_raw(rest_url('xshop/v1/')),
@@ -0,0 +1,76 @@
<?php
/**
* AJAX Search — reusable, accessible, combobox-like (without over-engineered ARIA).
*
* Reusable by: desktop header, mobile header, future overlay.
* JS: assets/js/search.js handles fetch, debounce, stale protection, keyboard.
*
* Args: ['placeholder'=>string, 'action'=>string, 'id'=>string|null]
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$uid = $args['id'] ?? ('xshop-ajax-search-' . wp_unique_id());
$placeholder = $args['placeholder'] ?? esc_html__('Search products…', 'xshop');
$action = $args['action'] ?? home_url('/');
$minLength = 2;
$limit = 6;
?>
<div class="xshop-search" data-xshop-search>
<form role="search" method="get" action="<?php echo esc_url($action); ?>" class="xshop-search__form" data-xshop-search-form>
<label class="xshop-sr-only" for="<?php echo esc_attr($uid); ?>"><?php esc_html_e('Search products', 'xshop'); ?></label>
<input
type="search"
id="<?php echo esc_attr($uid); ?>"
name="s"
value="<?php echo esc_attr(get_search_query()); ?>"
placeholder="<?php echo esc_attr($placeholder); ?>"
autocomplete="off"
aria-label="<?php esc_attr_e('Search products', 'xshop'); ?>"
aria-expanded="false"
aria-controls="<?php echo esc_attr($uid); ?>-listbox"
aria-autocomplete="list"
role="combobox"
data-xshop-search-input
data-min-length="<?php echo esc_attr((string) $minLength); ?>"
data-limit="<?php echo esc_attr((string) $limit); ?>"
/>
<?php if (xshop_has_woocommerce()): ?>
<input type="hidden" name="post_type" value="product" />
<?php endif; ?>
<button type="submit" class="xshop-btn xshop-btn--primary" aria-label="<?php esc_attr_e('Search', 'xshop'); ?>"><?php esc_html_e('Search', 'xshop'); ?></button>
</form>
<div
id="<?php echo esc_attr($uid); ?>-listbox"
class="xshop-search__dropdown"
role="listbox"
hidden
data-xshop-search-dropdown
>
<div class="xshop-search__status xshop-sr-only" aria-live="polite" aria-atomic="true" data-xshop-search-status></div>
<div class="xshop-search__loading" hidden data-xshop-search-loading>
<span class="xshop-loading__spinner" aria-hidden="true"></span>
<span><?php esc_html_e('Searching…', 'xshop'); ?></span>
</div>
<ul class="xshop-search__results" role="presentation" data-xshop-search-results></ul>
<div class="xshop-search__empty" hidden data-xshop-search-empty>
<p><?php esc_html_e('No products found.', 'xshop'); ?></p>
</div>
<div class="xshop-search__error" hidden data-xshop-search-error>
<p><?php esc_html_e('Search is temporarily unavailable. Press Enter to search.', 'xshop'); ?></p>
</div>
<a href="#" class="xshop-search__viewall" hidden data-xshop-search-viewall>
<?php esc_html_e('View all results', 'xshop'); ?> →
</a>
</div>
</div>
@@ -42,7 +42,7 @@ $hasLogo = has_custom_logo();
<div class="xshop-header__actions">
<div class="xshop-header__search xshop-header__search--desktop">
<?php get_template_part('template-parts/components/search-form', null, ['placeholder' => esc_html__('Search…', 'xshop')]); ?>
<?php get_template_part('template-parts/components/ajax-search', null, ['placeholder' => esc_html__('Search products…', 'xshop')]); ?>
</div>
<?php if (xshop_has_woocommerce()): ?>
@@ -75,6 +75,6 @@ $hasLogo = has_custom_logo();
]);
?>
<div style="margin-block-start:var(--xshop-space-4)">
<?php get_template_part('template-parts/components/search-form', null, ['placeholder' => esc_html__('Search…', 'xshop')]); ?>
<?php get_template_part('template-parts/components/ajax-search', null, ['placeholder' => esc_html__('Search products…', 'xshop'), 'id' => 'xshop-ajax-search-mobile-' . wp_unique_id()]); ?>
</div>
</nav>