К сожалению, нативные HTML контролы форм не подходят для такого типа взаимодействия, поэтому нам придется писать собственный автокомплит с нуля.
Важное предупреждение: это один из самых сложных UI-компонентов, с которыми вам когда-либо приходилось сталкиваться. Он намного сложнее, чем кажется.
Контрол автокомплита должен показывать список предложений, соответствующих тексту, который ввел пользователь. По мере ввода список изменяется. Можно кликнуть по одному из предложений, чтобы быстро завершить ввод, или же вводить текст дальше для получения более подходящих вариантов.
Базовая разметка
Чтобы элемент работал при отключенном JavaScript, следует начать с нативных HTML-контролов.
Радио-кнопки не подходят, потому что в нашем списке слишком много опций. Search box [разбирается в книге ранее] медленный и может выдавать нулевой результат. Datalist слишком забагованный. В общем, вариантов не остается, придется воспользоваться обычным селектом.
Улучшенная разметка
Если же JavaScript доступен, мы можем воспользоваться конструктором Autocomplete()
[который будет написан позже], чтобы сгенерировать более продвинутую верстку.
Прячем селект без нарушения его доступности
Чтобы спрятать элемент seleсt
, не сломав возможность отправки его значения на сервер [при отправке формы], нужно сделать следующее:
- Добавить класс
visually-hidden
, чтобы спрятать элемент от пользователей. Подробнее о паттерне visually-hidden - Добавить атрибут
aria-hidden="true"
, чтобы спрятать его от скринридеров. - Добавить атрибут
tabindex="-1"
, чтобы на нем нельзя было сфокусироваться с клавиатуры.
Такое скрытие намного лучше, чем использование display: none
. Оно дает такой же эффект, но при этом не препятствует отправке значения селекта на сервер. Хотя пользователь не будет взаимодействовать напрямую с элементом select
, так как мы его спрятали, его значение все еще необходимо отправлять на сервер для обработки, важно помнить об этом.
Перепривязка метки поля
Мы переместили атрибут id
c select
на input
, чтобы связать метку label
с текстовым полем. Это необходимо для скринридеров, а также увеличивает область взаимодействия (hit area).
name
– он не нужен, так как значение поля не должно отправляться на сервер. Оно предназначено исключительно для обеспечения взаимодействия с пользователем и используется как прокси для доступа к скрытому селекту.Атрибуты текстового поля
- Атрибут
role="combobox"
определяет тип поля ввода. Combo box – это "редактируемый контрол, связанный со списком предопределенных вариантов ввода". - Атрибут
aria-autocomplete="list"
сообщает пользователям, что будет доступен список опций. - Атрибут
aria-expanded
описывает текущее состояние этого списка – свернутое (false
) или развернутое (true
). - Атрибут
autocomplete="off"
запрещает браузеру добавлять свои собственные предложения, которые будут мешать работе компонента. - И наконец, атрибут
autocapitalize="none"
не позволяет автоматически превращать первую букву в заглавную. [Подробнее этот момент разбирается в четвертой главе книги]
SVG-иконка размещается на текстовом поле с помощью CSS. В Internet Explorer SVG-элемент по умолчанию доступны для фокусировки с клавиатуры, поэтому устанавливаем атрибут focusable="false".
Атрибуты меню
- Атрибут
role="list"
определяет меню как список опцией, каждая опция имеет атрибутrole="option"
. - Атрибут
aria-selected="true"
сообщает пользователю, какая опция в списке выделена в данный момент. Значение может переключаться междуtrue
иfalse
. - Атрибут
tabindex="-1"
означает, что фокус на опциях может быть установлен программно, при нажатии пользователем определенных клавиш. Этим мы займемся чуть позже. - Атрибут
data-option-value
содержит значение конкретной опции. Когда пользователь нажимает на нее, значение элементаselect
будет обновлено на значение опции. Таким образом мы синхронизируем видимый интерфейс и скрытый контрол, который будет отправлен на сервер.
Используем live region, чтобы скринридеры знали, когда отображается список опций
Пользователи без ограничений по зрению сами увидят, когда появляется список с предложенными вариантами, но скринридер этого не увидит, не выходя за пределы текстового поля.
Чтобы предоставить всем пользователям одинаковый опыт (первый принцип инклюзивного дизайна), мы будем использовать live region [описано в главе "Форма оформление заказа"].
После создания меню в live region (элемент с атрибутом aria-live
) будет указано количество доступных результатов ("13 результатов доступно"). Имея эту информацию, пользователь может самостоятельно принять решение: продолжить печатать, чтобы конкретизировать свой выбор, или посмотреть список и выбрать предложение из него.
Так как эта информация нужна только для пользователей скринридеров, мы скрываем ее от других пользователей с помощью класса visually-hidden
.
Обработка пользовательского ввода
Когда пользователь вводит текст в поле ввода, мы должны отслеживать каждое нажатие на клавиши.
Объект this.keys
– это коллекция числовых кодов, соответствующих конкретным клавишам. Мы специально используем именованные поля объекта, чтобы избежать магических чисел и сделать код понятнее.
С помощью конструкции switch
отфильтровываем нажатия на клавиши Escape
, Enter
, Tab
, Shift
, Пробел
и стрелки Вверх
, Влево
и Вправо
. Если этого не сделать, то запустится код из секции default
, и откроется меню с предложениями.
Вместо того, чтобы отфильтровывать клавиши, которые нас не интересуют, можно было бы, наоборот, указать те, которые нам нужны. Но тогда пришлось бы перечислять очень много кодов и было бы очень просто что-то забыть.
В основном нас интересуют две последних секции case
: нажатие на клавишу Down
(стрелка вниз) [будет разобран чуть позже] и дефолтный кейс – нажатие на обычные клавиши (буквы, цифры, знаки препинания и все такое). В последнем случае мы вызываем метод onTextBoxType()
:
Метод getOptions()
[описан чуть дальше в тексте] отфильтровывает только те опции, которые совпадают с пользовательским вводом.
Единый tab-стоп для составных контролов
Контрол автокомплита является составным: он содержит несколько интерактивных элементов внутри себя, на которых можно сфокусироваться. Например, пользователи могут печатать в текстовом поле, а затем перемещаться по меню для выбора нужной опции.
У таких сложных контролов должен быть только один tab-стоп, как говорит спецификация WAI-ARIA Authoring Practices 1.1:
Основное соглашение о навигации с клавиатуры, общее для всех платформ, заключается в том, что клавиши tab и shift+tab перемещают фокус с одного компонента пользовательского интерфейса на другой, в то время как другие клавиши, в первую очередь клавиши стрелок, перемещают фокус внутри компонентов, если они включают несколько интерактивных элементов. Путь, по которому перемещается фокус при нажатии на tab, – это последовательность табов (tab sequence), или кольцо табов (tab ring).
Группа радио-кнопок – это тоже составной контрол.
Как только происходит фокусировка на первой радио-кнопке, пользователь может перемещаться между ними с помощью кнопок-стрелок. Нажатие на Tab
должно переводить фокус на следующий элемент в последовательности табов [а не на следующую радио-кнопку в группе].
Вернемся к автокомплиту.
Фокус на поле ввода устанавливается естественным образом при нажатии на Tab
. Далее пользователь может использовать стрелки, чтобы перемещаться по меню опций. Нажатие на Tab должно приводить к закрытию меню (если оно открыто), чтобы оно не закрывало контент, находящийся под ним.
ARIA activedescendant не работает
Многие компоненты используют атрибут aria-activedescendant как альтернативный способ убедиться, что они имеют только один tab-стоп. Этот атрибут сохраняет фокус на контейнере компонента и ссылается на текущий активный элемент.
Для нашего компонента это не подходит, так как поле ввода это соседний элемент для меню – а не его родитель.
Скрытие меню по событию onblur не работает
Событие onblur
возникает, когда элемент теряет фокус. В нашем случае мы можем прослушивать это событие на текстовом поле – оно происходит, если пользователь покидает поле, нажав Tab
, или кликнув за его пределами.
К сожалению, программное перемещение фокуса с поля на меню тоже вызывает событие blur
, что приводит к скрытию меню и делает его недоступным для пользователей клавиатуры.
Решением может стать использование setTimeout()
. Мы устанавливаем задержку, и если за это время пользователь переместит фокус на список, то мы сбросим таймер с помощью clearTimeout
и не будем закрывать меню.
Но это не работает в iOS 10 из-за проблем с событием blur
. Оно некорректно вызывается, если пользователь закрывает экранную клавиатуру, так что в меню предложений все равно нельзя попасть.
Есть другое решение.
Скрытие меню по нажатию на Tab
Вместо того, чтобы прятать меню при потере фокуса полем ввода, мы можем использовать событие keydown и отслеживать нажатия на клавишу Tab
.
Но в отличие от события blur
, это решение не учитывает случаи, когда пользователь кликает где-то вне поля ввода, из-за чего оно теряет фокус.
Поэтому мы должны добавить еще обработчик кликов для всего документа и проверять, где именно сделан клик:
Нажатие на стрелку Вниз для перемещения в меню
Когда поле ввода находится в фокусе, нажатие на стрелку Вниз вызывает метод onTextBoxDownPressed()
, который перемещает пользователя в меню.
Если пользователь нажимает клавишу Вниз, ничего не напечатав, открывается полное меню со всеми опциями. Первая опция в списке получает фокус [метод highlightOption
будет рассмотрен далее].
То же самое происходит, если введенное значение точно совпадает с какой-либо опцией, хотя это бывает довольно редко. Большинство пользователей предпочитают выбирать значение из списка, так как это быстрее.
В остальных случаях мы показываем только подходящие опции (если они есть) и также фокусируемся на первой опции в списке.
Прокрутка меню
Меню может состоять из сотен опций. Чтобы убедиться, что все его элементы видны, мы используем такие стили:
Свойство max-height
ограничивает максимальную высоту меню. Если контент превышает эти размеры, то появляется вертикальный скролл (overflow-y: scroll
).
Последнее нестандартное свойство разрешает импульсную прокрутку (momentum scrolling) в iOS. Таким образом, список с предложениями будет прокручиваться так же, как и все остальные элементы.
Выбор опции
Для прослушивания кликов по опциям мы будем использовать делегирование событий. Это эффективнее, чем добавлять слушатель к каждой опции.
Обработчик события извлекает опцию, по которой кликнули (e.currentTarget
) и передает ее методу selectOption
.
Метод selectOption
извлекает значение опции из атрибута data-option-value
, передает его методу setValue
, который устанавливает его в поле ввода и в скрытый select
. Меню закрывается, фокус перемещается на поле ввода.
Те же самые действия выполняются, когда пользователь выбирает опцию с помощью клавиш Пробел
или Enter
.
Взаимодействие с меню с клавиатуры
Если фокус находится внутри меню, пользователь может перемещаться по нему с помощью клавиатуры. Для этого мы должны прослушивать событие keydown
.
Клавиша | Действие |
Up | Если первая опция находится в фокусе, то установить фокус на текстовое поле. Иначе установить фокус на предыдущую опцию в списке. |
Down | Установить фокус на следующую опцию. Если активная опция последняя в списке, то ничего не делать. |
Tab | Спрятать меню. |
Enter or Space | Выбрать опцию, которая сейчас активна, и установить фокус на поле ввода. |
Escape | Спрятать меню и установить фокус на поле ввода. |
Everything else | Установить фокус на поле ввода, чтобы пользователь мог продолжить печатать. |
Выделение активной опции
Когда пользователь фокусируется на опции, нажимая клавиши Вверх
и Вниз
, вызывается метод highlightOption()
.
Этот метод выполняет сразу несколько задач.
Для начала он проверяет, выделена ли уже какая-то опция. Если да, то значение ее атрибута aria-selected
изменяется на false
. Это гарантирует, что скринридеры узнают об изменениях.
Затем для выбранной опции aria-selected
изменяется на true
.
Так как меню имеет фиксированную высоту, новая активная опция может находиться за пределами видимой зоны. Мы проверяем это с помощью метода isElementVisible()
. Если опция не видна, регулируем прокрутку с помощью scrollTop
.
Далее сохраняем новую активную опцию, чтобы сослаться на нее в следующий раз при вызове метода. И наконец, устанавливаем фокус на нее, чтобы убедиться, что скринридеры оповещены о новом значении.
Чтобы сообщить об изменениях пользователям, использующим монитор, устанавливаем отдельные стили для выделенной опции:
Связывание состояния и стиля – хороший прием, который прямо синхронизирует функциональность и ее представление.
Фильтрация опций
Хорошая фильтрация должна прощать пользователю мелкие опечатки и перепутанные буквы.
Как вы помните, данные, из которых составляется список предложений находятся в элементах option
внутри скрытого селекта.
Когда нужно отобрать опции, соответствующие пользовательскому вводу, мы вызываем метод getOptions()
.
Метод принимает в качестве параметра текст, введенный пользователем. Затем он перебирает все элементы option
и сравнивает их текст с пользовательским.
Для проверки мы используем метод indexOf()
, который ищет подстроку в строке. То есть пользователь может ввести только часть названия страны и все равно получит подходящие предложения.
Перед сравнением все значения приводятся к нижнему регистру, а начальные и конечные пробелы обрезаются (метод trim
). Таким образом, пользователь может использовать и строчные, и прописные символы – например, у него может быть включен режим Caps Lock.
Все подходящие опции добавляются в массив matches
, который будет использован для рендера меню.
Поддержка эндонимов и опечаток
Пятый принцип инклюзивного дизайна гласит – "Предоставь выбор". Так что мы можем позволить пользователям использовать эндонимы.
Прежде всего, их нужно как-то обозначить. Например, в data
-атрибуте элемента option
.
Теперь изменим немного функцию фильтрации и добавим в нее проверку альтернативных значений:
Вы можете сделать то же самое для распространенных опечаток в названиях стран, если хотите.
Демо-версию созданного контрола автодополнения можно найти здесь.
Комментарии