Companies PiTS s.r.o. Companies PiTS s.r.o.
Příklad implementace

Našeptávač firem s autocomplete

Kompletní příklad input pole s automatickým doplňováním firem z registru ARES — debouncing, zrušení starých requestů a XSS-safe vykreslení výsledků. Vanilla JavaScript, bez závislostí.

Živé demo

01

HTML

Minimalistická struktura — input pro dotaz a kontejner pro výsledky. Atribut autocomplete="off" vypne nativní našeptávač prohlížeče.

<div class="company-search">
    <input
        id="company-search-input"
        type="text"
        placeholder="Zadejte název firmy..."
        autocomplete="off"
    />
    <ul id="company-search-results" hidden></ul>
</div>
02

JavaScript

Použijeme sjednocený endpoint /api/ares/lookup, který sám rozhodne, zda je dotaz IČO, nebo název. Volání má debouncing (300 ms), rušení starých requestů přes AbortController, XSS-safe rendering přes textContent a klávesnicovou navigaci v dropdownu (//Enter/Esc).

const API_BASE = 'https://companies.pits.cz';
const ACCESS_TOKEN = 'váš-access-token'; // ⚠ Vždy přes server-side proxy, nikdy ne v klientském JS!

const input = document.querySelector('#company-search-input');
const results = document.querySelector('#company-search-results');

let abortController = null;
let debounceTimer = null;
let currentItems = [];
let activeIndex = -1;

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

    clearTimeout(debounceTimer);
    abortController?.abort();

    if (query.length < 2) {
        results.replaceChildren();
        results.hidden = true;
        currentItems = [];
        activeIndex = -1;
        return;
    }

    debounceTimer = setTimeout(() => search(query), 300);
});

input.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
        event.preventDefault();
        results.hidden = true;
        return;
    }

    if (results.hidden || currentItems.length === 0) return;

    switch (event.key) {
        case 'ArrowDown':
            event.preventDefault();
            setActive((activeIndex + 1) % currentItems.length);
            break;
        case 'ArrowUp':
            event.preventDefault();
            setActive((activeIndex - 1 + currentItems.length) % currentItems.length);
            break;
        case 'Home':
            event.preventDefault();
            setActive(0);
            break;
        case 'End':
            event.preventDefault();
            setActive(currentItems.length - 1);
            break;
        case 'Enter':
            if (activeIndex >= 0) {
                event.preventDefault();
                handleSelect(currentItems[activeIndex]);
            }
            break;
    }
});

function setActive(nextIndex) {
    const nodes = results.querySelectorAll('li[data-company-id]');
    nodes.forEach((li, i) => {
        if (i === nextIndex) {
            li.classList.add('is-active');
            li.setAttribute('aria-selected', 'true');
            li.scrollIntoView({ block: 'nearest' });
        } else {
            li.classList.remove('is-active');
            li.removeAttribute('aria-selected');
        }
    });
    activeIndex = nextIndex;
}

async function search(query) {
    abortController = new AbortController();

    try {
        const url = new URL('/api/ares/lookup', API_BASE);
        url.searchParams.set('query', query);
        url.searchParams.set('limit', '10');

        const response = await fetch(url, {
            headers: {
                Authorization: `Bearer ${ACCESS_TOKEN}`,
                Accept: 'application/json',
            },
            signal: abortController.signal,
        });

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const data = await response.json();
        render(data.results);
    } catch (error) {
        if (error.name === 'AbortError') return;
        console.error('Vyhledávání selhalo:', error);
    }
}

function render(items) {
    results.replaceChildren();
    currentItems = items;
    activeIndex = -1;

    if (items.length === 0) {
        const li = document.createElement('li');
        li.textContent = 'Nic jsme nenašli';
        li.className = 'empty';
        results.append(li);
    } else {
        for (const item of items) {
            const li = document.createElement('li');
            li.dataset.companyId = item.companyId;
            li.setAttribute('role', 'option');

            const name = document.createElement('strong');
            name.textContent = item.companyName;

            const meta = document.createElement('span');
            meta.textContent = `IČO ${item.companyId}` + (item.address ? ` · ${item.address}` : '');

            li.append(name, meta);
            li.addEventListener('click', () => handleSelect(item));
            li.addEventListener('mouseenter', () => setActive(currentItems.indexOf(item)));
            results.append(li);
        }
    }

    results.hidden = false;
}

function handleSelect(item) {
    // Náhradou za alert() typicky vyplníte hidden input s IČO,
    // zavoláte GET /api/ares/companies/{companyId} pro detail,
    // nebo emitnete CustomEvent pro nadřazenou komponentu.
    alert(
        `Vybraná firma:\n\n` +
        `IČO:    ${item.companyId}\n` +
        `Název:  ${item.companyName}\n` +
        `Adresa: ${item.address ?? '—'}`
    );
}
03

CSS

Základní styl dropdownu — položky pod inputem, hover stav, prázdný stav. Přizpůsobte si paletu a typografii podle vašeho designu.

.company-search {
    position: relative;
    max-width: 480px;
}

.company-search input {
    width: 100%;
    padding: 0.625rem 1rem;
    border: 1px solid #0099ff66;
    border-radius: 0.5rem;
    font-size: 0.875rem;
}

.company-search input:focus {
    outline: none;
    border-color: #0099ff;
    box-shadow: 0 0 0 3px #0099ff33;
}

#company-search-results {
    position: absolute;
    inset-inline: 0;
    top: calc(100% + 0.5rem);
    background: #fff;
    border: 1px solid #0000001a;
    border-radius: 0.5rem;
    box-shadow: 0 10px 30px -10px #0000001f;
    list-style: none;
    margin: 0;
    padding: 0;
    z-index: 10;
    max-height: 20rem;
    overflow-y: auto;
    overscroll-behavior: contain;
}

#company-search-results li {
    padding: 0.75rem 1rem;
    border-top: 1px solid #00000010;
    cursor: pointer;
    display: flex;
    flex-direction: column;
    gap: 0.125rem;
}

#company-search-results li:first-child {
    border-top: 0;
}

#company-search-results li:hover,
#company-search-results li.is-active {
    background: #0099ff29;
}

#company-search-results li strong {
    color: #003d66;
    font-weight: 600;
    font-size: 0.875rem;
}

#company-search-results li span {
    color: #00000099;
    font-size: 0.75rem;
}

#company-search-results .empty {
    color: #00000080;
    cursor: default;
}
!

Co stojí za pozornost

  • Token nikdy v klientském JS. Přístupový token patří na server. Volání API z prohlížeče řešte přes vlastní backend proxy, která doplní hlavičku Authorization. Demo na téhle stránce takovou proxy používá — endpoint /priklady/naseptavac/proxy volá AresService přímo, rozhoduje o IČO vs. název a uplatňuje rate limit per IP.
  • Signed token, stateless. Stránka při renderu vygeneruje krátkodobý HMAC-SHA256 token (TTL 1 h) podepsaný APP_SECRET, který JS posílá v hlavičce X-Demo-Token. Proxy ho ověří bez session — bez znalosti secretu jej nelze zfalšovat, po expiraci přestane platit. Volání z curlu/Postmanu bez tokenu vrátí 403.
  • Debounce 300 ms. Snižuje počet requestů a šetří rate limit při psaní.
  • AbortController. Při novém požadavku zruší ten předchozí — v UI se nezobrazí zastaralé výsledky.
  • Minimum 2 znaky. Server odmítne kratší dotaz se stavem 400, proto je rozumné to ošetřit už v klientovi.
  • XSS-safe rendering. Příklad používá textContent a append místo innerHTML, aby název firmy z ARES nemohl injektovat HTML.
  • Rate limit hlavičky. Sledujte X-RateLimit-Remaining v odpovědi — pomůže to detekovat blížící se limit.
← Zpět na dokumentaci