diff --git a/select2.css b/select2.css index 5b110947..566ea513 100755 --- a/select2.css +++ b/select2.css @@ -44,6 +44,25 @@ text-overflow: ellipsis; } +.select2-container .select2-choice abbr { + display: block; + position: absolute; + right: 26px; + top: 8px; + width: 12px; + height: 12px; + font-size: 1px; + background: url(select2.png) right top no-repeat; + cursor: pointer; + text-decoration: none; + border:0; + outline: 0; +} +.select2-container .select2-choice abbr:hover { + background-position: right -11px; + cursor: pointer; +} + .select2-container .select2-drop { background: #fff; border: 1px solid #aaa; @@ -137,13 +156,15 @@ } -/* active styles */ -.select2-container-focused .select2-choice { - -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); - -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); - -o-box-shadow : 0 0 5px rgba(0,0,0,.3); - box-shadow : 0 0 5px rgba(0,0,0,.3); - border: 1px solid #5897fb; +.select2-container-active .select2-choice, +.select2-container-active .select2-choices { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + outline: thin dotted #333; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; + } .select2-dropdown-open .select2-choice { @@ -168,7 +189,6 @@ border-bottom-right-radius: 0; } - .select2-dropdown-open .select2-choice div { background: transparent; border-left: none; @@ -211,6 +231,19 @@ display: list-item; } +.select2-container .select2-results .select2-disabled.select2-highlighted { + color: #666; + background: #f4f4f4; + display: list-item; + cursor: default; +} +.select2-container .select2-results .select2-disabled { + background: #f4f4f4; + display: list-item; + cursor: default; +} + + .select2-more-results.select2-active { background: #f4f4f4 url('spinner.gif') no-repeat 100%; } @@ -220,12 +253,109 @@ display: list-item; } +/* multiselect */ + +.select2-container-multi .select2-choices { + background-color: #fff; + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); + background-image: -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: linear-gradient(top, #eeeeee 1%, #ffffff 15%); + border: 1px solid #aaa; + margin: 0; + padding: 0; + cursor: text; + overflow: hidden; + height: auto !important; + height: 1%; + position: relative; +} +.select2-container-multi .select2-choices li { + float: left; + list-style: none; +} +.select2-container-multi .select2-choices .select2-search-field { + white-space: nowrap; + margin: 0; + padding: 0; +} + +.select2-container-multi .select2-choices .select2-search-field input { + color: #666; + background: transparent !important; + font-family: sans-serif; + font-size: 100%; + height: 15px; + padding: 5px; + margin: 1px 0; + outline: 0; + border: 0; + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} +.select2-default { + color: #999 !important; +} + +.select2-container-multi .select2-choices .select2-search-choice { + -webkit-border-radius: 3px; + -moz-border-radius : 3px; + border-radius : 3px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; + background-color: #e4e4e4; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f4f4f4', endColorstr='#eeeeee', GradientType=0 ); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee)); + background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -o-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -ms-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + -webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + -moz-box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + color: #333; + border: 1px solid #aaaaaa; + line-height: 13px; + padding: 3px 20px 3px 5px; + margin: 3px 0 3px 5px; + position: relative; + cursor: default; +} +.select2-container-multi .select2-choices .select2-search-choice span { + cursor: default; +} +.select2-container-multi .select2-choices .select2-search-choice-focus { + background: #d4d4d4; +} +.select2-search-choice-close { + display: block; + position: absolute; + right: 3px; + top: 4px; + width: 12px; + height: 13px; + font-size: 1px; + background: url(select2.png) right top no-repeat; +} +.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { + background-position: right -11px; +} +.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { + background-position: right -11px; +} -/* -.select2-container .select2-drop { border: 1px solid red !important;} -.select2-container .select2-drop .select2-search { border: 1px solid green !important;} -.select2-container .select2-drop .select2-search input { border: 1px solid blue !important;} -*/ \ No newline at end of file +.select2-container-multi .select2-results { + margin: -1px 0 0; + padding: 0; +} + +/* end multiselect */ diff --git a/select2.js b/select2.js index 8e031d6c..6a17c933 100755 --- a/select2.js +++ b/select2.js @@ -20,50 +20,7 @@ "use strict"; /*global document, window, jQuery, console */ - var KEY, Util, DropDown, ResultList, Selection, Select2, Queries; - - function createClass(def) { - var type = function (attrs) { - var self = this; - if (def.attrs !== undefined) { - $.each(def.attrs, function (name, body) { - if (attrs[name] !== undefined) { - self[name] = attrs[name]; - } else { - if (body.required === true) { - throw "Value for required attribute: " + name + " not defined"; - } - if (body.init !== undefined) { - self[name] = typeof (body.init) === "function" ? body.init.apply(self) : body.init; - } - } - }); - } - - if (def.methods !== undefined && def.methods.init !== undefined) { - self.init(attrs); - } - }; - - if (def.methods !== undefined) { - if (def.methods.bind !== undefined) { - throw "Class cannot declare a method called 'bind'"; - } - - $.each(def.methods, function (name, body) { - type.prototype[name] = body; - }); - - type.prototype.bind = function (func) { - var self = this; - return function () { - func.apply(self, arguments); - }; - }; - } - - return type; - } + var uid = 0, KEY; KEY = { TAB: 9, @@ -82,695 +39,1076 @@ HOME: 36, END: 35, BACKSPACE: 8, - DELETE: 46 + DELETE: 46, + isArrow: function (k) { + k = k.which ? k.which : k; + switch (k) { + case KEY.LEFT: + case KEY.RIGHT: + case KEY.UP: + case KEY.DOWN: + return true; + } + return false; + }, + isControl: function (k) { + k = k.which ? k.which : k; + switch (k) { + case KEY.SHIFT: + case KEY.CTRL: + case KEY.ALT: + return true; + } + return false; + }, + isFunctionKey: function (k) { + k = k.which ? k.which : k; + return k >= 112 && k <= 123; + } }; - Util = {}; + function getSideBorderPadding(element) { + return element.outerWidth() - element.width(); + } - Util.debounce = function (threshold, fn) { + function installKeyUpChangeEvent(element) { + element.on("keydown", function () { + element.data("keyup-change-value", element.val()); + }); + element.on("keyup", function () { + if (element.val() !== element.data("keyup-change-value")) { + element.trigger("keyup-change"); + } + }); + } + + /** + * filters mouse events so an event is fired only if the mouse moved. + * + * filters out mouse events that occur when mouse is stationary but + * the elements under the pointer are scrolled. + */ + $(document).on("mousemove", function (e) { + $(this).data("select2-lastpos", {x: e.pageX, y: e.pageY}); + }); + function installFilteredMouseMove(element) { + var doc = $(document); + element.on("mousemove", function (e) { + var lastpos = doc.data("select2-lastpos"); + + if (lastpos === undefined || lastpos.x !== e.pageX || lastpos.y !== e.pageY) { + $(e.target).trigger("mousemove-filtered", e); + } + }); + } + + function debounce(threshold, fn) { var timeout; return function () { window.clearTimeout(timeout); timeout = window.setTimeout(fn, threshold); }; + } + + function installDebouncedScroll(threshold, element) { + var notify = debounce(threshold, function (e) { element.trigger("scroll-debounced", e);}); + element.on("scroll", function (e) { + if (element.get().indexOf(e.target) >= 0) notify(e); + }); + } + + function killEvent(event) { + event.preventDefault(); + event.stopPropagation(); + } + + function measureTextWidth(e) { + var sizer, width; + sizer = $("
").css({ + position: "absolute", + left: "-1000px", + top: "-1000px", + display: "none", + fontSize: e.css("fontSize"), + fontFamily: e.css("fontFamily"), + fontStyle: e.css("fontStyle"), + fontWeight: e.css("fontWeight"), + letterSpacing: e.css("letterSpacing"), + textTransform: e.css("textTransform"), + whiteSpace: "nowrap" + }); + sizer.text(e.val()); + $("body").append(sizer); + width = sizer.width(); + sizer.remove(); + return width; + } + + /** + * + * @param opts + */ + function AbstractSelect2() { + } + + AbstractSelect2.prototype.bind = function (func) { + var self = this; + return function () { + func.apply(self, arguments); + }; }; - Util.debounceEvent = function (element, threshold, event, debouncedEvent, direct) { - debouncedEvent = debouncedEvent || event + "-debounced"; - direct = direct || true; + AbstractSelect2.prototype.init = function (opts) { + var results, search; - var notify = Util.debounce(threshold, function (e) { - element.trigger(debouncedEvent, e); + this.uid = uid; + uid = uid + 1; + + // prepare options + this.opts = this.prepareOpts(opts); + + this.container = this.createContainer(); + + if (opts.element.attr("class") !== undefined) { + this.container.addClass(opts.element.attr("class")); + } + + // swap container for the element + + this.opts.element.data("select2", this) + .hide() + .after(this.container); + this.container.data("select2", this); + + this.dropdown = this.container.find(".select2-drop"); + this.results = results = this.container.find(".select2-results"); + this.search = search = this.container.find("input[type=text]"); + // initialize the container + + this.resultsPage = 0; + + this.initContainer(); + + installFilteredMouseMove(this.results); + results.on("mousemove-filtered", this.bind(this.highlightUnderEvent)); + + installDebouncedScroll(80, this.results); + results.on("scroll-debounced", this.bind(this.loadMoreIfNeeded)); + + // if jquery.mousewheel plugin is installed we can prevent out-of-bounds scrolling of results via mousewheel + if ($.fn.mousewheel) { + results.mousewheel(function (e, delta, deltaX, deltaY) { + var top = results.scrollTop(), height; + if (deltaY > 0 && top - deltaY <= 0) { + results.scrollTop(0); + killEvent(e); + } else if (deltaY < 0 && results.get(0).scrollHeight - results.scrollTop() + deltaY <= results.height()) { + results.scrollTop(results.get(0).scrollHeight - results.height()); + killEvent(e); + } + }); + } + + installKeyUpChangeEvent(search); + search.on("keyup-change", this.bind(this.updateResults)); + + results.on("click", this.bind(function (e) { + if ($(e.target).closest(".select2-result:not(.select2-disabled)").length > 0) { + this.highlightUnderEvent(e); + this.selectHighlighted(e); + } else { + killEvent(e); + this.focusSearch(); + } + })); + }; + + AbstractSelect2.prototype.prepareOpts = function (opts) { + var element, select; + + opts = $.extend({}, { + formatResult: function (data) { return data.text; }, + formatSelection: function (data) { return data.text; }, + formatNoMatches: function () { return "No matches found"; }, + formatInputTooShort: function (input, min) { return "Please enter " + (min - input.length) + " more characters"; } + }, opts); + + element = opts.element; + + if (element.get(0).tagName.toLowerCase() === "select") { + this.select = select = opts.element; + } + + // TODO add missing validation logic + if (select) { + /*$.each(["multiple", "ajax", "query", "minimumInputLength"], function () { + if (this in opts) { + throw "Option '" + this + "' is not allowed for Select2 when attached to a select element"; + } + });*/ + this.opts = opts = $.extend({}, { + miniumInputLength: 0 + }, opts); + } else { + this.opts = opts = $.extend({}, { + miniumInputLength: 0 + }, opts); + } + + if (select) { + opts.query = this.bind(function (query) { + var data = {results: [], more: false}, + term = query.term.toUpperCase(), + placeholder = this.getPlaceholder(); + element.find("option").each(function (i) { + var e = $(this), + text = e.text(); + + if (i === 0 && placeholder !== undefined && text === "") return true; + + if (text.toUpperCase().indexOf(term) >= 0) { + data.results.push({id: e.attr("value"), text: text}); + } + }); + query.callback(data); + }); + } else { + if (!opts.query && opts.ajax) { + opts.query = (function () { + var timeout, // current scheduled but not yet executed request + requestSequence = 0, // sequence used to drop out-of-order responses + quietMillis = opts.ajax.quietMillis || 100; + + return function (query) { + window.clearTimeout(timeout); + timeout = window.setTimeout(function () { + requestSequence += 1; // increment the sequence + var requestNumber = requestSequence, // this request's sequence number + options = opts.ajax, // ajax parameters + data = options.data; // ajax data function + + data = data.call(this, query.term, query.page); + + $.ajax({ + url: options.url, + dataType: options.dataType, + data: data + }).success( + function (data) { + if (requestNumber < requestSequence) { + return; + } + query.callback(options.results(data, query.page)); + } + ); + }, quietMillis); + }; + }()); + } + } + if (typeof(opts.query) !== "function") { + throw "query function not defined for Select2 " + opts.element.attr("id"); + } + + return opts; + }; + + AbstractSelect2.prototype.opened = function () { + return this.container.hasClass("select2-dropdown-open"); + }; + + AbstractSelect2.prototype.alignDropdown = function () { + this.dropdown.css({ + top: this.container.height(), + width: this.container.outerWidth() - getSideBorderPadding(this.dropdown) }); + }; - element.on(event, function (e) { - if (direct && element.get().indexOf(e.target) < 0) { + AbstractSelect2.prototype.open = function () { + var width; + + if (this.opened()) return; + + this.container.addClass("select2-dropdown-open").addClass("select2-container-active"); + + this.alignDropdown(); + this.dropdown.show(); + + // register click-outside-closes-dropdown listener + $(document).on("click.id" + this.uid, this.bind(function (e) { + if ($(e.target).closest(this.container).length === 0) { + this.blur(); + } + })); + }; + + AbstractSelect2.prototype.close = function () { + if (!this.opened()) return; + + this.dropdown.hide(); + this.container.removeClass("select2-dropdown-open"); + $(document).off("click.id" + this.uid); + + if (this.select) { + // TODO see if we can always clear here and reset on open + this.search.val(""); // not using clearSearch() because it may set a placeholder + this.updateResults(); // needed since we just set the search text to "" + } else { + this.results.empty(); + } + + this.clearSearch(); + }; + + AbstractSelect2.prototype.clearSearch = function () { + + }; + + AbstractSelect2.prototype.ensureHighlightVisible = function () { + var results = this.results, children, index, child, hb, rb, y, more; + + children = results.children(".select2-result"); + index = this.highlight(); + + if (index < 0) return; + + child = $(children[index]); + + hb = child.offset().top + child.outerHeight(); + + // if this is the last child lets also make sure select2-more-results is visible + if (index === children.length - 1) { + more = results.find("li.select2-more-results"); + if (more.length > 0) { + hb = more.offset().top + more.outerHeight(); + } + } + + rb = results.offset().top + results.outerHeight(); + if (hb > rb) { + results.scrollTop(results.scrollTop() + (hb - rb)); + } + y = child.offset().top - results.offset().top; + + // make sure the top of the element is visible + if (y < 0) { + results.scrollTop(results.scrollTop() + y); // y is negative + } + }; + + AbstractSelect2.prototype.moveHighlight = function (delta) { + var choices = this.results.children(".select2-result"), + index = this.highlight(); + + while (index > -1 && index < choices.length) { + index += delta; + if (!$(choices[index]).hasClass("select2-disabled")) { + this.highlight(index); + break; + } + } + }; + + AbstractSelect2.prototype.highlight = function (index) { + var choices = this.results.children(".select2-result"); + + if (arguments.length === 0) { + return choices.get().indexOf(choices.filter(".select2-highlighted")[0]); + } + + choices.removeClass("select2-highlighted"); + + if (index >= choices.length) index = choices.length - 1; + if (index < 0) index = 0; + + $(choices[index]).addClass("select2-highlighted"); + this.ensureHighlightVisible(); + + if (this.opened()) this.focusSearch(); + }; + + AbstractSelect2.prototype.highlightUnderEvent = function (event) { + var el = $(event.target).closest(".select2-result"); + if (el.length > 0) { + this.highlight(el.index()); + } + }; + + AbstractSelect2.prototype.loadMoreIfNeeded = function () { + var results = this.results, + more = results.find("li.select2-more-results"), + below, // pixels the element is below the scroll fold, below==0 is when the element is starting to be visible + offset = -1; // index of first element without data + + if (more.length === 0) return; + + below = more.offset().top - results.offset().top - results.height(); + + if (below <= 0) { + more.addClass("select2-active"); + this.opts.query({term: this.search.val(), page: (this.resultsPage + 1), callback: this.bind(function (data) { + var parts = [], self = this; + $(data.results).each(function () { + parts.push("
  • "); + parts.push(self.opts.formatResult(this)); + parts.push("
  • "); + }); + more.before(parts.join("")); + results.find(".select2-result").each(function (i) { + var e = $(this); + if (e.data("select2-data") !== undefined) { + offset = i; + } else { + e.data("select2-data", data.results[i - offset - 1]); + } + }); + if (data.more) { + more.removeClass("select2-active"); + } else { + more.remove(); + } + this.resultsPage = data.page; + })}); + } + }; + + AbstractSelect2.prototype.updateResults = function () { + var search = this.search, results = this.results, opts = this.opts; + + search.addClass("select2-active"); + + function render(html) { + results.html(html); + results.scrollTop(0); + search.removeClass("select2-active"); + } + + if (search.val().length < opts.minimumInputLength) { + render("
  • " + opts.formatInputTooShort(search.val(), opts.minimumInputLength) + "
  • "); + return; + } + + this.resultsPage = 0; + opts.query({term: search.val(), page: this.resultsPage, callback: this.bind(function (data) { + var parts = []; // html parts + + if (data.results.length === 0) { + render("
  • " + opts.formatNoMatches(search.val()) + "
  • "); return; } - notify(e); - }); + + $(data.results).each(function () { + parts.push("
  • "); + parts.push(opts.formatResult(this)); + parts.push("
  • "); + }); + + if (data.more === true) { + parts.push("
  • Loading more results...
  • "); + } + + render(parts.join("")); + results.children(".select2-result").each(function (i) { + var d = data.results[i]; + $(this).data("select2-data", d); + }); + this.postprocessResults(); + })}); }; - (function () { + AbstractSelect2.prototype.cancel = function () { + this.close(); + }; - var lastpos; + AbstractSelect2.prototype.blur = function () { + /* we do this in a timeout so that current event processing can complete before this code is executed. + this allows tab index to be preserved even if this code blurs the textfield */ + window.setTimeout(this.bind(function () { + this.close(); + this.container.removeClass("select2-container-active"); + this.clearSearch(); + this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + this.search.blur(); + }), 10); + }; - /** - * Filters mouse events so an event is fired only if the mouse moved. - * Filters out mouse events that occur when mouse is stationary but - * the elements under the pointer are scrolled - */ - Util.filterMouseEvent = function (element, event, filteredEvent, direct) { - filteredEvent = filteredEvent || event + "-filtered"; - direct = direct || false; + AbstractSelect2.prototype.focusSearch = function () { + /* we do this in a timeout so that current event processing can complete before this code is executed. + this makes sure the search field is focussed even if the current event would blur it */ + window.setTimeout(this.bind(function () { + this.search.focus(); + }), 10); + }; - element.on(event, "*", function (e) { - if (direct && element.get().indexOf(e.target) < 0) { - return; - } - if (lastpos === undefined || lastpos.x !== e.pageX || lastpos.y !== e.pageY) { - $(e.target).trigger(filteredEvent, e); - lastpos = {x: e.pageX, y: e.pageY}; - } - }); - }; - }()); + AbstractSelect2.prototype.selectHighlighted = function () { + var data = this.results.find(".select2-highlighted:not(.select2-disabled)").data("select2-data"); + if (data) { + this.onSelect(data); + } + }; - DropDown = createClass({ - attrs: { - container: {required: true}, - element: {required: true}, - bus: {required: true} - }, - methods: { - open: function () { - if (this.isOpen()) { - return; - } + AbstractSelect2.prototype.getPlaceholder = function () { + var placeholder = this.opts.element.data("placeholder"); + if (placeholder !== undefined) return placeholder; + return this.opts.placeholder; + }; - this.container.addClass("select2-dropdown-open"); + function SingleSelect2() { + } - // register click-outside-closes-dropdown listener - $(document).on("mousedown.dropdown", this.bind(function (e) { - var inside = false, - container = this.container.get(0); - $(e.target).parents().each(function () { - return !(inside = (this === container)); - }); - if (!inside) { - this.close(); - } - })); + SingleSelect2.prototype = new AbstractSelect2(); + SingleSelect2.prototype.constructor = SingleSelect2; + SingleSelect2.prototype.parent = AbstractSelect2.prototype; - this.element.show(); - this.bus.trigger("opened"); - }, + SingleSelect2.prototype.createContainer = function () { + return $("
    ", { + "class": "select2-container", + "style": "width: " + this.opts.element.outerWidth() + "px" + }).html([ + " ", + " ", + "
    " , + "
    ", + " "].join("")); + }; - close: function () { - if (!this.isOpen()) { - return; - } + SingleSelect2.prototype.open = function () { - this.container.removeClass("select2-dropdown-open"); + var width; - $(document).off("mousedown.dropdown"); - this.element.hide(); - this.bus.trigger("closed"); - }, + if (this.opened()) return; - isOpen: function () { - return this.container.hasClass("select2-dropdown-open"); - }, + this.parent.open.apply(this, arguments); - toggle: function () { - if (this.isOpen()) { - this.close(); - } else { - this.open(); - } + // size the search field + + width = this.dropdown.width(); + width -= getSideBorderPadding(this.container.find(".select2-search")); + width -= getSideBorderPadding(this.search); + this.search.css({width: width}); + + if (!this.select) this.updateResults(); + + this.ensureHighlightVisible(); + + this.focusSearch(); + }; + + SingleSelect2.prototype.close = function () { + if (!this.opened()) return; + this.parent.close.apply(this, arguments); + }; + + SingleSelect2.prototype.cancel = function () { + this.parent.cancel.apply(this, arguments); + this.selection.focus(); + }; + + SingleSelect2.prototype.initContainer = function () { + + var selection, container = this.container, clickingInside = false, + selected; + + this.selection = selection = container.find(".select2-choice"); + + this.search.on("keydown", this.bind(function (e) { + switch (e.which) { + case KEY.UP: + case KEY.DOWN: + this.moveHighlight((e.which === KEY.UP) ? -1 : 1); + killEvent(e); + return; + case KEY.TAB: + case KEY.ENTER: + this.selectHighlighted(); + killEvent(e); + return; + case KEY.ESC: + this.cancel(e); + e.preventDefault(); + return; } + })); + + selection.on("click", this.bind(function (e) { + clickingInside = true; + + if (this.opened()) { + this.close(); + selection.focus(); + } else { + this.open(); + } + e.preventDefault(); + + clickingInside = false; + })); + selection.on("keydown", this.bind(function (e) { + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { + return; + } + this.open(); + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN || e.which === KEY.SPACE) { + // prevent the page from scrolling + killEvent(e); + } + if (e.which === KEY.ENTER) { + // do not propagate the event otherwise we open, and propagate enter which closes + killEvent(e); + } + })); + selection.on("focus", function () { container.addClass("select2-container-active"); }); + selection.on("blur", this.bind(function () { + if (clickingInside) return; + if (!this.opened()) this.blur(); + })); + + selection.find("abbr") + .on("click", this.bind(function (e) { + this.val(""); + killEvent(e); + this.close(); + } + )); + + if (this.select) { + selected = this.select.find(":selected"); + this.updateSelection({id: selected.attr("value"), text: selected.text()}); + + // preload all results + this.updateResults(); } - }); + this.setPlaceholder(); + }; - ResultList = createClass({ - attrs: { - element: {required: true}, - bus: {required: true}, - formatInputTooShort: {required: true}, - formatNoMatches: {required: true}, - formatResult: {required: true}, - minimumInputLength: {required: true}, - query: {required: true}, - selection: {required: true} - }, - methods: { - init: function () { - var self = this; + SingleSelect2.prototype.setPlaceholder = function () { + var placeholder = this.getPlaceholder(); - this.search = this.element.find("input"); - this.results = this.element.find("ul"); - this.scrollPosition = 0; - this.vars = {}; + if (this.opts.element.val() === "" && placeholder !== undefined) { - this.search.on("keyup", function (e) { - if (e.which >= 48 || e.which === KEY.SPACE || e.which === KEY.BACKSPACE || e.which === KEY.DELETE) { - self.update(); - } - }); + // check for a first blank option if attached to a select + if (this.select && this.select.find("option:first").text() !== "") return; - this.search.on("keydown", function (e) { - switch (e.which) { - case KEY.TAB: - e.preventDefault(); - self.select(); - return; - case KEY.ENTER: - e.preventDefault(); - e.stopPropagation(); - self.select(); - return; - case KEY.UP: - self.moveSelection(-1); - e.preventDefault(); - e.stopPropagation(); - return; - case KEY.DOWN: - self.moveSelection(1); - e.preventDefault(); - e.stopPropagation(); - return; - case KEY.ESC: - e.preventDefault(); - e.stopPropagation(); - self.cancel(); - return; - } - }); + if (typeof(placeholder) === "object") { + this.updateSelection(placeholder); + } else { + this.selection.find("span").html(placeholder); + } + this.selection.addClass("select2-default"); -// this.results.on("mouseleave", "li.select2-result", this.bind(this.unhighlight)); - Util.filterMouseEvent(this.results, "mousemove"); - this.results.on("mousemove-filtered", this.bind(function (e) { - var el = $(e.target).closest("li.select2-result"); - if (el.length < 1) { - return; - } - this.setSelection(el.index()); - })); - this.results.on("click", this.bind(function (e) { - var el = $(e.target).closest("li.select2-result"); - if (el.length < 1) { - return; - } - this.bus.trigger("selected", [el.data("select2-result")]); - })); + this.selection.find("abbr").hide(); + } + }; - Util.debounceEvent(this.results, 100, "scroll"); + SingleSelect2.prototype.postprocessResults = function () { + var selected = 0, self = this; + this.results.find(".select2-result").each(function (i) { + if ($(this).data("select2-data").id === self.opts.element.val()) { + selected = i; + return false; + } + }); + this.highlight(selected); + }; - this.results.on("scroll-debounced", this.bind(function (e) { - this.scrollPosition = this.results.scrollTop(); + SingleSelect2.prototype.onSelect = function (data) { + this.opts.element.val(data.id); + this.updateSelection(data); + this.close(); + this.selection.focus(); + }; - var more = this.results.find("li.select2-more-results"), below; + SingleSelect2.prototype.updateSelection = function (data) { + this.selection.find("span").html(this.opts.formatSelection(data)); + this.selection.removeClass("select2-default"); + if (this.opts.allowClear && this.getPlaceholder() !== undefined) { + this.selection.find("abbr").show(); + } + }; - if (more.length === 0) { - return; - } + SingleSelect2.prototype.val = function () { + var val, data = null; - // pixels the element is below the scroll fold, below==0 is when the element is starting to be visible - below = more.offset().top - this.results.offset().top - this.results.height(); + if (arguments.length === 0) { + return this.opts.element.val(); + } - if (below <= 0) { - more.addClass("select2-active"); - this.query({term: this.search.val(), vars: this.vars, callback: this.bind(this.append)}); - } + val = arguments[0]; - })); - }, - open: function (e) { - this.search.focus(); - this.results.scrollTop(this.scrollPosition); - if (this.results.children().length === 0) { - // first time the dropdown is opened, update the results - this.update(); - } - }, - close: function () { - //this.search.val(""); - //this.clear(); - }, - clear: function () { - this.results.empty(); - }, - showInputTooShort: function () { - this.show("
  • " + this.formatInputTooShort(this.search.val(), this.minimumInputLength) + "
  • "); - }, - showNoMatches: function () { - this.show("
  • " + this.formatNoMatches(this.search.val()) + "
  • "); - }, - show: function (html) { - this.results.html(html); - this.results.scrollTop(0); - this.search.removeClass("select2-active"); - }, - update: function () { - var html = ""; + if (this.select) { + // val is an id + this.select.val(val); + this.select.find(":selected").each(function () { + data = {id: $(this).attr("value"), text: $(this).text()}; + return false; + }); + this.updateSelection(data); + } else { + // val is an object + this.opts.element.val((val === null) ? "" : val.id); + this.updateSelection(val); + } + this.setPlaceholder(); - if (this.search.val().length < this.minimumInputLength) { - this.showInputTooShort(); - return; - } + }; - this.search.addClass("select2-active"); - this.vars = {}; - this.query({term: this.search.val(), vars: this.vars, callback: this.bind(this.process)}); - }, - process: function (data) { - if (data.results.length === 0) { - this.showNoMatches(); - return; - } + SingleSelect2.prototype.clearSearch = function () { + this.search.val(""); + }; - var html = this.stringizeResults(data.results), selectedId = this.selection.val(), selectedIndex = 0; + function MultiSelect2(opts) { - if (data.more === true) { - html += "
  • Loading more results...
  • "; - } - this.vars = data.vars || {}; + } - this.show(html); + MultiSelect2.prototype = new AbstractSelect2(); + MultiSelect2.prototype.constructor = AbstractSelect2; + MultiSelect2.prototype.parent = AbstractSelect2.prototype; - this.findChoices().each(function (i) { - if (selectedId === data.results[i].id) { - selectedIndex = i; - } - $(this).data("select2-result", data.results[i]); - }); + MultiSelect2.prototype.createContainer = function () { + return $("
    ", { + "class": "select2-container select2-container-multi", + "style": "width: " + this.opts.element.outerWidth() + "px" + }).html([ + " " , + ""].join("")); + }; - this.setSelection(selectedIndex); + MultiSelect2.prototype.initContainer = function () { - }, - append: function (data) { + var selection, data; - var more = this.results.find("li.select2-more-results"), html, offset; + this.searchContainer = this.container.find(".select2-search-field"); + this.selection = selection = this.container.find(".select2-choices"); - this.vars = data.vars || {}; + this.search.on("keydown", this.bind(function (e) { + if (e.which === KEY.BACKSPACE && this.search.val() === "") { + this.close(); - if (data.results.length === 0) { - more.remove(); - return; - } - - html = this.stringizeResults(data.results); - - offset = this.results.find("li.select2-result").length; - - more.before(html); - - this.results.find("li.select2-result").each(function (i) { - if (i >= offset) { - $(this).data("select2-result", data.results[i - offset]); - } - }); - - if (data.more !== true) { - more.remove(); - } else { - more.removeClass("select2-active"); - } - - }, - stringizeResults: function (results, html) { - var i, l, classes; - html = html || ""; - for (i = 0, l = results.length; i < l; i += 1) { - html += "
  • "; - html += this.formatResult(results[i]); - html += "
  • "; - } - return html; - }, - - findChoices: function () { - return this.results.children("li.select2-result"); - }, - - removeSelection: function () { - this.findChoices().each(function () { - $(this).removeClass("select2-highlighted"); - }); - }, - - setSelection: function (index) { - this.removeSelection(); - - var children = this.findChoices(), - child = $(children[index]), - hb, - rb, - y, - more; - - child.addClass("select2-highlighted"); - - this.search.focus(); - - hb = child.offset().top + child.outerHeight(); - - // if this is the last child lets also make sure select2-more-results is visible - if (index === children.length - 1) { - more = this.results.find("li.select2-more-results"); - if (more.length > 0) { - hb = more.offset().top + more.outerHeight(); - } - } - - rb = this.results.offset().top + this.results.outerHeight(); - if (hb > rb) { - this.results.scrollTop(this.results.scrollTop() + (hb - rb)); - } - y = child.offset().top - this.results.offset().top; - - // make sure the top of the element is visible - if (y < 0) { - this.results.scrollTop(this.results.scrollTop() + y); // y is negative - } - - }, - - getSelectionIndex: function () { - var children = this.findChoices(), i = 0, l = children.length; - for (; i < l; i += 1) { - if ($(children[i]).hasClass("select2-highlighted")) { - return i; - } - } - return -1; - }, - - moveSelection: function (delta) { - var current = this.getSelectionIndex(), - children = this.findChoices(), - next = current + delta; - - if (current >= 0 && next >= 0 && next < children.length) { - this.setSelection(next); - } - }, - - select: function () { - var selected = this.results.find("li.select2-highlighted"); + var choices, + selected = this.selection.find(".select2-search-choice-focus"); if (selected.length > 0) { - this.bus.trigger("selected", [selected.data("select2-result")]); - } - }, - - cancel: function () { - this.bus.trigger("cancelled"); - }, - - val: function (data) { - var choices = this.findChoices(), index; - - choices.each(function (i) { - if ($(this).data("select2-result").id === data) { - index = i; - return false; - } - }); - - if (index === undefined && data.id !== undefined) { - choices.each(function (i) { - if ($(this).data("select2-result").id === data.id) { - index = i; - return false; - } - }); - } - - if (index !== undefined) { - this.setSelection(index); - this.select(); + this.unselect(selected.first()); + this.search.width(10); + killEvent(e); return; } - this.bus.trigger("selected", data); + choices = this.selection.find(".select2-search-choice"); + if (choices.length > 0) { + choices.last().addClass("select2-search-choice-focus"); + } + } else { + this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); } - } - }); - - Selection = createClass({ - attrs: { - bus: {required: true}, - element: {required: true}, - display: {init: function () { - return this.element.find("span"); - }}, - hidden: {required: true}, - formatSelection: {required: true}, - placeholder: {}, - dropdown: {required: true} - }, - methods: { - init: function () { - if (this.placeholder) { - this.select(this.placeholder); + if (this.opened()) { + switch (e.which) { + case KEY.UP: + case KEY.DOWN: + this.moveHighlight((e.which === KEY.UP) ? -1 : 1); + killEvent(e); + return; + case KEY.ENTER: + this.selectHighlighted(); + killEvent(e); + return; + case KEY.ESC: + this.cancel(e); + e.preventDefault(); + return; } - this.element.click(this.dropdown.bind(this.dropdown.toggle)); - - var self = this; - this.element.on("keydown", function (e) { - switch (e.which) { - case KEY.TAB: - case KEY.SHIFT: - case KEY.CTRL: - case KEY.ALT: - case KEY.LEFT: - case KEY.RIGHT: - return; - } - self.dropdown.open(); - }); - }, - select: function (data) { - this.display.html(this.formatSelection(data)); - this.hidden.val(data.id); - }, - focus: function () { - this.element.focus(); - }, - val: function () { - return this.hidden.val(); } - } - }); + if (e.which === KEY.TAB) { + this.blur(); + return; + } - Queries = {}; - Queries.select = function (select2, element) { - var options = []; - element.find("option").each(function () { - var e = $(this); - options.push({id: e.attr("value"), text: e.text()}); - }); - return function (query) { - var data = {results: [], more: false}, - text = query.term.toUpperCase(); - $.each(options, function (i) { - if (this.text.toUpperCase().indexOf(text) >= 0) { - data.results.push(this); - } + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.BACKSPACE || e.which === KEY.ESC) { + return; + } + + this.open(); + + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { + // prevent the page from scrolling + killEvent(e); + } + })); + + this.search.on("keyup", this.bind(this.resizeSearch)); + + this.selection.on("click", this.bind(function (e) { + if (this.select) { + this.open(); + } + this.focusSearch(); + e.preventDefault(); + })); + + this.search.on("focus", this.bind(function () { + this.container.addClass("select2-container-active"); + this.clearPlaceholder(); + })); + + if (this.select) { + data = []; + this.select.find(":selected").each(function () { + data.push({id: $(this).attr("value"), text: $(this).text()}); }); - query.callback(data); - }; - }; - Queries.ajax = function (select2, el) { - var timeout, // current scheduled but not yet executed request - requestSequence = 0, // sequence used to drop out-of-order responses - quietMillis = select2.ajax.quietMillis || 100; - return function (query) { - window.clearTimeout(timeout); - timeout = window.setTimeout(function () { - requestSequence += 1; // increment the sequence - var requestNumber = requestSequence, // this request's sequence number - options = select2.ajax, // ajax parameters - data = options.data; // ajax data function - - data = data.call(this, query.term, query.vars); - - $.ajax({ - url: options.url, - dataType: options.dataType, - data: data - }).success( - function (data) { - if (requestNumber < requestSequence) { - return; - } - query.callback(options.results(data, query.vars)); - } - ); - }, quietMillis); - }; - }; - - Select2 = createClass({ - attrs: { - el: {required: true}, - formatResult: {init: function () { - return function (data) { - return data.text; - }; - }}, - formatSelection: {init: function () { - return function (data) { - return data.text; - }; - }}, - formatNoMatches: {init: function () { - return function () { - return "No matches found"; - }; - }}, - formatInputTooShort: {init: function () { - return function (input, min) { - return "Please enter " + (min - input.length) + " more characters to start search"; - }; - }}, - minimumInputLength: {init: 0}, - placeholder: {init: undefined}, - ajax: {init: undefined}, - query: {init: undefined} - }, - methods: { - init: function () { - var self = this, width, dropdown, results, selected, select; - - this.el = $(this.el); - - width = this.el.outerWidth(); - - this.container = $("
    ", { - "class": "select2-container", - style: "width: " + width + "px" - }); - this.container.html( - " " + - " " + - "
    " + - "
    " + - "" + - "" - ); - - this.el.data("select2", this); - this.el.hide(); - this.el.after(self.container); - if (this.el.attr("class") !== undefined) { - this.container.addClass(this.el.attr("class")); - } - this.container.data("select2", this); - this.container.find("input[type=hidden]").attr("name", this.el.attr("name")); - - if (this.query === undefined && this.el.get(0).tagName.toUpperCase() === "SELECT") { - this.query = "select"; - select = true; - } - if (Queries[this.query] !== undefined) { - this.query = Queries[this.query](this, this.el); - } - - (function () { - - var dropdown, searchContainer, search, width; - - function getSideBorderPadding(e) { - return e.outerWidth() - e.width(); - } - - // position and size dropdown - dropdown = self.container.find("div.select2-drop"); - width = self.container.outerWidth() - getSideBorderPadding(dropdown); - dropdown.css({top: self.container.height(), width: width}); - - // size search field - searchContainer = self.container.find(".select2-search"); - search = searchContainer.find("input"); - width = dropdown.width(); - width -= getSideBorderPadding(searchContainer); - width -= getSideBorderPadding(search); - search.css({width: width}); - }()); - - dropdown = new DropDown({ - element: this.container.find("div.select2-drop"), - container: this.container, - bus: this.el - }); - - this.selection = new Selection({ - bus: this.el, - element: this.container.find(".select2-choice"), - hidden: this.container.find("input[type=hidden]"), - formatSelection: this.formatSelection, - placeholder: this.placeholder, - dropdown: dropdown - }); - - this.results = new ResultList({ - element: this.container.find("div.select2-drop"), - bus: this.el, - formatInputTooShort: this.formatInputTooShort, - formatNoMatches: this.formatNoMatches, - formatResult: this.formatResult, - minimumInputLength: this.minimumInputLength, - query: this.query, - selection: this.selection - }); - - this.el.on("selected", function (e, result) { - dropdown.close(); - self.selection.select(result); - }); - - this.el.on("cancelled", function () { - dropdown.close(); - }); - - this.el.on("opened", this.bind(function () { - this.results.open(); - })); - - this.el.on("closed", this.bind(function () { - this.container.removeClass("select2-dropdown-open"); - this.results.close(); - this.selection.focus(); - })); - - // if attached to a select do some default initialization - if (select) { - this.results.update(); // build the results - selected = this.el.find("option[selected]"); - if (selected.length < 1 && this.placeholder === undefined) { - selected = $(this.el.find("option")[0]); - } - if (selected.length > 0) { - this.val({id: selected.attr("value"), text: selected.text()}); - } - } - - }, - val: function () { - var data; - if (arguments.length === 0) { - return this.selection.val(); - } else { - data = arguments[0]; - this.results.val(data); - } - } + this.updateSelection(data); + // preload all results + this.updateResults(); } - }); + + // set the placeholder if necessary + this.clearSearch(); + }; + + MultiSelect2.prototype.clearSearch = function () { + var placeholder = this.getPlaceholder(); + + this.search.val("").width(10); + + if (placeholder !== undefined && this.getVal().length === 0) { + this.search.val(placeholder).addClass("select2-default"); + this.resizeSearch(); + } + }; + + MultiSelect2.prototype.clearPlaceholder = function () { + if (this.search.hasClass("select2-default")) { + this.search.val("").removeClass("select2-default"); + } + }; + + MultiSelect2.prototype.open = function () { + if (this.opened()) return; + this.parent.open.apply(this, arguments); + this.resizeSearch(); + this.focusSearch(); + }; + + MultiSelect2.prototype.close = function () { + if (!this.opened()) return; + this.parent.close.apply(this, arguments); + }; + + MultiSelect2.prototype.updateSelection = function (data) { + var self = this; + this.selection.find(".select2-search-choice").remove(); + $(data).each(function () { + self.addSelectedChoice(this); + }); + self.postprocessResults(); + this.alignDropdown(); + }; + + MultiSelect2.prototype.onSelect = function (data) { + this.addSelectedChoice(data); + if (this.select) { this.postprocessResults(); } + this.close(); + this.search.width(10); + this.focusSearch(); + }; + + MultiSelect2.prototype.cancel = function () { + this.close(); + this.focusSearch(); + }; + + MultiSelect2.prototype.addSelectedChoice = function (data) { + var choice, + id = data.id, + parts, + val = this.getVal(); + + parts = ["
  • ", + this.opts.formatSelection(data), + "", + "
  • " + ]; + + choice = $(parts.join("")); + choice.find("a") + .on("click", this.bind(function (e) { + this.unselect($(e.target)); + this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + killEvent(e); + this.close(); + this.focusSearch(); + })).on("focus", this.bind(function () { + this.container.addClass("select2-container-active"); + })).on("blur", this.bind(function () { + this.blur(); + })); + + choice.data("select2-data", data); + choice.insertBefore(this.searchContainer); + + val.push(id); + this.setVal(val); + }; + + MultiSelect2.prototype.unselect = function (selected) { + var val = this.getVal(), + index; + + selected = selected.closest(".select2-search-choice"); + + if (selected.length === 0) { + throw "Invalid argument: " + selected + ". Must be .select2-search-choice"; + } + + index = val.indexOf(selected.data("select2-data").id); + + if (index >= 0) { + val.splice(index, 1); + this.setVal(val); + if (this.select) this.postprocessResults(); + } + selected.remove(); + window.setTimeout(this.bind(this.alignDropdown), 20); + }; + + MultiSelect2.prototype.postprocessResults = function () { + var val = this.getVal(), + choices = this.results.find(".select2-result"), + self = this; + + choices.each(function () { + var choice = $(this), id = choice.data("select2-data").id; + if (val.indexOf(id) >= 0) { + choice.addClass("select2-disabled"); + } else { + choice.removeClass("select2-disabled"); + } + }); + + choices.each(function (i) { + if (!$(this).hasClass("select2-disabled")) { + self.highlight(i); + return false; + } + }); + + }; + + MultiSelect2.prototype.resizeSearch = function () { + + var minimumWidth, left, maxWidth, containerLeft, searchWidth; + + minimumWidth = measureTextWidth(this.search) + 10; + + left = this.search.offset().left; + + maxWidth = this.selection.width(); + containerLeft = this.selection.offset().left; + + searchWidth = maxWidth - (left - containerLeft) - getSideBorderPadding(this.search); + + if (searchWidth < minimumWidth) { + searchWidth = maxWidth - getSideBorderPadding(this.search); + } + + if (searchWidth < 40) { + searchWidth = maxWidth - getSideBorderPadding(this.search); + } + this.search.width(searchWidth); + }; + + MultiSelect2.prototype.getVal = function () { + var val; + if (this.select) { + val = this.select.val(); + return val === null ? [] : val; + } else { + val = this.opts.element.val(); + return (val === null || val === "") ? [] : val.split(","); + } + }; + + MultiSelect2.prototype.setVal = function (val) { + if (this.select) { + this.select.val(val); + } else { + this.opts.element.val(val.length === 0 ? "" : val.join(",")); + } + }; + + MultiSelect2.prototype.val = function () { + var val, data = []; + + if (arguments.length === 0) { + return this.getVal(); + } + + val = arguments[0]; + + if (this.select) { + // val is a list of ids + this.setVal(val); + this.select.find(":selected").each(function () { + data.push({id: $(this).attr("value"), text: $(this).text()}); + }); + this.updateSelection(data); + } else { + val = (val === null) ? [] : val; + this.setVal(val); + // val is a list of objects + + $(val).each(function () { data.push(this.id); }); + this.setVal(data); + this.updateSelection(val); + } + }; $.fn.select2 = function () { - var args = Array.prototype.slice.call(arguments, 0), value, tmp; + + var args = Array.prototype.slice.call(arguments, 0), + opts, + select2, + value, multiple, allowedMethods = ["val"]; + this.each(function () { - if (args.length === 0) { - tmp = new Select2({el: this}); - } else if (typeof (args[0]) === "object") { - args[0].el = this; - tmp = new Select2(args[0]); - } else if (typeof (args[0]) === "string") { - var select2 = $(this).data("select2"); + if (args.length === 0 || typeof(args[0]) === "object") { + opts = args.length === 0 ? {} : args[0]; + opts.element = $(this); + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + multiple = opts.element.prop("multiple"); + } else { + multiple = opts.multiple || false; + } + + select2 = multiple ? new MultiSelect2() : new SingleSelect2(); + select2.init(opts); + } else if (typeof(args[0]) === "string") { + + if (allowedMethods.indexOf(args[0]) < 0) { + throw "Unknown method: " + args[0]; + } + + value = undefined; + select2 = $(this).data("select2"); value = select2[args[0]].apply(select2, args.slice(1)); - return false; + if (value !== undefined) {return false;} } else { throw "Invalid arguments to select2 plugin: " + args; } @@ -778,4 +1116,4 @@ return (value === undefined) ? this : value; }; -}(jQuery)); \ No newline at end of file +}(jQuery));