---
url: /search-autocomplete.md
---
# 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](/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](/components#i-search-form)) 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:

```twig
{% 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-type`** on every result link — one of `product`, `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-id`** on product results — the numeric product id. Like `data-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"`**:

```twig
<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:

```twig
{% 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:

1. **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 Grid](/content-grid) `search` grid is the usual data source, letting the shop manage the content without a template change. If the form uses `min-length="0"`, this panel shows as soon as the field is focused.
2. **Results** — see below for composition recommendations.
3. **Nothing found** (`q` set, 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 [`highlight` Twig filter](/twig): {{ 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

```twig
{% 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 %}
```
