Пишем простым языком о сложных технических процессах

Кастомный <select> на чистом JavaScript

В предыдущей заметке мы говорили об атрибуте placeholder полей ввода и смогли реализовать замечательный эффект эмуляции ввода текста. В этот раз мы недалеко отойдём от темы и возьмёмся за конкретный элемент <select>.

Ведь в современной веб-сфере дизайнерами принято иллюстрировать элементы как им заблагорассудится и они пользуются этим по полному праву. Всё бы ничего, только есть одна проблема - данный элемент сложно стилизовать. Разные браузеры и устройства предоставляют разный инструментарий, ни один из которых не является полным, и в такой ситуации начинающий веб-разработчик сталкивается с трудностями решения. Самым простым способом является использование готовых решений таких, как Tom Select, Nice Select 2, Choises.js и других, но так не интересно, да и опыта мы почти не получим и поэтому предлагаем решить задачу самим.

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

  1. Создание разметки с использованием стилизуемых элементов.
  2. Программный рендеринг.
  3. Реализация взаимодействия с нашим элементом.
  4. Синхронизация поведения <select> и нашего элемента.

Создание разметки

Давайте начнём с разметки и используем кнопку и простой список.

html
<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.

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

js
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 (т.е. индекс первого элемента).

js
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-класс активного пункта.

js
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;
  }
}

И тут должна произойти магия, мы рендерим наш созданный элемент после селекта и тут же этот селект и скроем.

js
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>, а как результат мы получили нашу разметку.

Взаимодействие

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

  • Нажатие по кнопке должно раскрывать/скрывать список.
  • Нажатие по любому месту документа кроме нашего элемента должно скрывать список.
  • При раскрытии списка у одного элемента, должны скрываться у всех остальных (мы же понимаем, что наших элементов может быть несколько на странице).
js
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-класс активного пункта. А еще нужно помнить, что значение селекта может измениться не только при выборе элемента, а еще и при сбросе данных формы, и нам нужно также реагировать на это.

js
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> и решать задачи поставленные дизайнерами, и обратите внимание, это не готовое решение которое нужно скопипастить на свой проект. Здесь не хватает например доступности для разных групп людей, стилизация не учитывает длинные значения и т.п., конечно же это всё можно дорабатывать, но объем заметки уже превышает пределы, да и задачи такой не было. Здесь мы хотели на простом и понятном примере показать всю прелесть программирования и надеемся, что вы что-нибудь интересное для себя почерпнули. Спасибо за внимание.

Результат