☕ Доступный автокомплит с нуля на JavaScript
Руководство по созданию компонента автодополнения с учетом всех требований доступности. Эта статья – отрывок из книги Адама Сильвера Form Design Patterns из третьей главы «Формы бронирования авиабилетов», в которой рассматриваются способы, позволяющие пользователю указать страну назначения. К сожалению, нативные HTML контролы форм не подходят для такого типа взаимодействия, поэтому нам придется писать собственный автокомплит с нуля. Важное предупреждение: это один из самых сложных UI-компонентов, с которыми вам когда-либо приходилось сталкиваться. Он намного сложнее, чем кажется. Контрол автодополнения отображает список с тремя предложениями стран. Вторая опция выделена. Контрол автокомплита должен показывать список предложений, соответствующих тексту, который ввел пользователь. По мере ввода список изменяется. Можно кликнуть по одному из предложений, чтобы быстро завершить ввод, или же вводить текст дальше для получения более подходящих вариантов. Чтобы элемент работал при отключенном JavaScript, следует начать с нативных HTML-контролов. Радио-кнопки не подходят, потому что в нашем списке слишком много опций. Search box [разбирается в книге ранее] медленный и может выдавать нулевой результат. Datalist слишком забагованный. В общем, вариантов не остается, придется воспользоваться обычным селектом. Базовая разметка контрола автодополнения с использованием нативного элемента select Если же JavaScript доступен, мы можем воспользоваться конструктором Улучшенная разметка контрола автодополнения при доступном JavaScript Чтобы спрятать элемент Такое скрытие намного лучше, чем использование Мы переместили атрибут Обратите внимание, у текстового поля нет атрибута SVG-иконка размещается на текстовом поле с помощью CSS. В Internet Explorer SVG-элемент по умолчанию доступны для фокусировки с клавиатуры, поэтому устанавливаем атрибут Пользователи без ограничений по зрению сами увидят, когда появляется список с предложенными вариантами, но скринридер этого не увидит, не выходя за пределы текстового поля. Чтобы предоставить всем пользователям одинаковый опыт (первый принцип инклюзивного дизайна), мы будем использовать live region [описано в главе «Форма оформление заказа»]. После создания меню в live region (элемент с атрибутом Так как эта информация нужна только для пользователей скринридеров, мы скрываем ее от других пользователей с помощью класса Когда пользователь вводит текст в поле ввода, мы должны отслеживать каждое нажатие на клавиши. Объект С помощью конструкции Вместо того, чтобы отфильтровывать клавиши, которые нас не интересуют, можно было бы, наоборот, указать те, которые нам нужны. Но тогда пришлось бы перечислять очень много кодов и было бы очень просто что-то забыть. В основном нас интересуют две последних секции Метод Контрол автокомплита является составным: он содержит несколько интерактивных элементов внутри себя, на которых можно сфокусироваться. Например, пользователи могут печатать в текстовом поле, а затем перемещаться по меню для выбора нужной опции. У таких сложных контролов должен быть только один tab-стоп, как говорит спецификация WAI-ARIA Authoring Practices 1.1: Основное соглашение о навигации с клавиатуры, общее для всех платформ, заключается в том, что клавиши tab и shift+tab перемещают фокус с одного компонента пользовательского интерфейса на другой, в то время как другие клавиши, в первую очередь клавиши стрелок, перемещают фокус внутри компонентов, если они включают несколько интерактивных элементов. Путь, по которому перемещается фокус при нажатии на tab, – это последовательность табов (tab sequence), или кольцо табов (tab ring). Группа радио-кнопок – это тоже составной контрол. Как только происходит фокусировка на первой радио-кнопке, пользователь может перемещаться между ними с помощью кнопок-стрелок. Нажатие на Вернемся к автокомплиту. Фокус на поле ввода устанавливается естественным образом при нажатии на Многие компоненты используют атрибут aria-activedescendant как альтернативный способ убедиться, что они имеют только один tab-стоп. Этот атрибут сохраняет фокус на контейнере компонента и ссылается на текущий активный элемент. Для нашего компонента это не подходит, так как поле ввода это соседний элемент для меню – а не его родитель. Событие К сожалению, программное перемещение фокуса с поля на меню тоже вызывает событие Решением может стать использование Но это не работает в iOS 10 из-за проблем с событием Есть другое решение. Вместо того, чтобы прятать меню при потере фокуса полем ввода, мы можем использовать событие keydown и отслеживать нажатия на клавишу Но в отличие от события Поэтому мы должны добавить еще обработчик кликов для всего документа и проверять, где именно сделан клик: Когда поле ввода находится в фокусе, нажатие на стрелку Вниз вызывает метод Если пользователь нажимает клавишу Вниз, ничего не напечатав, открывается полное меню со всеми опциями. Первая опция в списке получает фокус [метод То же самое происходит, если введенное значение точно совпадает с какой-либо опцией, хотя это бывает довольно редко. Большинство пользователей предпочитают выбирать значение из списка, так как это быстрее. В остальных случаях мы показываем только подходящие опции (если они есть) и также фокусируемся на первой опции в списке. Меню может состоять из сотен опций. Чтобы убедиться, что все его элементы видны, мы используем такие стили: Свойство Последнее нестандартное свойство разрешает импульсную прокрутку (momentum scrolling) в iOS. Таким образом, список с предложениями будет прокручиваться так же, как и все остальные элементы. Для прослушивания кликов по опциям мы будем использовать делегирование событий. Это эффективнее, чем добавлять слушатель к каждой опции. Обработчик события извлекает опцию, по которой кликнули ( Метод Те же самые действия выполняются, когда пользователь выбирает опцию с помощью клавиш Если фокус находится внутри меню, пользователь может перемещаться по нему с помощью клавиатуры. Для этого мы должны прослушивать событие Когда пользователь фокусируется на опции, нажимая клавиши Этот метод выполняет сразу несколько задач. Для начала он проверяет, выделена ли уже какая-то опция. Если да, то значение ее атрибута Затем для выбранной опции Так как меню имеет фиксированную высоту, новая активная опция может находиться за пределами видимой зоны. Мы проверяем это с помощью метода Далее сохраняем новую активную опцию, чтобы сослаться на нее в следующий раз при вызове метода. И наконец, устанавливаем фокус на нее, чтобы убедиться, что скринридеры оповещены о новом значении. Чтобы сообщить об изменениях пользователям, использующим монитор, устанавливаем отдельные стили для выделенной опции: Связывание состояния и стиля – хороший прием, который прямо синхронизирует функциональность и ее представление. Хорошая фильтрация должна прощать пользователю мелкие опечатки и перепутанные буквы. Как вы помните, данные, из которых составляется список предложений находятся в элементах Когда нужно отобрать опции, соответствующие пользовательскому вводу, мы вызываем метод Метод принимает в качестве параметра текст, введенный пользователем. Затем он перебирает все элементы Для проверки мы используем метод Перед сравнением все значения приводятся к нижнему регистру, а начальные и конечные пробелы обрезаются (метод Все подходящие опции добавляются в массив Эндонимы – это «местные» названия географических объектов. Например, в английском языке Германия – это Germany, а в немецком Deutschland. Пятый принцип инклюзивного дизайна гласит – «Предоставь выбор». Так что мы можем позволить пользователям использовать эндонимы. Прежде всего, их нужно как-то обозначить. Например, в Теперь изменим немного функцию фильтрации и добавим в нее проверку альтернативных значений: Вы можете сделать то же самое для распространенных опечаток в названиях стран, если хотите. *** Демо-версию созданного контрола автодополнения можно найти здесь. Базовая разметка
<div class="field"> <label for="destination"> <span class="field-label">Destination</span> </label> <select name="destination" id="destination"> <option value="">Select</option> <option value="1">France</option> <option value="2">Germany</option> <!-- … --> </select> </div>
Улучшенная разметка
Autocomplete()
[который будет написан позже], чтобы сгенерировать более продвинутую верстку.
<div class="field"> <label for="destination"> <span class="field-label">Destination</span> </label> <select name="destination" aria-hidden="true" tabindex="-1" class="visually-hidden"> <!-- здесь опции --> </select> <div class="autocomplete"> <input aria-owns="autocomplete-options--destination" autocapitalize="none" type="text" autocomplete="off" aria-autocomplete="list" role="combobox" id="destination" aria-expanded="false"> <svg focusable="false" version="1.1" xmlns="http://www.w3.org/2000/svg"> <!-- контент SVG --> </svg> <ul id="autocomplete-options--destination" role="listbox" class="hidden"> <li role="option" tabindex="-1" aria-selected="false" data-option-value="1" id="autocomplete_1"> France </li> <li role="option" tabindex="-1" aria-selected="true" data-option-value="2" id="autocomplete_2"> Germany </li> <!-- остальные опции --> </ul> <div aria-live="polite" role="status" class="visually-hidden"> 13 results available. </div> </div> </div>
Прячем селект без нарушения его доступности
seleсt
, не сломав возможность отправки его значения на сервер [при отправке формы], нужно сделать следующее:visually-hidden
, чтобы спрятать элемент от пользователей. Подробнее о паттерне visually-hiddenaria-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"
не позволяет автоматически превращать первую букву в заглавную. [Подробнее этот момент разбирается в четвертой главе книги]focusable="false".
Атрибуты меню
role="list"
определяет меню как список опцией, каждая опция имеет атрибут role="option"
.aria-selected="true"
сообщает пользователю, какая опция в списке выделена в данный момент. Значение может переключаться между true
и false
.tabindex="-1"
означает, что фокус на опциях может быть установлен программно, при нажатии пользователем определенных клавиш. Этим мы займемся чуть позже.data-option-value
содержит значение конкретной опции. Когда пользователь нажимает на нее, значение элемента select
будет обновлено на значение опции. Таким образом мы синхронизируем видимый интерфейс и скрытый контрол, который будет отправлен на сервер.Используем live region, чтобы скринридеры знали, когда отображается список опций
aria-live
) будет указано количество доступных результатов («13 результатов доступно»). Имея эту информацию, пользователь может самостоятельно принять решение: продолжить печатать, чтобы конкретизировать свой выбор, или посмотреть список и выбрать предложение из него.visually-hidden
.Обработка пользовательского ввода
Autocomplete.prototype.createTextBox = function() { this.textBox.on('keyup', $.proxy(this, 'onTextBoxKeyUp')); }; Autocomplete.prototype.onTextBoxKeyUp = function(e) { switch (e.keyCode) { case this.keys.esc: case this.keys.up: case this.keys.left: case this.keys.right: case this.keys.space: case this.keys.enter: case this.keys.tab: case this.keys.shift: // игнорировать, иначе появится меню break; case this.keys.down: this.onTextBoxDownPressed(e); break; default: this.onTextBoxType(e); } };
this.keys
– это коллекция числовых кодов, соответствующих конкретным клавишам. Мы специально используем именованные поля объекта, чтобы избежать магических чисел и сделать код понятнее.switch
отфильтровываем нажатия на клавиши Escape
, Enter
, Tab
, Shift
, Пробел
и стрелки Вверх
, Влево
и Вправо
. Если этого не сделать, то запустится код из секции default
, и откроется меню с предложениями.case
: нажатие на клавишу Down
(стрелка вниз) [будет разобран чуть позже] и дефолтный кейс – нажатие на обычные клавиши (буквы, цифры, знаки препинания и все такое). В последнем случае мы вызываем метод onTextBoxType()
:
Autocomplete.prototype.onTextBoxType = function(e) { // опции отображаются только если в поле что-то введено if(this.textBox.val().trim().length > 0) { // получаем список подходящих опций var options = this.getOptions(this.textBox.val().trim().toLowerCase()); // рендерим список this.buildMenu(options); // показываем меню this.showMenu(); // обновляем live region this.updateStatus(options.length); } // обновляем значение элемента select this.updateSelectBox(); };
getOptions()
[описан чуть дальше в тексте] отфильтровывает только те опции, которые совпадают с пользовательским вводом.Единый tab-стоп для составных контролов
Tab
должно переводить фокус на следующий элемент в последовательности табов [а не на следующую радио-кнопку в группе].Tab
. Далее пользователь может использовать стрелки, чтобы перемещаться по меню опций. Нажатие на Tab должно приводить к закрытию меню (если оно открыто), чтобы оно не закрывало контент, находящийся под ним.ARIA activedescendant не работает
Скрытие меню по событию onblur не работает
onblur
возникает, когда элемент теряет фокус. В нашем случае мы можем прослушивать это событие на текстовом поле – оно происходит, если пользователь покидает поле, нажав Tab
, или кликнув за его пределами.
this.textBox.on('blur', function(e) { // спрятать меню });
blur
, что приводит к скрытию меню и делает его недоступным для пользователей клавиатуры.setTimeout()
. Мы устанавливаем задержку, и если за это время пользователь переместит фокус на список, то мы сбросим таймер с помощью clearTimeout
и не будем закрывать меню.
this.textBox.on('blur', $.proxy(function(e) { // задержка до закрытия меню this.timeout = window.setTimeout(function() { // закрыть меню }, 100); }, this)); this.menu.on('focus', $.proxy(function(e) { // отмена закрытия меню window.clearTimeout(this.timeout); }, this));
blur
. Оно некорректно вызывается, если пользователь закрывает экранную клавиатуру, так что в меню предложений все равно нельзя попасть.Скрытие меню по нажатию на Tab
Tab
.
this.textBox.on('keydown', $.proxy(function(e) { switch (e.keyCode) { case this.keys.tab: // спрятать меню break; } }, this));
blur
, это решение не учитывает случаи, когда пользователь кликает где-то вне поля ввода, из-за чего оно теряет фокус.
$(document).on('click', $.proxy(function(e) { if(!this.container[0].contains(e.target)) { // спрятать меню } }, this));
Нажатие на стрелку Вниз для перемещения в меню
onTextBoxDownPressed()
, который перемещает пользователя в меню.
Autocomplete.prototype.onTextBoxDownPressed = function(e) { var option; var options; var value = this.textBox.val().trim(); /* Если значение пустое или точно совпадает с опцией, показываем целое меню */ if(value.length === 0 || this.isExactMatch(value)) { // получаем список опций options = this.getAllOptions(); // рендерим меню this.buildMenu(options); // показываем меню this.showMenu(); // берем первую опцию option = this.getFirstOption(); // подсвечиваем первую опцию this.highlightOption(option); /* Если значение есть и оно не совпадает с опцией, показываем только подходящие опции */ } else { // получаем список опций options = this.getOptions(value); // если есть подходящие опции if(options.length > 0) { // рендерим меню this.buildMenu(options); // показываем меню this.showMenu(); // получаем первую опцию option = this.getFirstOption(); // подсвечиваем первую опцию this.highlightOption(option); } } };
highlightOption
будет рассмотрен далее].Прокрутка меню
.autocomplete [role=listbox] { max-height: 12em; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
max-height
ограничивает максимальную высоту меню. Если контент превышает эти размеры, то появляется вертикальный скролл (overflow-y: scroll
).Выбор опции
Autocomplete.prototype.createMenu = function() { this.menu.on('click', '[role=option]', $.proxy(this, 'onOptionClick')); }; Autocomplete.prototype.onOptionClick = function(e) { var option = $(e.currentTarget); this.selectOption(option); };
e.currentTarget
) и передает ее методу selectOption
.
Autocomplete.prototype.selectOption = function(option) { var value = option.attr('data-option-value'); this.setValue(value); this.hideMenu(); this.focusTextBox(); };
selectOption
извлекает значение опции из атрибута data-option-value
, передает его методу setValue
, который устанавливает его в поле ввода и в скрытый select
. Меню закрывается, фокус перемещается на поле ввода.Пробел
или Enter
.Взаимодействие с меню с клавиатуры
keydown
.
Autocomplete.prototype.createMenu = function() { this.menu.on('keydown', $.proxy(this, 'onMenuKeyDown')); }; Autocomplete.prototype.onMenuKeyDown = function(e) { switch (e.keyCode) { case this.keys.up: // ... break; case this.keys.down: // ... break; case this.keys.enter: // ... break; case this.keys.space: // ... break; case this.keys.esc: // ... break; case this.keys.tab: // ... break; default: this.textBox.focus(); } };
Клавиша
Действие
Up
Если первая опция находится в фокусе, то установить фокус на текстовое поле. Иначе установить фокус на предыдущую опцию в списке.
Down
Установить фокус на следующую опцию. Если активная опция последняя в списке, то ничего не делать.
Tab
Спрятать меню.
Enter or Space
Выбрать опцию, которая сейчас активна, и установить фокус на поле ввода.
Escape
Спрятать меню и установить фокус на поле ввода.
Everything else
Установить фокус на поле ввода, чтобы пользователь мог продолжить печатать.
Выделение активной опции
Вверх
и Вниз
, вызывается метод highlightOption()
.
Autocomplete.prototype.highlightOption = function(option) { // если активная опция уже есть if(this.activeOptionId) { // получить активную опцию var activeOption = this.getOptionById(this.activeOptionId); // убрать с нее выделение activeOption.attr('aria-selected', 'false'); } // установить выделение для новой активной опции option.attr('aria-selected', 'true'); // Если опция не видна в меню if(!this.isElementVisible(option.parent(), option)) { // прокрутить меню, чтобы опция была видна option.parent().scrollTop(option.parent().scrollTop() + option.position().top); } // сохранить идентификатор текущей активной опции this.activeOptionId = option[0].id; // переместить фокус на нее option.focus(); };
aria-selected
изменяется на false
. Это гарантирует, что скринридеры узнают об изменениях.aria-selected
изменяется на true
.isElementVisible()
. Если опция не видна, регулируем прокрутку с помощью scrollTop
.
.autocomplete [role=option][aria-selected="true"] { background-color: #005EA5; border-color: #005EA5; color: #ffffff; }
Фильтрация опций
option
внутри скрытого селекта.
<select> <option value="">Select</option> <option value="1">France</option> <option value="2">Germany</option> </select>
getOptions()
.
Autocomplete.prototype.getOptions = function(value) { var matches = []; // Цикл по всем элементам option this.select.find('option').each(function(i, el) { el = $(el); // если у опции есть значение // и текст опции совпадает с пользовательским текстом if(el.val().trim().length > 0 && el.text().toLowerCase().indexOf(value.toLowerCase()) > -1) { // добавляем ее в массив совпадений matches.push({ text: el.text(), value: el.val() }); } }); return matches; };
option
и сравнивает их текст с пользовательским.indexOf()
, который ищет подстроку в строке. То есть пользователь может ввести только часть названия страны и все равно получит подходящие предложения.trim
). Таким образом, пользователь может использовать и строчные, и прописные символы – например, у него может быть включен режим Caps Lock.matches
, который будет использован для рендера меню.Поддержка эндонимов и опечаток
data
-атрибуте элемента option
.
<select> <!-- другие опции --> <option value="2" data-alt="Deutschland">Germany</option> <!-- другие опции --> </select>
Autocomplete.prototype.getOptions = function(value) { var matches = []; // Цикл по элементам option this.select.find('option').each(function(i, el) { el = $(el); // если у опции есть значение // и текст опции совпадает с пользовательским текстом // или значение атрибута data-alt совпадает с пользовательским текстом if( el.val().trim().length > 0 && el.text().toLowerCase().indexOf(value.toLowerCase()) > -1 || el.attr('data-alt') && el.attr('data-alt').toLowerCase().indexOf(value.toLowerCase()) > -1 ) { // добавляем ее в массив совпадений matches.push({ text: el.text(), value: el.val() }); } }); return matches; };
- 9 views
- 0 Comment