Príklad: našepkávač
Našepkávač pod vyhľadávacím poľom nad endpointom GET /suggest. Ak nepotrebujete vlastný vzhľad ani správanie, rovnakú funkcionalitu poskytuje widget bez písania kódu.
Značky
Panel je listbox patriaci k vyhľadávaciemu poľu, takže ho čítačka obrazovky ohlási a klávesnica má po čom prechádzať:
<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>Formulár funguje aj bez JavaScriptu: Enter odošle dotaz na výsledkovú stránku.
Volanie z prehliadača
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()
}Odpoveď obsahuje najviac 8 návrhov dotazov a najviac 5 produktov v skrátenom tvare — bez popisu a bez zvýraznenia.
Debounce a zrušenie predchádzajúcej požiadavky
Bez debounce odošle vyhľadávacie pole požiadavku na každý stlačený znak a odpovede môžu doraziť v nesprávnom poradí. Oboje rieši AbortController:
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)
})Zrušená požiadavka vyvolá AbortError; ten sa nesmie prejaviť ako chyba, pretože ide o očakávaný stav pri rýchlom písaní.
Vykreslenie
Návrhy vedú na výsledkovú stránku s daným dotazom, produkty priamo na product.url. Cena je v poli price, mena katalógu v currency. Každý riadok je option s vlastným id, ktoré potrebuje klávesnica nižšie:
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} produktov`
)),
...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 je použitý zámerne namiesto innerHTML: /suggest vracia názov produktu ako čistý text a ten sa nesmie interpretovať ako značky. product.url je to, čo zapísal katalóg, takže prechádza cez safeHref(), kým sa stane cieľom — javascript: URL v katalógu by sa inak zmenila na kód spustený na vašej stránke.
Klávesnica a klikanie
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 bez vybraného riadka necháme formuláru, takže odošle napísaný dotaz na výsledkovú stránku. Klikanie obsluhujeme na mousedown s preventDefault(): blur by inak panel skryl skôr, než by click dorazil na riadok.
Zatvorenie panelu zároveň zastaví čakajúci timer aj rozbehnutú požiadavku. Bez toho by odpoveď, ktorá dorazí po Escape, panel znova otvorila nad poľom, z ktorého zákazník už odišiel.
Na čo dbať
| téma | odporúčanie |
|---|---|
| minimálna dĺžka dotazu | 2 znaky; kratší dotaz neposielajte |
| debounce | 120–200 ms |
| rate limit | 1800 požiadaviek za minútu, počíta sa na IP adresu návštevníka |
| prázdna odpoveď | panel skryte, nezobrazujte hlásenie o chybe |
| klávesnica | šípky, Enter a Escape obslúžte sami — panel nie je natívny <datalist> |
Podrobnosti o parametroch a tvare odpovede nájdete v sekcii Našepkávanie.