Подсказки при вводе
Посетитель не всегда помнит полное название урока. Подсказка при вводе помогает уточнить формулировку ещё до отправки формы. Но она добавляет отдельную асинхронную работу и управление фокусом: список не должен перескакивать из-за старого запроса или требовать мышь.
В этом уроке сделаем небольшой список подходящих названий. Он дополняет обычную форму, а не заменяет её. Полный поиск продолжает работать по Enter и кнопке, даже если подсказки недоступны.
Подсказка имеет свою задачу
Основная выдача ищет в заголовках, разделах и тексте. Для первой версии подсказок используем только названия. Это уменьшает объём представления и делает каждый вариант понятным: выбор подставляет название конкретного материала.
Такой список не обязан совпадать с полной выдачей. Термин из глубины статьи может давать результат после отправки формы, но не иметь подходящей подсказки по названию. Нужно объяснить это различие вместо обещания, что пять названий описывают всю библиотеку.
Пока применяем подстроку к нормализованному заголовку. Это помогает найти Nginx по ngi, хотя основной индекс ждёт законченный термин. Смысл подсказки — продолжить ввод, поэтому её правило совпадения может быть другим.
Число вариантов ограничим пятью. Ограничение относится к удобству списка, а не к общему числу найденных материалов. Не подписывайте его как точное количество результатов поиска.
Простая доступная модель
Используем обычный список кнопок, а не будем объявлять неполную реализацию сложным combobox. Кнопки получают фокус через Tab и выбираются через Enter или пробел. Дополнительно введём стрелки и Escape.
После поля запроса в форме добавьте список:
<ul id="suggestions" aria-label="Подходящие названия" hidden></ul>
Кнопки внутри имеют type="button", иначе выбор подсказки мог бы непреднамеренно отправить форму. Видимая подпись поля остаётся на месте. Пока список скрыт, он не должен участвовать в клавиатурном обходе.
Для отдельного проекта с настоящим раскрывающимся combobox следует реализовать соответствующие роли и всю клавиатурную модель. Образец такого элемента описан в руководстве WAI-ARIA. Наличие одного атрибута role не заменяет остальных действий.
Задержка и номер запроса
Если запускать поиск после каждого символа, быстрое слово создаст несколько операций. Небольшая задержка объединяет их: работа начинается, когда посетитель ненадолго остановился. Это называют debounce.
Задержка не гарантирует порядок завершения асинхронных операций. Поэтому вместе с таймером нужен номер запроса. Он изменяется сразу при новом вводе, ещё до запуска следующей операции.
Для подсказок используем отдельный номер. Основная выдача и варианты ввода обновляются независимо; общий счётчик мог бы неожиданно отменять одно действие при запуске другого.
В импорт из core.js добавьте normalize. Затем поместите следующий полный блок после определения loadIndex, readState и changeState в app.js:
const suggestions = document.querySelector("#suggestions");
let suggestionRevision = 0;
let suggestionTimer;
function hideSuggestions() {
suggestionRevision++;
clearTimeout(suggestionTimer);
suggestions.replaceChildren();
suggestions.hidden = true;
}
input.addEventListener("input", () => {
const requestId = ++suggestionRevision;
clearTimeout(suggestionTimer);
suggestions.replaceChildren();
suggestions.hidden = true;
const query = input.value.trim();
if (query.length < 2) return;
suggestionTimer = setTimeout(async () => {
try {
const index = await loadIndex();
if (requestId !== suggestionRevision) return;
const needle = normalize(query);
const found = [...index.byId.values()]
.filter(doc => normalize(doc.title).includes(needle))
.slice(0, 5);
for (const doc of found) {
const li = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.textContent = doc.title;
button.addEventListener("click", () => {
input.value = doc.title;
hideSuggestions();
changeState({ query: doc.title, section: section.value, page: 1 });
});
button.addEventListener("keydown", event => {
const buttons = [...suggestions.querySelectorAll("button")];
const at = buttons.indexOf(button);
if (event.key === "ArrowDown" && at + 1 < buttons.length) {
event.preventDefault(); buttons[at + 1].focus();
} else if (event.key === "ArrowUp") {
event.preventDefault(); (buttons[at - 1] || input).focus();
} else if (event.key === "Escape") {
event.preventDefault(); hideSuggestions(); input.focus();
}
});
li.append(button);
suggestions.append(li);
}
suggestions.hidden = !found.length;
} catch {
if (requestId === suggestionRevision) hideSuggestions();
}
}, 200);
});
input.addEventListener("keydown", event => {
if (event.key === "Escape") hideSuggestions();
if (event.key === "ArrowDown" && !suggestions.hidden) {
const button = suggestions.querySelector("button");
if (button) { event.preventDefault(); button.focus(); }
}
});
В начале существующей refresh дополнительно вызовите hideSuggestions(). Это закрывает прежние варианты при отправке формы и переходе по истории браузера. Функция определена до первого вызова refresh в конце файла.
Что происходит при быстром вводе
Представим ввод ng, а затем ngi. Первая операция может уже ожидать данные, когда появляется вторая строка. Если первая завершится позже, проверка номера не позволит ей показать список для устаревшего значения.
clearTimeout помогает только до начала операции. Когда асинхронная функция уже работает, отмена таймера не возвращает её назад. Поэтому проверка после await является самостоятельной необходимой частью.
Очистка списка происходит сразу при новом вводе. Это простое поведение предотвращает выбор варианта, который относится к прежней строке. В более сложном интерфейсе можно сохранять список с явным состоянием обновления, но тогда потребуется дополнительное объяснение посетителю.
Ошибка подсказок не очищает основную выдачу и не объявляет поиск целиком недоступным. Список закрывается, а форма остаётся рабочей. Необязательное улучшение интерфейса не должно блокировать основное действие.
Клавиатура и фокус
Стрелка вниз из поля переводит фокус на первую кнопку, если список уже показан. Между кнопками стрелки перемещают фокус; стрелка вверх с первой возвращает его к запросу. Escape закрывает варианты.
Асинхронное появление списка само по себе не перемещает фокус. Пользователь может продолжать печатать, менять раздел или отправлять форму. Автоматический перевод на первый вариант прервал бы ввод.
Выбор кнопки записывает полное название в запрос и запускает обычное обновление состояния. URL и пагинация проходят тот же путь, что при ручной отправке формы. Не нужно создавать второй независимый механизм выдачи.
Мышь использует обычный click кнопки. Здесь нет обработчика, который немедленно скрывает список при потере фокуса поля: такой обработчик мог бы уничтожить кнопку до завершения клика. Поведение за пределами списка можно добавить отдельно, проверяя порядок событий.
Граница небольшого примера
Подсказки перебирают названия в byId. Для маленького корпуса это удобно и объяснимо. Для большой библиотеки можно передать эту работу в Worker или использовать отдельный серверный запрос.
Список в этой версии не учитывает выбранный раздел. Это допустимый договор для подсказки общих названий, но после выбора раздела читатель может ожидать только его материалы. Если такое ожидание подтверждено, фильтруйте документы до ограничения пяти вариантов.
Случайный порядок документа тоже может влиять на подсказки. В нашей библиотеке он стабилен благодаря экспорту. В дальнейшем можно оценивать совпадение начала названия и точного термина, но такое улучшение нужно сравнивать отдельно.
Представьте ввод Ma, после которого посетитель быстро продолжил строку до Markdown. Первый ответ может прийти позднее второго. Его номер уже отличается от текущего, поэтому он не добавляет кнопки в список. Аналогично работает выбор подсказки: hideSuggestions меняет номер, и оставшийся старый ответ не открывает список поверх готовой выдачи. Проверяйте обе ситуации, поскольку задержка касается не только последовательности набранных букв, но и перехода от ввода к отправленному запросу.
Теперь интерфейс помогает уточнять запрос, сохраняя обычную форму и управляемый фокус. Следующий урок перенесёт вычисления из основного потока и разделит задержку загрузки, построения индекса и самого поиска.