03 сентября 2021

☕ Доступный автокомплит с нуля на JavaScript

Frontend-разработчик в Foquz. https://www.cat-in-web.ru/
Руководство по созданию компонента автодополнения с учетом всех требований доступности.
☕ Доступный автокомплит с нуля на JavaScript
Эта статья – отрывок из книги Адама Сильвера Form Design Patterns из третьей главы «Формы бронирования авиабилетов», в которой рассматриваются способы, позволяющие пользователю указать страну назначения.

К сожалению, нативные HTML контролы форм не подходят для такого типа взаимодействия, поэтому нам придется писать собственный автокомплит с нуля.

Важное предупреждение: это один из самых сложных UI-компонентов, с которыми вам когда-либо приходилось сталкиваться. Он намного сложнее, чем кажется.

Контрол автодополнения отображает список с тремя предложениями стран. Вторая опция выделена.
Контрол автодополнения отображает список с тремя предложениями стран. Вторая опция выделена.

Контрол автокомплита должен показывать список предложений, соответствующих тексту, который ввел пользователь. По мере ввода список изменяется. Можно кликнуть по одному из предложений, чтобы быстро завершить ввод, или же вводить текст дальше для получения более подходящих вариантов.

Базовая разметка

Чтобы элемент работал при отключенном JavaScript, следует начать с нативных HTML-контролов.

Радио-кнопки не подходят, потому что в нашем списке слишком много опций. Search box [разбирается в книге ранее] медленный и может выдавать нулевой результат. Datalist слишком забагованный. В общем, вариантов не остается, придется воспользоваться обычным селектом.

Базовая разметка контрола автодополнения с использованием нативного элемента select
        <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>
    

Улучшенная разметка

Если же JavaScript доступен, мы можем воспользоваться конструктором Autocomplete() [который будет написан позже], чтобы сгенерировать более продвинутую верстку.

Улучшенная разметка контрола автодополнения при доступном JavaScript
        <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, не сломав возможность отправки его значения на сервер [при отправке формы], нужно сделать следующее:

  1. Добавить класс visually-hidden, чтобы спрятать элемент от пользователей. Подробнее о паттерне visually-hidden
  2. Добавить атрибут aria-hidden="true", чтобы спрятать его от скринридеров.
  3. Добавить атрибут 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.

Обработка пользовательского ввода

Когда пользователь вводит текст в поле ввода, мы должны отслеживать каждое нажатие на клавиши.

        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-стоп, как говорит спецификация 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, или кликнув за его пределами.

        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));
    

Но это не работает в iOS 10 из-за проблем с событием blur. Оно некорректно вызывается, если пользователь закрывает экранную клавиатуру, так что в меню предложений все равно нельзя попасть.

Есть другое решение.

Скрытие меню по нажатию на Tab

Вместо того, чтобы прятать меню при потере фокуса полем ввода, мы можем использовать событие keydown и отслеживать нажатия на клавишу 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).

Последнее нестандартное свойство разрешает импульсную прокрутку (momentum scrolling) в iOS. Таким образом, список с предложениями будет прокручиваться так же, как и все остальные элементы.

Выбор опции

Для прослушивания кликов по опциям мы будем использовать делегирование событий. Это эффективнее, чем добавлять слушатель к каждой опции.

        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, который будет использован для рендера меню.

Поддержка эндонимов и опечаток

Эндонимы – это "местные" названия географических объектов. Например, в английском языке Германия – это Germany, а в немецком Deutschland.

Пятый принцип инклюзивного дизайна гласит – "Предоставь выбор". Так что мы можем позволить пользователям использовать эндонимы.

Прежде всего, их нужно как-то обозначить. Например, в 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;
};

    

Вы можете сделать то же самое для распространенных опечаток в названиях стран, если хотите.

***

Демо-версию созданного контрола автодополнения можно найти здесь.

Источники

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ