Files
crm.twinpol.com/include/ECM/comboselectbox.js
2025-05-12 15:45:17 +00:00

1361 lines
44 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* jquery.simple-combobox v1.1.16 (2015-02-25): jQuery combobox plugin | (c) 2014-2015 Ilya Kremer
* MIT license http://www.opensource.org/licenses/mit-license.php
*/
// Fill free to use this jQuery plugin in any projects you want
// while keeping the comment above on top of the script.
// Don't forget not to remove it from a minimised version also.
// Thank you!
// TODO consider to use markup when filling combobox from original select options
// TODO consider to add fadeout background for items (checkboxes mode)
// TODO implement items removal (for infinite number of items)
/**
* Original plugin structure taken and extended from http://stackoverflow.com/a/6871820/837165
* See and change default options at the end of the code or use
* $.scombobox.extendDefaults(options) method if you don't feel like
* touching the original plugin source code.
* This plugin uses following JS native methods:
*
* String.prototype.trim()
* Array.prototype.indexOf()
* Object.keys()
* console object
*
* so don't forget to add them to your project for better browser compatibility.
* You can use missed.js file for that purpose from original GitHub project:
* https://github.com/ivkremer/jquery-simple-combobox
*
* This plugin adds click listener on document, so don't forget to check if events
* can rich it or use scombobox.close method.
* @param {Object} $ jQuery reference
* @param {Object} document (HTMLDocument)
* @returns {undefined}
*/
(function($, document) {
'use strict';
var pname = 'scombobox'; // plugin name, don't forget to change css prefixes if necessary
var cp = '.' + pname;
var cdisplay = '-display', cvalue = '-value', cinvalid = '-invalid',
cdiv = cdisplay + '-div', cditem = cdiv + '-item', cdiremove = cditem + '-remove', cdholder = cdiv + '-holder',
clist = '-list', cmainspan = '-mainspan', chovered = '-hovered',
csep = '-separator', cpheader = '-header',
cddback = '-dropdown-background', cddarr = '-dropdown-arrow',
cdisabled = '-disabled';
function durations(d) {
return ({
fast: 200,
normal: 400,
slow: 600
})[d] || d;
}
var pInt = parseInt;
var methods = {
/**
* Initializes the combobox.
* @returns {Object} jQuery object
*/
init: function() {
var $div = this.find(cp + clist),
$select = this.find('select'),
$dropdownBack = this.find(cp + cddback),
$dropdownArr = this.find(cp + cddarr);
var opts = this.data(pname);
this.addClass(pname);
if ($select.length == 0) {
this.append($('<select />'));
}
if (this.attr('id')) {
$select.removeAttr('id');
}
if ($select.attr('multiple')) {
this.data(pname).mode = 'checkboxes';
}
if ($dropdownBack.length == 0) {
this.append('<div class="' + pname + cddback + '" />');
}
if ($dropdownArr.length == 0) {
this.append('<div class="' + pname + cddarr + '" />');
}
methods.displayDropdown.call(this, opts.showDropDown);
if (opts.mode != 'checkboxes') {
if (this.find(cp + cdisplay).length == 0) {
var $inputDisplay = $('<input class="' + pname + cdisplay + '" type="text" />');
$inputDisplay.attr('title', $select.attr('title'));
this.append($inputDisplay);
this.height(
+$inputDisplay.css('font-size') +
+$inputDisplay.css('padding-top') +
+$inputDisplay.css('padding-bottom')
);
}
}
if (opts.tabindex != null) {
this.find(cp + cdisplay).attr('tabindex', opts.tabindex);
}
if (this.find(cp + cvalue).length == 0) {
this.append('<input class="' + pname + cvalue + '" type="hidden" />');
}
if (this.find(cp + cdisplay).is(':disabled') || opts.disabled) {
this.find(cp + cddback + ', ' + cp + cddarr).hide();
}
if (opts.disabled) {
this.find(cp + cdisplay).prop('disabled', true);
this.addClass(pname + cdisabled);
}
if ($div.length == 0) {
this.append($div = $('<div class="' + pname + clist + '"></div>'));
}
if (opts.mode == 'checkboxes') {
this.addClass(pname + '-checkboxes');
this.find(cp + cdisplay).remove();
var $displayDiv = this.find(cp + cdisplay + '-div');
if ($displayDiv.length == 0) {
$displayDiv = this.append('<div class="' + pname + cdiv +'"><div class="' + pname + cdholder + '" /></div>');
}
$displayDiv.attr('title', $select.attr('title'));
$div.insertAfter(this.find(cp + cdisplay + '-div'));
var $dholder = this.find(cp + cdholder);
var $testItem = $('<div class="' + pname + cditem + '" id="' + pname + '-test-item"><div class="' + pname + cditem + '-text">x</div></div>');
$dholder.append($testItem.css('margin-left', '-9999px').show());
var height = $testItem.height()
+ pInt($testItem.css('padding-top')) + pInt($testItem.css('padding-top'))
+ pInt($testItem.css('margin-top')) + pInt($testItem.css('margin-top'))
+ pInt($testItem.css('border-top-width')) + pInt($testItem.css('border-top-width'))
+ pInt($dholder.css('padding-top')) + pInt($dholder.css('padding-top'));
this.find(cp + cdisplay + '-div').css('min-height', height + 'px');
$testItem.remove();
} else {
this.find(cp + '-display-div').remove();
$div.insertAfter(this.find(cp + cdisplay));
}
$div.css({'max-width': opts.listMaxWidth, 'max-height': opts.maxHeight});
if (opts.wrap == true) {
$div.css('white-space', 'normal');
}
if (opts.autoLoad != $.noop) {
opts.loopScrolling = false; // there is no way to support this feature when auto loading more items
}
addListeners.call(this);
this.data(pname + '-init', true); // true says that it is right after initialization, it is necessary for callback
return methods.fill.call(this, opts.data); // (will be set to false after filling)
},
/**
* Fills the combobox with specified data or using options list in select if no data given.
* @see comments in defaults
* @param {Array} data array of data objects. See comments in defaults
* @param {Number} appendMode flag defining if to append (1) or prepend (2) data to existing items
* @returns {Object} jQuery object
*/
fill: function(data, appendMode) {
var $options = this.find('select').children('option, optgroup');
// don't ever rely on div content, always use select options instead
var $div = this.find('.' + pname + clist), $select = this.find('select');
data = normalizeData(data);
var opts = this.data(pname);
var mode = opts.mode;
if (!data) { // no data were given; get data from select options
if (opts.removeDuplicates) {
removeDupsjQ($options);
purifyOptions($options);
$options = this.find('select').children('option, optgroup'); // update after removal
}
if ($options.length == 0) {
// TODO restore, using $p.data(pname).key if provided instead
} else { // here are options:
$options.each(function () {
var $t = $(this);
var $p = $('<p />');
$p.attr('title', $t.attr('title'));
if ($t.hasClass(pname + csep)) { // separator, not an option
if ($t.hasClass(pname + cpheader)) { // if header text also given then add only header
$div.append($p.addClass(pname + cpheader).text($t.text()));
} else { // else add separator itself
$p.addClass(pname + csep);
}
} else if (this.tagName.toLowerCase() == 'optgroup') {
var label = $t.attr('label');
var $innerOptions = $('option', this);
$t.before('<option />'); // don't know why after doesn't work correctly
$t.after($innerOptions); // unwrap it
$t.remove(); // remove optgroup tag itself
$div.append(label ? $p.addClass(pname + cpheader).text(label) : $p.addClass(pname + csep));
$innerOptions.each(function() {
$div.append($('<p />').attr('title', this.title).append($('<span class="' + pname + cmainspan + '" />').text($(this).text())).data('value', this.value));
});
return;
} else {
$p.append($('<span class="' + pname + cmainspan + '" />').text($t.text())).data('value', this.value);
if (mode == 'checkboxes') {
$p.prepend('<input type="checkbox" />');
}
}
$div.append($p);
});
}
} else { // fill directly from given data
if (opts.removeDuplicates) {
removeDups(data);
}
purifyData(data);
if (opts.sort) {
data.sort(sortF);
if (!opts.sortAsc) {
data.reverse();
}
}
if (!appendMode) {
$select.empty();
$div.empty();
this.children(cp + cvalue + ', ' + cp + cdisplay).val('');
} // TODO consider if appendMode == 2 is not a stupid piece of code
renderItems.call(this, data, appendMode == 2); // if appendMode == 2, then it is prepend
}
if (this.data(pname + '-init')) {
opts.callback.func.apply(this, opts.callback.args);
this.data(pname + '-init', false);
}
$options = this.find('select').children('option'); // update
if (!opts.empty) {
if (mode != 'checkboxes') {
this[pname]('val', $options.filter('option:selected:last').val());
} else {
var selectedValues = $options.filter(':selected').map(function() {
return $(this).val();
}).get();
this[pname]('val', selectedValues);
}
}
return this;
},
/**
* Removes all items from combobox (html-based removal)
* @returns {Object} jQuery object
*/
clear: function() { // TODO check why to or not to remove data itself
this.children('select').empty();
this.children(cp + clist).empty().width('');
this.children(cp + cdisplay).removeClass(pname + cinvalid);
this.children(cp + cddback).removeClass(pname + cddback + cinvalid);
return this;
},
/**
* Updates data without touching html items or gets the data.
* For updating combobox contents use fill method.
* @param {string} data
* @returns {Object} jQuery object
*/
data: function(data) { // this method is required because after setting new options
// via options method the data will be merged which probably will be wrong
if (arguments.length == 0) {
return this.data(pname).data;
} else {
this.data(pname).data = data;
}
return this;
},
/**
* Enables and disables combobox.
* @param {Boolean} b flag
* @returns {Object|Boolean} jQuery object or boolean desabled status.
*/
disabled: function(b) {
var mode = this.data(pname).mode;
if (arguments.length == 0) {
if (mode == 'checkboxes') {
return this.hasClass(pname + cdisabled);
} else { // default mode
return this.children(cp + cdisplay).prop('disabled');
}
}
b = !!b;
this.children(cp + cdisplay).prop('disabled', b);
if (b) {
this.addClass(pname + cdisabled);
this.children(cp + cddback + ', ' + cp + cddarr).hide();
} else {
this.removeClass(pname + cdisabled);
this.children(cp + cddback + ', ' + cp + cddarr).show();
}
return this;
},
/**
* Sets the tabindex attribute for search input.
* @param index
* @returns {Number|Object}
*/
tabindex: function(index) {
var $display = this.find(cp + cdisplay);
if (arguments.length == 0) {
return $display.attr('tabindex');
} else {
$display.attr('tabindex', index);
return this;
}
},
/**
* Resets options or see the options. Do not use this for changing data because merging is deep, so
* data may be merged instead of being replaced.
* For updating data use data method.
* @param {Object} options
* @returns {Object} jQuery object or options object
*/
options: function(options) {
if (arguments.length == 0) {
return this.data(pname);
}
$.extend(true, this.data(pname), toCamelCase(options));
return this;
},
/**
* Combobox value setter and getter.
* @param {String|Array} v value
* @returns {Object|String|Array} jQuery object or string/array combobox current value.
* Value returns as string in the default mode and as an array of values where items were
* checked in checkboxes mode.
* If combobox is disabled then empty string is returned.
*/ // TODO add the second parameter: flag if trigger changing the value (now it is triggering by default)
val: function(v) {
var opts = this.data(pname), mode = opts.mode;
if (arguments.length == 0) { // get the value
if (mode == 'default') {
var value = this.find(cp + cvalue).val();
}
return mode == 'default' ?
(this.find(cp + cdisplay).is(':disabled') ? '' : value) :
(mode == 'checkboxes' ? getValues.call(this) : null);
} else { // set the value
if (mode == 'default') {
setValue.call(this, v);
} else if (mode == 'checkboxes') {
setValues.call(this, v);
}
}
return this;
},
open: function() {
slide.call(this.children(cp + clist), 'down');
return this;
},
close: function() {
slide.call(this.children(cp + clist), 'up');
return this;
},
/*
* Listeners.
* Call $('#combo').combobox('keyup', null, 'namespace');
* to trigger an event of specific namespace.
*/
change: function(callback, namespace) {
return bindOrTrig.call(this, 'change', this.children(cp + cvalue), callback, namespace);
},
focus: function(callback, namespace) {
return bindOrTrig.call(this, 'focus', this.children(cp + cdisplay), callback, namespace);
},
blur: function(callback, namespace) {
return bindOrTrig.call(this, 'blur', this.children(cp + cdisplay), callback, namespace);
},
keyup: function(callback, namespace) {
return bindOrTrig.call(this, 'keyup', this.children(cp + cdisplay), callback, namespace);
},
keydown: function(callback, namespace) {
return bindOrTrig.call(this, 'keydown', this.children(cp + cdisplay), callback, namespace);
},
keypress: function(callback, namespace) {
return bindOrTrig.call(this, 'keypress', this.children(cp + cdisplay), callback, namespace);
},
click: function(callback, namespace) {
return bindOrTrig.call(this, 'click', this.children(cp + cdisplay), callback, namespace);
},
mousedown: function(callback, namespace) {
return bindOrTrig.call(this, 'mousedown', this.children(cp + cdisplay), callback, namespace);
},
clickDropdown: function(callback, namespace) {
return bindOrTrig.call(this, 'click', this.children(cp + cddarr), callback, namespace);
},
toSelect: function() {
var $select = this.children('select').insertAfter(this);
if (this.data(pname).reassignId) {
$select.attr('id', this.attr('id'));
}
this.remove();
return $select;
},
displayDropdown: function(b) {
if (arguments.length) {
if (!!b) {
this.children(cp + cddarr + ', ' + cp + cddback).show();
} else {
this.children(cp + cddarr + ', ' + cp + cddback).hide();
}
} else {
if (this.data(pname).showDropdown) {
this.children(cp + cddarr + ', ' + cp + cddback).show();
} else {
this.children(cp + cddarr + ', ' + cp + cddback).hide();
}
}
return this;
}
};
function bindOrTrig(type, $element, callback, namespace) {
if (typeof callback != 'function') { // trigger
var action = type + (typeof callback == 'string' ? '.' + callback : (typeof namespace == 'string' ? '.' + namespace : ''));
$element.trigger(action);
} else { // bind
addAdditionalListener.call($element, type, callback, namespace);
}
return this;
}
function addAdditionalListener(type, callback, namespace) {
var action = type + (typeof namespace == 'string' ? '.' + namespace : '');
this.bind(action, callback);
}
function getValues() { // for checkbox mode
return JSON.parse(this.find(cp + cvalue).val() || '[]');
}
/**
* Executes after checking a checkbox.
* this refers to combobox.
*/// TODO remove duplicate code if possible
function updateValueInput() { // used for checkboxes mode only
var $paragraphs = $(this).find(cp + clist + ' p'), $vInput = $(this).children(cp + cvalue), arrV = [];
$paragraphs.each(function() {
var $p = $(this);
var $check = $p.find(':checkbox');
if ($check.prop('checked')) {
arrV.push($p.data('value'));
}
});
$(this).children('select').val(arrV);
$vInput.val(JSON.stringify(arrV));
}
function setValues(values) { // for checkboxes mode; this refers to combobox
var $paragraphs = $(this).find(cp + clist + ' p'), $vInput = $(this).children(cp + cvalue), arrV = [];
var $lastChecked;
for (var i = 0; i < $paragraphs.length; i++) {
var $p = $paragraphs.eq(i), ind = values.indexOf($p.data('value'));
if (values.indexOf($p.data('value')) >= 0) {
$lastChecked = $p.find(':checkbox').prop('checked', true);
arrV.push(values[ind]);
} else {
$p.find(':checkbox').prop('checked', false);
}
}
$(this).children('select').val(values);
$lastChecked.trigger(pname + '-chupdate', [true]);
$vInput.val(JSON.stringify(arrV));
}
function setValue(value) { // for default mode
var $t = $(this);
var O = this.data(pname);
var $select = $t.children('select'), $valueInput = $t.children(cp + cvalue), $display = $t.children(cp + cdisplay);
var $selected = $select.children('[value="' + value + '"]');
$display.removeClass(pname + cinvalid).siblings(cp + cddback).removeClass(pname + cddback + cinvalid)
if (!$selected.length) { // no such value
$t.find(cp + clist + ' p').removeClass(pname + chovered);
$select.children().prop('selected', false);
if (!O.invalidAsValue) {
value = ''; // TODO make combobox return null instead of empty string (standard select behavior)
} else {
if (O.highlightInvalid || (O.invalidAsValue ? (O.highlightInvalid) : O.highlightInvalid === null)) {
$display.addClass(pname + cinvalid).siblings(cp + cddback)
.addClass(pname + cddback + cinvalid);
}
}
$valueInput.val(value);
$display.val(value);
return;
}
$t.find(cp + clist + ' p').eq($selected[0].index).addClass(pname + chovered).siblings().removeClass(pname + chovered);
$valueInput.val(value).data('changed', true);
$select.val(value).change();
}
/**
* Add all the combobox logic.
* @returns {undefined}
*/
var blurTimer;
function addListeners() {
if (this.data('listenersAdded')) { // prevent duplicating listeners
return;
}
var $T = this, O = $T.data(pname);
this.on('keyup', cp + cdisplay + ', ' + cp + cdiv, function(e) { // filter
// Ignore keys that can't alter input field value on their own
if ([38, //Up arrow
40, //Down arrow
13, //Enter
27, //Escape
9, //Tab
37, //Left arrow
39, //Right arrow
17, //Ctrl
18, //Alt
16, //Shift
20, //Caps lock
33, //Page up
34, //Page down
35, //End
36 //Home
].indexOf(e.which) >= 0) {
return;
}
// Some extra cases
if (!e.ctrlKey && !e.shiftKey && e.which==45) return; //Insert without modifier
if (e.ctrlKey && e.which==65) return; //Ctrl+A; imperfect because sometimes we release the A *after* the Ctrl
var fullMatch = O.fullMatch, highlight = O.highlight;
if (fullMatch) {
highlight = highlight !== false;
} else {
highlight = !!highlight;
}
var $t = $(this), search = this.value.trim();
if (O.filterIgnoreCase) {
search = search.toLowerCase();
}
var $div = $t.closest(cp).children(cp + clist);
slide.call($div, 'down', true);
var $options = $t.closest(cp).find('select option');
$(cp + ' ' + cp + clist).each(function() {
if ($div[0] != this) {
slide.call($(this), 'up');
}
});
if (!search) {
$div.children('p').show().each(function() {
$(cp + '-marker', this).contents().unwrap(); // remove selection
});
return;
}
var hideSelector = O.hideSeparatorsOnSearch ? 'p' : 'p:not(' + cp + csep + ', ' + cp + cpheader + ')';
$div.children(hideSelector).hide();
$options.each(function() {
var text = $(this).text().trim();
if (O.filterIgnoreCase) {
text = text.toLowerCase();
}
if (fullMatch ? text.indexOf(search) >= 0 : text.indexOf(search) == 0) {
// check index and show corresponding paragraph
var regexFlags = O.filterIgnoreCase ? 'i' : '';
var re = new RegExp("(" + search.replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1") + ")", fullMatch ? regexFlags + 'g' : regexFlags);
var $ps = $div.children('p:eq(' + $options.index(this) + '):not(' + cp + csep + ', ' + cp + cpheader + ')').show();
if (highlight) {
$ps.each(function() {
$(cp + '-marker', this).contents().unwrap(); // remove previous selection
var mainSpan = $(cp + cmainspan, this)[0];
mainSpan.innerHTML = mainSpan.innerHTML.replace(re, '<span class="' + pname + '-marker">$1</span>');
});
}
}
});
});
this.on('keydown', cp + cdisplay, function(e) {
if ([38, 40, 13, 27, 9].indexOf(e.which) >= 0) {
if (e.which != 9) {
e.preventDefault();
}
var $combobox = $(this).closest(cp);
var $div = $combobox.children(cp + clist);
var $hovered = $(cp + chovered, $div[0]), $curr, offset;
var $first = $('p:first', $div[0]);
var cycle = O.loopScrolling;
var notHeaderSelector = ':not(' + cp + csep + ', ' + cp + cpheader + ')';
} else {
return;
}
var fillOnArrow = O.mode == 'default' ? O.fillOnArrowPress : false; // always false for checkboxes mode
if ($div.is(':animated')) {
return; // keydown event is only for arrows, enter and escape
}
var v = this.value.trim();
v = (O.filterIgnoreCase) ? v.toLowerCase() : v;
var scrollTop = $div.scrollTop();
if (e.which == 40) { // arrdown
if ($div.is(':hidden')) {
slide.call($div, 'down');
return;
}
if ($hovered.length == 0) {
if ($first.is(':visible' + notHeaderSelector)) {
$curr = $first.addClass(pname + chovered);
} else {
$curr = $first.nextAll(':visible' + notHeaderSelector).first().addClass(pname + chovered);
}
} else {
if (!cycle) {
if (!$hovered.nextAll(':visible' + notHeaderSelector).first().length) {
return;
}
}
$curr = $hovered.removeClass(pname + chovered).nextAll(':visible' + notHeaderSelector).first().addClass(pname + chovered);
if ($curr.length == 0) {
if ($first.is(':visible')) {
$curr = $first.addClass(pname + chovered);
} else {
$curr = $first.nextAll(':visible' + notHeaderSelector).first().addClass(pname + chovered);
}
}
if ($curr.length == 0) {
$curr = $first;
}
offset = $curr.position().top - $div.position().top;
var currHeight = $curr.outerHeight();
if (offset + currHeight * 6 > $div.height()) { // keep 4 elements ahead
if ((offset + currHeight * 6) - $div.height() > currHeight * 1.5) { // $curr is under the visible bottom border
$div.scrollTop(scrollTop + offset);
} else { // no fix required
$div.scrollTop(scrollTop + currHeight); // incremental scrolltop
}
} else if (offset < 0) {
$div.scrollTop(scrollTop - -offset);
}
}
if (fillOnArrow) {
this.value = $curr.find(cp + cmainspan).text();
$combobox.children(cp + cdisplay).data('fillonarrow', true);
}
} else if (e.which == 38) { // arrup
if ($div.is(':visible')) {
if (!cycle) {
if (!$hovered.prevAll(':visible' + notHeaderSelector).first().length) {
return;
}
}
$curr = $hovered.removeClass(pname + chovered).prevAll(':visible' + notHeaderSelector).first().addClass(pname + chovered);
if ($curr.length == 0) {
$curr = $('p:visible' + notHeaderSelector + ':last', $div[0]).addClass(pname + chovered);
}
offset = $curr.position().top - $div.position().top;
currHeight = $curr.outerHeight();
if (offset < currHeight * 3) {
$div.scrollTop(scrollTop - -offset - currHeight * 3);
} else if (offset > $div.height() - currHeight * 3) {
$div.scrollTop(scrollTop + offset - currHeight * 3); // to the last (was $div[0].scrollHeight)
}
if (fillOnArrow) {
this.value = $curr.find(cp + cmainspan).text();
$combobox.children(cp + cdisplay).data('fillonarrow', true);
}
}
} else if (e.which == 13) { // enter
if (O.fillOnBlur) {
getFirstP($div).click();
return;
}
$div.children(cp + chovered).click();
if (O.mode == 'default') {
slide.call($div, 'up');
}
} else if (e.which == 27) { // escape
var $t = O.blurOnEscape ? $(this).blur() : $(this);
// If list is down, escape slides it up and doesn't propagate outward
if ($div.is(':visible')) {
slide.call($div, 'up');
e.stopPropagation();
}
} else if (e.which == 9) { // tab
if (O.fillOnTab) {
if (v) {
// Used to pick the first visible item in the dropdown
// Now pick the selected item (if any)
var $p = $div.children(cp + chovered);
if ($p.length) {
e.preventDefault();
$p.click();
}
}
}
}
});
this.on('change', 'select', function(e, checkboxesMode) { // someone triggered combobox select change
var $combo = $(this).closest(cp);
var dtext = $('option:selected', this).text();
$combo.children(cp + cdisplay).val(dtext).data('value', dtext);
var $valueInput = $combo.children(cp + cvalue);
if ($valueInput.data('changed')) {
$valueInput.data('changed', false);
return;
}
if (checkboxesMode) { // no slideup for checkboxes mode
updateValueInput.call($combo);
$valueInput.change();
return;
}
$valueInput.change();
slide.call($combo.children(cp + clist), 'up'); // can be triggered at the page load
});
this.on(pname + '-chupdate', cp + clist + ' p :checkbox', function(e, forRefresh) {
if (forRefresh) {
e.stopPropagation();
checkboxesModePClick.call($(this).parent(), e, true);
}
});
this.on('click', cp + clist + ' p', function(e) { // value selected by clicking
clearTimeout(blurTimer);
e.stopPropagation();
if ($(this).is(cp + csep + ', ' + cp + cpheader)) {
return;
}
$T.children(cp + cinvalid).removeClass(pname + cinvalid); // 100% it is not invalid now
$T.children(cp + cddback).removeClass(pname + cddback + cinvalid);
var $t = $(this), $div = $t.parent(), $ps = $div.children();
var index = $ps.index(this);
if ($T.data(pname).mode == 'checkboxes') {
checkboxesModePClick.call(this, e); // process checking
return;
}
var $select = $div.closest(cp).children('select');
$select.children('option').eq(index).prop('selected', true);
$select.siblings(cp + cvalue).val($select.val());
$select.change();
slide.call($t.parent(), 'up');
$t.addClass(pname + chovered).siblings().removeClass(pname + chovered);
});
this.on('blur', cp + cdisplay, function(e) {
// Need to do some stuff only when user moves off the scombobox.
// Do nothing in this handler if losing focus to another part of this
// combobox (e.g. the down/up button, or the list itself).
// IE needs this technique in addition to the timer one (see below) because
// clicking on the dropped-down div's scroller (if present) gives a blur
// but no suitable subsequent event with which to cancel the timer.
var rt = $(e.relatedTarget).closest(cp);
if (rt.length > 0 && rt[0] === $t.closest(cp)[0]) {
return;
}
// The relatedTarget technique doesn't work on Chrome or on Firefox.
// So we start a 200ms timer when display element loses focus. In click
// handlers of control's other elements clearTimeout cancels the timer.
// If the timer isn't cancelled it will fire and do the necessary stuff.
// Note that the timer's function's bind() method is used to supply it with the correct 'this'
blurTimer = setTimeout( function() {
var $t = $(this), O = $T.data(pname);
if (this===document.activeElement) {
// Suppress autoexpand on next focus if this blur was actually the entire window losing focus
// rather than this element losing focus to another element on the same window
$t.data('silentfocus', true);
}
$t.data('fillonarrow', false); // Prevent the slide-up from resetting value
slide.call($t.closest(cp).children(cp + clist), 'up'); // Make sure the list closes when we leave the control
if (O.fillOnBlur && !O.invalidAsValue) {
getFirstP($t.parent().children(cp + clist)).click();
return;
}
var vOriginal = $t.val().trim();
var $valueInput = $t.siblings(cp + cvalue);
var previousV = $valueInput.val();
if (!vOriginal) { // if combo was emptied then set its value to '':
$valueInput.val('');
} else {
var value;
$t.siblings('select').find('option').each(function () {
if (O.filterIgnoreCase) {
if (vOriginal.toLowerCase() == $(this).text().trim().toLowerCase()) {
value = this.value;
}
} else {
if (vOriginal == $(this).text().trim()) {
value = this.value;
}
}
});
if (!value) { // value not found (invalid)
$valueInput.val(O.invalidAsValue ? vOriginal : '');
} else {
$valueInput.val(value);
}
}
if (previousV !== $valueInput.val()) {
$valueInput.change().data('changed', true);
}
}.bind(this),
200)
});
this.on('focus', cp + cdisplay, function() {
// Check for indicator that focus shouldn't cause expansion
if ($(this).data('silentfocus')) {
$(this).data('silentfocus', false);
return;
}
if (!this.value.trim()) { // focusing in empty field
// should trigger full dropdown:
if (($T.data(pname).expandOnFocus)||($(this).data('expandfocus'))) {
$(this).keyup();
}
} else { // input.display is not empty
if (($T.data(pname).expandOnFocusWithValue)||($(this).data('expandfocus'))) {
if ($T[pname]('val')) { // if value is valid
var $listDiv = $T.children(cp + clist);
$listDiv.children().show();
slide.call($listDiv, 'down');
} else {
$(this).keyup(); // else start filtering
}
}
}
$(this).data('expandfocus', false);
});
this.on('click', cp + cdisplay + '-div', function() {
if ($T.data(pname).disabled) {
return;
}
slide.call($(this).siblings(cp + clist), 'down');
});
this.on('click', cp + cdisplay, function(e) {
var t = $(this).closest(cp)[0];
$(cp).each(function() { // close all other comboboxes
if (this != t) {
$(this)[pname]('close');
}
});
e.stopPropagation();
});
this.on('click', cp + cddarr, function(e) {
clearTimeout(blurTimer);
e.stopPropagation();
var $t = $(this), $combo = $t.closest(cp);
var $div = $combo.children(cp + clist);
if ($div.is(':visible')) {
slide.call($div, 'up');
$combo.children(cp + cdisplay).data('silentfocus', true).focus();
} else {
$combo.children(cp + cdisplay).data('expandfocus', true).focus();
}
});
this.on('click', cp + cdiremove, function(e) {
clearTimeout(blurTimer);
e.stopPropagation();
var $t = $(this);
var $item = $t.parent(), $div = $T.children(cp + clist);
$div.children('p').eq($t.data('index')).find(':checkbox').prop('checked', false);
$item.fadeOut(O.animation.duration);
$t.closest(cp).children('select').trigger('change', [true]);
});
// scroll listener is for ajax loading
if (O.autoLoad != $.noop) {
$(cp + clist, this).scroll(function() {
var $t = $(this), $select = $T.children('select');
var currentScrollTop = $t.scrollTop();
var overhead = 50;
if (currentScrollTop > $t.data('scrollTop')) { // scrolling down
if (this.scrollHeight - currentScrollTop - overhead < $t.height()) {
if (!$T.data('pending')) {
$T.data('pending', true);
O.autoLoad.call($T, $select.find('option[value]:last').val(), 'bottom');
}
}
} else { // scrolling up
if (currentScrollTop < $t.height() / 2) {
if (!$T.data('pending')) {
$T.data('pending', true);
O.autoLoad.call($T, $select.find('option[value]:first').val(), 'top');
}
}
}
$t.data('scrollTop', currentScrollTop);
}).data('scrollTop', 0);
}
$(document).bind('click.' + pname, {thisIs: this}, function(e) {
slide.call($(e.data.thisIs).children(cp + clist), 'up');
});
this.data('listenersAdded', true);
}
/**
* Converts given data to final form in the most convenient way.
* @param {Array} data data given as options.data param
* @returns {Array|Boolean} array of data objects or false if no data were given
*/
function normalizeData(data) {
if (typeof data == 'string') { // json given
data = $.parseJSON(data);
if (data == null) { // null == empty array
return [];
}
}
if (!data) { // all falsy except empty string
return false;
}
if (!(data instanceof Array)) { // object (probably) was given, convert it to array
if (typeof data != 'object') {
return false;
}
if (typeof data.length == 'undefined') {
data.length = Object.keys(data).length;
}
data = [].slice.call(data);
}
return data; // array was given
}
function purifyData(data) {
for (var i = 0; i < data.length; i++) {
if ((!data[i].value || !data[i].text) && !(data[i].hasOwnProperty('separator'))) {
data.splice(i, 1);
}
}
}
function purifyOptions($options) {
for (var i = 0; i < $options.length; i++) {
if (!$options[i].value && !$($options[i]).hasClass(pname + csep) && $options[i].tagName.toLowerCase() != 'optgroup') { // if no value,
// but if it is a separator, then it is no matter if there is a not empty value
// if this is an optgroup tag, then it will be used as a separator
$($options[i]).remove();
}
}
}
function sortF(a, b) {
var aT = a.text.trim().toLowerCase(), bT = b.text.trim().toLowerCase();
return aT > bT ? 1 : aT == bT ? 0 : -1;
}
function removeDups(a) {
for (var i = 0; i < a.length; i++) {
for (var j = i + 1; j < a.length; j++) {
if (!a[i] || !a[j])
continue;
if (a[i].value == a[j].value)
a.splice(i, 1);
}
}
}
function removeDupsjQ(a) {
for (var i = 0; i < a.length; i++) {
for (var j = i + 1; j < a.length; j++) {
if (!a[i] || !a[j])
continue;
if (a[i].value == a[j].value && a[i].tagName.toLowerCase() != 'optgroup') {
$(a[i]).remove();
}
}
}
}
/**
* `this` refers to combobox
*/
function checkForInvalid() {
var $display = this.children(cp + cdisplay), $select = this.children('select'), O = this.data(pname);
var value, v = $display.val().trim();
v = (O.filterIgnoreCase) ? v.toLowerCase() : v;
// check if such value exists in options
$select.find('option').each(function () {
var candidate = $(this).text().trim();
candidate = (O.filterIgnoreCase) ? candidate.toLowerCase() : candidate;
if (candidate == v) {
value = this.value;
}
});
var invalid = (!value && v);
if (invalid) {
if (O.forbidInvalid) {
$display.closest(cp).find(cp + cdisplay).val('').data('value', '');
} else {
// if highlightInvalid is enabled directly (default is null)
// or invalidAsValue is on and highlightInvalid is not its default:
// TODO refactor to make a more readable code:
if (O.highlightInvalid || (O.invalidAsValue ? (O.highlightInvalid) : O.highlightInvalid === null)) {
$display.addClass(pname + cinvalid).siblings(cp + cddback)
.addClass(pname + cddback + cinvalid);
}
}
if (!O.invalidAsValue) { // TODO check if this code affects anything
$display.siblings('select, ' + cp + cvalue).val('');
}
} else {
$display.removeClass(pname + cinvalid).siblings(cp + cddback).removeClass(pname + cddback + cinvalid);
}
}
/**
* Slides the div with a list. `this` refers to the list
* @param dir 'up' = collapse, 'down' = expand.
* @param backspace to fix backspace bug
*/ // TODO rename and comment backspace argument
function slide(dir, backspace) {
if (this.is(':animated') || !this.length) {
return;
}
if (dir == 'up' && this.is(':hidden') && this.length == 1) {
return; // todo put a comment: why? (one reason is probably optimization, but what is this.length == 1 for?)
}
var options = this.parent().data(pname).animation;
if (!$.easing[options.easing]) {
console.warn('no such easing: ' + options.easing);
options.easing = 'swing';
}
var $combobox = this.parent(), O = $combobox.data(pname);
if (dir == 'up') {
O.beforeClose.call($combobox);
options.complete = function() {
if (O.mode != 'checkboxes') {
checkForInvalid.call($combobox);
}
O.afterClose.call($combobox);
};
this.slideUp(options).data('p-clicked-index', -1);
$combobox.children(cp + cddarr).removeClass(pname + cddarr + '-up');
} else {
O.beforeOpen.call($combobox);
options.complete = function() {O.afterOpen.call($combobox)};
this.slideDown(options);
$combobox.children(cp + cddarr).addClass(pname + cddarr + '-up');
// Every edit keystroke will call a slide down; use this opportunity to reset the list's display characteristics fully.
$combobox.find(cp + chovered).removeClass(pname + chovered); // remove previous selection
$(cp + '-marker', $combobox).contents().unwrap(); // remove previous highlight
// Reveal everything whenever we slide down, so that user gets to see all the options.
// If the slide down was triggered by entry of a character, filtering will immediately reduce the list
// to matching items. If the slide down was by clicking the down-button, or entry of cursor-down,
// all entries will remain displayed.
$combobox.children(cp + clist).children('p').show();
}
var $display = $combobox.children(cp + cdisplay); // code for fillOnArrowPress feature
$display.each(function() {
var $t = $(this);
if ($t.data('fillonarrow') && !backspace) { // fix backspace bug
$t.data('fillonarrow', false).val($t.data('value'));
}
// Highlight first full match when dropping down
if (dir == 'down') {
var search = this.value.trim();
if (O.filterIgnoreCase) {
search = search.toLowerCase();
}
var $selopts = $combobox.find('select option');
$selopts.each(function() {
var text = $(this).text().trim();
if (O.filterIgnoreCase) {
text = text.toLowerCase();
}
if (text == search) {
$combobox.children(cp + clist).children('p:eq(' + $selopts.index(this) + '):not(' + cp + csep + ', ' + cp + cpheader + ')').first().addClass(pname + chovered);
return false;
}
});
}
});
}
function checkboxesModePClick(e, forRefresh) { // this refers to paragraph dom element
var $t = $(this), $combo = $t.closest(cp), $div = $t.parent(), $ps = $div.children('p'),
index = $ps.index(this), duration = durations($div.parent().data(pname).animation.duration);
if (!forRefresh) {
var $chbox = $t.find(':checkbox');
// don't toggle prop('checked') if checkbox itself was clicked.
if (!$(e.target).is(':checkbox')) {
$chbox.prop('checked', !$chbox.prop('checked')); // avoid clicking, change prop instead
}
var choice = $chbox.prop('checked');
if (e.shiftKey) { // mark between last click and current
if ($div.data('p-clicked-index') >= 0) { // not for the first time
var f = $div.data('p-clicked-index');
var from = f < index ? f : index, to = f < index ? index : f;
for (var i = from; i <= to; i++) {
$($ps[i]).find(':checkbox').prop('checked', choice);
}
}
}
}
var $dispDivHolder = $combo.find(cp + cdholder).prepend('<span />');
$combo.find(cp + cdholder).fadeOut(duration / 5, function() {
$dispDivHolder.empty().show();
// get all selected properties
$ps.each(function(i) {
var $t = $(this);
if ($t.find(':checkbox').prop('checked')) {
$dispDivHolder.append(
$('<div />').addClass(pname + cditem)
.append($('<div />').addClass(pname + cditem + '-text').text($t.find(cp + cmainspan).text()))
.append($('<div />').addClass(pname + cdiremove).text('×').data('index', i)).fadeIn(duration * 1.5)
.attr('title', $t.attr('title'))
);
}
});
$dispDivHolder.append('<div style="clear: both" />');
});
$div.data('p-clicked-index', index);
$t.closest(cp).children('select').trigger('change', [true]); // true for do not slideup the items div
}
/**
* @param items
* @param prepend flag if prepend instead of appending
*/
function renderItems(items, prepend) {
var settings = this.data(pname);
var $select = this.find('select'), $div = this.find(cp + clist);
for (var i = 0; i < items.length; i++) {
if (items[i].hasOwnProperty('separator')) { // if separator given then
if (items[i].hasOwnProperty('header')) { // if header text also given then add only header
$p = $('<p class="' + pname + cpheader + '" />').text(items[i].header);
} else { // else add separator itself
var $p = $('<p class="' + pname + csep + '" />');
}
var $option = $('<option />');
} else { // regular item
$option = $('<option />').val(items[i].value).text(items[i].text).prop('selected', !!items[i].selected);
$p = settings.pFillFunc.call(this, items[i], settings);
if (settings.mode == 'checkboxes') {
$p.prepend('<input type="checkbox" />');
}
}
$p.data('value', items[i].value);
if (prepend) {
$select.prepend($option);
$div.prepend($p);
} else {
$select.append($option);
$div.append($p);
}
}
}
function getFirstP($clist) {
var $closestP = $clist.children(cp + chovered + ':visible');
if ($closestP.length == 0) {
$closestP = $clist.children(':visible:first');
}
return $closestP;
}
function toCamelCase(o) {
if (o == null) {
return null;
}
var keys = Object.keys(o);
for (var k = 0; k < keys.length; k++) {
var key = keys[k].replace(/-([a-z])/g, function(g) { return g[1].toUpperCase() });
if (keys[k] != key) { // hyphened property
o[key] = o[keys[k]];
delete o[keys[k]];
}
if (typeof o[key] == 'object' && key != 'data') {
toCamelCase(o[key]);
}
}
return o;
}
/**
* The core.
* @param {Object|String} actOrOpts action (string) or options (object)
* @returns {Object|void} jQuery object on success. Throws error on undefined method.
*/
$.fn[pname] = function(actOrOpts) {
if (typeof actOrOpts == 'string') {
if (!this.length) { // method called on empty collection
$.error('Calling ' + pname + '.' + actOrOpts + '() method on empty collection');
}
if (this.data(pname + '-init') == null) { // it can be legally false, but not undefined
$.error('Calling ' + pname + '.' + actOrOpts + '() method prior to initialization');
}
var method = methods[actOrOpts];
if (!method) {
$.error('No such method: ' + actOrOpts + ' in jQuery.' + pname + '()');
}
} else if (['object', 'undefined'].indexOf(typeof actOrOpts) >= 0) {
var options = $.extend(true, {}, $.fn[pname].defaults, toCamelCase(actOrOpts));
} else {
$.error('Incorrect usage');
return this;
}
if (method) {
return method.apply(this, Array.prototype.slice.call(arguments, 1));
}
return this.each(function() {
var $t = $(this);
if (!($t.is('select, div'))) {
console.warn('target element is incorrect: ', this);
return;
}
if ($t.is('select')) {
$t.wrap('<div />');
if (options.reassignId) {
$t.parent().attr('id', $t.attr('id'));
}
$t = $t.parent();
}
$t.data(pname, $.extend(true, {}, options)); // cloning object is required for cases like:
// $('multiple targets selector').combobox(settings)
// $('one of a bunch').combobox('options', propertiesToChange)
// If the options object is not cloned above,
// then changing properties will affect every target in the original set.
methods.init.apply($t);
});
};
$.fn[pname].defaults = {
/**
* If no data given combobox is filled relying on $('select option') list.
* By default (see pMarkup and pFillFunc) the data is an array of objects:
* {value: '', text: '', additional: '', selected: true/false, anyCustomOption: customValue}
* You can also provide json or object with enumerated properties:
* {0: {...}, 1: {...}, ...}
*/
data: null,
/**
* Whether combobox is empty by default (true) or has an original select value (usually it the first value,
* but can be changed by added a `selected` prop).
*/
empty: false,
/**
* Whether set combobox disabled.
*/
disabled: false,
/**
* Whether to sort options alphabetically or not
*/
sort: true,
/**
* false to sort descending
*/
sortAsc: true,
/**
* Whether to remove duplicates (regarding to values only).
* Not removing duplicated may cause an error, so be careful
*/
removeDuplicates: true,
/**
* Whether to match in any part of the option text or only start from the beginning of the text
*/
fullMatch: false,
/**
* By default highlighting is turned on when fullMatch is turned on.
* Set it strictly to false to disable it anyway or to any truthy value to set it always enabled
*/
highlight: null,
/**
* Searching ignore case.
*/
filterIgnoreCase: true,
/**
* Hide separators when typing something in a combo.
*/
hideSeparatorsOnSearch: false,
/**
* When false options list does not drop down on focus (applies on an empty combobox).
* In this case you have to click on arrow to expand the list or start typing.
*/
expandOnFocus: true,
/**
* When false options list does not drop down on focus (applies on a filled combobox).
*/
expandOnFocusWithValue: true,
/**
* Set tabindex
*/
tabindex: null,
/**
* When true, invalid values are forbidden what means combobox search input empties on blur in case the value
* was not chosen and search field contained wrong text.
* When false, incorrect filled combobox search field will has invalid css class.
*/
forbidInvalid: false,
/**
* When true, then value from visible input will be a value returned by `$(combo).scombobox('val');`
*/
invalidAsValue: false,
/**
* Whether to mark a combobox with invalid value with red or not. By default it is turned on.
* When `invalidAsValue` option is set to true, `highlightInvalid` is considered false by default.
* If you want to enabled or disable it regardless to `invalidAsValue`, set it to a any truthy value or not null
* falsy value correspondingly.
*/
highlightInvalid: null,
/**
* If true id from select will be reassigned to the created combobox div when query target was select, like $('select').combobox()
*/
reassignId: true,
/**
* Combobox mode 'default' means it is looking like select box with input for searching.
* mode 'checkboxes' means every option has a checkbox. In checkboxes mode the value of
* combobox is an array of values which were checked.
*/
mode: 'default',
/**
* Don't forget to change pFillFunc if necessary when you change the markup.
* <span class="mainspan"></span> is required to use marker highlighting while typing. Highlighting is only working for the text
* in this span. That means filter does not apply to additional text. See data parameter.
*/
pMarkup: '<span class="' + pname + cmainspan + '">${text}</span> <span>${additional}</span>',
/**
* Change replacements lines in this function if necessary after changing pMarkup.
* this refers to combobox
* @param item {Object} item from data array
* @param options {Object} plugin instance properties
*/
pFillFunc: function(item, options) {
return $('<p />').html(options.pMarkup
.replace('${text}', item.text)
.replace('${additional}', item.additional ? item.additional : '')
);
},
/**
* Animation settings.
*/
animation: {
duration: 'fast', // animation speed
easing: 'swing' // easing effect
},
/**
* Dropdown div max width
*/
listMaxWidth: window.screen.width / 2,
/**
* Use this to handle long text options lists.
* If true then long text options will take multiple lines. If false, then horizontal slider appears in list.
*/
wrap: true,
/**
* Items list div maximum height (css property)
*/
maxHeight: '',
/**
* Put main text in input while walking though the options with arrow keys
*/
fillOnArrowPress: true,
/**
* Select hovered or first matching option on blur
*/
fillOnBlur: false,
/**
* Blurs the search field on escape keypress
*/
blurOnEscape: false,
/**
* Whether to set the first visible item as a value on tab key press (works only if search input is not empty).
* If set to false then the default action is working (going to the next input on page).
*/
fillOnTab: true,
/**
* If set to true dropdown arrow appears in the right corner of combobox
*/
showDropDown: true,
/**
* Callback executes after finishing initialization.
*/
callback: {
func: $.noop, // this refers to combobox's div holder
args: [] // arguments
},
beforeOpen: $.noop,
beforeClose: $.noop,
afterOpen: $.noop,
afterClose: $.noop,
/**
* This option is for ajax loading (appending/prepending items). This function usage is:
* function(value, direction) {
* // value here is the edge value in the list (last for appending or first for prepending).
* // direction here is the scrolling direction, which can be either 'top' or 'bottom'
* // so you can do something like this:
* var $t = $(this);
* $.post('your url here' + (direction == 'top' ? '?prepend' : ''), {id: value}, function(res) {
* $t.scombobox('fill', res, direction == 'top' ? 2 : 1); // 1 for prepending, 2 for appending
* $t.data('pending', false); // this line is compulsory
* });
* }
*/
autoLoad: $.noop,
/**
* Enables infinite scrolling for up and down arrows keys.
* When autoLoad function provided then loopScrolling is set to false.
*/
loopScrolling: true
};
/**
* This function lets you override the default params without touching original plugin code.
* Usage: $.scombobox.extendDefaults(yourDefaults);
* @param options {Object} your custom defaults.
*/
$.fn[pname].extendDefaults = function(options) {
$.extend(true, $.fn[pname].defaults, options);
};
})(jQuery, document);