Поиск с автодополнением

Input с выпадающим списком подсказок, фильтрующихся по мере ввода — debounce + ARIA combobox для доступности.

Задача

Поле поиска должно показывать подсказки по мере ввода: фильтрация локального массива или запрос к API с задержкой.

Решение

<div class="autocomplete" id="autocomplete">
  <input
    class="autocomplete__input"
    id="searchInput"
    type="text"
    placeholder="Начните вводить..."
    role="combobox"
    aria-expanded="false"
    aria-haspopup="listbox"
    aria-autocomplete="list"
    aria-controls="suggestionsList"
    autocomplete="off"
  />
  <ul
    class="autocomplete__list"
    id="suggestionsList"
    role="listbox"
    hidden
  ></ul>
</div>
.autocomplete { position: relative; max-width: 400px; }

.autocomplete__input {
  width: 100%;
  padding: 10px 14px;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  font-size: 0.95rem;
}

.autocomplete__input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }

.autocomplete__list {
  position: absolute;
  top: calc(100% + 4px);
  left: 0; right: 0;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0,0,0,0.1);
  list-style: none;
  padding: 4px 0;
  margin: 0;
  z-index: 100;
  max-height: 240px;
  overflow-y: auto;
}

.autocomplete__item {
  padding: 9px 14px;
  cursor: pointer;
  font-size: 0.9rem;
}
.autocomplete__item:hover,
.autocomplete__item[aria-selected="true"] { background: #f1f5f9; }

.autocomplete__item mark {
  background: #fef08a;
  border-radius: 2px;
  padding: 0 1px;
}
const input = document.getElementById('searchInput');
const list  = document.getElementById('suggestionsList');

// Локальные данные (или заменить на API-запрос)
const ITEMS = ['JavaScript', 'TypeScript', 'React', 'Vue', 'Angular', 'Node.js', 'CSS', 'HTML'];

function debounce(fn, ms) {
  let timer;
  return (...args) => { clearTimeout(timer); timer = setTimeout( => fn(...args), ms); };
}

function highlight(text, query) {
  const re = new RegExp(`(${query.replace(/[.*+?^${}|[\]\\]/g, '\\$&')})`, 'gi');
  return text.replace(re, '<mark>$1</mark>');
}

function showSuggestions(query) {
  const matches = ITEMS.filter((item) =>
    item.toLowerCase().includes(query.toLowerCase())
  ).slice(0, 8);

  list.innerHTML = '';

  if (!matches.length || !query) {
    closeList;
    return;
  }

  matches.forEach((item) => {
    const li = document.createElement('li');
    li.className = 'autocomplete__item';
    li.role = 'option';
    li.innerHTML = highlight(item, query);
    li.addEventListener('mousedown', (e) => {
      e.preventDefault(); // не убирать фокус с input
      input.value = item;
      closeList;
    });
    list.appendChild(li);
  });

  list.hidden = false;
  input.setAttribute('aria-expanded', 'true');
}

function closeList() {
  list.hidden = true;
  input.setAttribute('aria-expanded', 'false');
}

input.addEventListener('input', debounce((e) => showSuggestions(e.target.value), 250));
input.addEventListener('blur', closeList);
document.addEventListener('click', (e) => {
  if (!input.contains(e.target)) closeList;
});

Ключевые моменты

  • debounce на input — не искать при каждом символе, а подождать паузу (250 мс для локального фильтра, 400+ для API).
  • e.preventDefault() в mousedown на пункте — без него blur срабатывает раньше click и список закрывается до выбора.
  • ARIA combobox/listbox/option — screen reader объявит список подсказок.
  • mark тег с подсветкой — помогает пользователю видеть что совпало с запросом.

Варианты

  • <datalist> — нативное автодополнение без JS: <input list="items"> + <datalist id="items">.
  • Combobox с API: заменить массив на fetch('/api/search?q=' + query) в showSuggestions.

Связанные рецепты / темы