// Predictive search over the Ajax API. Two guards make it feel instant without
// hammering the store: a 150 ms debounce, so "serum" typed quickly is one request,
// and an AbortController, so a slow answer for "se" can't land after the one for "serum".
//
// Section Rendering variant: request
// `${Shopify.routes.root}search/suggest?q=${q}&resources[type]=product§ion_id=predictive-search`
// and swap in the HTML of sections/predictive-search.liquid. The markup then stays in
// Liquid (money filter, translations, image_url) and the keyboard code below is unchanged.
class PredictiveSearch extends HTMLElement {
#controller = null;
#timer = 0;
#active = -1;
connectedCallback() {
this.input = this.querySelector('[role="combobox"]');
this.list = this.querySelector('[role="listbox"]');
this.status = this.querySelector('[aria-live]');
this.input.addEventListener('input', () => {
clearTimeout(this.#timer);
this.#timer = setTimeout(() => this.search(this.input.value.trim()), 150);
});
this.input.addEventListener('keydown', (e) => this.onKey(e));
this.addEventListener('focusout', (e) => {
if (!this.contains(e.relatedTarget)) this.close();
});
}
async search(q) {
this.#controller?.abort(); // whatever is still in flight is stale now
if (!q) return this.close();
this.#controller = new AbortController();
try {
const res = await fetch(
`${Shopify.routes.root}search/suggest.json?q=${encodeURIComponent(q)}&resources[type]=product&resources[limit]=4`,
{ signal: this.#controller.signal },
);
if (!res.ok) throw new Error(String(res.status)); // 429: throttled, keep the last results
const { resources } = await res.json();
this.render(resources.results.products, q);
} catch (err) {
if (err.name !== 'AbortError') console.warn('Predictive search failed', err);
}
}
render(products, q) {
const rows = products.map((p, i) => option(`ps-option-${i}`, p.url, [
p.featured_image?.url ? img(p.featured_image) : '',
highlight(p.title, q),
money(p.price), // a decimal string in the buyer's currency
]));
// Last option: the full results page. Labels come translated from the Liquid markup
rows.push(option('ps-option-all', `${Shopify.routes.root}search?q=${encodeURIComponent(q)}`, [
this.dataset.searchFor.replace('[terms]', q),
]));
this.list.replaceChildren(...rows);
this.#active = -1;
this.input.removeAttribute('aria-activedescendant');
this.input.setAttribute('aria-expanded', 'true');
this.status.textContent = this.dataset.resultsCount.replace('[count]', products.length);
}
onKey(e) {
const options = [...this.list.querySelectorAll('[role="option"]')];
const n = options.length;
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && n) {
e.preventDefault(); // keep the caret where it is
const down = e.key === 'ArrowDown';
this.select(this.#active < 0 ? (down ? 0 : n - 1) : (this.#active + (down ? 1 : -1) + n) % n, options);
} else if (e.key === 'Enter' && this.#active > -1) {
e.preventDefault(); // open the product instead of submitting the search form
options[this.#active].querySelector('a').click();
} else if (e.key === 'Escape') {
this.close();
}
}
select(i, options) {
options[this.#active]?.setAttribute('aria-selected', 'false');
this.#active = i;
options[i].setAttribute('aria-selected', 'true');
options[i].scrollIntoView({ block: 'nearest' });
// Focus stays in the input; screen readers follow aria-activedescendant
this.input.setAttribute('aria-activedescendant', options[i].id);
}
close() {
this.#active = -1;
this.input.setAttribute('aria-expanded', 'false');
this.input.removeAttribute('aria-activedescendant');
this.list.replaceChildren();
}
}
// Titles are merchant data: build text nodes and <mark>, never innerHTML
function highlight(text, q) {
const words = q.split(/\s+/).filter(Boolean).map(RegExp.escape);
const span = document.createElement('span');
text.split(new RegExp(`(${words.join('|')})`, 'gi')).forEach((part, i) => {
span.append(i % 2 ? Object.assign(document.createElement('mark'), { textContent: part }) : part);
});
return span;
}
function option(id, href, content) {
const li = Object.assign(document.createElement('li'), { id });
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
const a = Object.assign(document.createElement('a'), { href, tabIndex: -1 });
a.append(...content);
li.append(a);
return li;
}
const img = ({ url }) => Object.assign(new Image(48, 48), { src: url, alt: '', loading: 'lazy' });
const money = (amount) =>
new Intl.NumberFormat(Shopify.locale, { style: 'currency', currency: Shopify.currency.active }).format(amount);
customElements.define('predictive-search', PredictiveSearch);
{%- comment -%}
The Section Rendering variant. GET /search/suggest?q=…&resources[type]=product§ion_id=predictive-search
returns this section rendered with the predictive_search object. It outputs the same
listbox (ids, roles) the JSON variant builds in predictive-search.js, so the CSS and
the keyboard handling work with either one.
{%- endcomment -%}
{%- if predictive_search.performed -%}
{%- assign products = predictive_search.resources.products -%}
<ul
id="predictive-search-results"
role="listbox"
aria-label="{{ 'templates.search.products' | t }}"
data-count="{{ products.size }}"
>
{%- for product in products -%}
<li id="ps-option-{{ forloop.index0 }}" class="ps__option" role="option" aria-selected="false">
<a href="{{ product.url }}" tabindex="-1">
{%- if product.featured_image -%}
{{ product.featured_image | image_url: width: 96 | image_tag: loading: 'lazy', alt: '' }}
{%- endif -%}
<span class="ps__title">{{ product.title | escape | highlight: predictive_search.terms }}</span>
<span class="ps__price">{{ product.price | money }}</span>
</a>
</li>
{%- endfor -%}
<li id="ps-option-all" class="ps__option" role="option" aria-selected="false">
<a href="{{ routes.search_url }}?q={{ predictive_search.terms | url_encode }}" tabindex="-1">
{{- 'templates.search.search_for' | t: terms: predictive_search.terms -}}
</a>
</li>
</ul>
{%- endif -%}
{% schema %}
{
"name": "t:sections.predictive_search.name",
"settings": []
}
{% endschema %}