English
English
Appearance
English
English
Appearance
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.
The panel is a listbox belonging to the input, so a screen reader announces it and the keyboard has something to move through:
<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.
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.
Without debouncing the input fires a request on every keystroke and responses can arrive out of order. AbortController solves both:
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.
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:
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.
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.
| topic | recommendation |
|---|---|
| minimum query length | 2 characters; do not send anything shorter |
| debounce | 120–200 ms |
| rate limit | 1800 requests per minute, counted per visitor IP address |
| empty response | hide the panel, do not show an error message |
| keyboard | arrows, Enter and Escape are yours to handle — the panel is not a native <datalist> |
The parameters and the response shape are documented under Autocomplete.