Skip to content

Example: autocomplete ​

An autocomplete panel under the search input, built on GET /suggest. If you do not need a custom look or behaviour, the widget provides the same functionality without any code.

Markup ​

The panel is a listbox belonging to the input, so a screen reader announces it and the keyboard has something to move through:

html
<form action="/search" method="get">
    <input id="metty-input" name="q" type="search" autocomplete="off"
           role="combobox" aria-autocomplete="list" aria-expanded="false"
           aria-controls="metty-panel">
    <ul id="metty-panel" role="listbox" hidden></ul>
</form>

The form still works without JavaScript: Enter submits the query to the result page.

Calling from the browser ​

js
const ENDPOINT = 'https://search.api.metty.eu/suggest'
const input = document.querySelector('#metty-input')
const panel = document.querySelector('#metty-panel')

async function suggest(query, signal) {
    const params = new URLSearchParams({ key: PUBLIC_API_KEY, q: query, limit: '8' })
    const response = await fetch(`${ENDPOINT}?${params}`, { signal })

    if (!response.ok) {
        throw new Error(`suggest failed: ${response.status}`)
    }

    return response.json()
}

The response holds at most 8 query suggestions and at most 5 products in a compact form — without descriptions and without highlighting.

Debouncing and cancelling the previous request ​

Without debouncing the input fires a request on every keystroke and responses can arrive out of order. AbortController solves both:

js
let controller = null
let timer = null

input.addEventListener('input', () => {
    const query = input.value.trim()

    clearTimeout(timer)
    controller?.abort()

    if (query.length < 2) {
        render(null)
        return
    }

    timer = setTimeout(async () => {
        controller = new AbortController()

        try {
            render(await suggest(query, controller.signal))
        } catch (error) {
            if (error.name !== 'AbortError') {
                render(null)
            }
        }
    }, 150)
})

A cancelled request raises AbortError; it must not surface as an error, because it is the expected outcome of fast typing.

Rendering ​

Suggestions lead to the result page for that query, products go straight to product.url. The price is in price, the catalog currency in currency. Every row is an option with its own id, which the keyboard needs below:

js
let activeIndex = -1

function safeHref(url) {
    try {
        const target = new URL(url, document.baseURI)

        return ['http:', 'https:'].includes(target.protocol) ? target.href : null
    } catch {
        return null
    }
}

function row(id, url, label, hint) {
    const href = safeHref(url)

    if (href === null) {
        return null
    }

    const node = document.createElement('li')

    node.id = id
    node.dataset.href = href
    node.setAttribute('role', 'option')
    node.setAttribute('aria-selected', 'false')
    node.append(label, ' ', Object.assign(document.createElement('small'), { textContent: hint }))

    return node
}

function render(data) {
    activeIndex = -1
    input.removeAttribute('aria-activedescendant')

    const rows = data
        ? [
            ...data.suggestions.map((item, index) => row(
                `metty-suggestion-${index}`,
                `/search?q=${encodeURIComponent(item.query)}`,
                item.query,
                `${item.count} products`
            )),
            ...data.products.map((product, index) => row(
                `metty-product-${index}`,
                product.url,
                product.name,
                `${product.price} ${product.currency}`
            ))
        ].filter((node) => node !== null)
        : []

    panel.replaceChildren(...rows)
    panel.hidden = rows.length === 0
    input.setAttribute('aria-expanded', String(!panel.hidden))
}

textContent is used deliberately instead of innerHTML: /suggest returns the product name as plain text and it must not be interpreted as markup. product.url is what the catalog wrote, so it goes through safeHref() before it becomes a target — a javascript: URL in the catalog would otherwise turn into code executed on your page.

Keyboard and clicking ​

js
function closePanel() {
    clearTimeout(timer)
    controller?.abort()
    render(null)
}

function openRow(row) {
    window.location.href = row.dataset.href
}

function move(step) {
    const rows = [...panel.children]

    if (rows.length === 0) {
        return
    }

    rows[activeIndex]?.setAttribute('aria-selected', 'false')
    activeIndex = (activeIndex + step + rows.length) % rows.length

    const active = rows[activeIndex]

    active.setAttribute('aria-selected', 'true')
    active.scrollIntoView({ block: 'nearest' })
    input.setAttribute('aria-activedescendant', active.id)
}

input.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
        closePanel()
        return
    }

    if (panel.hidden) {
        return
    }

    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
        event.preventDefault()
        move(event.key === 'ArrowDown' ? 1 : -1)
        return
    }

    if (event.key === 'Enter' && activeIndex >= 0) {
        event.preventDefault()
        openRow(panel.children[activeIndex])
    }
})

panel.addEventListener('mousedown', (event) => {
    const row = event.target.closest('[role="option"]')

    if (row) {
        event.preventDefault()
        openRow(row)
    }
})

input.addEventListener('blur', closePanel)

Enter without a highlighted row is left to the form, so it submits the typed query to the result page. Clicking is handled on mousedown with preventDefault(): blur would otherwise hide the panel before the click reached the row.

Closing the panel also stops the pending timer and the running request. Without that, a response that arrives after Escape would reopen the panel over a field the customer has already left.

Things to watch ​

topicrecommendation
minimum query length2 characters; do not send anything shorter
debounce120–200 ms
rate limit1800 requests per minute, counted per visitor IP address
empty responsehide the panel, do not show an error message
keyboardarrows, Enter and Escape are yours to handle — the panel is not a native <datalist>

The parameters and the response shape are documented under Autocomplete.

Metty documentation