Настройка select с помощью ванильного JavaScript

Часто в дизайне требуется стилизация 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.

Оцените статью
Procodings.ru
Добавить комментарий