Skip to content

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ť:

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>

Formulár funguje aj bez JavaScriptu: Enter odošle dotaz na výsledkovú stránku.

Volanie z prehliadača ​

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()
}

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:

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)
})

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:

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} 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 ​

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 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émaodporúčanie
minimálna dĺžka dotazu2 znaky; kratší dotaz neposielajte
debounce120–200 ms
rate limit1800 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.

Dokumentácia Metty