UNPKG

dropdownizer

Version:

Converts HTML <select> elements into customizable dropdowns.

760 lines (621 loc) 22.9 kB
/** * @author Leandro Silva * @copyright 2015, 2017-2018 Leandro Silva (http://grafluxe.com) * @license MIT * * @desc Creates a new Dropdownizer instance. * @throws {TypeError} Throws if an unexpected argument was passed in. * @throws {ReferenceError} Throws if no such element exists in the DOM. * @throws {ReferenceError} Throws if your element has already been dropdownized. * @throws {ReferenceError} Throws if your element already has the reserved class name 'dropdownizer.' * @param {String|HTMLElement} el The element(s) to dropdownize. */ class Dropdownizer { constructor(el) { let dds = []; if (typeof el === "string") { el = document.querySelectorAll(el); } else if (el && el.nodeType) { el = [el]; } if (!el || !el.forEach || el.length === 0) { throw new ReferenceError("No such element exists."); } el.forEach(element => dds.push(new Dropdownize(element))); this._dropdowns = Object.freeze(dds); } /** * Programmatically selects list items. * @throws {Error} Throws if your search returns multiple matches. * @throws {RangeError} Throws if the index is out of bounds. * @param {Number|String} at The list items index or name. Use a negative number to select * from the end of the list. Note that if using a string, letter case * is ignored. * @returns {Dropdownizer} The Dropdownizer instance. */ selectItem(at) { this._dropdowns.forEach(dropdown => dropdown.selectItem(at)); return this; } /** * Gets information about the currently selected list item(s). * @type {Array|Object} */ get selectedItem() { let selectedItems = this._dropdowns.map(dropdown => dropdown.selectedItem); return (selectedItems.length > 1 ? selectedItems : selectedItems[0]); } /** * Listens for change events. * @param {Function} callback The callback function to execute when a list item changes. * @returns {Dropdownizer} The Dropdownizer instance. * @deprecated This method has been renamed to 'onChange'. */ change(callback) { console.warn("The Dropdownizer method 'change' has been renamed to 'onChange'. Please update your logic accordingly, as the 'change' method will be removed in a future release."); return this.onChange(callback); } /** * Listens for change events. * @param {Function} callback The callback function to execute when a list item changes. * @returns {Dropdownizer} The Dropdownizer instance. */ onChange(callback) { this._dropdowns.forEach(dropdown => dropdown.onChange(callback)); return this; } /** * Listens for open events. * @param {Function} callback The callback function to execute when a dropdown is opened. * @returns {Dropdownizer} The Dropdownizer instance. */ onOpen(callback) { this._dropdowns.forEach(dropdown => dropdown.onOpen(callback)); return this; } /** * Listens for close events. * @param {Function} callback The callback function to execute when a dropdown is closed. * @returns {Dropdownizer} The Dropdownizer instance. */ onClose(callback) { this._dropdowns.forEach(dropdown => dropdown.onClose(callback)); return this; } /** * Deletes list items. Note that this method properly syncs your original select elements. * @throws {Error} Throws if your search returns multiple matches. * @throws {RangeError} Throws if the index is out of bounds. * @param {Number|String} at The list items index or name. Use a negative number to select * from the end of the list. Note that if using a string, letter case * is ignored. * @returns {Dropdownizer} The Dropdownizer instance. */ removeItem(at) { this._dropdowns.forEach(dropdown => dropdown.removeItem(at)); return this; } /** * Adds list items. Note that this method properly syncs your original select elements. * @throws {RangeError} Throws if the index is out of bounds. * @param {String} value The items value. * @param {Object=} attributes={} Attributes to add to the list item. The supported * properties are 'label', 'disabled', and 'selected'. * @param {Number=} at=NaN The index in which to insert your new list item * (defaults to the last item if not set). Use a * negative number to insert from the end of the list. * @returns {Dropdownizer} The Dropdownizer instance. */ addItem(value, attributes = {}, at = NaN) { this._dropdowns.forEach(dropdown => dropdown.addItem(value, attributes, at)); return this; } /** * Removes all listeners. * @returns {Dropdownizer} The Dropdownizer instance. */ removeListeners() { this._dropdowns.forEach(dropdown => dropdown.removeListeners()); return this; } /** * Enables the disabled dropdowns. * @returns {Dropdownizer} The Dropdownizer instance. */ enable() { this._dropdowns.forEach(dropdown => dropdown.enable()); return this; } /** * Disables the dropdowns. * @returns {Dropdownizer} The Dropdownizer instance. */ disable() { this._dropdowns.forEach(dropdown => dropdown.disable()); return this; } /** * Removes listeners and destroys the dropdownizer instances. * @param {Boolean=} resetOriginalElement=false Whether to reset the original 'select' elements. * @returns {Dropdownizer} The Dropdownizer instance. */ destroy(resetOriginalElement = false) { this._dropdowns.forEach(dropdown => dropdown.destroy(resetOriginalElement)); return this; } /** * Gets all dropdowns. */ get dropdowns() { return this._dropdowns; } /** * Prevents native mobile dropdowns. If prevented, dropdowns on mobile/touchable devices will work as * they do on desktops. */ static preventNative() { Dropdownize._preventNative = true; } } /** * @ignore */ class Dropdownize { /** * Creates a new Dropdownize instance. * @throws {TypeError} Throws if an unexpected argument was passed in. * @throws {ReferenceError} Throws if no such element exists in the DOM. * @throws {ReferenceError} Throws if your element has already been dropdownized. * @throws {ReferenceError} Throws if your element already has the reserved class name 'dropdownizer.' * @param {String|HTMLElement} el The element to dropdownize. */ constructor(el) { if (typeof el === "string") { el = document.querySelector(el); } if (!el || el.length === 0) { throw new ReferenceError("No such element exists."); } if (!el.nodeType) { throw new TypeError("An unexpected argument was passed in."); } if (el.hasOwnProperty("dropdownized") || el.hasOwnProperty("dropdownizer")) { throw new ReferenceError("Your element has already been dropdownized."); } if (el.classList.contains("dropdownizer")) { throw new ReferenceError("The class name 'dropdownizer' is reserved. Please choose a different class name."); } this._el = el; this._createElements(); this._bindEvents(); this._convertOptionsToListItems(); this._setBtn(); this._setDropdown(); this._addListItemsListeners(); this._addToDOM(); } _createElements() { this._ui = { div: document.createElement("div"), btn: document.createElement("button"), ul: document.createElement("ul") }; } _bindEvents() { this._onClickBtn = this._openList.bind(this); this._onMouseOver = this._mouseOver.bind(this); this._onMouseLeave = this._mouseLeave.bind(this); this._onChange = this._syncDropdowns.bind(this); this._onClickListItem = this._listSelect.bind(this); this._onDocClick = this._preventNativeClick.bind(this); } _mouseLeave() { this._leaveTimer = setTimeout(this._closeList.bind(this), 250); this._ui.div.addEventListener("mouseover", this._onMouseOver); } _mouseOver() { this._ui.div.removeEventListener("mouseover", this._onMouseOver); clearTimeout(this._leaveTimer); } _convertOptionsToListItems() { this._listItems = []; this._lastSelectedIndex = 0; this._options = Array.from(this._el.querySelectorAll("option")); this._longestLine = 0; this._options.forEach((option, i) => { let listItem = document.createElement("li"); this._setAttributes(listItem, option, i); listItem.innerHTML = option.label; if (option.label.length > this._longestLine) { this._longestLine = option.label.length; } this._listItems.push(listItem); this._ui.ul.appendChild(listItem); }); this._listItems[this._lastSelectedIndex].setAttribute("data-selected", true); } _setAttributes(listItem, option, i) { listItem.setAttribute("data-value", option.value); Array.from(option.attributes).forEach(attr => { if (attr.name === "selected") { this._lastSelectedIndex = i; } else { listItem.setAttribute("data-" + attr.name, attr.value || true); } }); } _setBtn() { this._touchable = window.hasOwnProperty("ontouchstart") || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; this._copySelectElementAttributes(); this._bindFromOriginalElement(); this._ui.btn.addEventListener("click", this._onClickBtn); this._ui.btn.innerHTML = this._options[this._lastSelectedIndex].label; } _copySelectElementAttributes() { let supportedInDivSpec = [ "accesskey", "class", "contenteditable", "contextmenu", "id", "dir", "draggable", "dropzone", "hidden", "id", "itemid", "itemprop", "itemref", "itemscope", "itemtype", "lang", "slot", "name", "spellcheck", "style", "tabindex", "title", "translate" ]; Array.from(this._el.attributes).forEach(attr => { let dataTag = ""; if (!supportedInDivSpec.includes(attr.name) && attr.name.slice(0, 5) !== "data-") { dataTag = "data-"; } this._ui.div.setAttribute(dataTag + attr.name, attr.value || true); }); } _openList(evt) { evt.preventDefault(); if (this._ui.div.hasAttribute("disabled") || this._el.hasAttribute("disabled")) { return; } if (this._touchable && !Dropdownize._preventNative) { this._el.classList.remove("dd-x"); this._el.focus(); this._el.classList.add("dd-x"); } else { if (this._ui.div.classList.contains("dd-open")) { this._closeList(); } else { this._ui.div.classList.add("dd-open"); if (Dropdownize._preventNative) { document.addEventListener("click", this._onDocClick); } else { this._ui.div.addEventListener("mouseleave", this._onMouseLeave); } } } if (this._openCallback && this._ui.div.classList.contains("dd-open")) { this._openCallback({ target: this._ui.div, type: "open" }); } } _preventNativeClick(evt) { if (evt.target.parentNode !== this._ui.div) { document.removeEventListener("click", this._onDocClick); this._closeList(); } } _closeList() { if (this._ui.div.classList.contains("dd-open")) { this._ui.div.classList.remove("dd-open"); if (this._closeCallback) { this._closeCallback({ target: this._ui.div, type: "close" }); } } } _bindFromOriginalElement() { this._el.addEventListener("change", this._onChange); } _syncDropdowns(evt) { let selectedListItem = this._listItems[evt.target.options.selectedIndex]; this._changeFromOriginalElement = true; selectedListItem.click(); selectedListItem.focus(); } _setDropdown() { let computedStyles = window.getComputedStyle(this._el), divWidth = this._el.offsetWidth; if (this._touchable && computedStyles.minWidth === "0px") { divWidth += 9; } this._ui.div.dropdownizer = this; this._ui.div.style.minWidth = divWidth + "px"; this._ui.div.classList = this._el.classList; this._ui.div.classList.add("dropdownizer"); this._ui.div.appendChild(this._ui.btn); this._ui.div.appendChild(this._ui.ul); if (this._el.offsetWidth === 0) { // Reestimate width if 'offsetWidth' is 0. Added since invisible items have a 0 'offsetWidth'. setTimeout(() => { let btnComputedStyles = window.getComputedStyle(this._ui.btn), padding = parseInt(btnComputedStyles.paddingLeft) + parseInt(btnComputedStyles.paddingRight), fontSize = Math.max(parseInt(computedStyles.fontSize), parseInt(btnComputedStyles.fontSize)); divWidth = Math.ceil((fontSize / 2) * this._longestLine + padding); this._ui.div.style.minWidth = divWidth + "px"; }, 0); } } _addListItemsListeners() { this._listItems.forEach(listItem => { listItem.addEventListener("click", this._onClickListItem); }); } _listSelect(evt) { if (evt.target.dataset.disabled || evt.target === this.selectedItem.selectedTarget) { return; } this.selectItem(this._listItems.indexOf(evt.target)); this._closeList(); } _addToDOM() { this._el.parentNode.insertBefore(this._ui.div, this._el.nextSibling); if (this._el.id) { this._origId = this._el.id; this._ui.div.id = this._el.id; this._el.id = "__" + this._el.id; } this._origClasses = this._el.classList.toString(); this._el.dropdownized = true; this._el.classList = "dd-x"; } /** * Programmatically selects a list item. * @throws {Error} Throws if your search returns multiple matches. * @throws {RangeError} Throws if the index is out of bounds. * @param {Number|String} at The list items index or name. Use a negative number to select * from the end of the list. Note that if using a string, letter case * is ignored. * @returns {Dropdownize} The Dropdownize instance. */ selectItem(at) { if (typeof at === "string") { at = this._convertToIndex(at); } if (at < 0) { at = this._listItems.length + at; } let listItem = this._listItems[at]; if (!listItem) { throw new RangeError("Your index is out of bounds."); } if (listItem === this._listItems[this._lastSelectedIndex]) { return; } this._listItems[this._lastSelectedIndex].removeAttribute("data-selected"); this._lastSelectedIndex = at; this._ui.btn.innerHTML = listItem.innerHTML; listItem.setAttribute("data-selected", true); this._el.selectedIndex = this._lastSelectedIndex; if (this._changeCallback) { this._changeCallback(this._callbackArgs(listItem, "change")); } if (!this._changeFromOriginalElement) { this._el.dispatchEvent(new Event("change")); } this._changeFromOriginalElement = false; return this; } _convertToIndex(at) { at = at.toLowerCase(); let match = this._listItems.filter(li => { let val = li.dataset.label || li.dataset.value; return val.toLowerCase() === at; }); if (match.length > 1) { throw new Error("Your search returns multiple matches. Use an index instead."); } return this._listItems.indexOf(match[0]); } _callbackArgs(listItem, type) { let data = Object.assign({index: this._lastSelectedIndex}, listItem.dataset), out; delete data.selected; out = { target: this._ui.div, selectedTarget: listItem, data }; if (type) { out.type = type; } return out; } /** * Gets information about the currently selected list item. * @type {Object} */ get selectedItem() { return this._callbackArgs(this._listItems[this._lastSelectedIndex]); } /** * Listens for change events. * @param {Function} callback The callback function to execute when a list item changes. * @returns {Dropdownize} The Dropdownize instance. */ onChange(callback) { this._changeCallback = callback; return this; } /** * Listens for an open event. * @param {Function} callback The callback function to execute when a dropdown is opened. * @returns {Dropdownize} The Dropdownize instance. */ onOpen(callback) { this._openCallback = callback; return this; } /** * Listens for a close event. * @param {Function} callback The callback function to execute when a dropdown is closed. * @returns {Dropdownize} The Dropdownize instance. */ onClose(callback) { this._closeCallback = callback; return this; } /** * Deletes a list item. Note that this method properly syncs your original select element. * @throws {Error} Throws if your search returns multiple matches. * @throws {RangeError} Throws if the index is out of bounds. * @param {Number|String} at The list items index or name. Use a negative number to select * from the end of the list. Note that if using a string, letter case * is ignored. * @returns {Dropdownize} The Dropdownize instance. */ removeItem(at) { if (typeof at === "string") { at = this._convertToIndex(at); } if (at < 0) { at = this._listItems.length + at; } let listItem = this._listItems[at]; if (!listItem) { throw new RangeError("Your index is out of bounds."); } this._ui.ul.removeChild(listItem); this._listItems.splice(at, 1); this._el.removeChild(this._options[at]); this._options.splice(at, 1); if (at === this._lastSelectedIndex) { let next = Math.max(at - 1, 0); this._lastSelectedIndex = next; if (this._listItems.length > 0) { this._ui.btn.innerHTML = this._listItems[next].innerHTML; this._listItems[next].setAttribute("data-selected", true); } else { this._ui.btn.innerHTML = "&nbsp;"; } this._el.selectedIndex = this._lastSelectedIndex; } return this; } /** * Adds a list item. Note that this method properly syncs your original select element. * @throws {RangeError} Throws if the index is out of bounds. * @param {String} value The items value. * @param {Object=} attributes={} Attributes to add to the list item. The supported * properties are 'label', 'disabled', and 'selected'. * @param {Number=} at=NaN The index in which to insert your new list item * (defaults to the last item if not set). Use a * negative number to insert from the end of the list. * @returns {Dropdownize} The Dropdownize instance. */ addItem(value, attributes = {}, at = NaN) { if (at < 0) { at = this._listItems.length + at; } if (at > this._el.childElementCount || at < 0) { throw new RangeError("Your index is out of bounds."); } else if (isNaN(at) || at === null) { at = this._el.childElementCount; } this._addToSelect(value, attributes, at); this._addToList(value, attributes, at); return this; } _addToSelect(value, attributes = {}, at = NaN) { let option = document.createElement("option"), existingOptions = Array.from(this._el.childNodes).filter(node => node.nodeName === "OPTION"); option.innerHTML = value; this._el.insertBefore(option, existingOptions[at]); this._options.splice(at, 0, option); if (attributes.hasOwnProperty("label")) { option.setAttribute("label", attributes.label || true); } if (attributes.hasOwnProperty("disabled")) { option.setAttribute("disabled", attributes.disabled || true); } if (attributes.hasOwnProperty("selected")) { option.setAttribute("selected", attributes.selected || true); } } _addToList(value, attributes = {}, at = NaN) { let li = document.createElement("li"); li.dataset.value = value; li.innerHTML = attributes.label || value; this._ui.ul.insertBefore(li, this._ui.ul.childNodes[at]); this._listItems.splice(at, 0, li); li.addEventListener("click", this._onClickListItem); if (attributes.hasOwnProperty("label")) { li.setAttribute("data-label", attributes.label || true); } if (attributes.hasOwnProperty("disabled")) { li.setAttribute("data-disabled", attributes.disabled || true); } if (attributes.hasOwnProperty("selected")) { li.setAttribute("data-selected", attributes.selected || true); if (!attributes.hasOwnProperty("disabled")) { if (at === 0) { this._lastSelectedIndex++; } this.selectItem(at); } } } /** * Removes all listeners. * @returns {Dropdownize} The Dropdownize instance. */ removeListeners() { this._ui.btn.removeEventListener("click", this._onClickBtn); this._ui.div.removeEventListener("mouseleave", this._onMouseLeave); this._ui.div.removeEventListener("mouseover", this._onMouseOver); this._el.removeEventListener("change", this._onChange); document.removeEventListener("click", this._onDocClick); this._listItems.forEach(listItem => { listItem.removeEventListener("click", this._onClickListItem); }); return this; } /** * Enables a disabled dropdown. * @returns {Dropdownize} The Dropdownize instance. */ enable() { this._el.removeAttribute("disabled"); this._ui.div.removeAttribute("data-disabled"); return this; } /** * Disables the dropdown. * @returns {Dropdownize} The Dropdownize instance. */ disable() { this._el.setAttribute("disabled", "true"); this._ui.div.setAttribute("data-disabled", "true"); return this; } /** * Removes listeners and destroys the dropdownizer instance. * @param {Boolean=} resetOriginalElement=false Whether to reset the original 'select' element. * @returns {Dropdownize} The Dropdownize instance. */ destroy(resetOriginalElement = false) { if (!this._destroyed) { this._destroyed = true; this.removeListeners(); this._el.parentNode.removeChild(this._ui.div); if (resetOriginalElement) { if (this._origId) { this._el.id = this._origId; } this._el.classList = this._origClasses; } } return this; } } // Support CJS/Node if (typeof module === "object" && module.exports) { module.exports = Dropdownizer; }