DEV Community

Vadim Filimonov
Vadim Filimonov

Posted on

Customize select with vanilla JavaScript

Often the design calls for a styled select. The best solution would be to style only the container and leave its options as standard.

If this approach does not suit you, then you need to make your own.
There are ready-made solutions:
Select2 in jQuery;
Chosen in jQuery;
Choices in JavaScript without deps.

Here we will try to make our own.

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>
Enter fullscreen mode Exit fullscreen mode

Let's add a select with options. The data-custom-select-class attribute itself will let us know that this select should be styled, and its value will tell us which class to use in the new markup.

JavaScript

Here is the full code of the script, you can quickly go through it, a little below will be a detailed breakdown.

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);
Enter fullscreen mode Exit fullscreen mode

You can also view the demo at codepen.

Find selects

// 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);
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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);
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

hideSelect

// hiding the real select inside the container
const hideSelect = ({ node, select }) => node.appendChild(select);
Enter fullscreen mode Exit fullscreen mode

2. findElements

The function stores all the necessary elements in instance.
For clarity, I will duplicate 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>
`);
Enter fullscreen mode Exit fullscreen mode

See and correlate the elements:

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Open/Close select

Read from bottom function to top function

// 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);
};
Enter fullscreen mode Exit fullscreen mode

onNodeClick

Since we make the listener for the whole document, it will work even when we click on our select.
To prevent this from happening, let's remove the popup.

const onNodeClick = event => event.stopPropagation();
Enter fullscreen mode Exit fullscreen mode

You can read more about this at learn.javascript.ru

4. render

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);
};
Enter fullscreen mode Exit fullscreen mode

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), '');
}
Enter fullscreen mode Exit fullscreen mode

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));
};
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

hideDropdown

This is a hideOptions function written in instance. See above.

That's all. If you don't understand any of the points - write to the comments - I'll try to explain them, and see the demo at codepen.

Discussion (0)