/** * Multiple Selection Component for Bootstrap * Check nicolasbize.github.io/magicsuggest/ for latest updates. * * Author: Nicolas Bize * Created: Feb 8th 2013 * Last Updated: Oct 16th 2014 * Version: 2.1.4 * Licence: MagicSuggest is licenced under MIT licence (http://opensource.org/licenses/MIT) */ (function($) { "use strict"; var MagicSuggest = function(element, options) { var ms = this; /** * Initializes the MagicSuggest component */ var defaults = { /********** CONFIGURATION PROPERTIES ************/ /** * Restricts or allows the user to validate typed entries. * Defaults to true. */ allowFreeEntries: true, /** * Restricts or allows the user to add the same entry more than once * Defaults to false. */ allowDuplicates: false, /** * Additional config object passed to each $.ajax call */ ajaxConfig: {}, /** * If a single suggestion comes out, it is preselected. */ autoSelect: true, /** * Auto select the first matching item with multiple items shown */ selectFirst: false, /** * Allow customization of query parameter */ queryParam: 'query', /** * A function triggered just before the ajax request is sent, similar to jQuery */ beforeSend: function(){ }, /** * A custom CSS class to apply to the field's underlying element. */ cls: '', /** * JSON Data source used to populate the combo box. 3 options are available here: * No Data Source (default) * When left null, the combo box will not suggest anything. It can still enable the user to enter * multiple entries if allowFreeEntries is * set to true (default). * Static Source * You can pass an array of JSON objects, an array of strings or even a single CSV string as the * data source.For ex. data: [* {id:0,name:"Paris"}, {id: 1, name: "New York"}] * You can also pass any json object with the results property containing the json array. * Url * You can pass the url from which the component will fetch its JSON data.Data will be fetched * using a POST ajax request that will * include the entered text as 'query' parameter. The results * fetched from the server can be: * - an array of JSON objects (ex: [{id:...,name:...},{...}]) * - a string containing an array of JSON objects ready to be parsed (ex: "[{id:...,name:...},{...}]") * - a JSON object whose data will be contained in the results property * (ex: {results: [{id:...,name:...},{...}] * Function * You can pass a function which returns an array of JSON objects (ex: [{id:...,name:...},{...}]) * The function can return the JSON data or it can use the first argument as function to handle the data. * Only one (callback function or return value) is needed for the function to succeed. * See the following example: * function (response) { var myjson = [{name: 'test', id: 1}]; response(myjson); return myjson; } */ data: null, /** * Additional parameters to the ajax call */ dataUrlParams: {}, /** * Start the component in a disabled state. */ disabled: false, /** * Name of JSON object property that defines the disabled behaviour */ disabledField: null, /** * Name of JSON object property displayed in the combo list */ displayField: 'name', /** * Set to false if you only want mouse interaction. In that case the combo will * automatically expand on focus. */ editable: true, /** * Set starting state for combo. */ expanded: false, /** * Automatically expands combo on focus. */ expandOnFocus: false, /** * JSON property by which the list should be grouped */ groupBy: null, /** * Set to true to hide the trigger on the right */ hideTrigger: false, /** * Set to true to highlight search input within displayed suggestions */ highlight: true, /** * A custom ID for this component */ id: null, /** * A class that is added to the info message appearing on the top-right part of the component */ infoMsgCls: '', /** * Additional parameters passed out to the INPUT tag. Enables usage of AngularJS's custom tags for ex. */ inputCfg: {}, /** * The class that is applied to show that the field is invalid */ invalidCls: 'ms-inv', /** * Set to true to filter data results according to case. Useless if the data is fetched remotely */ matchCase: false, /** * Once expanded, the combo's height will take as much room as the # of available results. * In case there are too many results displayed, this will fix the drop down height. */ maxDropHeight: 290, /** * Defines how long the user free entry can be. Set to null for no limit. */ maxEntryLength: null, /** * A function that defines the helper text when the max entry length has been surpassed. */ maxEntryRenderer: function(v) { return 'Please reduce your entry by ' + v + ' character' + (v > 1 ? 's':''); }, /** * The maximum number of results displayed in the combo drop down at once. */ maxSuggestions: null, /** * The maximum number of items the user can select if multiple selection is allowed. * Set to null to remove the limit. */ maxSelection: 10, /** * A function that defines the helper text when the max selection amount has been reached. The function has a single * parameter which is the number of selected elements. */ maxSelectionRenderer: function(v) { return 'You cannot choose more than ' + v + ' item' + (v > 1 ? 's':''); }, /** * The method used by the ajax request. */ method: 'POST', /** * The minimum number of characters the user must type before the combo expands and offers suggestions. */ minChars: 0, /** * A function that defines the helper text when not enough letters are set. The function has a single * parameter which is the difference between the required amount of letters and the current one. */ minCharsRenderer: function(v) { return 'Please type ' + v + ' more character' + (v > 1 ? 's':''); }, /** * Whether or not sorting / filtering should be done remotely or locally. * Use either 'local' or 'remote' */ mode: 'local', /** * The name used as a form element. */ name: null, /** * The text displayed when there are no suggestions. */ noSuggestionText: 'No suggestions', /** * The default placeholder text when nothing has been entered */ placeholder: 'Type or click here', /** * A function used to define how the items will be presented in the combo */ renderer: null, /** * Whether or not this field should be required */ required: false, /** * Set to true to render selection as a delimited string */ resultAsString: false, /** * Text delimiter to use in a delimited string. */ resultAsStringDelimiter: ',', /** * Name of JSON object property that represents the list of suggested objects */ resultsField: 'results', /** * A custom CSS class to add to a selected item */ selectionCls: '', /** * An optional element replacement in which the selection is rendered */ selectionContainer: null, /** * Where the selected items will be displayed. Only 'right', 'bottom' and 'inner' are valid values */ selectionPosition: 'inner', /** * A function used to define how the items will be presented in the tag list */ selectionRenderer: null, /** * Set to true to stack the selectioned items when positioned on the bottom * Requires the selectionPosition to be set to 'bottom' */ selectionStacked: false, /** * Direction used for sorting. Only 'asc' and 'desc' are valid values */ sortDir: 'asc', /** * name of JSON object property for local result sorting. * Leave null if you do not wish the results to be ordered or if they are already ordered remotely. */ sortOrder: null, /** * If set to true, suggestions will have to start by user input (and not simply contain it as a substring) */ strictSuggest: false, /** * Custom style added to the component container. */ style: '', /** * If set to true, the combo will expand / collapse when clicked upon */ toggleOnClick: false, /** * Amount (in ms) between keyboard registers. */ typeDelay: 400, /** * If set to true, tab won't blur the component but will be registered as the ENTER key */ useTabKey: false, /** * If set to true, using comma will validate the user's choice */ useCommaKey: true, /** * Determines whether or not the results will be displayed with a zebra table style */ useZebraStyle: false, /** * initial value for the field */ value: null, /** * name of JSON object property that represents its underlying value */ valueField: 'id', /** * regular expression to validate the values against */ vregex: null, /** * type to validate against */ vtype: null }; var conf = $.extend({},options); var cfg = $.extend(true, {}, defaults, conf); /********** PUBLIC METHODS ************/ /** * Add one or multiple json items to the current selection * @param items - json object or array of json objects * @param isSilent - (optional) set to true to suppress 'selectionchange' event from being triggered */ this.addToSelection = function(items, isSilent) { if (!cfg.maxSelection || _selection.length < cfg.maxSelection) { if (!$.isArray(items)) { items = [items]; } var valuechanged = false; $.each(items, function(index, json) { if (cfg.allowDuplicates || $.inArray(json[cfg.valueField], ms.getValue()) === -1) { _selection.push(json); valuechanged = true; } }); if(valuechanged === true) { self._renderSelection(); this.empty(); if (isSilent !== true) { $(this).trigger('selectionchange', [this, this.getSelection()]); } } } this.input.attr('placeholder', (cfg.selectionPosition === 'inner' && this.getValue().length > 0) ? '' : cfg.placeholder); }; /** * Clears the current selection * @param isSilent - (optional) set to true to suppress 'selectionchange' event from being triggered */ this.clear = function(isSilent) { this.removeFromSelection(_selection.slice(0), isSilent); // clone array to avoid concurrency issues }; /** * Collapse the drop down part of the combo */ this.collapse = function() { if (cfg.expanded === true) { this.combobox.detach(); cfg.expanded = false; $(this).trigger('collapse', [this]); } }; /** * Set the component in a disabled state. */ this.disable = function() { this.container.addClass('ms-ctn-disabled'); cfg.disabled = true; ms.input.attr('disabled', true); }; /** * Empties out the combo user text */ this.empty = function(){ this.input.val(''); }; /** * Set the component in a enable state. */ this.enable = function() { this.container.removeClass('ms-ctn-disabled'); cfg.disabled = false; ms.input.attr('disabled', false); }; /** * Expand the drop drown part of the combo. */ this.expand = function() { if (!cfg.expanded && (this.input.val().length >= cfg.minChars || this.combobox.children().size() > 0)) { this.combobox.appendTo(this.container); self._processSuggestions(); cfg.expanded = true; $(this).trigger('expand', [this]); } }; /** * Retrieve component enabled status */ this.isDisabled = function() { return cfg.disabled; }; /** * Checks whether the field is valid or not * @return {boolean} */ this.isValid = function() { var valid = cfg.required === false || _selection.length > 0; if(cfg.vtype || cfg.vregex){ $.each(_selection, function(index, item){ valid = valid && self._validateSingleItem(item[cfg.valueField]); }); } return valid; }; /** * Gets the data params for current ajax request */ this.getDataUrlParams = function() { return cfg.dataUrlParams; }; /** * Gets the name given to the form input */ this.getName = function() { return cfg.name; }; /** * Retrieve an array of selected json objects * @return {Array} */ this.getSelection = function() { return _selection; }; /** * Retrieve the current text entered by the user */ this.getRawValue = function(){ return ms.input.val(); }; /** * Retrieve an array of selected values */ this.getValue = function() { return $.map(_selection, function(o) { return o[cfg.valueField]; }); }; /** * Remove one or multiples json items from the current selection * @param items - json object or array of json objects * @param isSilent - (optional) set to true to suppress 'selectionchange' event from being triggered */ this.removeFromSelection = function(items, isSilent) { if (!$.isArray(items)) { items = [items]; } var valuechanged = false; $.each(items, function(index, json) { var i = $.inArray(json[cfg.valueField], ms.getValue()); if (i > -1) { _selection.splice(i, 1); valuechanged = true; } }); if (valuechanged === true) { self._renderSelection(); if(isSilent !== true){ $(this).trigger('selectionchange', [this, this.getSelection()]); } if(cfg.expandOnFocus){ ms.expand(); } if(cfg.expanded) { self._processSuggestions(); } } this.input.attr('placeholder', (cfg.selectionPosition === 'inner' && this.getValue().length > 0) ? '' : cfg.placeholder); }; /** * Get current data */ this.getData = function(){ return _cbData; }; /** * Set up some combo data after it has been rendered * @param data */ this.setData = function(data){ cfg.data = data; self._processSuggestions(); }; /** * Set the maximum allowed selections * @param {integer} name */ this.setMaxSelection = function(max) { cfg.maxSelection = max; }; /** * Sets the name for the input field so it can be fetched in the form * @param name */ this.setName = function(name){ cfg.name = name; if(name){ cfg.name += name.indexOf('[]') > 0 ? '' : '[]'; } if(ms._valueContainer){ $.each(ms._valueContainer.children(), function(i, el){ el.name = cfg.name; }); } }; /** * Sets the current selection with the JSON items provided * @param items */ this.setSelection = function(items){ this.clear(); this.addToSelection(items); }; /** * Sets a value for the combo box. Value must be an array of values with data type matching valueField one. * @param data */ this.setValue = function(values) { var items = []; $.each(values, function(index, value) { // first try to see if we have the full objects from our data set var found = false; $.each(_cbData, function(i,item){ if(item[cfg.valueField] == value){ items.push(item); found = true; return false; } }); if(!found){ if(typeof(value) === 'object'){ items.push(value); } else { var json = {}; json[cfg.valueField] = value; json[cfg.displayField] = value; items.push(json); } } }); if(items.length > 0) { this.addToSelection(items); } }; /** * Sets data params for subsequent ajax requests * @param params */ this.setDataUrlParams = function(params) { cfg.dataUrlParams = $.extend({},params); }; /********** PRIVATE ************/ var _selection = [], // selected objects _comboItemHeight = 0, // height for each combo item. _timer, _hasFocus = false, _groups = null, _cbData = [], _ctrlDown = false, _cntInMf = false, // Content is in modification mode. KEYCODES = { BACKSPACE: 8, TAB: 9, ENTER: 13, CTRL: 17, ESC: 27, SPACE: 32, UPARROW: 38, DOWNARROW: 40, COMMA: 188 }; var self = { /** * Empties the result container and refills it with the array of json results in input * @private */ _displaySuggestions: function(data) { ms.combobox.show(); ms.combobox.empty(); var resHeight = 0, // total height taken by displayed results. nbGroups = 0; if(_groups === null) { self._renderComboItems(data); resHeight = _comboItemHeight * data.length; } else { for(var grpName in _groups) { nbGroups += 1; $('
', { 'class': 'ms-res-group', html: grpName }).appendTo(ms.combobox); self._renderComboItems(_groups[grpName].items, true); } var _groupItemHeight = ms.combobox.find('.ms-res-group').outerHeight(); if(_groupItemHeight !== null) { var tmpResHeight = nbGroups * _groupItemHeight; resHeight = (_comboItemHeight * data.length) + tmpResHeight; } else { resHeight = _comboItemHeight * (data.length + nbGroups); } } if(resHeight < ms.combobox.height() || resHeight <= cfg.maxDropHeight) { ms.combobox.height(resHeight); } else if(resHeight >= ms.combobox.height() && resHeight > cfg.maxDropHeight) { ms.combobox.height(cfg.maxDropHeight); } if(data.length === 1 && cfg.autoSelect === true) { ms.combobox.children().filter(':not(.ms-res-item-disabled):last').addClass('ms-res-item-active'); } if (cfg.selectFirst === true) { ms.combobox.children().filter(':not(.ms-res-item-disabled):first').addClass('ms-res-item-active'); } if(data.length === 0 && ms.getRawValue() !== "") { var noSuggestionText = cfg.noSuggestionText.replace(/\{\{.*\}\}/, ms.input.val()); self._updateHelper(noSuggestionText); ms.collapse(); } // When free entry is off, add invalid class to input if no data matches if(cfg.allowFreeEntries === false) { if(data.length === 0) { $(ms.input).addClass(cfg.invalidCls); ms.combobox.hide(); } else { $(ms.input).removeClass(cfg.invalidCls); } } }, /** * Returns an array of json objects from an array of strings. * @private */ _getEntriesFromStringArray: function(data) { var json = []; $.each(data, function(index, s) { var entry = {}; entry[cfg.displayField] = entry[cfg.valueField] = $.trim(s); json.push(entry); }); return json; }, /** * Replaces html with highlighted html according to case * @param html * @private */ _highlightSuggestion: function(html) { var q = ms.input.val(); //escape special regex characters var specialCharacters = ['^', '$', '*', '+', '?', '.', '(', ')', ':', '!', '|', '{', '}', '[', ']']; $.each(specialCharacters, function (index, value) { q = q.replace(value, "\\" + value); }) if(q.length === 0) { return html; // nothing entered as input } var glob = cfg.matchCase === true ? 'g' : 'gi'; return html.replace(new RegExp('(' + q + ')(?!([^<]+)?>)', glob), '$1'); }, /** * Moves the selected cursor amongst the list item * @param dir - 'up' or 'down' * @private */ _moveSelectedRow: function(dir) { if(!cfg.expanded) { ms.expand(); } var list, start, active, scrollPos; list = ms.combobox.find(".ms-res-item:not(.ms-res-item-disabled)"); if(dir === 'down') { start = list.eq(0); } else { start = list.filter(':last'); } active = ms.combobox.find('.ms-res-item-active:not(.ms-res-item-disabled):first'); if(active.length > 0) { if(dir === 'down') { start = active.nextAll('.ms-res-item:not(.ms-res-item-disabled)').first(); if(start.length === 0) { start = list.eq(0); } scrollPos = ms.combobox.scrollTop(); ms.combobox.scrollTop(0); if(start[0].offsetTop + start.outerHeight() > ms.combobox.height()) { ms.combobox.scrollTop(scrollPos + _comboItemHeight); } } else { start = active.prevAll('.ms-res-item:not(.ms-res-item-disabled)').first(); if(start.length === 0) { start = list.filter(':last'); ms.combobox.scrollTop(_comboItemHeight * list.length); } if(start[0].offsetTop < ms.combobox.scrollTop()) { ms.combobox.scrollTop(ms.combobox.scrollTop() - _comboItemHeight); } } } list.removeClass("ms-res-item-active"); start.addClass("ms-res-item-active"); }, /** * According to given data and query, sort and add suggestions in their container * @private */ _processSuggestions: function(source) { var json = null, data = source || cfg.data; if(data !== null) { if(typeof(data) === 'function'){ data = data.call(ms, ms.getRawValue(),cfg); } if(typeof(data) === 'string') { // get results from ajax $(ms).trigger('beforeload', [ms]); var queryParams = {} queryParams[cfg.queryParam] = ms.input.val(); var params = $.extend(queryParams, cfg.dataUrlParams); $.ajax($.extend({ type: cfg.method, url: data, data: params, beforeSend: cfg.beforeSend, success: function(asyncData){ json = typeof(asyncData) === 'string' ? JSON.parse(asyncData) : asyncData; self._processSuggestions(json); $(ms).trigger('load', [ms, json]); if(self._asyncValues){ ms.setValue(typeof(self._asyncValues) === 'string' ? JSON.parse(self._asyncValues) : self._asyncValues); self._renderSelection(); delete(self._asyncValues); } }, error: function(){ throw("Could not reach server"); } }, cfg.ajaxConfig)); return; } else { // results from local array if(data.length > 0 && typeof(data[0]) === 'string') { // results from array of strings _cbData = self._getEntriesFromStringArray(data); } else { // regular json array or json object with results property _cbData = data[cfg.resultsField] || data; } } var sortedData = cfg.mode === 'remote' ? _cbData : self._sortAndTrim(_cbData); self._displaySuggestions(self._group(sortedData)); } }, /** * Render the component to the given input DOM element * @private */ _render: function(el) { ms.setName(cfg.name); // make sure the form name is correct // holds the main div, will relay the focus events to the contained input element. ms.container = $('', { 'class': 'ms-ctn form-control ' + (cfg.resultAsString ? 'ms-as-string ' : '') + cfg.cls + ($(el).hasClass('input-lg') ? ' input-lg' : '') + ($(el).hasClass('input-sm') ? ' input-sm' : '') + (cfg.disabled === true ? ' ms-ctn-disabled' : '') + (cfg.editable === true ? '' : ' ms-ctn-readonly') + (cfg.hideTrigger === false ? '' : ' ms-no-trigger'), style: cfg.style, id: cfg.id }); ms.container.focus($.proxy(handlers._onFocus, this)); ms.container.blur($.proxy(handlers._onBlur, this)); ms.container.keydown($.proxy(handlers._onKeyDown, this)); ms.container.keyup($.proxy(handlers._onKeyUp, this)); // holds the input field ms.input = $('', $.extend({ type: 'text', 'class': cfg.editable === true ? '' : ' ms-input-readonly', readonly: !cfg.editable, placeholder: cfg.placeholder, disabled: cfg.disabled }, cfg.inputCfg)); ms.input.focus($.proxy(handlers._onInputFocus, this)); ms.input.click($.proxy(handlers._onInputClick, this)); // holds the suggestions. will always be placed on focus ms.combobox = $('', { 'class': 'ms-res-ctn dropdown-menu' }).height(cfg.maxDropHeight); // bind the onclick and mouseover using delegated events (needs jQuery >= 1.7) ms.combobox.on('click', 'div.ms-res-item', $.proxy(handlers._onComboItemSelected, this)); ms.combobox.on('mouseover', 'div.ms-res-item', $.proxy(handlers._onComboItemMouseOver, this)); if(cfg.selectionContainer){ ms.selectionContainer = cfg.selectionContainer; $(ms.selectionContainer).addClass('ms-sel-ctn'); } else { ms.selectionContainer = $('', { 'class': 'ms-sel-ctn' }); } ms.selectionContainer.click($.proxy(handlers._onFocus, this)); if(cfg.selectionPosition === 'inner' && !cfg.selectionContainer) { ms.selectionContainer.append(ms.input); } else { ms.container.append(ms.input); } ms.helper = $('', { 'class': 'ms-helper ' + cfg.infoMsgCls }); self._updateHelper(); ms.container.append(ms.helper); // Render the whole thing $(el).replaceWith(ms.container); if(!cfg.selectionContainer){ switch(cfg.selectionPosition) { case 'bottom': ms.selectionContainer.insertAfter(ms.container); if(cfg.selectionStacked === true) { ms.selectionContainer.width(ms.container.width()); ms.selectionContainer.addClass('ms-stacked'); } break; case 'right': ms.selectionContainer.insertAfter(ms.container); ms.container.css('float', 'left'); break; default: ms.container.append(ms.selectionContainer); break; } } // holds the trigger on the right side if(cfg.hideTrigger === false) { ms.trigger = $('', { 'class': 'ms-trigger', html: '' }); ms.trigger.click($.proxy(handlers._onTriggerClick, this)); ms.container.append(ms.trigger); } $(window).resize($.proxy(handlers._onWindowResized, this)); // do not perform an initial call if we are using ajax unless we have initial values if((cfg.value !== null || cfg.data !== null) && ms.container.is(':visible')){ if(typeof(cfg.data) === 'string'){ self._asyncValues = cfg.value; self._processSuggestions(); } else { self._processSuggestions(); if(cfg.value !== null){ ms.setValue(cfg.value); self._renderSelection(); } } } $("body").click(function(e) { if(ms.container.hasClass('ms-ctn-focus') && ms.container.has(e.target).length === 0 && e.target.className.indexOf('ms-res-item') < 0 && e.target.className.indexOf('ms-close-btn') < 0 && ms.container[0] !== e.target) { handlers._onBlur(); } }); if(cfg.expanded === true) { cfg.expanded = false; ms.expand(); } }, /** * Renders each element within the combo box * @private */ _renderComboItems: function(items, isGrouped) { var ref = this, html = ''; $.each(items, function(index, value) { var displayed = cfg.renderer !== null ? cfg.renderer.call(ref, value) : value[cfg.displayField]; var disabled = cfg.disabledField !== null && value[cfg.disabledField] === true; var resultItemEl = $('', { 'class': 'ms-res-item ' + (isGrouped ? 'ms-res-item-grouped ':'') + (disabled ? 'ms-res-item-disabled ':'') + (index % 2 === 1 && cfg.useZebraStyle === true ? 'ms-res-odd' : ''), html: cfg.highlight === true ? self._highlightSuggestion(displayed) : displayed, 'data-json': JSON.stringify(value) }); html += $('').append(resultItemEl).html(); }); ms.combobox.append(html); _comboItemHeight = ms.combobox.find('.ms-res-item:first').outerHeight(); }, /** * Renders the selected items into their container. * @private */ _renderSelection: function() { var ref = this, w = 0, inputOffset = 0, items = [], asText = cfg.resultAsString === true && !_hasFocus; ms.selectionContainer.find('.ms-sel-item').remove(); if(ms._valueContainer !== undefined) { ms._valueContainer.remove(); } $.each(_selection, function(index, value){ var selectedItemEl, delItemEl, selectedItemHtml = cfg.selectionRenderer !== null ? cfg.selectionRenderer.call(ref, value) : value[cfg.displayField]; var validCls = self._validateSingleItem(value[cfg.displayField]) ? '' : ' ms-sel-invalid'; // tag representing selected value if(asText === true) { selectedItemEl = $('', { 'class': 'ms-sel-item ms-sel-text ' + cfg.selectionCls + validCls, html: selectedItemHtml + (index === (_selection.length - 1) ? '' : cfg.resultAsStringDelimiter) }).data('json', value); } else { selectedItemEl = $('', { 'class': 'ms-sel-item ' + cfg.selectionCls + validCls, html: selectedItemHtml }).data('json', value); if(cfg.disabled === false){ // small cross img delItemEl = $('', { 'class': 'ms-close-btn' }).data('json', value).appendTo(selectedItemEl); delItemEl.click($.proxy(handlers._onTagTriggerClick, ref)); if (cfg.allowFreeEntries === true){ selectedItemEl.dblclick($.proxy(handlers._onTagEditTriggerDblClick, ref)); } } } items.push(selectedItemEl); }); ms.selectionContainer.prepend(items); // store the values, behaviour of multiple select ms._valueContainer = $('', { style: 'display: none;' }); $.each(ms.getValue(), function(i, val){ var el = $('', { type: 'hidden', name: cfg.name, value: val }); el.appendTo(ms._valueContainer); }); ms._valueContainer.appendTo(ms.selectionContainer); if(cfg.selectionPosition === 'inner' && !cfg.selectionContainer && (ms.container.is(':visible')||ms.getValue().length>0)) { var inputPadding = ms.input.outerWidth(true) - ms.input.width(); ms.input.width(0); inputOffset = Math.max(0,ms.input.offset().left - ms.selectionContainer.offset().left); w = ms.container.width() - inputOffset - (cfg.hideTrigger ? inputPadding : 42); ms.input.width(w); } if(_selection.length === cfg.maxSelection){ self._updateHelper(cfg.maxSelectionRenderer.call(this, _selection.length)); } else { ms.helper.hide(); } }, /** * Select an item either through keyboard or mouse * @param item * @private */ _selectItem: function(item) { if(cfg.maxSelection === 1){ _selection = []; } ms.addToSelection(item.data('json')); item.removeClass('ms-res-item-active'); if(cfg.expandOnFocus === false || _selection.length === cfg.maxSelection){ ms.collapse(); } if(!_hasFocus){ ms.input.focus(); } else if(_hasFocus && (cfg.expandOnFocus || _ctrlDown)){ self._processSuggestions(); if(_ctrlDown){ ms.expand(); } } }, /** * Sorts the results and cut them down to max # of displayed results at once * @private */ _sortAndTrim: function(data) { var q = ms.getRawValue(), filtered = [], newSuggestions = [], selectedValues = ms.getValue(); // filter the data according to given input if(q.length > 0) { $.each(data, function(index, obj) { var name = obj[cfg.displayField]; if((cfg.matchCase === true && name.indexOf(q) > -1) || (cfg.matchCase === false && name.toLowerCase().indexOf(q.toLowerCase()) > -1)) { if(cfg.strictSuggest === false || name.toLowerCase().indexOf(q.toLowerCase()) === 0) { filtered.push(obj); } } }); } else { filtered = data; } // take out the ones that have already been selected $.each(filtered, function(index, obj) { if (cfg.allowDuplicates || $.inArray(obj[cfg.valueField], selectedValues) === -1) { newSuggestions.push(obj); } }); // sort the data if(cfg.sortOrder !== null) { newSuggestions.sort(function(a,b) { if(a[cfg.sortOrder] < b[cfg.sortOrder]) { return cfg.sortDir === 'asc' ? -1 : 1; } if(a[cfg.sortOrder] > b[cfg.sortOrder]) { return cfg.sortDir === 'asc' ? 1 : -1; } return 0; }); } // trim it down if(cfg.maxSuggestions && cfg.maxSuggestions > 0) { newSuggestions = newSuggestions.slice(0, cfg.maxSuggestions); } return newSuggestions; }, _group: function(data){ // build groups if(cfg.groupBy !== null) { _groups = {}; $.each(data, function(index, value) { var props = cfg.groupBy.indexOf('.') > -1 ? cfg.groupBy.split('.') : cfg.groupBy; var prop = value[cfg.groupBy]; if(typeof(props) != 'string'){ prop = value; while(props.length > 0){ prop = prop[props.shift()]; } } if(_groups[prop] === undefined) { _groups[prop] = {title: prop, items: [value]}; } else { _groups[prop].items.push(value); } }); } return data; }, /** * Update the helper text * @private */ _updateHelper: function(html) { ms.helper.html(html); if(!ms.helper.is(":visible")) { ms.helper.fadeIn(); } }, /** * Validate an item against vtype or vregex * @private */ _validateSingleItem: function(value){ if(cfg.vregex !== null && cfg.vregex instanceof RegExp){ return cfg.vregex.test(value); } else if(cfg.vtype !== null) { switch(cfg.vtype){ case 'alpha': return (/^[a-zA-Z_]+$/).test(value); case 'alphanum': return (/^[a-zA-Z0-9_]+$/).test(value); case 'email': return (/^(\w+)([\-+.][\w]+)*@(\w[\-\w]*\.){1,5}([A-Za-z]){2,6}$/).test(value); case 'url': return (/(((^https?)|(^ftp)):\/\/([\-\w]+\.)+\w{2,3}(\/[%\-\w]+(\.\w{2,})?)*(([\w\-\.\?\\\/+@`~=%!]*)(\.\w{2,})?)*\/?)/i).test(value); case 'ipaddress': return (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/).test(value); } } return true; } }; var handlers = { /** * Triggered when blurring out of the component * @private */ _onBlur: function() { ms.container.removeClass('ms-ctn-focus'); ms.collapse(); _cntInMf = false; _hasFocus = false; if(ms.getRawValue() !== '' && cfg.allowFreeEntries === true){ var obj = {}; obj[cfg.displayField] = obj[cfg.valueField] = ms.getRawValue().trim(); ms.addToSelection(obj); } self._renderSelection(); if(ms.isValid() === false) { ms.container.addClass(cfg.invalidCls); } else if(ms.input.val() !== '' && cfg.allowFreeEntries === false) { ms.empty(); self._updateHelper(''); } $(ms).trigger('blur', [ms]); }, /** * Triggered when hovering an element in the combo * @param e * @private */ _onComboItemMouseOver: function(e) { var target = $(e.currentTarget); if(!target.hasClass('ms-res-item-disabled')){ ms.combobox.children().removeClass('ms-res-item-active'); target.addClass('ms-res-item-active'); } }, /** * Triggered when an item is chosen from the list * @param e * @private */ _onComboItemSelected: function(e) { var target = $(e.currentTarget); if(!target.hasClass('ms-res-item-disabled')){ self._selectItem($(e.currentTarget)); } }, /** * Triggered when focusing on the container div. Will focus on the input field instead. * @private */ _onFocus: function() { if (!_cntInMf) { ms.input.focus(); } else { _cntInMf = false; } }, /** * Triggered when clicking on the input text field * @private */ _onInputClick: function(){ if (ms.isDisabled() === false && _hasFocus && !_cntInMf) { if (cfg.toggleOnClick === true) { if (cfg.expanded){ ms.collapse(); } else { ms.expand(); } } } }, /** * Triggered when focusing on the input text field. * @private */ _onInputFocus: function() { if(ms.isDisabled() === false && !_hasFocus && !_cntInMf) { _hasFocus = true; ms.container.addClass('ms-ctn-focus'); ms.container.removeClass(cfg.invalidCls); var curLength = ms.getRawValue().length; if(cfg.expandOnFocus === true){ ms.expand(); } if(_selection.length === cfg.maxSelection) { self._updateHelper(cfg.maxSelectionRenderer.call(this, _selection.length)); } else if(curLength < cfg.minChars) { self._updateHelper(cfg.minCharsRenderer.call(this, cfg.minChars - curLength)); } setTimeout(function(){self._renderSelection()},400); $(ms).trigger('focus', [ms]); } }, /** * Triggered when the user presses a key while the component has focus * This is where we want to handle all keys that don't require the user input field * since it hasn't registered the key hit yet * @param e keyEvent * @private */ _onKeyDown: function(e) { // check how tab should be handled var active = ms.combobox.find('.ms-res-item-active:not(.ms-res-item-disabled):first'), freeInput = ms.input.val(); $(ms).trigger('keydown', [ms, e]); if(e.keyCode === KEYCODES.TAB && (cfg.useTabKey === false || (cfg.useTabKey === true && active.length === 0 && ms.input.val().length === 0))) { handlers._onBlur(); return; } switch(e.keyCode) { case KEYCODES.BACKSPACE: if(freeInput.length === 0 && ms.getSelection().length > 0 && cfg.selectionPosition === 'inner') { _selection.pop(); self._renderSelection(); $(ms).trigger('selectionchange', [ms, ms.getSelection()]); ms.input.attr('placeholder', (cfg.selectionPosition === 'inner' && ms.getValue().length > 0) ? '' : cfg.placeholder); ms.input.focus(); e.preventDefault(); } break; case KEYCODES.TAB: case KEYCODES.ESC: e.preventDefault(); break; case KEYCODES.ENTER: if(freeInput !== '' || cfg.expanded){ e.preventDefault(); } break; case KEYCODES.COMMA: if(cfg.useCommaKey === true){ e.preventDefault(); } break; case KEYCODES.CTRL: _ctrlDown = true; break; case KEYCODES.DOWNARROW: e.preventDefault(); self._moveSelectedRow("down"); break; case KEYCODES.UPARROW: e.preventDefault(); self._moveSelectedRow("up"); break; default: if(_selection.length === cfg.maxSelection) { e.preventDefault(); } break; } }, /** * Triggered when a key is released while the component has focus * @param e * @private */ _onKeyUp: function(e) { var freeInput = ms.getRawValue(), inputValid = $.trim(ms.input.val()).length > 0 && (!cfg.maxEntryLength || $.trim(ms.input.val()).length <= cfg.maxEntryLength), selected, obj = {}; $(ms).trigger('keyup', [ms, e]); clearTimeout(_timer); // collapse if escape, but keep focus. if(e.keyCode === KEYCODES.ESC && cfg.expanded) { ms.combobox.hide(); } // ignore a bunch of keys if((e.keyCode === KEYCODES.TAB && cfg.useTabKey === false) || (e.keyCode > KEYCODES.ENTER && e.keyCode < KEYCODES.SPACE)) { if(e.keyCode === KEYCODES.CTRL){ _ctrlDown = false; } return; } switch(e.keyCode) { case KEYCODES.UPARROW: case KEYCODES.DOWNARROW: e.preventDefault(); break; case KEYCODES.ENTER: case KEYCODES.TAB: case KEYCODES.COMMA: if(e.keyCode !== KEYCODES.COMMA || cfg.useCommaKey === true) { e.preventDefault(); if(cfg.expanded === true){ // if a selection is performed, select it and reset field selected = ms.combobox.find('.ms-res-item-active:not(.ms-res-item-disabled):first'); if(selected.length > 0) { self._selectItem(selected); return; } } // if no selection or if freetext entered and free entries allowed, add new obj to selection if(inputValid === true && cfg.allowFreeEntries === true) { obj[cfg.displayField] = obj[cfg.valueField] = freeInput.trim(); ms.addToSelection(obj); ms.collapse(); // reset combo suggestions ms.input.focus(); } break; } default: if(_selection.length === cfg.maxSelection){ self._updateHelper(cfg.maxSelectionRenderer.call(this, _selection.length)); } else { if(freeInput.length < cfg.minChars) { self._updateHelper(cfg.minCharsRenderer.call(this, cfg.minChars - freeInput.length)); if(cfg.expanded === true) { ms.collapse(); } } else if(cfg.maxEntryLength && freeInput.length > cfg.maxEntryLength) { self._updateHelper(cfg.maxEntryRenderer.call(this, freeInput.length - cfg.maxEntryLength)); if(cfg.expanded === true) { ms.collapse(); } } else { ms.helper.hide(); if(cfg.minChars <= freeInput.length){ _timer = setTimeout(function() { if(cfg.expanded === true) { self._processSuggestions(); } else { ms.expand(); } }, cfg.typeDelay); } } } break; } }, /** * Triggered when clicking upon cross for deletion * @param e * @private */ _onTagTriggerClick: function(e) { ms.removeFromSelection($(e.currentTarget).data('json')); }, /** * Triggerd when double clicking upon selected item in order to edit its content * @param e * @private */ _onTagEditTriggerDblClick: function(e) { var itemData = $(e.currentTarget).data('json'); if (ms.input.val() === '') { ms.input.val(itemData.label); ms.removeFromSelection(itemData); ms.input.select(); _cntInMf = true; // item is in modification mode } }, /** * Triggered when clicking on the small trigger in the right * @private */ _onTriggerClick: function() { if(ms.isDisabled() === false && !(cfg.expandOnFocus === true && _selection.length === cfg.maxSelection)) { $(ms).trigger('triggerclick', [ms]); if(cfg.expanded === true) { ms.collapse(); } else { var curLength = ms.getRawValue().length; if(curLength >= cfg.minChars){ ms.input.focus(); ms.expand(); } else { self._updateHelper(cfg.minCharsRenderer.call(this, cfg.minChars - curLength)); } } } }, /** * Triggered when the browser window is resized * @private */ _onWindowResized: function() { self._renderSelection(); } }; // startup point if(element !== null) { self._render(element); } }; $.fn.magicSuggest = function(options) { var obj = $(this); if(obj.size() === 1 && obj.data('magicSuggest')) { return obj.data('magicSuggest'); } obj.each(function(i) { // assume $(this) is an element var cntr = $(this); // Return early if this element already has a plugin instance if(cntr.data('magicSuggest')){ return; } if(this.nodeName.toLowerCase() === 'select'){ // rendering from select options.data = []; options.value = []; $.each(this.children, function(index, child){ if(child.nodeName && child.nodeName.toLowerCase() === 'option'){ options.data.push({id: child.value, name: child.text}); if($(child).attr('selected')){ options.value.push(child.value); } } }); } var def = {}; // set values from DOM container element $.each(this.attributes, function(i, att){ def[att.name] = att.name === 'value' && att.value !== '' ? JSON.parse(att.value) : att.value; }); var field = new MagicSuggest(this, $.extend([], $.fn.magicSuggest.defaults, options, def)); cntr.data('magicSuggest', field); field.container.data('magicSuggest', field); }); if(obj.size() === 1) { return obj.data('magicSuggest'); } return obj; }; $.fn.magicSuggest.defaults = {}; })(jQuery);