Как создавать пользовательские виджеты форм

Существует много случаев, когда возможностей стандартных виджетов HTML форм недостаточно. Если вы хотите придать лучший вид каким-либо виджетам как, например, <select>, или вы хотите создать особое поведение виджета, то у вас нет другого выбора, кроме как создать собственные виджеты.

В этой статье мы рассмотрим как создать такой виджет. Для этого мы возьмем пример: переделка элемента <select> .

Замечание: Мы сфокусируемся на создании виджетов, а не на том чтобы сделать код  универсальным и многоразовым; поэтому будут использоваться некоторый нетривиальный JavaScript код и манипуляции DOM в неизвестном контексте, что выходит за рамки этой статьи.

Дизайн, структура и семантика

В начале создания пользовательского виджета необходимо обрисовать что именно вы хотите. Это сэкономит драгоценное время. Особенно важно четко определить все состояния вашего виджета. Чтобы это сделать, лучше начать с существущего виджета, состояния и реакции которго хорошо известны, так что вы сможете просто подражать им насколько это возможно.

В нашем примере мы будем переделывать элемент <select>. Вот такой результат мы хотим достичь:

The three states of a select box

Этот скриншот показывает три основных состояния нашего виджета: нормальное состояние (слева); активное состояние (посередине) и развернутое состояние (справа).

С точки зрения реакций нужно чтобы наш виджет взаимодействовал как с мышью, так и с клавиатурой, так же как и стандартный виджет. Давайте сначала определим, как виджет приходит в каждое состояние:

Виджет в нормальном состоянии когда:
  • страница загружается
  • виджет был активным и пользователь кликает где-то вне виджета
  • виджет был активным и пользователь перемещает фокус на другой виджет при помощи клавиатуры

Замечание: Перемещение фокуса по странице обычно осуществялется клавишей "tab", но не везде. Например в Safari циклический переход между ссылками на странице осуществляется по усмолчанию комбинацией Option+Tab.

Виджет в активном состоянии когда:
  • пользователь кликает на него
  • пользователь нажимает клавишу Tab, и он получает фокус
  • виджет был в развернутом состоянии и позователь кликает на виджет.
Виджет в развернутом состоянии:
  • виджет в любом другом состоянии и пользователь кликает на него

Теперь, когда мы знаем, как изменяются состояния, важно определить, как изменить значение виджета:

Значение изменяется когда:
  • пользователь кликает на один-из-вариантов когда виджет в развернутом состоянии
  • пользователь нажимает клавиши стрелка вверх или вниз когда виджет в активном состоянии

Наконец, давайте определим, как будут вести себя варианты виджета:

  • когда виджет развернут, выбранный вариант подсвечен
  • когда курсор мыши находится над вариантом, он подсвечен и ранее подсвеченный вариант возвращается в его обычное состояние

Для нашего примера остановимся на этом; но, если вы внимательный читатель, вы заметите, что некоторые реакции отсутствуют. Например, как вы думаете, что произойдет если пользователь нажмет клавишу "tab" когда виджет в развернутом состоянии? Ответом будет... ничего. OK, правильная реакция кажется очевидной, но поскольку она не определена в наших спецификациях, то очень легко пропустить реализацию этой реакции. Это особенно верно для командной работы, когда те, кто опеределяет какими должны быть реакции виджета сами не реализуют их.

Другой забавный пример: что произойдет, если пользователь нажмет клавишу вверх или вниз когда виджет находитися в развернутом состоянии? Это немного сложнее. Если вы предположите, что активное и развернутое состояние полностью различны, то ответом снова будет "ничего не произойдет" , потому что мы не определили никаких взаимодействий с клавиатурой в открытом состоянии. С другой стороны, если вы предположите, что активное и развернутое состояние немного похожи, значение может изменится, но выбранный вариант точно не будет соответственно подсвечен, опять же потому, что мы не определили никаких действий с клавиатуры над вариантами когда виджет находится в развернутом состоянии (мы определили только то, что произойдет, когда виджет развернется, но ничего более).

В нашем примере пропущенные спецификации очевидны, так что мы с ними справимся, но это может стать реальной проблемой для новых экзотических виджетов, когда никто не имеет ни малейшего представления о том как они должны реагировать. Всегда лучше потратить время на этом этапе дизайна, потому что если вы плохо определите, или забудете определить реакцию виджета, то будет очень сложно изменять ее, когда пользователи уже привыкнут. Если у вас есть сомнения - спросите мнения у окружающих, и, если позволяет бюджет,  не стесняйтесь выполнять пользовательские тесты. Этот процесс называется UX Design (Дизайн взаимодействия с пользователем). Если вы хотите узнать больше об этой теме, вам следует посетить следующие полезные ресурсы:

Замечание:  Также, в большинстве систем, есть способ развернуть элемент <select> чтобы посмотреть все доступные варианты (это то-же что кликнуть мышью элемент <select> ).  Это возможно комбинацией Alt+Стрелка вниз для Windows и не реализовано в нашем примере —но это будет просто сделать, так как механизм уже реализован дл события click.

Определение структуры и семантики HTML

Теперь, когда основной функционал виджета определен, пора начать создание виджета. Первым делом определим его HTML структуру и придадим основную семантику. Вот все что нам нужно чтобы переделать элемент <select>:

<!-- Это основной контейнер для нашего виджета.
     Аттрибут tabindex позволяет пользователю переместить фокус на виджет. 
     Позже мы увидим, что лучше его установить через JavaScript. -->
<div class="select" tabindex="0">
  
  <!-- Этот контейнер послужит для отображения текущего значения виджета -->
  <span class="value">Cherry</span>
  
  <!-- Этот контейнер содержит все варинты. доступные для нашего виджета.
       Так как это список, то есть смысл использовать элемент ul. -->
  <ul class="optList">
    <!-- Каждый вариант содержит то значение, которое будет отображено, позже мы увидим 
         как получить то значение, которое будет отппралено вместе с данными формы -->
    <li class="option">Cherry</li>
    <li class="option">Lemon</li>
    <li class="option">Banana</li>
    <li class="option">Strawberry</li>
    <li class="option">Apple</li>
  </ul>

</div>

Обратите внимание на использование имен классов: они описывают каждый соответствующий элемет независимо от фактически используемых базовых элементов HTML. Важно быть уверенными что нам не придется жестко привязывать наши CSS и JavaScript к HTML структуре,тогда мы сможем позже вносить изменения не нарушая код виджета. Например, если вы захотите создать эквивалент элемента <optgroup>.

Создание внешнего вида с помощью CSS

Теперь у нас есть структура и мы можем заняться дизайном нашего виджета. Весь смысл создания нашего собственного виджета в том, чтобы мы могли придать ему такой внешний вид, как мы захотим. Поэтому мы разделим нашу работу с CSS на две части: в первой части будут правила CSS абсолютно необходимые чтобы реакции нашего виджета были как у элемента <select> , а вторая чать будет состоять из красивеньких рюшечек, чтобы виджет выглядел так, как нам нравится.

Обязательные стили

Обязательные стили - это те, которые необходимы для обработки трех состояний нашего виджета..

.select {
  /* Это создаст контекст позиционирования для списка вариантов */
  position: relative;
 
  /* Это сделает наш виджет частью текстового потока и одновременно сделает его 
     изменяемого размера */
  display : inline-block;
}

Еще нам нужен дополнительный класс active, чтобы определить, как будет выглядеть наш виджет в активном состоянии. Так как наш виджет может находится в фокусе, то мы укажем этот стиль еще и для псевдокласса :focus чтобы быть уверенными, что виджет будет вести себя одинаково.

.select .active,
.select:focus {
  outline: none;
 
  /* Это свойство - box-shadow - необязательно, однако нам важно видеть активное состояние
    т.к. мы используем его по умолчанию. Можете спокойно его переопределить. */
  box-shadow: 0 0 3px 1px #227755;
}

Теперь давайте стилизуем список опций:

/* Селектор .select здесь применен для удобства (синтаксический сахар), чтобы быть уверенными,
   что определяемые классы находятся в нашем виджете. */
.select .optList {
/* Это позволит нам быть уверенными, что список вариантов будет показан ниже значения 
   и вне HTML потока */
  position : absolute;
  top      : 100%;
  left     : 0;
}

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

.select .optList.hidden {
  /* Это самый простой из доступных способов путь скрыть список, 
     а о доступности мы еще поговрим в конце */
  max-height: 0;
  visibility: hidden;
}

Украшательства

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

.select {
  /* Все размеры будут выражены в em по соображениям удобства
     (чтобы быть уверенными, что виджет будет изменять размер если пользователь будет 
     использовать увеличение в текстовом режиме браузера). Вычисления сделаны из расчета что
     1em == 16px что является умолчанием для большинства браузеров.
     Если вы затрудняетесь с преобразованием px в em, попробуйте http://riddle.pl/emcalc/ */
  font-size   : 0.625em; /* это (10px) новый размер шрифта для нашего контекста для значения
                            em в исходном контексте */
  font-family : Verdana, Arial, sans-serif;

  -moz-box-sizing : border-box;
  box-sizing : border-box;

  /* Нам нужно добавить дополнительное пространство для стрелки вниз */
  padding : .1em 2.5em .2em .5em; /* 1px 25px 2px 5px */
  width   : 10em; /* 100px */

  border        : .2em solid #000; /* 2px */
  border-radius : .4em; /* 4px */
  box-shadow    : 0 .1em .2em rgba(0,0,0,.45); /* 0 1px 2px */
  
  /* Первое объявление - для бразуеров не поддерживающих линейный градиент.
     Второе объявление - потому что основанные на WebKit браузеры еще не избавились от префикса в нем.
     Если вам нужна поддержка устаревших браузеров, попробуйте http://www.colorzilla.com/gradient-editor/ */
  background : #F0F0F0;
  background : -webkit-linear-gradient(90deg, #E3E3E3, #fcfcfc 50%, #f0f0f0);
  background : linear-gradient(0deg, #E3E3E3, #fcfcfc 50%, #f0f0f0);
}

.select .value {
  /* Так как значение может быть шире, чем наш виджет, нужно быть уверенными, что оно не изменит
     ширину виджета */
  display  : inline-block;
  width    : 100%;
  overflow : hidden;

  vertical-align: top;

  /* И, если содержимое слишком длинное, лучше иметь красивенькие точечки. */
  white-space  : nowrap;
  text-overflow: ellipsis;
}

Нам не нужен дополнительный элемент, чтобы создать стрелку вниз; вместо этого мы используем псевдоэлемент :after. Также её можно создать при помощи простого фонового изображения в классе select.

.select:after {
  content : "▼"; /* Мы используем Unicode символ U+25BC; смотрите http://www.utf8-chartable.de */
  position: absolute;
  z-index : 1; /* Важно чтобы стрелка не перекрывала элементы списка */
  top     : 0;
  right   : 0;

  -moz-box-sizing : border-box;
  box-sizing : border-box;

  height  : 100%;
  width   : 2em;  /* 20px */
  padding-top : .1em; /* 1px */

  border-left  : .2em solid #000; /* 2px */
  border-radius: 0 .1em .1em 0;  /* 0 1px 1px 0 */

  background-color : #000;
  color : #FFF;
  text-align : center;
}

Далее стилизуем список вариантов:

.select .optList {
  z-index : 2; /* Мы явно сказали, что список вариантов всегда будет перекрывать стрелку вниз */

  /* это сбросит значения стиля по умолчанию для элемента ul */
  list-style: none;
  margin : 0;
  padding: 0;

  -moz-box-sizing : border-box;
  box-sizing : border-box;

  /* Это для того, чтобы убедиться что если значения будут короче виджета
     то список вариантов останется таким же по размеру как и сам виджет */
  min-width : 100%;

  /* В случае, если список слишком длинный, его содержимое не будет помещаться по вертикали 
     (что автоматически добавит полосу прокрутки), но этого никогда не произойдет по горизонтали
     (потому что мы не установили ширину и содержимое списка будет регулировать ее 
     автоматически. Если это будет невозможно - содержимое будет обрезано.) */
  max-height: 10em; /* 100px */
  overflow-y: auto;
  overflow-x: hidden;

  border: .2em solid #000; /* 2px */
  border-top-width : .1em; /* 1px */
  border-radius: 0 0 .4em .4em; /* 0 0 4px 4px */

  box-shadow: 0 .2em .4em rgba(0,0,0,.4); /* 0 2px 4px */
  background: #f0f0f0;
}

Для вариантов нам нужно добавить класс highlight чтобы сделать возможным индентифицировать значение которе пользователь выберет (или выбрал).

.select .option {
  padding: .2em .3em; /* 2px 3px */
}

.select .highlight {
  background: #000;
  color: #FFFFFF;
}

Итак, вот результат с нашими тремя состояниями:

Основное состояние Активное состояние Развернутое состояние
Посмотреть исходный код

Оживи свой виджет с JavaScript

Теперь, когда наш дизайн и структура готовы, мы можем написать код на JavaScript чтобы виджет действительно заработал.

Предупреждение: Следующий код представлен в образовательных целях и не может быть использован как-есть. Помимо прочего, как мы убедимся, он не пригоден для дальнейшего развития и не будет работать в устаревших браузерах. В нем также есть избыточность которую необходимо оптимизировать использования в рабочем режиме.

Замечание: Создание многократно используемых виджетов может быть немного сложнее. W3C Web Component draft является одним из ответов на этот конкретный вопрос. The X-Tag project попытка реализовать эту спецификацию; пожалуйста, посмотрите этот проект.

Почему он не работает?

Прежде чем мы начнем, запомните одну важную вещь о JavaScript: в браузере это ненадежная технология. Когда вы создаете пользовательские виджеты, вы будете полагаться на JavaScript потому что это необходимое звено для связки. Однако во многих случаях  JavaScript невозможно запустить в браузере:

  • Пользователь отключил JavaScript: Это самый редкий случай; сейчас очень мало людей отключают JavaScript.
  • Скрипт не загружается. Это один из самых распространенных случаев, особенно в мобильном мире, где сеть не очень надежная.
  • Скрипт глючит.Вы должны всегда учитывать эту возможность.
  • Скрипт конфликтует со сторонним скриптом. Это может случиться со скриптами отслеживания или любыми закладурками (bookmarklets), которые использует пользователь.
  • Скрипт конфликтует с расширением браузера или зависит от него (такими как расширение NoScript в Firefox, или расширенние NotScripts в Chrome).
  • Пользователь использует устаревший браузер, и одна из требуемых функций не поддерживается. Это часто случается, когда вы используете передовые API.

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

В нашем примере, если наш JavaScript код не работает, мы используем стандартный элемент <select>. Для этого, нам нужны две вещи.

Во-первых нам нужно добавить стандартный элемент <select> перед каждым использованием нашего пользовательского виджета. Это также необходимо, чтобы отправить данные из нашего пользовательского виджета вместе с остальными данными формы; подробнее рассмотрим это позже.

<body class="no-widget">
  <form>
    <select name="myFruit">
      <option>Cherry</option>
      <option>Lemon</option>
      <option>Banana</option>
      <option>Strawberry</option>
      <option>Apple</option>
    </select>

    <div class="select">
      <span class="value">Cherry</span>
      <ul class="optList hidden">
        <li class="option">Cherry</li>
        <li class="option">Lemon</li>
        <li class="option">Banana</li>
        <li class="option">Strawberry</li>
        <li class="option">Apple</li>
      </ul>
    </div>
  </form>

</body>

Во-вторых нам нужно два новых класса, чтобы скрыть ненужные элементы (то есть  "настоящие" элементы <select>, если скрипт запустился, или наш пользовательский виджет, если скрипт не запустился). По умолчанию наш HTML код скрывает наш пользовательский виджет.

.widget select,
.no-widget .select {
  /* Этот CSS селектор значит:
     - или мы присваиваем классу body значение "widget" и таким образом мы скрываем элемент <select>
     - или мы не меняем класс body, тогда класс body остается в значении "no-widget",
       и элементы, чей класс "select" будут скрыты */
  position : absolute;
  left     : -5000em;
  height   : 0;
  overflow : hidden;
}

Теперь нам нужен модуль JavaScript, чтобы определить, запущен скрипт или нет. Этот модуль очень простой: если наш скрипт запустится во время загрузки страницы, то он удалит класс класс no-widget и добавит класс widget, чем поменяет видимость элемента  <select> и нашего пользовательского виджета.

window.addEventListener("load", function () {
  document.body.classList.remove("no-widget");
  document.body.classList.add("widget");
});
Без JS С JS
Посмотреть исходный код

Замечание: Если вы действительно хотите сделать свой код универсальным и многоразовым, то вместо переключения классов гораздо лучше просто добавить класс элементам <select> чтобы их скрыть, и динамически добавлять дерево DOM представляющее пользовательский виджет после каждого элемента <select> на странице.

Облегчение работы

В коде который мы собираемся написать, для выполнения всех необходимых действий мы будем использовать стандартный DOM API. Однако, хотя поддержка DOM API в браузерах стала гораздо лучше, все еще есть нюансы с устраевшеними браузерами  (особенно со старым добрым Internet Explorer).

Чтобы избежать неприятностей с устаревшими браузерами есть два способа: использовать отдельный фреймворк такой как jQuery, $dom, prototype, Dojo, YUI, и т.п., или самостоятельно реализовать недостающие функции которые вам нужны (что можно легко сделать через условную загрузку, например используя библиотеку yepnope).

Мы планируем использовать следующие функции (от самых рискованных до самых безопасных):

  1. classList
  2. addEventListener
  3. forEach (This is not DOM but modern JavaScript)
  4. querySelector and querySelectorAll

Помимо доступности этих специфических функций, остается еще одна проблема чтобы начать. Объект возвращаемый функцией querySelectorAll() имеет тип NodeList что отличается от Array. Это важно потому, что объекты  Array поддерживают функцию forEach, а NodeList не поддерживает. Так как  NodeList очень похож на Array и нам очень удобно использовать forEach, мы можем просто добавить forEach к объекту NodeList чтобы облегчить нам жизнь, например так:

NodeList.prototype.forEach = function (callback) {
  Array.prototype.forEach.call(this, callback);
}

Мы не шутили, когда сказали, что это легко сделать.

Создание процедур обработки событий

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

// Эта функция будет вызываться каждый раз, когда наш виджет будет деактивирован
// Ей передается один параметр
// select : DOM нода класса `select` который должен быть деактивирован
function deactivateSelect(select) {

  // Если виджет не активен, то и делать-то нечего
  if (!select.classList.contains('active')) return;

  // Получаем список опций для нашего виджета
  var optList = select.querySelector('.optList');

  // Закрываем список опций
  optList.classList.add('hidden');

  // и деактивируем сам виджет
  select.classList.remove('active');
}

// Эта функция бедт вызываться какждый раз, когда пользователь захочет (де)активровать наш виджет
// Ей передаются два параметра:
// select : DOM нода класса `select` для активации
// selectList : список всех DOM нод с классом `select`
function activeSelect(select, selectList) {

  // Если виджет активен, то и делать-то нечего
  if (select.classList.contains('active')) return;

  // Нам нужно отключить активное состояние всех наших виджетов
  // Так как функция deactivateSelect соответствует всем требованиям 
  // функции forEach мы вызываем ее без использования промежуточной анонимной функции
  selectList.forEach(deactivateSelect);

  // А теперь мы возвращаем активное состояние нужного виджета
  select.classList.add('active');
}

// Эта функция будет вызываться каждый раз, когда пользователь будет открывать/закрывать список вариантов
// Ей передается один параметр:
// select : DOM нода со списком для переключения состояния
function toggleOptList(select) {

  // Список хранится в виджете
  var optList = select.querySelector('.optList');

  // Мы меняем класс виджета чтобы показать/скрыть его
  optList.classList.toggle('hidden');
}

// Эта функция будет вызываться каждый раз, когда нам нужно подсветить вариант
// Ей передаются два параметра:
// select : DOM нода класса `select` содержащая вариант для подсветки
// option : DOM нода класса `option` для подсветки
function highlightOption(select, option) {

  // Мы получаем список всех вариантов доступных в нашем элементе
  var optionList = select.querySelectorAll('.option');

  // Мы удаляем подсветку всех вариантов
  optionList.forEach(function (other) {
    other.classList.remove('highlight');
  });

  // Подсвечиваем нужный вариант
  option.classList.add('highlight');
};

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

Далее мы связываем эти функции с соответствующими событиями:

// Мы обрабатываем событие при загрузке документа.
window.addEventListener('load', function () {
  var selectList = document.querySelectorAll('.select');

  // Каждый наш собственный виджет должен быть проинициализирован
  selectList.forEach(function (select) {

    // также как и его элементы `option`
    var optionList = select.querySelectorAll('.option');

    // Когда пользователь проводит мышью над элементом `option`, мы подсвечиваем этот вариант
    optionList.forEach(function (option) {
      option.addEventListener('mouseover', function () {
        // Замечание: использование переменных `select` и `option`
        // ограничено рамками нашей функции.
        highlightOption(select, option);
      });
    });

    // Когда позоватль кликает на наш виджет
    select.addEventListener('click', function (event) {
       // Замечание: использование переменной `select` 
       // ограничено рамками нашей функции. 

       // Мы переключаем видимость списка вариантов
      toggleOptList(select);
    });

    // Когда виджет получает фокус
    // Виджет получает фокус когда пользователь кликает на него
    // или переходит на него клавишей табуляции
    select.addEventListener('focus', function (event) {
      // Замечание: использование переменных `select` и `selectList`
      // ограничено рамками нашей функции.

      // Мы активируем наш виджет
      activeSelect(select, selectList);
    });

    // Когда виджет теряет фокус
    select.addEventListener('blur', function (event) {
      // Замечание: использование переменной `select`
      // ограничено рамками нашей функции.

      // Мы деактивируем виджет
      deactivateSelect(select);
    });
  });
});

В этот момент наш виджет будет изменятт состояние в соответствии с нашим дизайном, но не будет обновлять его значение. С этим мы разберемся дальше.

Пример
Посмотреть исходный код

Обработка значения виджета

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

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

Как было показано ранее, у нас есть стандартный виджет <select> в качестве запасного варианта для повышения доступности; поэтому мы просто синхронизируем его значение с нашим собственнным виджетом:

// Эта функция обновляет отображенное значение и синхронизирует его со стандартным виджетом 
// Ей передается два параметра:
// select : DOM нода класса `select` содержащая значение которое будет обновлено
// index  : индекс выбранного значения
function updateValue(select, index) {
  // Нам нужно получить стандартный виджет для данного пользовательского
  // В нашем примере стандартный виджет является братом (sibling) пользовательского виджета
  var nativeWidget = select.previousElementSibling;

  // Нам также нужно получить значение заполнителя нашего пользовательского виджета
  var value = select.querySelector('.value');

  // И нам нужен весь список вариантов
  var optionList = select.querySelectorAll('.option');

  // Установим значение текущего номера выбранного элемента равным index
  nativeWidget.selectedIndex = index;

  // Соответственно установим значение заполнителя
  value.innerHTML = optionList[index].innerHTML;

  // И мы подсвечиваем соответствующий вариант нашего пользовательского виджета
  highlightOption(select, optionList[index]);
};

// Эта функция возвращает текущий номер выбранного элемента в стандартном виджете
// Ей передается один параметр:
// select : DOM нода класса `select` соответствующая стандарному виджету
function getIndex(select) {
  // Нам нужно получить доступ к стандартному виджету соответствующему данному 
  // пользовательскому виджету
  // В нашем примере стандартный виджет - брат (sibling) пользовательского виджета
  var nativeWidget = select.previousElementSibling;

  return nativeWidget.selectedIndex;
};

Исползуя эти две функции мы можем связать стандартный виджет с пользовательским:

// Мы обрабатываем привязку события при загрузке документа.
window.addEventListener('load', function () {
  var selectList = document.querySelectorAll('.select');

  // Каждый пользовательский виджет необходимсо инциализировать:
  selectList.forEach(function (select) {
    var optionList = select.querySelectorAll('.option'),
        selectedIndex = getIndex(select);

    // Мы делаем наш пользовательский виджет фокусируемым
    select.tabIndex = 0;

    // Мы делаем стандартный виджет более не фокусируемым
    select.previousElementSibling.tabIndex = -1;

    // Убеждаемся, что выбранное по умолчанию значение корректно отображено
    updateValue(select, selectedIndex);

    // Кажды раз когда пользователь кликает на вариант, мы соответсвенно обновляем значение
    optionList.forEach(function (option, index) {
      option.addEventListener('click', function (event) {
        updateValue(select, index);
      });
    });

    // Когда виджет находится в фокусе, с каждым нажатием на клаиатуре, мы соответственно 
    // обновляем  значение 
    select.addEventListener('keyup', function (event) {
      var length = optionList.length,
          index  = getIndex(select);

      // Когда пользователь нажимает стрелку вниз, мы переходим на следующий вариант
      if (event.keyCode === 40 && index < length - 1) { index++; }

      // Когда пользователь нажимает стрелку вверх, мы переходим на предыдущий вариант
      if (event.keyCode === 38 && index > 0) { index--; }

      updateValue(select, index);
    });
  });
});

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

С этим мы закончили! Вот результат:

Пример
Посмотреть исходный код

Но секундочку, мы точно закончили?

Делаем доступным

Мы создали нечто работающее, и, хотя это далеко от полнофункционального селектбокса, работает это хорошо. Однако то, что мы сделали, это не более, чем возня с DOM. У него нет настоящей семантики, и хотя оно выглыдит как селектбокс, с точки зрения браузера - это не так, поэтому вспомогательные технологии не смогут понять что это селектбокс. Короче говоря, этот хорошенький селектбокс не является доступным для людей с ограниченными возможностями!

К счастью существует решение, и оно называется ARIA. ARIA - аббревиатура для "Accessible Rich Internet Application" (Доступное всем интернет приложение), и представляет собой W3C спецификацию специально разработанную для того, что мы здесь делаем: делаем веб приложения и пользовательские виджеты ассистивными (доступными для людей с ограниченными возможностями). В основном, это набор атрибутов, которые расширяют HTML, чтобы мы смогли лучше описать роли, состояния и свойства, так что только что изобретенный элемент выглядит как будто он был тем стандартным, за которого он себя выдает. Использовать эти атрибуты очень просто, поэтому давайте сделаем это.

Аттрибут role

Ключевой аттрибут используемый в ARIA - это role. Аттрибут role принимает значение, определяющее для чего используется элемент. Каждая роль определяет свои собственные требования и поведение. В нашем примере мы используем роль listbox. Это "составная роль" ("composite role"), т.е. элементы такой роли имеют потомков, у каждого из которых есть отдельная роль (в данном случае, как минимум один дочерний элемент с ролью option).

Стоит также отметить что ARIA определяет роли, которые по умолчанию применяются к стандартной разметке HTML. Например, элемент <table> соответствует роли grid, а элемент <ul> соответствует роли list. Так как мы используем элемент <ul>, то нам необходимо убедиться что роль listbox нашего виджета заменит роль list элемента <ul>. С этой целью, мы будем использовать роль presentation. Эта роль разработана чтобы можно было отметить, что элемент не имеет особого значения, а используется исключительно чтобы представить информацию. Мы применим его к нашему элемету <ul>.

Чтобы ввести роль listbox нам нужно просто внести следующие изменения в HTML:

<!-- Мы добавили аттрибут role="listbox" в наш элемент верхнего уровня -->
<div class="select" role="listbox">
  <span class="value">Cherry</span>
  <!-- Также мы добавили аттрибут role="presentation" в элемент ul -->
  <ul class="optList" role="presentation">
    <!-- И мы добавили аттрибут role="option" во все элементы li -->
    <li role="option" class="option">Cherry</li>
    <li role="option" class="option">Lemon</li>
    <li role="option" class="option">Banana</li>
    <li role="option" class="option">Strawberry</li>
    <li role="option" class="option">Apple</li>
  </ul>
</div>

Замечание: Включение как атрибута role так и атрибута class необходимо только если вы хотите обеспечить поддержку устаревших браузеров, которые не поддерживают  селекторы атрибутов CSS.

Атрибут aria-selected 

Использовать только аттрибут role недостаточно. ARIA также предоставляет множество атрибутов состояний и свойств. Чем больше и уместнее вы их используете, тем ваш виджет будет более понятен для вспомогательных технологий. В нашем случае мы ограничимся использованием одного аттрибута: aria-selected.

Атрибут aria-selected используется для отметки текущего выбранного варианта; это позволяет ассистивным технологиям информировать пользователя о текущем выборе. Мы будем используя JavaScript динамически отмечать выбранный вариант каждый раз, когда пользователь его выбирает. С этой целью нам нужно пересмотреть нашу функцию updateValue():

function updateValue(select, index) {
  var nativeWidget = select.previousElementSibling;
  var value = select.querySelector('.value');
  var optionList = select.querySelectorAll('.option');

  // Мы уверены что все варианты не выбраны
  optionList.forEach(function (other) {
    other.setAttribute('aria-selected', 'false');
  });

  // Мы уверены что выбранный вариант отмечен
  optionList[index].setAttribute('aria-selected', 'true');

  nativeWidget.selectedIndex = index;
  value.innerHTML = optionList[index].innerHTML;
  highlightOption(select, optionList[index]);
};

Вот окончательный результат всех этих изменений (вы сможете это лучше прочувствовать если испробуете это со вспомогательными технологиями, такими как NVDA или VoiceOver):

Пример
Посмотреть исходный код

Заключение

Мы рассмотрели все основы создания пользовательского виджета формы, и это, как вы видите, нетривиальная задача, и часто проще и лучше положиться на сторонние библиотеки, чем писать их самому с нуля (если, конечно, ваша цель - не создать такую библиотеку).

Вот несколько библиотек, которые вам стоит рассмотреть перед тем как создавать собственную:

Если вы хотите двигаться далее, то код в этом примере нуждается в некоторм улучшении прежде чем станет универсальным и многоразовым. Это упражнение, которое вы можете попробовать выполнить. Две подсказки, которые помогут вам в этом: первый аргумент всех наших функций одинаков, это значит что эти функции должны быть в одном контексте. Было бы разумным создать объект для совместного использования этого контекста. Также вам нужно сделать его функциональным; это значит, что ему необходимо одинаково хорошо работать с различными браузерами, чья соместимость с  Web стандартами  очень отличается. Повеселись!

В этом модуле