Кастомный селект на Vanilla JS

Часто дизайн требует стилизовать селект. Лучшим решением было бы стилизовать только контейнер, а его options оставить стандартными.

Если такой подход не устраивает, то необходимо делай свой.

Из готовых решений есть:

Select2 на jQuery;

Chosen тоже на jQuery;

— Choices на JavaScript без зависимостей.


Здесь мы попробуем сделать свое.

HTML

<select data-custom-select-class="select">
<option value="All">All frameworks</option>
<option value="React">React</option>
<option value="Vue">Vue</option>
<option value="Swelte">Swelte</option>
</select>

Добавим селект с вариантами. Сам атрибут data-custom-select-class даст нам понять, что этот селект нужно стилизовать, а его значение — какой класс использовать в новой разметке.

JavaScript

Вот полный код скрипта, можете быстро пройтись по нему, чуть ниже будет детальный разбор

const findElements = (object) => {
const instance = object;
const { node, select } = instance;
instance.toggle = node.children[0];
instance.holder = node.children[1];
instance.isActive = false;
instance.options = select.options;
instance.active = select.selectedIndex >= 0 ? select.selectedIndex : 0;
return instance;
};
const isOption = (target, { className }) => target.classList.contains(`${className}__option`);
const shouldDropdown = (target, { className }) => target.classList.contains(`${className}__option`);
const createBaseHTML = (value, className) => (`
<div class="${className}">
<button class="${className}__toggle" type="button">${value}</button>
<div class="${className}__options"></div>
</div>
`);
const insertBase = (select, className) => {
const selectedIndex = select.selectedIndex >= 0 ? select.selectedIndex : 0;
const value = select.options[selectedIndex].textContent;
const html = createBaseHTML(value, className);
select.insertAdjacentHTML('afterend', html);
};
const renderOption = (html, option, index, active, className) => {
const activeClassName = index === active ? `${className}__option--active` : '';
return `
${html}
<button class="${className}__option ${activeClassName}" type="button" data-index="${index}">${option.textContent}</button>
`;
};
const renderOptions = (options, active, className) => {
return [...options].reduce((acc, option, index) => renderOption(acc, option, index, active, className), '');
};
const pickOption = (object) => {
const instance = object;
const { select, active, customOptions, className } = instance;
select.selectedIndex = active;
instance.optionActive.classList.remove(`${className}__option--active`);
instance.optionActive = customOptions[active];
instance.optionActive.classList.add(`${className}__option--active`);
instance.toggle.textContent = instance.optionActive.textContent;
};
const onOptionsClick = (event, object) => {
event.preventDefault();
const instance = object;
const { select, hideDropdown } = instance;
const { target } = event;
if (isOption(target, instance)) {
instance.active = target.dataset.index;
pickOption(instance);
}
if (shouldDropdown(target, instance)) {
hideDropdown();
}
};
const initOptionsEvents = (instance) => {
instance.holder.addEventListener('click', event => onOptionsClick(event, instance));
};
const render = (object) => {
const instance = object;
const { holder, options, className, active } = instance;
const html = renderOptions(options, active, className);
holder.insertAdjacentHTML('afterbegin', html);
instance.customOptions = [...holder.children];
instance.optionActive = instance.customOptions[active];
initOptionsEvents(instance);
};
const hideSelect = ({ node, select }) => node.appendChild(select);
const wrapSelect = (object) => {
const instance = object;
const { select, className } = instance;
return new Promise((resolve) => {
requestIdleCallback(() => {
insertBase(select, className);
instance.node = select.nextElementSibling;
hideSelect(instance);
resolve(instance);
});
});
};
const unsubscribeDocument = ({ hideDropdown }) => document.removeEventListener('click', hideDropdown);
const subscribeDocument = ({ hideDropdown }) => document.addEventListener('click', hideDropdown);
const hideOptions = (object) => {
const instance = object;
const { node, className } = instance;
instance.isActive = false;
node.classList.remove(`${className}--active`);
unsubscribeDocument(instance);
};
const showOptions = (object) => {
const instance = object;
const { node, className } = instance;
instance.isActive = true;
node.classList.add(`${className}--active`);
subscribeDocument(instance);
};
const toggleOptions = (instance) => {
if (instance.isActive) hideOptions(instance);
else showOptions(instance);
};
const onNodeClick = event => event.stopPropagation();
const initEvents = (object) => {
const instance = object;
const { node, toggle } = instance;
const showDropdown = () => { showOptions(instance); };
const hideDropdown = () => { hideOptions(instance); };
const toggleDropdown = () => { toggleOptions(instance); };
instance.showDropdown = showDropdown;
instance.hideDropdown = hideDropdown;
instance.toggleDropdown = toggleDropdown;
toggle.addEventListener('click', toggleDropdown);
node.addEventListener('click', onNodeClick);
return instance;
};
const constructor = (select) => {
const instance = {
select,
className: select.dataset.customSelectClass,
};
const init = () => {
wrapSelect(instance)
.then(findElements)
.then(initEvents)
.then(render);
};
init();
};
const selects = document.querySelectorAll('[data-custom-select-class]');
selects.forEach(constructor);

Также Demo можно посмотреть на codepen.

Ищем селекты

// селектов, которые нужно стилизовать может быть несколько
// находим их по атрибут
const selects = document.querySelectorAll('[data-custom-select-class]');
// и через цикл передаем их в "конструктор"
selects.forEach(constructor);

Constructor

// нода селекта передается как аргумент функции
const constructor = (select) => {
// создаем объект
const instance = {
// в котором будет нода селекта
select,
// и класс, который будет использоваться в новом селекта
className: select.dataset.customSelectClass,
};
// Инициилизируем функцию
const init = () => {
// которая
// 1. обернет настоящий селект
// в контейнер нового
wrapSelect(instance)
// 2. сохранит в instance новые элементы
.then(findElements)
// 3. создаст события для открытия/закрытия и выбора варианта
.then(initEvents)
// 4. нарисует все options
.then(render);
};
init();
};


1. WrapSelect

const wrapSelect = (object) => {
const instance = object;
const { select, className } = instance;
// создавать контейнер будем асинхронно
return new Promise((resolve) => {
requestIdleCallback(() => {
// вставляем контейнер нового селекта
insertBase(select, className);
// сохраняем его в instance
instance.node = select.nextElementSibling;
// прячем настоящий селект в контейнер
hideSelect(instance);
resolve(instance);
});
});
};

InsertBase

// разметка с контейнером нового селекта
const createBaseHTML = (value, className) => (`
<div class="${className}">
<button class="${className}__toggle" type="button">${value}</button>
<div class="${className}__options"></div>
</div>
`);
const insertBase = (select, className) => {
// ищем активный option
const selectedIndex = select.selectedIndex >= 0 ? select.selectedIndex : 0;
// вынимаем его содержимое
const value = select.options[selectedIndex].textContent;
// создаем контейнер
const html = createBaseHTML(value, className);
// и вставляем его сразу после настоящего селекта
select.insertAdjacentHTML('afterend', html);
};

hideSelect

// прячем настоящий селект внутрь контейнера
const hideSelect = ({ node, select }) => node.appendChild(select);

2. findElements

Функция сохраняет в instance все необходимые элементы.

Для наглядности продублирую createBaseHTML

const createBaseHTML = (value, className) => (`
<div class="${className}">
// станет instance.toggle
<button class="${className}__toggle" type="button">${value}</button>
// станет instance.holder
<div class="${className}__options"></div>
</div>
`);

Смотрим и соотносим элементы:

const findElements = (object) => {
const instance = object;
const { node, select } = instance;
// кнопка открытия/закрытия селекта
instance.toggle = node.children[0];
// родитель кастосных options
instance.holder = node.children[1];
// флаг отвечает за то
// открыт или закрыт селект
instance.isActive = false;
// копируем options в instance
instance.options = select.options;
// сохраняем индекс активного селекта
instance.active = select.selectedIndex >= 0 ? select.selectedIndex : 0;
return instance;
};

3. initEvents

const initEvents = (object) => {
const instance = object;
const { node, toggle } = instance;
// отвечает за открытие селекта
const showDropdown = () => { showOptions(instance); };
// за закрытие селекта
const hideDropdown = () => { hideOptions(instance); };
// открывает или закрывает селект
// в зависимости от текущего состояния
const toggleDropdown = () => { toggleOptions(instance); };
instance.showDropdown = showDropdown;
instance.hideDropdown = hideDropdown;
instance.toggleDropdown = toggleDropdown;
// создаем слушатель для кнопки
toggle.addEventListener('click', toggleDropdown);
// для всего контейнера создаем функцию
// которая остановит всплытие
// ниже подробней
node.addEventListener('click', onNodeClick);
return instance;
};

Открытие/закрытие селекта

Читаем от нижней функции к верхней


// дабы функция не срабатывала при закрытом селекта
// удалим listener
const unsubscribeDocument = ({ hideDropdown }) => document.removeEventListener('click', hideDropdown);
const hideOptions = (object) => {
const instance = object;
const { node, className } = instance;
// меняем статус на неактивый
instance.isActive = false;
// убиваем класс "видимости"
node.classList.remove(`${className}--active`);
убираем слушатель со всего документа
unsubscribeDocument(instance);
};
// если пользователь кликнет на что угодно кроме селекта
// селект закроется
const subscribeDocument = ({ hideDropdown }) => document.addEventListener('click', hideDropdown);
const showOptions = (object) => {
const instance = object;
const { node, className } = instance;
// меняем статус на активый
instance.isActive = true;
// добавляем класс, отвечающий за видимость options
node.classList.add(`${className}--active`);
// создаем слушатель на весь документ
subscribeDocument(instance);
};
const toggleOptions = (instance) => {
// если селект уже открыт - закрываем
if (instance.isActive) hideOptions(instance);
// иначе - открываем
else showOptions(instance);
};

onNodeClick

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

Чтобы такое не происходило — уберем всплытие.

const onNodeClick = event => event.stopPropagation();

Подробнее про это можно прочитать на learn.javascript.ru


4. render


const render = (object) => {
const instance = object;
const { holder, options, className, active } = instance;
// создаем html новых options
const html = renderOptions(options, active, className);
// вставляем его в их родитель
holder.insertAdjacentHTML('afterbegin', html);
// сохраняем их в instance
instance.customOptions = [...holder.children];
// а также активный из них
instance.optionActive = instance.customOptions[active];
// создадим события для выбора options
initOptionsEvents(instance);
};

renderOptions

const renderOption = (html, option, index, active, className) => {
// генерим активный класс если option - активный
const activeClassName = index === active ? `${className}__option--active` : '';
return `
// склеиваем все ранее сгенерированные options с текущим
${html}
<button class="${className}__option ${activeClassName}" type="button" data-index="${index}">${option.textContent}</button>
`;
};
const renderOptions = (options, active, className) => {
// напоминаю, что options - скопированные options настоящего селекта
return [...options].reduce((acc, option, index) => renderOption(acc, option, index, active, className), '');
};

initOptionsEvent

const isOption = (target, { className }) => target.classList.contains(`${className}__option`);
const onOptionsClick = (event, object) => {
event.preventDefault();
const instance = object;
const { select, hideDropdown } = instance;
const { target } = event;
// если кликнутый элемент - option
if (isOption(target, instance)) {
// обновляем идекс активного элемента в instance
instance.active = target.dataset.index;
// выбираем option
pickOption(instance);
}
// если select нужно закрыть
if (shouldDropdown(target, instance)) {
// закрываем
hideDropdown();
}
};
const initOptionsEvents = (instance) => {
// добавляем слушатель на родитель options
instance.holder.addEventListener('click', event => onOptionsClick(event, instance));
};
isOption и shouldDropdown
// оба предиката возвращают наличие класса option
const isOption = (target, { className }) => target.classList.contains(`${className}__option`);
const shouldDropdown = (target, { className }) => target.classList.contains(`${className}__option`);
pickOption
const pickOption = (object) => {
const instance = object;
const { select, active, customOptions, className } = instance;
// устанавливаем активный option настоящему селекту
select.selectedIndex = active;
// удаляем класс active у предыдущего активного option
instance.optionActive.classList.remove(`${className}__option--active`);
// находим новый активный option
instance.optionActive = customOptions[active];
// и задаем ему класс active
instance.optionActive.classList.add(`${className}__option--active`);
// меняем текст у toggle
instance.toggle.textContent = instance.optionActive.textContent;
};

hideDropdown
Это функция hideOptions, записанная в instance. Смотри выше.

Это всё. Если какой-то момент оказался не понятным — пишите в комментарии — попробую разъяснить, а также смотрите демо в codepen.