/**
* @author Leandro Silva
* @copyright 2015, 2017 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 = " ";
}
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")) {
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;
}