Search Autocomplete
TIP
This page is the contract. For what a good dropdown shows in each state, how the better shop layouts build it, and the audit checklist, see Search Autocomplete — Best Practices.
The search dropdown (autocomplete, našeptávač) is rendered server-side by your template. The <i:search-form> component (see Components) wires the input to the shared frontend script; everything the customer sees in the dropdown comes from one template file you own: ajax/search.tpl (or a custom file via the template attribute).
Request cycle
While the customer types, the script debounces for 200 ms and requests /_search_html?q=…. The response — your rendered template — replaces the dropdown content. On shops with the new fulltext engine a second, better-ranked response may replace the content again shortly after; the template is rendered the same way both times, so you don't need to do anything special for it.
Because the template renders on every keystroke, keep it cheap:
- Put every
repository.*.findBy()call inside the branch that actually renders it. A query placed before the{% if q is not empty %}check runs even when only the default panel is shown. - Never call a repository inside a result loop.
Template variables
| Variable | Description |
|---|---|
q | The search phrase. Empty when the customer typed fewer than 3 bytes (strlen, so a two-letter word with a diacritic already counts as a phrase) — render the default panel in that case. |
searchPageUrl | Link to the full results listing for q. Use it for the "show all results" row. It already carries vypis=1, which guarantees the customer lands on the listing (and not on a category/brand page via the submitted-search redirect) — do not build this URL by hand. |
Everything else you fetch yourself, with full control over what your shop searches:
{% if q is not empty %}
{% set products = repository.product.findBy({'search': q, 'limit': 5}) %}
{% set categories = repository.category.findBy({'search': q, 'limit': 4}) %}
{% set brands = repository.brand.findBy({'search': q, 'limit': 4}) %}
{% set articles = repository.article.findBy({'search': q, 'limit': 3}) %}
{% set pages = repository.textPage.findBy({'search': q, 'limit': 3}) %}
{% endif %}Collections expose totalCount for "Products (2960)"-style section headings and result counts on the show-all link.
Category suggestions match name-first with inflection and typo tolerance ("sekačka" finds Sekačky, "hodinki" finds Hodinky, "hodi" already matches while typing) — every suggested category is explainable by its visible name, so render the name as-is with the highlight filter.
Text-page suggestions (repository.textPage) only ever return pages the shop has explicitly marked "zobrazovat ve vyhledávání na e-shopu" in the page's administration detail — the flag exists because the same admin section also holds internal fragment pages (cart texts, footer blocks) that must never surface. Matching is accent-insensitive and name-first with mild typo tolerance ("vraceni" and "reklamacni" find Vrácení a reklamace); a phrase word found only in the page body still matches, but always ranks below name matches. An empty collection therefore also means "no page is flagged yet", not necessarily "nothing matched".
Result annotation
The frontend script is markup-agnostic — your design is entirely yours. It only needs each result annotated:
data-result-typeon every result link — one ofproduct,category,brand,article,page(text pages),more(the show-all row). The attribute may sit on the<a>itself or on a wrapper element around it (e.g. the<li>); the script finds the link either way.data-product-idon product results — the numeric product id. Likedata-result-type, it may sit on the link or on any element wrapping it. Omitting it costs the shop product-level click attribution: the click is still recorded, but without the product it landed on.
These attributes drive search analytics (what was clicked, at which position, from which section). A template without any data-result-type sends no analytics at all; a wrong type value (e.g. labelling articles as brand) silently corrupts the shop's search statistics. Use the vocabulary exactly.
Keyboard navigation
Customers can move through results with the arrow keys; the current item gets the CSS class search-current-item (style it), and Enter opens it.
There are two ways to define the traversal order:
Section mode (recommended)
Wrap each results section in an element with data-search-section="N":
<div data-search-section="1">
{# categories … #}
</div>
<div data-search-section="2">
{# products … #}
</div>
<a href="{{ searchPageUrl }}" data-result-type="more">…</a>The arrow keys walk sections in numeric order and items in DOM order within each section — so a sidebar that renders first in markup but sits visually after the products simply gets a higher number. Links outside any section (typically the show-all row) come last. No per-item bookkeeping is needed.
Two links to the same target with the same result type — the usual image-link + title-link pair on a product card — count as one item, so the keyboard never visits a product twice.
Legacy mode
Without any data-search-section in the fragment, the script falls back to the original contract: every result link carries an explicit tabindex (1, 2, 3, … in the desired traversal order), maintained by hand:
{% set tabIndex = 1 %}
{% for product in products %}
<a href="{{ product.url }}" tabindex="{{ tabIndex }}" data-result-type="product" data-product-id="{{ product.id }}">…</a>
{% set tabIndex = tabIndex + 1 %}
{% endfor %}The number may sit on the link itself or on the element wrapping it (the <li> or <div> of the row) — the script looks upwards from the annotated link to find it, so <li tabindex="6"><a data-result-type="product"> numbers the row just as well as putting both attributes on the <a>. Only positive numbers are positions: tabindex="0" and tabindex="-1" are focus management and are skipped, so the search continues up to the wrapper.
Keep tabindex off decorative elements inside a result row (a tooltip trigger, for instance). Click analytics ignores them — a rank is only ever read from the row's link or its wrapper — but the arrow-key ring is built from every [tabindex] in the fragment, so a decorative one becomes a keyboard stop that highlights nothing and does nothing on Enter.
Existing templates keep working unchanged. For new templates, prefer section mode — the counters are error-prone (duplicate or skipped numbers break both the traversal and the click analytics).
Dropdown states
Your template decides all three states explicitly:
- Default panel (
q is empty— focus on an empty field, or fewer than 3 characters). Good content: popular searches, bestsellers, recently viewed products. The Content Gridsearchgrid is the usual data source, letting the shop manage the content without a template change. If the form usesmin-length="0", this panel shows as soon as the field is focused. - Results — see below for composition recommendations.
- Nothing found (
qset, all collections empty). Always render an explicit message ("We found nothing for q, try…"). Never leave the customer with an empty box or unrelated filler — a recommendation strip under the message counts as filler: it looks like results for the phrase.
Composition recommendations
Grounded in e-commerce UX research (Baymard Institute, only ~19 % of shops get autocomplete right):
- Keep it small. Around 10 items on desktop, 4–8 on mobile. More causes choice paralysis, not more clicks.
- Distinguish result types visually — customers must see at a glance what is a category, a brand, a product, an article.
- Highlight the phrase in result names with the
highlightTwig filter:{{ product.name|highlight(q) }}. - Show availability and price on product rows — they build the confidence to click.
- Always render the show-all row (
searchPageUrl,data-result-type="more") with the total count when you have it. - Mobile: big tap targets, adequate spacing, no interference from sticky headers — the dropdown competes with the keyboard for viewport.
Minimal reference template
{% if q is empty %}
{# default panel — popular searches / bestsellers #}
{% else %}
{% set products = repository.product.findBy({'search': q, 'limit': 5}) %}
{% set categories = repository.category.findBy({'search': q, 'limit': 4}) %}
{% if products is empty and categories is empty %}
<p>{% trans 'search.nothing-found' %}</p>
{% else %}
{% if categories is not empty %}
<ul data-search-section="1">
{% for category in categories %}
<li data-result-type="category">
<a href="{{ category.url }}">{{ category.name|highlight(q) }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if products is not empty %}
<ul data-search-section="2">
{% for product in products %}
<li data-result-type="product" data-product-id="{{ product.id }}">
<a href="{{ product.url }}">
<i:img src="product.image" format="40x40" fill/>
{{ product.name|highlight(q) }}
<span>{{ product.item.price.current }}</span>
</a>
</li>
{% endfor %}
</ul>
<a href="{{ searchPageUrl }}" data-result-type="more">
{% trans 'search.show-all' %} ({{ products.totalCount }})
</a>
{% endif %}
{% endif %}
{% endif %}