UNPKG

jsoneditor

Version:

A web-based tool to view, edit, format, and validate JSON

2,147 lines (1,737 loc) 50.6 kB
/*! * Selectr 2.4.0 * https://github.com/Mobius1/Selectr * * Released under the MIT license */ 'use strict'; /** * Default configuration options * @type {Object} */ var defaultConfig = { /** * Emulates browser behaviour by selecting the first option by default * @type {Boolean} */ defaultSelected: true, /** * Sets the width of the container * @type {String} */ width: "auto", /** * Enables/ disables the container * @type {Boolean} */ disabled: false, /** * Enables / disables the search function * @type {Boolean} */ searchable: true, /** * Enable disable the clear button * @type {Boolean} */ clearable: false, /** * Sort the tags / multiselect options * @type {Boolean} */ sortSelected: false, /** * Allow deselecting of select-one options * @type {Boolean} */ allowDeselect: false, /** * Close the dropdown when scrolling (@AlexanderReiswich, #11) * @type {Boolean} */ closeOnScroll: false, /** * Allow the use of the native dropdown (@jonnyscholes, #14) * @type {Boolean} */ nativeDropdown: false, /** * Set the main placeholder * @type {String} */ placeholder: "Select an option...", /** * Allow the tagging feature * @type {Boolean} */ taggable: false, /** * Set the tag input placeholder (@labikmartin, #21, #22) * @type {String} */ tagPlaceholder: "Enter a tag..." }; /** * Event Emitter */ var Events = function() {}; /** * Event Prototype * @type {Object} */ Events.prototype = { /** * Add custom event listener * @param {String} event Event type * @param {Function} func Callback * @return {Void} */ on: function(event, func) { this._events = this._events || {}; this._events[event] = this._events[event] || []; this._events[event].push(func); }, /** * Remove custom event listener * @param {String} event Event type * @param {Function} func Callback * @return {Void} */ off: function(event, func) { this._events = this._events || {}; if (event in this._events === false) return; this._events[event].splice(this._events[event].indexOf(func), 1); }, /** * Fire a custom event * @param {String} event Event type * @return {Void} */ emit: function(event /* , args... */ ) { this._events = this._events || {}; if (event in this._events === false) return; for (var i = 0; i < this._events[event].length; i++) { this._events[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); } } }; /** * Event mixin * @param {Object} obj * @return {Object} */ Events.mixin = function(obj) { var props = ['on', 'off', 'emit']; for (var i = 0; i < props.length; i++) { if (typeof obj === 'function') { obj.prototype[props[i]] = Events.prototype[props[i]]; } else { obj[props[i]] = Events.prototype[props[i]]; } } return obj; }; /** * Helpers * @type {Object} */ var util = { extend: function(src, props) { props = props || {}; var p; for (p in src) { if (src.hasOwnProperty(p)) { if (!props.hasOwnProperty(p)) { props[p] = src[p]; } } } return props; }, each: function(a, b, c) { if ("[object Object]" === Object.prototype.toString.call(a)) { for (var d in a) { if (Object.prototype.hasOwnProperty.call(a, d)) { b.call(c, d, a[d], a); } } } else { for (var e = 0, f = a.length; e < f; e++) { b.call(c, e, a[e], a); } } }, createElement: function(e, a) { var d = document, el = d.createElement(e); if (a && "[object Object]" === Object.prototype.toString.call(a)) { var i; for (i in a) if (i in el) el[i] = a[i]; else if ("html" === i) el.innerHTML = a[i]; else if ("text" === i) { var t = d.createTextNode(a[i]); el.appendChild(t); } else el.setAttribute(i, a[i]); } return el; }, hasClass: function(a, b) { if (a) return a.classList ? a.classList.contains(b) : !!a.className && !!a.className.match(new RegExp("(\\s|^)" + b + "(\\s|$)")); }, addClass: function(a, b) { if (!util.hasClass(a, b)) { if (a.classList) { a.classList.add(b); } else { a.className = a.className.trim() + " " + b; } } }, removeClass: function(a, b) { if (util.hasClass(a, b)) { if (a.classList) { a.classList.remove(b); } else { a.className = a.className.replace(new RegExp("(^|\\s)" + b.split(" ").join("|") + "(\\s|$)", "gi"), " "); } } }, closest: function(el, fn) { return el && el !== document.body && (fn(el) ? el : util.closest(el.parentNode, fn)); }, isInt: function(val) { return typeof val === 'number' && isFinite(val) && Math.floor(val) === val; }, debounce: function(a, b, c) { var d; return function() { var e = this, f = arguments, g = function() { d = null; if (!c) a.apply(e, f); }, h = c && !d; clearTimeout(d); d = setTimeout(g, b); if (h) { a.apply(e, f); } }; }, rect: function(el, abs) { var w = window; var r = el.getBoundingClientRect(); var x = abs ? w.pageXOffset : 0; var y = abs ? w.pageYOffset : 0; return { bottom: r.bottom + y, height: r.height, left: r.left + x, right: r.right + x, top: r.top + y, width: r.width }; }, includes: function(a, b) { return a.indexOf(b) > -1; }, truncate: function(el) { while (el.firstChild) { el.removeChild(el.firstChild); } } }; function isset(obj, prop) { return obj.hasOwnProperty(prop) && (obj[prop] === true || obj[prop].length); } /** * Append an item to the list * @param {Object} item * @param {Object} custom * @return {Void} */ function appendItem(item, parent, custom) { if (item.parentNode) { if (!item.parentNode.parentNode) { parent.appendChild(item.parentNode); } } else { parent.appendChild(item); } util.removeClass(item, "excluded"); if (!custom) { item.innerHTML = item.textContent; } } /** * Render the item list * @return {Void} */ var render = function() { if (this.items.length) { var f = document.createDocumentFragment(); if (this.config.pagination) { var pages = this.pages.slice(0, this.pageIndex); util.each(pages, function(i, items) { util.each(items, function(j, item) { appendItem(item, f, this.customOption); }, this); }, this); } else { util.each(this.items, function(i, item) { appendItem(item, f, this.customOption); }, this); } if (f.childElementCount) { util.removeClass(this.items[this.navIndex], "active"); this.navIndex = f.querySelector(".selectr-option").idx; util.addClass(this.items[this.navIndex], "active"); } this.tree.appendChild(f); } }; /** * Dismiss / close the dropdown * @param {obj} e * @return {void} */ var dismiss = function(e) { var target = e.target; if (!this.container.contains(target) && (this.opened || util.hasClass(this.container, "notice"))) { this.close(); } }; /** * Build a list item from the HTMLOptionElement * @param {int} i HTMLOptionElement index * @param {HTMLOptionElement} option * @param {bool} group Has parent optgroup * @return {void} */ var createItem = function(option, data) { data = data || option; var content = this.customOption ? this.config.renderOption(data) : option.textContent; var opt = util.createElement("li", { class: "selectr-option", html: content, role: "treeitem", "aria-selected": false }); opt.idx = option.idx; this.items.push(opt); if (option.defaultSelected) { this.defaultSelected.push(option.idx); } if (option.disabled) { opt.disabled = true; util.addClass(opt, "disabled"); } return opt; }; /** * Build the container * @return {Void} */ var build = function() { this.requiresPagination = this.config.pagination && this.config.pagination > 0; // Set width if (isset(this.config, "width")) { if (util.isInt(this.config.width)) { this.width = this.config.width + "px"; } else { if (this.config.width === "auto") { this.width = "100%"; } else if (util.includes(this.config.width, "%")) { this.width = this.config.width; } } } this.container = util.createElement("div", { class: "selectr-container" }); // Custom className if (this.config.customClass) { util.addClass(this.container, this.config.customClass); } // Mobile device if (this.mobileDevice) { util.addClass(this.container, "selectr-mobile"); } else { util.addClass(this.container, "selectr-desktop"); } // Hide the HTMLSelectElement and prevent focus this.el.tabIndex = -1; // Native dropdown if (this.config.nativeDropdown || this.mobileDevice) { util.addClass(this.el, "selectr-visible"); } else { util.addClass(this.el, "selectr-hidden"); } this.selected = util.createElement("div", { class: "selectr-selected", disabled: this.disabled, tabIndex: 1, // enable tabIndex (#9) "aria-expanded": false }); this.label = util.createElement(this.el.multiple ? "ul" : "span", { class: "selectr-label" }); var dropdown = util.createElement("div", { class: "selectr-options-container" }); this.tree = util.createElement("ul", { class: "selectr-options", role: "tree", "aria-hidden": true, "aria-expanded": false }); this.notice = util.createElement("div", { class: "selectr-notice" }); this.el.setAttribute("aria-hidden", true); if (this.disabled) { this.el.disabled = true; } if (this.el.multiple) { util.addClass(this.label, "selectr-tags"); util.addClass(this.container, "multiple"); // Collection of tags this.tags = []; // Collection of selected values this.selectedValues = this.getSelectedProperties('value'); // Collection of selected indexes this.selectedIndexes = this.getSelectedProperties('idx'); } this.selected.appendChild(this.label); if (this.config.clearable) { this.selectClear = util.createElement("button", { class: "selectr-clear", type: "button" }); this.container.appendChild(this.selectClear); util.addClass(this.container, "clearable"); } if (this.config.taggable) { var li = util.createElement('li', { class: 'input-tag' }); this.input = util.createElement("input", { class: "selectr-tag-input", placeholder: this.config.tagPlaceholder, tagIndex: 0, autocomplete: "off", autocorrect: "off", autocapitalize: "off", spellcheck: "false", role: "textbox", type: "search" }); li.appendChild(this.input); this.label.appendChild(li); util.addClass(this.container, "taggable"); this.tagSeperators = [","]; if (this.config.tagSeperators) { this.tagSeperators = this.tagSeperators.concat(this.config.tagSeperators); } } if (this.config.searchable) { this.input = util.createElement("input", { class: "selectr-input", tagIndex: -1, autocomplete: "off", autocorrect: "off", autocapitalize: "off", spellcheck: "false", role: "textbox", type: "search" }); this.inputClear = util.createElement("button", { class: "selectr-input-clear", type: "button" }); this.inputContainer = util.createElement("div", { class: "selectr-input-container" }); this.inputContainer.appendChild(this.input); this.inputContainer.appendChild(this.inputClear); dropdown.appendChild(this.inputContainer); } dropdown.appendChild(this.notice); dropdown.appendChild(this.tree); // List of items for the dropdown this.items = []; // Establish options this.options = []; // Check for options in the element if (this.el.options.length) { this.options = [].slice.call(this.el.options); } // Element may have optgroups so // iterate element.children instead of element.options var group = false, j = 0; if (this.el.children.length) { util.each(this.el.children, function(i, element) { if (element.nodeName === "OPTGROUP") { group = util.createElement("ul", { class: "selectr-optgroup", role: "group", html: "<li class='selectr-optgroup--label'>" + element.label + "</li>" }); util.each(element.children, function(x, el) { el.idx = j; group.appendChild(createItem.call(this, el, group)); j++; }, this); } else { element.idx = j; createItem.call(this, element); j++; } }, this); } // Options defined by the data option if (this.config.data && Array.isArray(this.config.data)) { this.data = []; var optgroup = false, option; group = false; j = 0; util.each(this.config.data, function(i, opt) { // Check for group options if (isset(opt, "children")) { optgroup = util.createElement("optgroup", { label: opt.text }); group = util.createElement("ul", { class: "selectr-optgroup", role: "group", html: "<li class='selectr-optgroup--label'>" + opt.text + "</li>" }); util.each(opt.children, function(x, data) { option = new Option(data.text, data.value, false, data.hasOwnProperty("selected") && data.selected === true); option.disabled = isset(data, "disabled"); this.options.push(option); optgroup.appendChild(option); option.idx = j; group.appendChild(createItem.call(this, option, data)); this.data[j] = data; j++; }, this); } else { option = new Option(opt.text, opt.value, false, opt.hasOwnProperty("selected") && opt.selected === true); option.disabled = isset(opt, "disabled"); this.options.push(option); option.idx = j; createItem.call(this, option, opt); this.data[j] = opt; j++; } }, this); } this.setSelected(true); var first; this.navIndex = 0; for (var i = 0; i < this.items.length; i++) { first = this.items[i]; if (!util.hasClass(first, "disabled")) { util.addClass(first, "active"); this.navIndex = i; break; } } // Check for pagination / infinite scroll if (this.requiresPagination) { this.pageIndex = 1; // Create the pages this.paginate(); } this.container.appendChild(this.selected); this.container.appendChild(dropdown); this.placeEl = util.createElement("div", { class: "selectr-placeholder" }); // Set the placeholder this.setPlaceholder(); this.selected.appendChild(this.placeEl); // Disable if required if (this.disabled) { this.disable(); } this.el.parentNode.insertBefore(this.container, this.el); this.container.appendChild(this.el); }; /** * Navigate through the dropdown * @param {obj} e * @return {void} */ var navigate = function(e) { e = e || window.event; // Filter out the keys we don"t want if (!this.items.length || !this.opened || !util.includes([13, 38, 40], e.which)) { this.navigating = false; return; } e.preventDefault(); if (e.which === 13) { if (this.config.taggable && this.input.value.length > 0) { return false; } return this.change(this.navIndex); } var direction, prevEl = this.items[this.navIndex]; switch (e.which) { case 38: direction = 0; if (this.navIndex > 0) { this.navIndex--; } break; case 40: direction = 1; if (this.navIndex < this.items.length - 1) { this.navIndex++; } } this.navigating = true; // Instead of wasting memory holding a copy of this.items // with disabled / excluded options omitted, skip them instead while (util.hasClass(this.items[this.navIndex], "disabled") || util.hasClass(this.items[this.navIndex], "excluded")) { if (direction) { this.navIndex++; } else { this.navIndex--; } if (this.searching) { if (this.navIndex > this.tree.lastElementChild.idx) { this.navIndex = this.tree.lastElementChild.idx; break; } else if (this.navIndex < this.tree.firstElementChild.idx) { this.navIndex = this.tree.firstElementChild.idx; break; } } } // Autoscroll the dropdown during navigation var r = util.rect(this.items[this.navIndex]); if (!direction) { if (this.navIndex === 0) { this.tree.scrollTop = 0; } else if (r.top - this.optsRect.top < 0) { this.tree.scrollTop = this.tree.scrollTop + (r.top - this.optsRect.top); } } else { if (this.navIndex === 0) { this.tree.scrollTop = 0; } else if ((r.top + r.height) > (this.optsRect.top + this.optsRect.height)) { this.tree.scrollTop = this.tree.scrollTop + ((r.top + r.height) - (this.optsRect.top + this.optsRect.height)); } // Load another page if needed if (this.navIndex === this.tree.childElementCount - 1 && this.requiresPagination) { load.call(this); } } if (prevEl) { util.removeClass(prevEl, "active"); } util.addClass(this.items[this.navIndex], "active"); }; /** * Add a tag * @param {HTMLElement} item */ var addTag = function(item) { var that = this, r; var docFrag = document.createDocumentFragment(); var option = this.options[item.idx]; var data = this.data ? this.data[item.idx] : option; var content = this.customSelected ? this.config.renderSelection(data) : option.textContent; var tag = util.createElement("li", { class: "selectr-tag", html: content }); var btn = util.createElement("button", { class: "selectr-tag-remove", type: "button" }); tag.appendChild(btn); // Set property to check against later tag.idx = item.idx; tag.tag = option.value; this.tags.push(tag); if (this.config.sortSelected) { var tags = this.tags.slice(); // Deal with values that contain numbers r = function(val, arr) { val.replace(/(\d+)|(\D+)/g, function(that, $1, $2) { arr.push([$1 || Infinity, $2 || ""]); }); }; tags.sort(function(a, b) { var x = [], y = [], ac, bc; if (that.config.sortSelected === true) { ac = a.tag; bc = b.tag; } else if (that.config.sortSelected === 'text') { ac = a.textContent; bc = b.textContent; } r(ac, x); r(bc, y); while (x.length && y.length) { var ax = x.shift(); var by = y.shift(); var nn = (ax[0] - by[0]) || ax[1].localeCompare(by[1]); if (nn) return nn; } return x.length - y.length; }); util.each(tags, function(i, tg) { docFrag.appendChild(tg); }); this.label.innerHTML = ""; } else { docFrag.appendChild(tag); } if (this.config.taggable) { this.label.insertBefore(docFrag, this.input.parentNode); } else { this.label.appendChild(docFrag); } }; /** * Remove a tag * @param {HTMLElement} item * @return {void} */ var removeTag = function(item) { var tag = false; util.each(this.tags, function(i, t) { if (t.idx === item.idx) { tag = t; } }, this); if (tag) { this.label.removeChild(tag); this.tags.splice(this.tags.indexOf(tag), 1); } }; /** * Load the next page of items * @return {void} */ var load = function() { var tree = this.tree; var scrollTop = tree.scrollTop; var scrollHeight = tree.scrollHeight; var offsetHeight = tree.offsetHeight; var atBottom = scrollTop >= (scrollHeight - offsetHeight); if ((atBottom && this.pageIndex < this.pages.length)) { var f = document.createDocumentFragment(); util.each(this.pages[this.pageIndex], function(i, item) { appendItem(item, f, this.customOption); }, this); tree.appendChild(f); this.pageIndex++; this.emit("selectr.paginate", { items: this.items.length, total: this.data.length, page: this.pageIndex, pages: this.pages.length }); } }; /** * Clear a search * @return {void} */ var clearSearch = function() { if (this.config.searchable || this.config.taggable) { this.input.value = null; this.searching = false; if (this.config.searchable) { util.removeClass(this.inputContainer, "active"); } if (util.hasClass(this.container, "notice")) { util.removeClass(this.container, "notice"); util.addClass(this.container, "open"); this.input.focus(); } util.each(this.items, function(i, item) { // Items that didn't match need the class // removing to make them visible again util.removeClass(item, "excluded"); // Remove the span element for underlining matched items if (!this.customOption) { item.innerHTML = item.textContent; } }, this); } }; /** * Query matching for searches * @param {string} query * @param {HTMLOptionElement} option * @return {bool} */ var match = function(query, option) { var result = new RegExp(query, "i").exec(option.textContent); if (result) { return option.textContent.replace(result[0], "<span class='selectr-match'>" + result[0] + "</span>"); } return false; }; // Main Lib var Selectr = function(el, config) { config = config || {}; if (!el) { throw new Error("You must supply either a HTMLSelectElement or a CSS3 selector string."); } this.el = el; // CSS3 selector string if (typeof el === "string") { this.el = document.querySelector(el); } if (this.el === null) { throw new Error("The element you passed to Selectr can not be found."); } if (this.el.nodeName.toLowerCase() !== "select") { throw new Error("The element you passed to Selectr is not a HTMLSelectElement."); } this.render(config); }; /** * Render the instance * @param {object} config * @return {void} */ Selectr.prototype.render = function(config) { if (this.rendered) return; // Merge defaults with user set config this.config = util.extend(defaultConfig, config); // Store type this.originalType = this.el.type; // Store tabIndex this.originalIndex = this.el.tabIndex; // Store defaultSelected options for form reset this.defaultSelected = []; // Store the original option count this.originalOptionCount = this.el.options.length; if (this.config.multiple || this.config.taggable) { this.el.multiple = true; } // Disabled? this.disabled = isset(this.config, "disabled"); this.opened = false; if (this.config.taggable) { this.config.searchable = false; } this.navigating = false; this.mobileDevice = false; if (/Android|webOS|iPhone|iPad|BlackBerry|Windows Phone|Opera Mini|IEMobile|Mobile/i.test(navigator.userAgent)) { this.mobileDevice = true; } this.customOption = this.config.hasOwnProperty("renderOption") && typeof this.config.renderOption === "function"; this.customSelected = this.config.hasOwnProperty("renderSelection") && typeof this.config.renderSelection === "function"; // Enable event emitter Events.mixin(this); build.call(this); this.bindEvents(); this.update(); this.optsRect = util.rect(this.tree); this.rendered = true; // Fixes macOS Safari bug #28 if (!this.el.multiple) { this.el.selectedIndex = this.selectedIndex; } var that = this; setTimeout(function() { that.emit("selectr.init"); }, 20); }; Selectr.prototype.getSelected = function () { var selected = this.el.querySelectorAll('option:checked'); return selected; }; Selectr.prototype.getSelectedProperties = function (prop) { var selected = this.getSelected(); var values = [].slice.call(selected) .map(function(option) { return option[prop]; }) .filter(function(i) { return i!==null && i!==undefined; }); return values; }; /** * Attach the required event listeners */ Selectr.prototype.bindEvents = function() { var that = this; this.events = {}; this.events.dismiss = dismiss.bind(this); this.events.navigate = navigate.bind(this); this.events.reset = this.reset.bind(this); if (this.config.nativeDropdown || this.mobileDevice) { this.container.addEventListener("touchstart", function(e) { if (e.changedTouches[0].target === that.el) { that.toggle(); } }); if (this.config.nativeDropdown || this.mobileDevice) { this.container.addEventListener("click", function(e) { e.preventDefault(); // Jos: Added to prevent emitting clear directly after select e.stopPropagation(); // Jos: Added to prevent emitting clear directly after select if (e.target === that.el) { that.toggle(); } }); } var getChangedOptions = function(last, current) { var added=[], removed=last.slice(0); var idx; for (var i=0; i<current.length; i++) { idx = removed.indexOf(current[i]); if (idx > -1) removed.splice(idx, 1); else added.push(current[i]); } return [added, removed]; }; // Listen for the change on the native select // and update accordingly this.el.addEventListener("change", function(e) { if (that.el.multiple) { var indexes = that.getSelectedProperties('idx'); var changes = getChangedOptions(that.selectedIndexes, indexes); util.each(changes[0], function(i, idx) { that.select(idx); }, that); util.each(changes[1], function(i, idx) { that.deselect(idx); }, that); } else { if (that.el.selectedIndex > -1) { that.select(that.el.selectedIndex); } } }); } // Open the dropdown with Enter key if focused if (this.config.nativeDropdown) { this.container.addEventListener("keydown", function(e) { if (e.key === "Enter" && that.selected === document.activeElement) { // Show the native that.toggle(); // Focus on the native multiselect setTimeout(function() { that.el.focus(); }, 200); } }); } // Non-native dropdown this.selected.addEventListener("click", function(e) { if (!that.disabled) { that.toggle(); } e.preventDefault(); e.stopPropagation(); // Jos: Added to prevent emitting clear directly after select }); // Remove tag this.label.addEventListener("click", function(e) { if (util.hasClass(e.target, "selectr-tag-remove")) { that.deselect(e.target.parentNode.idx); } }); // Clear input if (this.selectClear) { this.selectClear.addEventListener("click", this.clear.bind(this)); } // Prevent text selection this.tree.addEventListener("mousedown", function(e) { e.preventDefault(); }); // Select / deselect items this.tree.addEventListener("click", function(e) { e.preventDefault(); // Jos: Added to prevent emitting clear directly after select e.stopPropagation(); // Jos: Added to prevent emitting clear directly after select var item = util.closest(e.target, function(el) { return el && util.hasClass(el, "selectr-option"); }); if (item) { if (!util.hasClass(item, "disabled")) { if (util.hasClass(item, "selected")) { if (that.el.multiple || !that.el.multiple && that.config.allowDeselect) { that.deselect(item.idx); } } else { that.select(item.idx); } if (that.opened && !that.el.multiple) { that.close(); } } } }); // Mouseover list items this.tree.addEventListener("mouseover", function(e) { if (util.hasClass(e.target, "selectr-option")) { if (!util.hasClass(e.target, "disabled")) { util.removeClass(that.items[that.navIndex], "active"); util.addClass(e.target, "active"); that.navIndex = [].slice.call(that.items).indexOf(e.target); } } }); // Searchable if (this.config.searchable) { // Show / hide the search input clear button this.input.addEventListener("focus", function(e) { that.searching = true; }); this.input.addEventListener("blur", function(e) { that.searching = false; }); this.input.addEventListener("keyup", function(e) { that.search(); if (!that.config.taggable) { // Show / hide the search input clear button if (this.value.length) { util.addClass(this.parentNode, "active"); } else { util.removeClass(this.parentNode, "active"); } } }); // Clear the search input this.inputClear.addEventListener("click", function(e) { that.input.value = null; clearSearch.call(that); if (!that.tree.childElementCount) { render.call(that); } }); } if (this.config.taggable) { this.input.addEventListener("keyup", function(e) { that.search(); if (that.config.taggable && this.value.length) { var val = this.value.trim(); if (e.which === 13 || util.includes(that.tagSeperators, e.key)) { util.each(that.tagSeperators, function(i, k) { val = val.replace(k, ''); }); var option = that.add({ value: val, text: val, selected: true }, true); if (!option) { this.value = ''; that.setMessage('That tag is already in use.'); } else { that.close(); clearSearch.call(that); } } } }); } this.update = util.debounce(function() { // Optionally close dropdown on scroll / resize (#11) if (that.opened && that.config.closeOnScroll) { that.close(); } if (that.width) { that.container.style.width = that.width; } that.invert(); }, 50); if (this.requiresPagination) { this.paginateItems = util.debounce(function() { load.call(this); }, 50); this.tree.addEventListener("scroll", this.paginateItems.bind(this)); } // Dismiss when clicking outside the container document.addEventListener("click", this.events.dismiss); window.addEventListener("keydown", this.events.navigate); window.addEventListener("resize", this.update); window.addEventListener("scroll", this.update); // Listen for form.reset() (@ambrooks, #13) if (this.el.form) { this.el.form.addEventListener("reset", this.events.reset); } }; /** * Check for selected options * @param {bool} reset */ Selectr.prototype.setSelected = function(reset) { // Select first option as with a native select-one element - #21, #24 if (!this.config.data && !this.el.multiple && this.el.options.length) { // Browser has selected the first option by default if (this.el.selectedIndex === 0) { if (!this.el.options[0].defaultSelected && !this.config.defaultSelected) { this.el.selectedIndex = -1; } } this.selectedIndex = this.el.selectedIndex; if (this.selectedIndex > -1) { this.select(this.selectedIndex); } } // If we're changing a select-one to select-multiple via the config // and there are no selected options, the first option will be selected by the browser // Let's prevent that here. if (this.config.multiple && this.originalType === "select-one" && !this.config.data) { if (this.el.options[0].selected && !this.el.options[0].defaultSelected) { this.el.options[0].selected = false; } } util.each(this.options, function(i, option) { if (option.selected && option.defaultSelected) { this.select(option.idx); } }, this); if (this.config.selectedValue) { this.setValue(this.config.selectedValue); } if (this.config.data) { if (!this.el.multiple && this.config.defaultSelected && this.el.selectedIndex < 0) { this.select(0); } var j = 0; util.each(this.config.data, function(i, opt) { // Check for group options if (isset(opt, "children")) { util.each(opt.children, function(x, item) { if (item.hasOwnProperty("selected") && item.selected === true) { this.select(j); } j++; }, this); } else { if (opt.hasOwnProperty("selected") && opt.selected === true) { this.select(j); } j++; } }, this); } }; /** * Destroy the instance * @return {void} */ Selectr.prototype.destroy = function() { if (!this.rendered) return; this.emit("selectr.destroy"); // Revert to select-single if programtically set to multiple if (this.originalType === 'select-one') { this.el.multiple = false; } if (this.config.data) { this.el.innerHTML = ""; } // Remove the className from select element util.removeClass(this.el, 'selectr-hidden'); // Remove reset listener from parent form if (this.el.form) { util.off(this.el.form, "reset", this.events.reset); } // Remove event listeners attached to doc and win util.off(document, "click", this.events.dismiss); util.off(document, "keydown", this.events.navigate); util.off(window, "resize", this.update); util.off(window, "scroll", this.update); // Replace the container with the original select element this.container.parentNode.replaceChild(this.el, this.container); this.rendered = false; }; /** * Change an options state * @param {Number} index * @return {void} */ Selectr.prototype.change = function(index) { var item = this.items[index], option = this.options[index]; if (option.disabled) { return; } if (option.selected && util.hasClass(item, "selected")) { this.deselect(index); } else { this.select(index); } if (this.opened && !this.el.multiple) { this.close(); } }; /** * Select an option * @param {Number} index * @return {void} */ Selectr.prototype.select = function(index) { var item = this.items[index], options = [].slice.call(this.el.options), option = this.options[index]; if (this.el.multiple) { if (util.includes(this.selectedIndexes, index)) { return false; } if (this.config.maxSelections && this.tags.length === this.config.maxSelections) { this.setMessage("A maximum of " + this.config.maxSelections + " items can be selected.", true); return false; } this.selectedValues.push(option.value); this.selectedIndexes.push(index); addTag.call(this, item); } else { var data = this.data ? this.data[index] : option; this.label.innerHTML = this.customSelected ? this.config.renderSelection(data) : option.textContent; this.selectedValue = option.value; this.selectedIndex = index; util.each(this.options, function(i, o) { var opt = this.items[i]; if (i !== index) { if (opt) { util.removeClass(opt, "selected"); } o.selected = false; o.removeAttribute("selected"); } }, this); } if (!util.includes(options, option)) { this.el.add(option); } item.setAttribute("aria-selected", true); util.addClass(item, "selected"); util.addClass(this.container, "has-selected"); option.selected = true; option.setAttribute("selected", ""); this.emit("selectr.change", option); this.emit("selectr.select", option); }; /** * Deselect an option * @param {Number} index * @return {void} */ Selectr.prototype.deselect = function(index, force) { var item = this.items[index], option = this.options[index]; if (this.el.multiple) { var selIndex = this.selectedIndexes.indexOf(index); this.selectedIndexes.splice(selIndex, 1); var valIndex = this.selectedValues.indexOf(option.value); this.selectedValues.splice(valIndex, 1); removeTag.call(this, item); if (!this.tags.length) { util.removeClass(this.container, "has-selected"); } } else { if (!force && !this.config.clearable && !this.config.allowDeselect) { return false; } this.label.innerHTML = ""; this.selectedValue = null; this.el.selectedIndex = this.selectedIndex = -1; util.removeClass(this.container, "has-selected"); } this.items[index].setAttribute("aria-selected", false); util.removeClass(this.items[index], "selected"); option.selected = false; option.removeAttribute("selected"); this.emit("selectr.change", null); this.emit("selectr.deselect", option); }; /** * Programmatically set selected values * @param {String|Array} value - A string or an array of strings */ Selectr.prototype.setValue = function(value) { var isArray = Array.isArray(value); if (!isArray) { value = value.toString().trim(); } // Can't pass array to select-one if (!this.el.multiple && isArray) { return false; } util.each(this.options, function(i, option) { if (isArray && util.includes(value.toString(), option.value) || option.value === value) { this.change(option.idx); } }, this); }; /** * Set the selected value(s) * @param {bool} toObject Return only the raw values or an object * @param {bool} toJson Return the object as a JSON string * @return {mixed} Array or String */ Selectr.prototype.getValue = function(toObject, toJson) { var value; if (this.el.multiple) { if (toObject) { if (this.selectedIndexes.length) { value = {}; value.values = []; util.each(this.selectedIndexes, function(i, index) { var option = this.options[index]; value.values[i] = { value: option.value, text: option.textContent }; }, this); } } else { value = this.selectedValues.slice(); } } else { if (toObject) { var option = this.options[this.selectedIndex]; value = { value: option.value, text: option.textContent }; } else { value = this.selectedValue; } } if (toObject && toJson) { value = JSON.stringify(value); } return value; }; /** * Add a new option or options * @param {object} data */ Selectr.prototype.add = function(data, checkDuplicate) { if (data) { this.data = this.data || []; this.items = this.items || []; this.options = this.options || []; if (Array.isArray(data)) { // We have an array on items util.each(data, function(i, obj) { this.add(obj, checkDuplicate); }, this); } // User passed a single object to the method // or Selectr passed an object from an array else if ("[object Object]" === Object.prototype.toString.call(data)) { if (checkDuplicate) { var dupe = false; util.each(this.options, function(i, option) { if (option.value.toLowerCase() === data.value.toLowerCase()) { dupe = true; } }); if (dupe) { return false; } } var option = util.createElement('option', data); this.data.push(data); // Add the new option to the list this.options.push(option); // Add the index for later use option.idx = this.options.length > 0 ? this.options.length - 1 : 0; // Create a new item createItem.call(this, option); // Select the item if required if (data.selected) { this.select(option.idx); } return option; } // We may have had an empty select so update // the placeholder to reflect the changes. this.setPlaceholder(); // Recount the pages if (this.config.pagination) { this.paginate(); } return true; } }; /** * Remove an option or options * @param {Mixed} o Array, integer (index) or string (value) * @return {Void} */ Selectr.prototype.remove = function(o) { var options = []; if (Array.isArray(o)) { util.each(o, function(i, opt) { if (util.isInt(opt)) { options.push(this.getOptionByIndex(opt)); } else if (typeof o === "string") { options.push(this.getOptionByValue(opt)); } }, this); } else if (util.isInt(o)) { options.push(this.getOptionByIndex(o)); } else if (typeof o === "string") { options.push(this.getOptionByValue(o)); } if (options.length) { var index; util.each(options, function(i, option) { index = option.idx; // Remove the HTMLOptionElement this.el.remove(option); // Remove the reference from the option array this.options.splice(index, 1); // If the item has a parentNode (group element) it needs to be removed // otherwise the render function will still append it to the dropdown var parentNode = this.items[index].parentNode; if (parentNode) { parentNode.removeChild(this.items[index]); } // Remove reference from the items array this.items.splice(index, 1); // Reset the indexes util.each(this.options, function(i, opt) { opt.idx = i; this.items[i].idx = i; }, this); }, this); // We may have had an empty select now so update // the placeholder to reflect the changes. this.setPlaceholder(); // Recount the pages if (this.config.pagination) { this.paginate(); } } }; /** * Remove all options */ Selectr.prototype.removeAll = function() { // Clear any selected options this.clear(true); // Remove the HTMLOptionElements util.each(this.el.options, function(i, option) { this.el.remove(option); }, this); // Empty the dropdown util.truncate(this.tree); // Reset variables this.items = []; this.options = []; this.data = []; this.navIndex = 0; if (this.requiresPagination) { this.requiresPagination = false; this.pageIndex = 1; this.pages = []; } // Update the placeholder this.setPlaceholder(); }; /** * Perform a search * @param {string} query The query string */ Selectr.prototype.search = function(string) { if (this.navigating) return; string = string || this.input.value; var f = document.createDocumentFragment(); // Remove message this.removeMessage(); // Clear the dropdown util.truncate(this.tree); if (string.length > 1) { // Check the options for the matching string util.each(this.options, function(i, option) { var item = this.items[option.idx]; var includes = util.includes(option.textContent.toLowerCase(), string.toLowerCase()); if (includes && !option.disabled) { appendItem(item, f, this.customOption); util.removeClass(item, "excluded"); // Underline the matching results if (!this.customOption) { item.innerHTML = match(string, option); } } else { util.addClass(item, "excluded"); } }, this); if (!f.childElementCount) { if (!this.config.taggable) { this.setMessage("no results."); } } else { // Highlight top result (@binary-koan #26) var prevEl = this.items[this.navIndex]; var firstEl = f.firstElementChild; util.removeClass(prevEl, "active"); this.navIndex = firstEl.idx; util.addClass(firstEl, "active"); } } else { render.call(this); } this.tree.appendChild(f); }; /** * Toggle the dropdown * @return {void} */ Selectr.prototype.toggle = function() { if (!this.disabled) { if (this.opened) { this.close(); } else { this.open(); } } }; /** * Open the dropdown * @return {void} */ Selectr.prototype.open = function() { var that = this; if (!this.options.length) { return false; } if (!this.opened) { this.emit("selectr.open"); } this.opened = true; if (this.mobileDevice || this.config.nativeDropdown) { util.addClass(this.container, "native-open"); if (this.config.data) { // Dump the options into the select // otherwise the native dropdown will be empty util.each(this.options, function(i, option) { this.el.add(option); }, this); } return; } util.addClass(this.container, "open"); render.call(this); this.invert(); this.tree.scrollTop = 0; util.removeClass(this.container, "notice"); this.selected.setAttribute("aria-expanded", true); this.tree.setAttribute("aria-hidden", false); this.tree.setAttribute("aria-expanded", true); if (this.config.searchable && !this.config.taggable) { setTimeout(function() { that.input.focus(); // Allow tab focus that.input.tabIndex = 0; }, 10); } }; /** * Close the dropdown * @return {void} */ Selectr.prototype.close = function() { if (this.opened) { this.emit("selectr.close"); } this.opened = false; if (this.mobileDevice || this.config.nativeDropdown) { util.removeClass(this.container, "native-open"); return; } var notice = util.hasClass(this.container, "notice"); if (this.config.searchable && !notice) { this.input.blur(); // Disable tab focus this.input.tabIndex = -1; this.searching = false; } if (notice) { util.removeClass(this.container, "notice"); this.notice.textContent = ""; } util.removeClass(this.container, "open"); util.removeClass(this.container, "native-open"); this.selected.setAttribute("aria-expanded", false); this.tree.setAttribute("aria-hidden", true); this.tree.setAttribute("aria-expanded", false); util.truncate(this.tree); clearSearch.call(this); }; /** * Enable the element * @return {void} */ Selectr.prototype.enable = function() { this.disabled = false; this.el.disabled = false; this.selected.tabIndex = this.originalIndex; if (this.el.multiple) { util.each(this.tags, function(i, t) { t.lastElementChild.tabIndex = 0; }); } util.removeClass(this.container, "selectr-disabled"); }; /** * Disable the element * @param {boolean} container Disable the container only (allow value submit with form) * @return {void} */ Selectr.prototype.disable = function(container) { if (!container) { this.el.disabled = true; } this.selected.tabIndex = -1; if (this.el.multiple) { util.each(this.tags, function(i, t) { t.lastElementChild.tabIndex = -1; }); } this.disabled = true; util.addClass(this.container, "selectr-disabled"); }; /** * Reset to initial state * @return {void} */ Selectr.prototype.reset = function() { if (!this.disabled) { this.clear(); this.setSelected(true); util.each(this.defaultSelected, function(i, idx) { this.select(idx); }, this); this.emit("selectr.reset"); } }; /** * Clear all selections * @return {void} */ Selectr.prototype.clear = function(force) { if (this.el.multiple) { // Loop over the selectedIndexes so we don't have to loop over all the options // which can be costly if there are a lot of them if (this.selectedIndexes.length) { // Copy the array or we'll get an error var indexes = this.selectedIndexes.slice(); util.each(indexes, function(i, idx) { this.deselect(idx); }, this); } } else { if (this.selectedIndex > -1) { this.deselect(this.selectedIndex, force); } } this.emit("selectr.clear"); }; /** * Return serialised data * @param {boolean} toJson * @return {mixed} Returns either an object or JSON string */ Selectr.prototype.serialise = function(toJson) { var data = []; util.each(this.options, function(i, option) { var obj = { value: option.value, text: option.textContent }; if (option.selected) { obj.selected = true; } if (option.disabled) { obj.disabled = true; } data[i] = obj; }); return toJson ? JSON.stringify(data) : data; }; /** * Localised version of serialise() method */ Selectr.prototype.serialize = function(toJson) { return this.serialise(toJson); }; /** * Sets the placeholder * @param {String} placeholder */ Selectr.prototype.setPlaceholder = function(placeholder) { // Set the placeholder placeholder = placeholder || this.config.placeholder || this.el.getAttribute("placeholder"); if (!this.options.length) { placeholder = "No options available"; } this.placeEl.innerHTML = placeholder; }; /** * Paginate the option list * @return {Array} */ Selectr.prototype.paginate = function() { if (this.items.length) { var that = this; this.pages = this.items.map(function(v, i) { return i % that.config.pagination === 0 ? that.items.slice(i, i + that.config.pagination) : null; }).filter(function(pages) { return pages; }); return this.pages; } }; /** * Display a message * @param {String} message The message */ Selectr.prototype.setMessage = function(message, close) { if (close) { this.close(); } util.addClass(this.container, "notice"); this.notice.textContent = message; }; /** * Dismiss the current message */ Selectr.prototype.removeMessage = function() { util.removeClass(this.container, "notice"); this.notice.innerHTML = ""; }; /** * Keep the dropdown within the window * @return {void} */ Selectr.prototype.invert = function() { var rt = util.rect(this.selected), oh = this.tree.parentNode.offsetHeight, wh = window.innerHeight, doInvert = rt.top + rt.height + oh > wh; if (doInvert) { util.addClass(this.container, "inverted"); this.isInverted = true; } else { util.removeClass(this.container, "inverted"); this.isInverted = false; } this.optsRect = util.rect(this.tree); }; /** * Get an option via it's