UNPKG

custom-select

Version:

A lightweight JavaScript library for custom HTML <select> creation and managing. No dependencies needed.

618 lines (544 loc) 21.1 kB
/** * custom-select * A lightweight JS script for custom select creation. * Needs no dependencies. * * v0.0.1 * (https://github.com/custom-select/custom-select) * * Copyright (c) 2016 Gionatan Lombardi & Marco Nucara * MIT License */ import 'custom-event-polyfill'; const defaultParams = { containerClass: 'custom-select-container', openerClass: 'custom-select-opener', panelClass: 'custom-select-panel', optionClass: 'custom-select-option', optgroupClass: 'custom-select-optgroup', isSelectedClass: 'is-selected', hasFocusClass: 'has-focus', isDisabledClass: 'is-disabled', isOpenClass: 'is-open', }; function builder(el, builderParams) { const containerClass = 'customSelect'; let isOpen = false; let uId = ''; const select = el; let container; let opener; let focusedElement; let selectedElement; let panel; let currLabel; let resetSearchTimeout; let searchKey = ''; // // Inner Functions // // Sets the focused element with the neccessary classes substitutions function setFocusedElement(cstOption) { if (focusedElement) { focusedElement.classList.remove(builderParams.hasFocusClass); } if (typeof cstOption !== 'undefined') { focusedElement = cstOption; focusedElement.classList.add(builderParams.hasFocusClass); // Offset update: checks if the focused element is in the visible part of the panelClass // if not dispatches a custom event if (isOpen) { if (cstOption.offsetTop < cstOption.offsetParent.scrollTop || cstOption.offsetTop > (cstOption.offsetParent.scrollTop + cstOption.offsetParent.clientHeight) - cstOption.clientHeight) { cstOption.dispatchEvent(new CustomEvent('custom-select:focus-outside-panel', { bubbles: true })); } } } else { focusedElement = undefined; } } // Reassigns the focused and selected custom option // Updates the opener text // IMPORTANT: the setSelectedElement function doesn't change the select value! function setSelectedElement(cstOption) { if (selectedElement) { selectedElement.classList.remove(builderParams.isSelectedClass); selectedElement.removeAttribute('id'); opener.removeAttribute('aria-activedescendant'); } if (typeof cstOption !== 'undefined') { cstOption.classList.add(builderParams.isSelectedClass); cstOption.setAttribute('id', `${containerClass}-${uId}-selectedOption`); opener.setAttribute('aria-activedescendant', `${containerClass}-${uId}-selectedOption`); selectedElement = cstOption; opener.children[0].textContent = selectedElement.customSelectOriginalOption.text; } else { selectedElement = undefined; opener.children[0].textContent = ''; } setFocusedElement(cstOption); } function setValue(value) { // Gets the option with the provided value let toSelect = select.querySelector(`option[value='${value}']`); // If no option has the provided value get the first if (!toSelect) { [toSelect] = select.options; } // The option with the provided value becomes the selected one // And changes the select current value toSelect.selected = true; setSelectedElement(select.options[select.selectedIndex].customSelectCstOption); } function moveFocuesedElement(direction) { // Get all the .custom-select-options // Get the index of the current focused one const currentFocusedIndex = [].indexOf.call(select.options, focusedElement.customSelectOriginalOption); // If the next or prev custom option exist // Sets it as the new focused one if (select.options[currentFocusedIndex + direction]) { setFocusedElement(select.options[currentFocusedIndex + direction].customSelectCstOption); } } // Open/Close function (toggle) function open(bool) { // Open if (bool || typeof bool === 'undefined') { // If present closes an opened instance of the plugin // Only one at time can be open const openedCustomSelect = document.querySelector(`.${containerClass}.${builderParams.isOpenClass}`); if (openedCustomSelect) { openedCustomSelect.customSelect.open = false; } // Opens only the clicked one container.classList.add(builderParams.isOpenClass); // aria-expanded update container.classList.add(builderParams.isOpenClass); opener.setAttribute('aria-expanded', 'true'); // Updates the scrollTop position of the panel in relation with the focused option if (selectedElement) { panel.scrollTop = selectedElement.offsetTop; } // Dispatches the custom event open container.dispatchEvent(new CustomEvent('custom-select:open')); // Sets the global state isOpen = true; // Close } else { // Removes the css classes container.classList.remove(builderParams.isOpenClass); // aria-expanded update opener.setAttribute('aria-expanded', 'false'); // Sets the global state isOpen = false; // When closing the panel the focused custom option must be the selected one setFocusedElement(selectedElement); // Dispatches the custom event close container.dispatchEvent(new CustomEvent('custom-select:close')); } return isOpen; } function clickEvent(e) { // Opener click if (e.target === opener || opener.contains(e.target)) { if (isOpen) { open(false); } else { open(); } // Custom Option click } else if ( e.target.classList && e.target.classList.contains(builderParams.optionClass) && panel.contains(e.target)) { setSelectedElement(e.target); // Sets the corrisponding select's option to selected updating the select's value too selectedElement.customSelectOriginalOption.selected = true; open(false); // Triggers the native change event of the select select.dispatchEvent(new CustomEvent('change')); // click on label or select (click on label corrispond to select click) } else if (e.target === select) { // if the original select is focusable (for any external reason) let the focus // else trigger the focus on opener if (opener !== document.activeElement && select !== document.activeElement) { opener.focus(); } // Click outside the container closes the panel } else if (isOpen && !container.contains(e.target)) { open(false); } } function mouseoverEvent(e) { // On mouse move over and options it bacames the focused one if (e.target.classList && e.target.classList.contains(builderParams.optionClass)) { setFocusedElement(e.target); } } function keydownEvent(e) { if (!isOpen) { // On "Arrow down", "Arrow up" and "Space" keys opens the panel if (e.keyCode === 40 || e.keyCode === 38 || e.keyCode === 32) { open(); } } else { switch (e.keyCode) { case 13: case 32: // On "Enter" or "Space" selects the focused element as the selected one setSelectedElement(focusedElement); // Sets the corrisponding select's option to selected updating the select's value too selectedElement.customSelectOriginalOption.selected = true; // Triggers the native change event of the select select.dispatchEvent(new CustomEvent('change')); open(false); break; case 27: // On "Escape" closes the panel open(false); break; case 38: // On "Arrow up" set focus to the prev option if present moveFocuesedElement(-1); break; case 40: // On "Arrow down" set focus to the next option if present moveFocuesedElement(+1); break; default: // search in panel (autocomplete) if (e.keyCode >= 48 && e.keyCode <= 90) { // clear existing reset timeout if (resetSearchTimeout) { clearTimeout(resetSearchTimeout); } // reset timeout for empty search key resetSearchTimeout = setTimeout(() => { searchKey = ''; }, 1500); // update search keyword appending the current key searchKey += String.fromCharCode(e.keyCode); // search the element for (let i = 0, l = select.options.length; i < l; i++) { // removed cause not supported by IE: // if (options[i].text.startsWith(searchKey)) if (select.options[i].text.toUpperCase().substr(0, searchKey.length) === searchKey) { setFocusedElement(select.options[i].customSelectCstOption); break; } } } break; } } } function changeEvent() { const index = select.selectedIndex; const element = index === -1 ? undefined : select.options[index].customSelectCstOption; setSelectedElement(element); } // When the option is outside the visible part of the opened panel, updates the scrollTop position // This is the default behaviour // To block it the plugin user must // add a "custom-select:focus-outside-panel" eventListener on the panel // with useCapture set to true // and stopPropagation function scrollToFocused(e) { const currPanel = e.currentTarget; const currOption = e.target; // Up if (currOption.offsetTop < currPanel.scrollTop) { currPanel.scrollTop = currOption.offsetTop; // Down } else { currPanel.scrollTop = (currOption.offsetTop + currOption.clientHeight) - currPanel.clientHeight; } } function addEvents() { document.addEventListener('click', clickEvent); panel.addEventListener('mouseover', mouseoverEvent); panel.addEventListener('custom-select:focus-outside-panel', scrollToFocused); select.addEventListener('change', changeEvent); container.addEventListener('keydown', keydownEvent); } function removeEvents() { document.removeEventListener('click', clickEvent); panel.removeEventListener('mouseover', mouseoverEvent); panel.removeEventListener('custom-select:focus-outside-panel', scrollToFocused); select.removeEventListener('change', changeEvent); container.removeEventListener('keydown', keydownEvent); } function disabled(bool) { if (bool && !select.disabled) { container.classList.add(builderParams.isDisabledClass); select.disabled = true; opener.removeAttribute('tabindex'); container.dispatchEvent(new CustomEvent('custom-select:disabled')); removeEvents(); } else if (!bool && select.disabled) { container.classList.remove(builderParams.isDisabledClass); select.disabled = false; opener.setAttribute('tabindex', '0'); container.dispatchEvent(new CustomEvent('custom-select:enabled')); addEvents(); } } // Form a given select children DOM tree (options and optgroup), // Creates the corresponding custom HTMLElements list (divs with different classes and attributes) function parseMarkup(children) { const nodeList = children; const cstList = []; if (typeof nodeList.length === 'undefined') { throw new TypeError('Invalid Argument'); } for (let i = 0, li = nodeList.length; i < li; i++) { if (nodeList[i] instanceof HTMLElement && nodeList[i].tagName.toUpperCase() === 'OPTGROUP') { const cstOptgroup = document.createElement('div'); cstOptgroup.classList.add(builderParams.optgroupClass); cstOptgroup.setAttribute('data-label', nodeList[i].label); // IMPORTANT: Stores in a property of the created custom option group // a hook to the the corrisponding select's option group cstOptgroup.customSelectOriginalOptgroup = nodeList[i]; // IMPORTANT: Stores in a property of select's option group // a hook to the created custom option group nodeList[i].customSelectCstOptgroup = cstOptgroup; const subNodes = parseMarkup(nodeList[i].children); for (let j = 0, lj = subNodes.length; j < lj; j++) { cstOptgroup.appendChild(subNodes[j]); } cstList.push(cstOptgroup); } else if (nodeList[i] instanceof HTMLElement && nodeList[i].tagName.toUpperCase() === 'OPTION') { const cstOption = document.createElement('div'); cstOption.classList.add(builderParams.optionClass); cstOption.textContent = nodeList[i].text; cstOption.setAttribute('data-value', nodeList[i].value); cstOption.setAttribute('role', 'option'); // IMPORTANT: Stores in a property of the created custom option // a hook to the the corrisponding select's option cstOption.customSelectOriginalOption = nodeList[i]; // IMPORTANT: Stores in a property of select's option // a hook to the created custom option nodeList[i].customSelectCstOption = cstOption; // If the select's option is selected if (nodeList[i].selected) { setSelectedElement(cstOption); } cstList.push(cstOption); } else { throw new TypeError('Invalid Argument'); } } return cstList; } function append(nodePar, appendIntoOriginal, targetPar) { let target; if (typeof targetPar === 'undefined' || (targetPar === select)) { target = panel; } else if (targetPar instanceof HTMLElement && targetPar.tagName.toUpperCase() === 'OPTGROUP' && select.contains(targetPar)) { target = targetPar.customSelectCstOptgroup; } else { throw new TypeError('Invalid Argument'); } // If the node provided is a single HTMLElement it is stored in an array const node = nodePar instanceof HTMLElement ? [nodePar] : nodePar; // Injects the options|optgroup in the select if (appendIntoOriginal) { for (let i = 0, l = node.length; i < l; i++) { if (target === panel) { select.appendChild(node[i]); } else { target.customSelectOriginalOptgroup.appendChild(node[i]); } } } // The custom markup to append const markupToInsert = parseMarkup(node); // Injects the created DOM content in the panel for (let i = 0, l = markupToInsert.length; i < l; i++) { target.appendChild(markupToInsert[i]); } return node; } function insertBefore(node, targetPar) { let target; if (targetPar instanceof HTMLElement && targetPar.tagName.toUpperCase() === 'OPTION' && select.contains(targetPar)) { target = targetPar.customSelectCstOption; } else if (targetPar instanceof HTMLElement && targetPar.tagName.toUpperCase() === 'OPTGROUP' && select.contains(targetPar)) { target = targetPar.customSelectCstOptgroup; } else { throw new TypeError('Invalid Argument'); } // The custom markup to append const markupToInsert = parseMarkup(node.length ? node : [node]); target.parentNode.insertBefore(markupToInsert[0], target); // Injects the option or optgroup node in the original select and returns the injected node return targetPar.parentNode.insertBefore(node.length ? node[0] : node, targetPar); } function remove(node) { let cstNode; if (node instanceof HTMLElement && node.tagName.toUpperCase() === 'OPTION' && select.contains(node)) { cstNode = node.customSelectCstOption; } else if (node instanceof HTMLElement && node.tagName.toUpperCase() === 'OPTGROUP' && select.contains(node)) { cstNode = node.customSelectCstOptgroup; } else { throw new TypeError('Invalid Argument'); } cstNode.parentNode.removeChild(cstNode); const removedNode = node.parentNode.removeChild(node); changeEvent(); return removedNode; } function empty() { const removed = []; while (select.children.length) { panel.removeChild(panel.children[0]); removed.push(select.removeChild(select.children[0])); } setSelectedElement(); return removed; } function destroy() { for (let i = 0, l = select.options.length; i < l; i++) { delete select.options[i].customSelectCstOption; } const optGroup = select.getElementsByTagName('optgroup'); for (let i = 0, l = optGroup.length; i < l; i++) { delete optGroup.customSelectCstOptgroup; } removeEvents(); return container.parentNode.replaceChild(select, container); } // // Custom Select DOM tree creation // // Creates the container/wrapper container = document.createElement('div'); container.classList.add(builderParams.containerClass, containerClass); // Creates the opener opener = document.createElement('span'); opener.className = builderParams.openerClass; opener.setAttribute('role', 'combobox'); opener.setAttribute('aria-autocomplete', 'list'); opener.setAttribute('aria-expanded', 'false'); opener.innerHTML = `<span> ${(select.selectedIndex !== -1 ? select.options[select.selectedIndex].text : '')} </span>`; // Creates the panel // and injects the markup of the select inside // with some tag and attributes replacement panel = document.createElement('div'); // Create random id const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; for (let i = 0; i < 5; i++) { uId += possible.charAt(Math.floor(Math.random() * possible.length)); } panel.id = `${containerClass}-${uId}-panel`; panel.className = builderParams.panelClass; panel.setAttribute('role', 'listbox'); opener.setAttribute('aria-owns', panel.id); append(select.children, false); // Injects the container in the original DOM position of the select container.appendChild(opener); select.parentNode.replaceChild(container, select); container.appendChild(select); container.appendChild(panel); // ARIA labelledby - label if (document.querySelector(`label[for="${select.id}"]`)) { currLabel = document.querySelector(`label[for="${select.id}"]`); } else if (container.parentNode.tagName.toUpperCase() === 'LABEL') { currLabel = container.parentNode; } if (typeof currLabel !== 'undefined') { currLabel.setAttribute('id', `${containerClass}-${uId}-label`); opener.setAttribute('aria-labelledby', `${containerClass}-${uId}-label`); } // Event Init if (select.disabled) { container.classList.add(builderParams.isDisabledClass); } else { opener.setAttribute('tabindex', '0'); select.setAttribute('tabindex', '-1'); addEvents(); } // Stores the plugin public exposed methods and properties, directly in the container HTMLElement container.customSelect = { get pluginOptions() { return builderParams; }, get open() { return isOpen; }, set open(bool) { open(bool); }, get disabled() { return select.disabled; }, set disabled(bool) { disabled(bool); }, get value() { return select.value; }, set value(val) { setValue(val); }, append: (node, target) => append(node, true, target), insertBefore: (node, target) => insertBefore(node, target), remove, empty, destroy, opener, select, panel, container, }; // Stores the plugin directly in the original select select.customSelect = container.customSelect; // Returns the plugin instance, with the public exposed methods and properties return container.customSelect; } export default function customSelect(element, customParams) { // Overrides the default options with the ones provided by the user const nodeList = []; const selects = []; return (function init() { // The plugin is called on a single HTMLElement if (element && element instanceof HTMLElement && element.tagName.toUpperCase() === 'SELECT') { nodeList.push(element); // The plugin is called on a selector } else if (element && typeof element === 'string') { const elementsList = document.querySelectorAll(element); for (let i = 0, l = elementsList.length; i < l; ++i) { if (elementsList[i] instanceof HTMLElement && elementsList[i].tagName.toUpperCase() === 'SELECT') { nodeList.push(elementsList[i]); } } // The plugin is called on any HTMLElements list (NodeList, HTMLCollection, Array, etc.) } else if (element && element.length) { for (let i = 0, l = element.length; i < l; ++i) { if (element[i] instanceof HTMLElement && element[i].tagName.toUpperCase() === 'SELECT') { nodeList.push(element[i]); } } } // Launches the plugin over every HTMLElement // And stores every plugin instance for (let i = 0, l = nodeList.length; i < l; ++i) { selects.push(builder(nodeList[i], Object.assign({}, defaultParams, customParams))); } // Returns all plugin instances return selects; }()); }