В предыдущей заметке мы говорили об атрибуте placeholder
полей ввода и смогли реализовать замечательный эффект эмуляции ввода текста. В этот раз мы недалеко отойдём от темы и возьмёмся за конкретный элемент <select>
.
Ведь в современной веб-сфере дизайнерами принято иллюстрировать элементы как им заблагорассудится и они пользуются этим по полному праву. Всё бы ничего, только есть одна проблема - данный элемент сложно стилизовать. Разные браузеры и устройства предоставляют разный инструментарий, ни один из которых не является полным, и в такой ситуации начинающий веб-разработчик сталкивается с трудностями решения. Самым простым способом является использование готовых решений таких, как Tom Select, Nice Select 2, Choises.js и других, но так не интересно, да и опыта мы почти не получим и поэтому предлагаем решить задачу самим.
Раз уж мы не можем стилизовать данный элемент, то мы сделаем это с другими, связав их поведение в двухстороннем порядке. Если на данный момент вам не совсем понятно, то давайте разработаем план который поможет нам в этом:
- Создание разметки с использованием стилизуемых элементов.
- Программный рендеринг.
- Реализация взаимодействия с нашим элементом.
- Синхронизация поведения
<select>
и нашего элемента.
Создание разметки
Давайте начнём с разметки и используем кнопку и простой список.
<div class="awesome-select">
<button class="awesome-select__button">Ваш любимый фильм</button>
<ul class="awesome-select__list">
<li class="awesome-select__item">Дети небес</li>
<li class="awesome-select__item">Вечное сияние чистого разума</li>
<li class="awesome-select__item">Паразиты</li>
<li class="awesome-select__item">Три билборда на границе Эббинга, Миссури</li>
<li class="awesome-select__item">Прежде чем мы расстанемся</li>
</ul>
</div>
У нас есть обёртка внутри которого элемент button
и список с пунктами. Теперь надо бы их стилизовать, но мы не будем подробно на этом останавливаться, это нетривиальная задача с которой вы должны без проблем справиться.
Если коротко, то список с абсолютным позиционированием относительно обёртки, по умолчанию скрыт, но если добавить класс _opened
обёртке, то станет видимым, также есть класс модификатор _selected
для пунктов списка, который визуально выделяет элемент, а всё остальное на ваш или дизайнера вкус. Как видите, ничего сложного. Теперь можно приступить к более интересным задачам.
Программный рендеринг
Теперь нам нужно создавать данную разметку программно. Для этого напишем простой класс и в его конструкторе реализуем данную задачу.
Подсказка
Помним, что в JavaScript классы принято писать в PascalCase.
class AwesomeSelect {
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
}
}
В конструкторе класса мы будем принимать один аргумент el
и проверять, если тип значения данного атрибута будет являться строкой, то заносим результат вызова метода document.querySelector(el)
. Далее проверяем является ли это свойство всё-таки нужным для нас select
-ом, иначе выбрасываем ошибку. Данный код даст нам гибкость и позволит создавать экземпляр класса передавая как сам элемент так и селектор.
Далее объявим 2 приватных свойства _selectedClass
и _openedClass
в которых будут храниться CSS-классы состояний для удобства использования в коде.
class AwesomeSelect {
_selectedClass = '_selected';
_openedClass = '_opened';
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
}
}
И эти свойства неспроста начинаются с символа нижнего подчёркивания, это тоже общепринятое правило указывания приватных свойств в таком виде, т.к. они называются "приватными" очень условно и доступ к ним никак не закрыт. Пока не забивайте голову, это отдельная тема для разговора.
Создаём обёртку и кнопку, и для того чтобы в кнопке сразу же отображался текст нужной опции, создадим геттер который будет возвращать индекс выбранной опции или если не будет такого, то вернёт 0 (т.е. индекс первого элемента).
class AwesomeSelect {
_selectedClass = '_selected';
_openedClass = '_opened';
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
this.$wrapper = document.createElement('div');
this.$wrapper.classList.add('awesome-select');
this.$button = document.createElement('button');
this.$button.classList.add('awesome-select__button');
this.$button.innerText = this.el.options[this.defaultIndex].text;
this.$wrapper.append(this.$button);
}
get defaultIndex () {
const index = Array.from(this.el.options).findIndex(option => option.defaultSelected);
return index > -1 ? index : 0;
}
}
Создадим список и перебирая опции нашего селекта, создадим пункты списка, не забывая указать CSS-класс активного пункта.
class AwesomeSelect {
_selectedClass = '_selected';
_openedClass = '_opened';
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
this.$wrapper = document.createElement('div');
this.$wrapper.classList.add('awesome-select');
this.$button = document.createElement('button');
this.$button.classList.add('awesome-select__button');
this.$button.innerText = this.el.options[this.defaultIndex].text;
this.$wrapper.append(this.$button);
this.$list = document.createElement('ul');
this.$list.classList.add('awesome-select__list');
for (const { innerText, index } of this.el.options) {
const item = document.createElement('li');
item.classList.add('awesome-select__item');
item.innerText = innerText;
item.setAttribute('title', innerText);
if (this.defaultIndex === index) {
item.classList.add(this._selectedClass);
}
this.$list.append(item);
}
this.$wrapper.append(this.$list);
}
get defaultIndex () {
const index = Array.from(this.el.options).findIndex(option => option.defaultSelected);
return index > -1 ? index : 0;
}
}
И тут должна произойти магия, мы рендерим наш созданный элемент после селекта и тут же этот селект и скроем.
class AwesomeSelect {
_selectedClass = '_selected';
_openedClass = '_opened';
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
this.$wrapper = document.createElement('div');
this.$wrapper.classList.add('awesome-select');
this.$button = document.createElement('button');
this.$button.classList.add('awesome-select__button');
this.$button.innerText = this.el.options[this.defaultIndex].text;
this.$wrapper.append(this.$button);
this.$list = document.createElement('ul');
this.$list.classList.add('awesome-select__list');
for (const { innerText, index } of this.el.options) {
const item = document.createElement('li');
item.classList.add('awesome-select__item');
item.innerText = innerText;
item.setAttribute('title', innerText);
if (this.defaultIndex === index) {
item.classList.add(this._selectedClass);
}
this.$list.append(item);
}
this.$wrapper.append(this.$list);
this.el.after(this.$wrapper);
this.el.style.display = 'none';
}
get defaultIndex () {
const index = Array.from(this.el.options).findIndex(option => option.defaultSelected);
return index > -1 ? index : 0;
}
}
С рендерингом мы закончили и если мы создадим экземпляр нашего класса, то при загрузке страницы, обычный селект превратится в красивый элемент который мы до этого стилизовали.
Внимание
Обратите внимание, в HTML у нас обычный <select>
, а как результат мы получили нашу разметку.
Взаимодействие
Теперь нам нужно приступить к реализации взаимодействия, ведь какой толк в том что у нас есть красивый элемент, если даже его список сейчас никак не раскрыть? Давайте займемся этим. А еще лучше, напишем список возможных событий и взаимодействий.
- Нажатие по кнопке должно раскрывать/скрывать список.
- Нажатие по любому месту документа кроме нашего элемента должно скрывать список.
- При раскрытии списка у одного элемента, должны скрываться у всех остальных (мы же понимаем, что наших элементов может быть несколько на странице).
class AwesomeSelect {
_selectedClass = '_selected';
_openedClass = '_opened';
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
this.$wrapper = document.createElement('div');
this.$wrapper.classList.add('awesome-select');
this.$button = document.createElement('button');
this.$button.classList.add('awesome-select__button');
this.$button.innerText = this.el.options[this.defaultIndex].text;
this.$button.addEventListener('click', e => {
e.preventDefault();
this.toggle();
});
this.$wrapper.append(this.$button);
this.$list = document.createElement('ul');
this.$list.classList.add('awesome-select__list');
for (const { innerText, index } of this.el.options) {
const item = document.createElement('li');
item.classList.add('awesome-select__item');
item.innerText = innerText;
item.setAttribute('title', innerText);
if (this.defaultIndex === index) {
item.classList.add(this._selectedClass);
}
item.addEventListener('click', () => {
this.close();
});
this.$list.append(item);
}
this.$wrapper.append(this.$list);
this.el.after(this.$wrapper);
this.el.style.display = 'none';
document.addEventListener('click', e => {
if (!this.$wrapper.contains(e.target) && this.isOpened) {
this.close();
}
});
}
open () {
this.$wrapper.classList.add(this._openedClass);
Array.from(document.querySelectorAll('.awesome-select'))
.filter(el => el.classList.contains(this._openedClass) && el !== this.$wrapper)
.forEach(el => el.classList.remove(this._openedClass));
}
close () {
this.$wrapper.classList.remove(this._openedClass);
}
toggle () {
if (this.isOpened) {
this.close();
} else {
this.open();
}
}
get isOpened () {
return this.$wrapper.classList.contains(this._openedClass);
}
get defaultIndex () {
const index = Array.from(this.el.options).findIndex(option => option.defaultSelected);
return index > -1 ? index : 0;
}
}
У нас появился геттер isOpened
, который возвращает true
если список раскрыт, иначе, соответственно false
. Метод open
, который не только раскрывает список, но еще и скрывает другие. Метод close
который скрывает список и метод toggle
, который переключает, вызывая методы open
и close
в зависимости от состояния. И мы вызываем метод toggle
при нажатии на кнопку, вызываем метод close
при нажатии на пункт списка и на остальной документ.
Таким образом мы смогли реализовать взаимодействие с нашим элементом, давайте тестировать наш код.
Синхронизация
Это заключительная часть нашего плана и самая важная. Как уже было сказано выше, синхронизация должна быть двухсторонней и мы должны рассмотреть следующие сценарии:
- Происходит взаимодействие с нашим элементом и нужно обновлять значения
<select>
. - Происходит взаимодействие c
<select>
(программно) и нужно обновлять уже значения нашего элемента.
Первый сценарий, пользователь нажимает на пункт списка и мы по индексу обновляем значение <select>
. Второй, слушаем событие изменения <select>
и обновляем значения нашего элемента, такие как: текст кнопки и CSS-класс активного пункта. А еще нужно помнить, что значение селекта может измениться не только при выборе элемента, а еще и при сбросе данных формы, и нам нужно также реагировать на это.
class AwesomeSelect {
_selectedClass = '_selected';
_openedClass = '_opened';
constructor (el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
if (!(this.el instanceof HTMLSelectElement)) {
throw new Error('Element is undefined');
}
this.$wrapper = document.createElement('div');
this.$wrapper.classList.add('awesome-select');
this.$button = document.createElement('button');
this.$button.classList.add('awesome-select__button');
this.$button.innerText = this.el.options[this.defaultIndex].text;
this.$button.addEventListener('click', e => {
e.preventDefault();
this.toggle();
});
this.$wrapper.append(this.$button);
this.$list = document.createElement('ul');
this.$list.classList.add('awesome-select__list');
for (const { innerText, index } of this.el.options) {
const item = document.createElement('li');
item.classList.add('awesome-select__item');
item.innerText = innerText;
item.setAttribute('title', innerText);
if (this.defaultIndex === index) {
item.classList.add(this._selectedClass);
}
item.addEventListener('click', () => {
this.el.selectedIndex = index;
this.el.dispatchEvent(new Event('change'));
});
this.$list.append(item);
}
this.$wrapper.append(this.$list);
this.el.after(this.$wrapper);
this.el.style.display = 'none';
this.el.form?.addEventListener('reset', () => {
this.el.selectedIndex = this.defaultIndex;
this.el.dispatchEvent(new Event('change'));
});
this.el.addEventListener('change', () => this.update());
document.addEventListener('click', e => {
if (!this.$wrapper.contains(e.target) && this.isOpened) {
this.close();
}
});
}
open () {
this.$wrapper.classList.add(this._openedClass);
Array.from(document.querySelectorAll('.awesome-select'))
.filter(el => el.classList.contains(this._openedClass) && el !== this.$wrapper)
.forEach(el => el.classList.remove(this._openedClass));
}
close () {
this.$wrapper.classList.remove(this._openedClass);
}
toggle () {
if (this.isOpened) {
this.close();
} else {
this.open();
}
}
update (index = this.el.selectedIndex) {
const option = this.el.options[index];
this.$button.innerText = option.text || option.value;
this.$list.querySelectorAll('li').forEach((el, i) => el.classList[i === index ? 'add' : 'remove'](this._selectedClass));
this.close();
}
get defaultIndex () {
const index = Array.from(this.el.options).findIndex(option => option.defaultSelected);
return index > -1 ? index : 0;
}
get isOpened () {
return this.$wrapper.classList.contains(this._openedClass);
}
}
Вот таким нехитрым способом можно реализовать кастомный <select>
и решать задачи поставленные дизайнерами, и обратите внимание, это не готовое решение которое нужно скопипастить на свой проект. Здесь не хватает например доступности для разных групп людей, стилизация не учитывает длинные значения и т.п., конечно же это всё можно дорабатывать, но объем заметки уже превышает пределы, да и задачи такой не было. Здесь мы хотели на простом и понятном примере показать всю прелесть программирования и надеемся, что вы что-нибудь интересное для себя почерпнули. Спасибо за внимание.