Companies PiTS Companies PiTS
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, klávesnicovou navigaci (//Enter/Esc) a stránkování dalších výsledků přes parametr start. Odpověď obsahuje total a hasMore — podle nich víte, jestli pro dotaz typu „Jan Novák" zbývají další záznamy a kdy přestat dotahovat.

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 PAGE_SIZE = 5;

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;
let currentQuery = '';
let currentTotal = 0;

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

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

    if (query.length < 2) {
        results.replaceChildren();
        results.hidden = true;
        currentItems = [];
        currentTotal = 0;
        currentQuery = '';
        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 fetchPage(query, start, signal) {
    const url = new URL('/api/ares/lookup', API_BASE);
    url.searchParams.set('query', query);
    url.searchParams.set('limit', String(PAGE_SIZE));
    url.searchParams.set('start', String(start));

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

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

    return response.json();
}

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

    try {
        const data = await fetchPage(query, 0, abortController.signal);

        currentTotal = data.total ?? data.results.length;
        renderInitial(data.results);

        if (data.hasMore) {
            renderLoadMore();
        }
    } catch (error) {
        if (error.name === 'AbortError') return;
        console.error('Vyhledávání selhalo:', error);
    }
}

async function loadMore() {
    if (!currentQuery || currentItems.length >= currentTotal) return;

    try {
        const data = await fetchPage(currentQuery, currentItems.length);

        // De-dupe podle IČO – ARES může vrátit překryv mezi stránkami.
        const seen = new Set(currentItems.map((it) => it.companyId));
        for (const item of data.results) {
            if (seen.has(item.companyId)) continue;
            seen.add(item.companyId);
            currentItems.push(item);
            results.append(buildRow(item));
        }

        currentTotal = data.total ?? currentTotal;
        results.querySelector('[data-load-more]')?.remove();
        if (currentItems.length < currentTotal) renderLoadMore();
    } catch (error) {
        console.error('Načtení další stránky selhalo:', error);
    }
}

function buildRow(item) {
    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)));
    return li;
}

function renderInitial(items) {
    results.replaceChildren();
    currentItems = items.slice();
    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 currentItems) {
            results.append(buildRow(item));
        }
    }

    results.hidden = false;
}

function renderLoadMore() {
    const li = document.createElement('li');
    li.dataset.loadMore = 'true';
    li.className = 'load-more';
    li.textContent = `Načíst další (zobrazeno ${currentItems.length} z ${currentTotal})`;
    li.addEventListener('click', () => loadMore());
    results.append(li);
}

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

#company-search-results .load-more {
    color: #0099ff;
    font-weight: 600;
    text-align: center;
    border-top: 1px solid #00000010;
    background: #0099ff0f;
}
!

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.
  • Stránkování přes start. Obecné dotazy (např. „Jan Novák") mívají desítky shod — odpověď vrací total a hasMore, podle kterých zobrazíte „Načíst další" a doplníte stránku voláním téhož endpointu s start = počet již zobrazených. Vždy deduplikujte podle companyId — ARES může přes stránky vrátit překryv.
← Zpět na dokumentaci