Часто в дизайне требуется стилизация select. Лучшим решением будет стилизовать только контейнер, а его опции оставить стандартными.
Если такой подход вас не устраивает, то необходимо сделать свой собственный.
Существуют готовые решения:
— Select2 в jQuery;
— Chosen в jQuery;
— Choices в JavaScript без deps.
Здесь мы попробуем сделать свой собственный.
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);
Вы также можете посмотреть демо-версию на codepen.
Найти выбирает
// there can be several selections to be styled
// find them by attribute
const selects = document.querySelectorAll('[data-custom-select-class]');
// and pass them through a loop to the "constructor"
selects.forEach(constructor);
Конструктор
// node of select is passed as an argument to the function
const constructor = (select) => {
// create object
const instance = {
// which will contain the node of the selection
select,
// and the class to be used in the new select
className: select.dataset.customSelectClass,
};
// Init func
const init = () => {
// which
// 1. will wrap the real select
// into the container of the new
wrapSelect(instance)
// 2. save the new elements in instance
.then(findElements)
// 3. create events for opening/closing and selecting an option
.then(initEvents)
// 4. draws all options
.then(render);
};
init();
};
1. WrapSelect
const wrapSelect = (object) => {
const instance = object;
const { select, className } = instance;
// we will create the container asynchronously
return new Promise((resolve) => {
requestIdleCallback(() => {
// insert the container of the new select
insertBase(select, className);
// save it into instance
instance.node = select.nextElementSibling;
// hiding the real select in a container
hideSelect(instance);
resolve(instance);
});
});
};
InsertBase
// markup with the container of the new selection
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) => {
// find active option
const selectedIndex = select.selectedIndex >= 0 ? select.selectedIndex : 0;
// take out its contents
const value = select.options[selectedIndex].textContent;
// create container
const html = createBaseHTML(value, className);
// and insert it right after the real select
select.insertAdjacentHTML('afterend', html);
};
hideSelect
// hiding the real select inside the container
const hideSelect = ({ node, select }) => node.appendChild(select);
2. findElements
Функция сохраняет все необходимые элементы в экземпляре.
Для наглядности я продублирую createBaseHTML
const createBaseHTML = (value, className) => (`
<div class="${className}">
// becomes instance.toggle
<button class="${className}__toggle" type="button">${value}</button>
// becomes instance.holder
<div class="${className}__options"></div>
</div>
`);
Просмотреть и соотнести элементы:
const findElements = (object) => {
const instance = object;
const { node, select } = instance;
// open/close selector button
instance.toggle = node.children[0];
// parent of custom options
instance.holder = node.children[1];
// the flag is responsible for
// open or closed select
instance.isActive = false;
// copy options into instance
instance.options = select.options;
// save the index of the active select
instance.active = select.selectedIndex >= 0 ? select.selectedIndex : 0;
return instance;
};
3. initEvents
const initEvents = (object) => {
const instance = object;
const { node, toggle } = instance;
// is responsible for opening the select
const showDropdown = () => { showOptions(instance); };
// for closing the select
const hideDropdown = () => { hideOptions(instance); };
// opens or closes the select
// depending on the current state
const toggleDropdown = () => { toggleOptions(instance); };
instance.showDropdown = showDropdown;
instance.hideDropdown = hideDropdown;
instance.toggleDropdown = toggleDropdown;
// Create a listener for the button
toggle.addEventListener('click', toggleDropdown);
// create a function for the entire container
// which will stop the pop-up
// more details below
node.addEventListener('click', onNodeClick);
return instance;
};
Выбор открытия/закрытия
Чтение от нижней функции к верхней
// so that the function is not triggered when the selector is closed
// remove listener
const unsubscribeDocument = ({ hideDropdown }) => document.removeEventListener('click', hideDropdown);
const hideOptions = (object) => {
const instance = object;
const { node, className } = instance;
// change the status to inactive
instance.isActive = false;
// kill the "visibility" class
node.classList.remove(${className}--active);
// remove the listener from the whole document
unsubscribeDocument(instance);
};
// if the user clicks on anything but the select
// the select will be closed
const subscribeDocument = ({ hideDropdown }) => document.addEventListener('click', hideDropdown);
const showOptions = (object) => {
const instance = object;
const { node, className } = instance;
// change the status to active
instance.isActive = true;
// add the class responsible for the visibility of options
node.classList.add(${className}--active);
// create a listener for the whole document
subscribeDocument(instance);
};
const toggleOptions = (instance) => {
// if the select is already open - close it
if (instance.isActive) hideOptions(instance);
// otherwise - open
else showOptions(instance);
};
onNodeClick
Поскольку мы создаем слушатель для всего документа, он будет работать даже тогда, когда мы щелкнем на нашем селекте.
Чтобы этого не произошло, давайте уберем всплывающее окно.
const onNodeClick = event => event.stopPropagation();
Подробнее об этом вы можете прочитать на сайте learn.javascript.ru
4. рендеринг
const render = (object) => {
const instance = object;
const { holder, options, className, active } = instance;
// create new options html
const html = renderOptions(options, active, className);
// insert it into their parent
holder.insertAdjacentHTML('afterbegin', html);
// store them in an instance
instance.customOptions = [...holder.children];
// as well as the active one of them
instance.optionActive = instance.customOptions[active];
// create events to select options
initOptionsEvents(instance);
};
renderOptions
const renderOption = (html, option, index, active, className) => {
// generate an active class if option is active
const activeClassName = index === active ? `${className}_option--active : '';
return
// glue all previously generated options to the current one
${html}
<button class="${className}_option ${activeClassName}" type="button" data-index="${index}">${option.textContent}</button>
`;
};
const renderOptions = (options, active, className) => {
// recall that options are copied options of the present selector
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;
// if the clicked item is an option
if (isOption(target, instance)) {
// update the index of the active item in instance
instance.active = target.dataset.index;
// choose option
pickOption(instance);
}
// if select needs to close
if (shouldDropdown(target, instance)) {
// close
hideDropdown();
}
};
const initOptionsEvents = (instance) => {
// add a listener to the parent options
instance.holder.addEventListener('click', event => onOptionsClick(event, instance));
};
isOption и shouldDropdown
// both predicates return the presence of the option class
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;
// set the active option to the present selector
select.selectedIndex = active;
// remove the active class from the previous active option
instance.optionActive.classList.remove(${className}__option--active);
// find the new active option
instance.optionActive = customOptions[active];
// and set it as a class active
instance.optionActive.classList.add(${className}__option--active);
// change the text of toggle
instance.toggle.textContent = instance.optionActive.textContent;
};
hideDropdown
Это функция hideOptions, написанная в экземпляре. См. выше.
Вот и все. Если какие-то моменты вам непонятны — пишите в комментариях — постараюсь объяснить, а демо смотрите на codepen.