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
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>
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 ?? '—'}`
);
}
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/proxyvoláAresServicepří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čceX-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á
textContentaappendmístoinnerHTML, aby název firmy z ARES nemohl injektovat HTML. -
Rate limit hlavičky.
Sledujte
X-RateLimit-Remainingv odpovědi — pomůže to detekovat blížící se limit.