/** * All auto suggestion boxes are fucked up or badly written. * This is an attempt to create something that doesn't suck... * * Requires: jQuery * * Author: Nicolas Bize * Date: Feb. 8th 2013 * Version: 1.3.1 * Licence: MagicSuggest is licenced under MIT licence (http://www.opensource.org/licenses/mit-license.php) */ (function($) { "use strict"; var MagicSuggest = function(element, options) { var ms = this; /** * Initializes the MagicSuggest component * @param defaults - see config below */ var defaults = { /********** CONFIGURATION PROPERTIES ************/ /** * @cfg {Boolean} allowFreeEntries *

Restricts or allows the user to validate typed entries.

* Defaults to true. */ allowFreeEntries: true, /** * @cfg {String} cls *

A custom CSS class to apply to the field's underlying element.

* Defaults to ''. */ cls: '', /** * @cfg {Array / String / Function} data * 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; }

* Defaults to null */ data: null, /** * @cfg {Object} dataParams *

Additional parameters to the ajax call

* Defaults to {} */ dataUrlParams: {}, /** * @cfg {Boolean} disabled *

Start the component in a disabled state.

* Defaults to false. */ disabled: false, /** * @cfg {String} displayField *

name of JSON object property displayed in the combo list

* Defaults to name. */ displayField: 'name', /** * @cfg {Boolean} editable *

Set to false if you only want mouse interaction. In that case the combo will * automatically expand on focus.

* Defaults to true. */ editable: true, /** * @cfg {String} emptyText *

The default placeholder text when nothing has been entered

* Defaults to 'Type or click here' or just 'Click here' if not editable. */ emptyText: function() { return cfg.editable ? 'Type or click here' : 'Click here'; }, /** * @cfg {String} emptyTextCls *

A custom CSS class to style the empty text

* Defaults to 'ms-empty-text'. */ emptyTextCls: 'ms-empty-text', /** * @cfg {Boolean} expanded *

Set starting state for combo.

* Defaults to false. */ expanded: false, /** * @cfg {Boolean} expandOnFocus *

Automatically expands combo on focus.

* Defaults to false. */ expandOnFocus: function() { return cfg.editable ? false : true; }, /** * @cfg {String} groupBy *

JSON property by which the list should be grouped

* Defaults to null */ groupBy: null, /** * @cfg {Boolean} hideTrigger *

Set to true to hide the trigger on the right

* Defaults to false. */ hideTrigger: false, /** * @cfg {Boolean} highlight *

Set to true to highlight search input within displayed suggestions

* Defaults to true. */ highlight: true, /** * @cfg {String} id *

A custom ID for this component

* Defaults to 'ms-ctn-{n}' with n positive integer */ id: function() { return 'ms-ctn-' + $('div[id^="ms-ctn"]').length; }, /** * @cfg {String} infoMsgCls *

A class that is added to the info message appearing on the top-right part of the component

* Defaults to '' */ infoMsgCls: '', /** * @cfg {Object} inputCfg *

Additional parameters passed out to the INPUT tag. Enables usage of AngularJS's custom tags for ex.

* Defaults to {} */ inputCfg: {}, /** * @cfg {String} invalidCls *

The class that is applied to show that the field is invalid

* Defaults to ms-ctn-invalid */ invalidCls: 'ms-ctn-invalid', /** * @cfg {Boolean} matchCase *

Set to true to filter data results according to case. Useless if the data is fetched remotely

* Defaults to false. */ matchCase: false, /** * @cfg {Integer} maxDropHeight (in px) *

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.

* Defaults to 290 px. */ maxDropHeight: 290, /** * @cfg {Integer} maxEntryLength *

Defines how long the user free entry can be. Set to null for no limit.

* Defaults to null. */ maxEntryLength: null, /** * @cfg {String} maxEntryRenderer *

A function that defines the helper text when the max entry length has been surpassed.

* Defaults to function(v){return 'Please reduce your entry by ' + v + ' character' + (v > 1 ? 's':'');} */ maxEntryRenderer: function(v) { return 'Please reduce your entry by ' + v + ' character' + (v > 1 ? 's':''); }, /** * @cfg {Integer} maxSuggestions *

The maximum number of results displayed in the combo drop down at once.

* Defaults to null. */ maxSuggestions: null, /** * @cfg {Integer} maxSelection *

The maximum number of items the user can select if multiple selection is allowed. * Set to null to remove the limit.

* Defaults to 10. */ maxSelection: 10, /** * @cfg {Function} maxSelectionRenderer *

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.

* Defaults to function(v){return 'You cannot choose more than ' + v + ' item' + (v > 1 ? 's':'');} */ maxSelectionRenderer: function(v) { return 'You cannot choose more than ' + v + ' item' + (v > 1 ? 's':''); }, /** * @cfg {String} method *

The method used by the ajax request.

* Defaults to 'POST' */ method: 'POST', /** * @cfg {Integer} minChars *

The minimum number of characters the user must type before the combo expands and offers suggestions. * Defaults to 0. */ minChars: 0, /** * @cfg {Function} minCharsRenderer *

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.

* Defaults to function(v){return 'Please type ' + v + ' more character' + (v > 1 ? 's':'');} */ minCharsRenderer: function(v) { return 'Please type ' + v + ' more character' + (v > 1 ? 's':''); }, /** * @cfg {String} name *

The name used as a form element.

* Defaults to 'null' */ name: null, /** * @cfg {String} noSuggestionText *

The text displayed when there are no suggestions.

* Defaults to 'No suggestions" */ noSuggestionText: 'No suggestions', /** * @cfg {Boolean} preselectSingleSuggestion *

If a single suggestion comes out, it is preselected.

* Defaults to true. */ preselectSingleSuggestion: true, /** * @cfg (function) renderer *

A function used to define how the items will be presented in the combo

* Defaults to null. */ renderer: null, /** * @cfg {Boolean} required *

Whether or not this field should be required

* Defaults to false */ required: false, /** * @cfg {Boolean} resultAsString *

Set to true to render selection as comma separated string

* Defaults to false. */ resultAsString: false, /** * @cfg {String} resultsField *

Name of JSON object property that represents the list of suggested objets

* Defaults to results */ resultsField: 'results', /** * @cfg {String} selectionCls *

A custom CSS class to add to a selected item

* Defaults to ''. */ selectionCls: '', /** * @cfg {String} selectionPosition *

Where the selected items will be displayed. Only 'right', 'bottom' and 'inner' are valid values

* Defaults to 'inner', meaning the selected items will appear within the input box itself. */ selectionPosition: 'inner', /** * @cfg (function) selectionRenderer *

A function used to define how the items will be presented in the tag list

* Defaults to null. */ selectionRenderer: null, /** * @cfg {Boolean} selectionStacked *

Set to true to stack the selectioned items when positioned on the bottom * Requires the selectionPosition to be set to 'bottom'

* Defaults to false. */ selectionStacked: false, /** * @cfg {String} sortDir *

Direction used for sorting. Only 'asc' and 'desc' are valid values

* Defaults to 'asc'. */ sortDir: 'asc', /** * @cfg {String} sortOrder *

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.

* * Defaults to null. */ sortOrder: null, /** * @cfg {Boolean} strictSuggest *

If set to true, suggestions will have to start by user input (and not simply contain it as a substring)

* Defaults to false. */ strictSuggest: false, /** * @cfg {String} style *

Custom style added to the component container.

* * Defaults to ''. */ style: '', /** * @cfg {Boolean} toggleOnClick *

If set to true, the combo will expand / collapse when clicked upon

* Defaults to false. */ toggleOnClick: false, /** * @cfg {Integer} typeDelay *

Amount (in ms) between keyboard registers.

* * Defaults to 400 */ typeDelay: 400, /** * @cfg {Boolean} useTabKey *

If set to true, tab won't blur the component but will be registered as the ENTER key

* Defaults to false. */ useTabKey: false, /** * @cfg {Boolean} useCommaKey *

If set to true, using comma will validate the user's choice

* Defaults to true. */ useCommaKey: true, /** * @cfg {Boolean} useZebraStyle *

Determines whether or not the results will be displayed with a zebra table style

* Defaults to true. */ useZebraStyle: true, /** * @cfg {String/Object/Array} value *

initial value for the field

* Defaults to null. */ value: null, /** * @cfg {String} valueField *

name of JSON object property that represents its underlying value

* Defaults to id. */ valueField: 'id', /** * @cfg {Integer} width (in px) *

Width of the component

* Defaults to underlying element width. */ width: function() { return $(this).width(); } }; var conf = $.extend({},options); var cfg = $.extend(true, {}, defaults, conf); // some init stuff if ($.isFunction(cfg.emptyText)) { cfg.emptyText = cfg.emptyText.call(this); } if ($.isFunction(cfg.expandOnFocus)) { cfg.expandOnFocus = cfg.expandOnFocus.call(this); } if ($.isFunction(cfg.id)) { cfg.id = cfg.id.call(this); } /********** 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 ($.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.getSelectedItems()]); } } } }; /** * 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.removeClass(cfg.emptyTextCls); 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() { return cfg.required === false || _selection.length > 0; }; /** * 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.getSelectedItems = function() { return _selection; }; /** * Retrieve the current text entered by the user */ this.getRawValue = function(){ return ms.input.val() !== cfg.emptyText ? 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.getSelectedItems()]); } if(cfg.expandOnFocus){ ms.expand(); } if(cfg.expanded) { self._processSuggestions(); } } }; /** * Set up some combo data after it has been rendered * @param data */ this.setData = function(data){ cfg.data = data; self._processSuggestions(); }; /** * 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(ms._valueContainer){ ms._valueContainer.name = name; } }; /** * Sets a value for the combo box. Value must be a value or an array of value with data type matching valueField one. * @param data */ this.setValue = function(data) { var values = data, items = []; if(!$.isArray(data)){ if(typeof(data) === 'string'){ if(data.indexOf('[') > -1){ values = eval(data); } else if(data.indexOf(',') > -1){ values = data.split(','); } } else { values = [data]; } } $.each(_cbData, function(index, obj) { if($.inArray(obj[cfg.valueField], values) > -1) { items.push(obj); } }); 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; var self = { /** * Empties the result container and refills it with the array of json results in input * @private */ _displaySuggestions: function(data) { 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); } 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.preselectSingleSuggestion === true) { ms.combobox.children().filter(':last').addClass('ms-res-item-active'); } if(data.length === 0 && ms.getRawValue() !== "") { self._updateHelper(cfg.noSuggestionText); ms.collapse(); } }, /** * 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() !== cfg.emptyText ? ms.input.val() : ''; if(q.length === 0) { return html; // nothing entered as input } if(cfg.matchCase === true) { html = html.replace(new RegExp('(' + q + ')(?!([^<]+)?>)','g'), '$1'); } else { html = html.replace(new RegExp('(' + q + ')(?!([^<]+)?>)','gi'), '$1'); } return html; }, /** * 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"); if(dir === 'down') { start = list.eq(0); } else { start = list.filter(':last'); } active = ms.combobox.find('.ms-res-item-active:first'); if(active.length > 0) { if(dir === 'down') { start = active.nextAll('.ms-res-item').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').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); } if(typeof(data) === 'string' && data.indexOf(',') < 0) { // get results from ajax $(ms).trigger('beforeload', [ms]); var params = $.extend({query: ms.input.val()}, cfg.dataUrlParams); $.ajax({ type: cfg.method, url: data, data: params, success: function(asyncData){ json = typeof(asyncData) === 'string' ? JSON.parse(asyncData) : asyncData; self._processSuggestions(json); $(ms).trigger('load', [ms, json]); }, error: function(){ throw("Could not reach server"); } }); return; } else if(typeof(data) === 'string' && data.indexOf(',') > -1) { // results from csv string _cbData = self._getEntriesFromStringArray(data.split(',')); } 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; } } self._displaySuggestions(self._sortAndTrim(_cbData)); } }, /** * Render the component to the given input DOM element * @private */ _render: function(el) { $(ms).trigger('beforerender', [ms]); var w = $.isFunction(cfg.width) ? cfg.width.call(el) : cfg.width; // holds the main div, will relay the focus events to the contained input element. ms.container = $('
', { id: cfg.id, 'class': 'ms-ctn ' + cfg.cls + (cfg.disabled === true ? ' ms-ctn-disabled' : '') + (cfg.editable === true ? '' : ' ms-ctn-readonly'), style: cfg.style }).width(w); 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({ id: 'ms-input-' + $('input[id^="ms-input"]').length, type: 'text', 'class': cfg.emptyTextCls + (cfg.editable === true ? '' : ' ms-input-readonly'), value: cfg.emptyText, readonly: !cfg.editable, disabled: cfg.disabled }, cfg.inputCfg)).width(w - (cfg.hideTrigger ? 16 : 42)); ms.input.focus($.proxy(handlers._onInputFocus, this)); ms.input.click($.proxy(handlers._onInputClick, this)); // holds the trigger on the right side if(cfg.hideTrigger === false) { ms.trigger = $('
', { id: 'ms-trigger-' + $('div[id^="ms-trigger"]').length, 'class': 'ms-trigger', html: '
' }); ms.trigger.click($.proxy(handlers._onTriggerClick, this)); ms.container.append(ms.trigger); } // holds the suggestions. will always be placed on focus ms.combobox = $('
', { id: 'ms-res-ctn-' + $('div[id^="ms-res-ctn"]').length, 'class': 'ms-res-ctn ' }).width(w).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)); ms.selectionContainer = $('
', { id: 'ms-sel-ctn-' + $('div[id^="ms-sel-ctn"]').length, 'class': 'ms-sel-ctn' }); ms.selectionContainer.click($.proxy(handlers._onFocus, this)); if(cfg.selectionPosition === 'inner') { 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); 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; } if(cfg.value !== null) { ms.setValue(cfg.value); self._renderSelection(); } $(ms).trigger('afterrender', [ms]); $("body").click(function(e) { if(ms.container.hasClass('ms-ctn-bootstrap-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(); } }, _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 resultItemEl = $('
', { 'class': 'ms-res-item ' + (isGrouped ? 'ms-res-item-grouped ':'') + (index % 2 === 1 && cfg.useZebraStyle === true ? 'ms-res-odd' : ''), html: cfg.highlight === true ? self._highlightSuggestion(displayed) : displayed, 'data-json': JSON.stringify(value) }); resultItemEl.click($.proxy(handlers._onComboItemSelected, ref)); resultItemEl.mouseover($.proxy(handlers._onComboItemMouseOver, ref)); 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]; // tag representing selected value if(asText === true) { selectedItemEl = $('
', { 'class': 'ms-sel-item ms-sel-text ' + cfg.selectionCls, html: selectedItemHtml + (index === (_selection.length - 1) ? '' : ',') }).data('json', value); } else { selectedItemEl = $('
', { 'class': 'ms-sel-item ' + cfg.selectionCls, 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)); } } items.push(selectedItemEl); }); ms.selectionContainer.prepend(items); ms._valueContainer = $('', { type: 'hidden', name: cfg.name, value: JSON.stringify(ms.getValue()) }); ms._valueContainer.appendTo(ms.selectionContainer); if(cfg.selectionPosition === 'inner') { ms.input.width(0); inputOffset = ms.input.offset().left - ms.selectionContainer.offset().left; w = ms.container.width() - inputOffset - 42; ms.input.width(w); ms.container.height(ms.selectionContainer.height()); } 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(); var server_search = typeof cfg.data == 'string' && cfg.data.indexOf(',') < 0 // 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) || cfg.strictSuggest === false && server_search) { 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($.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); } // build groups if(cfg.groupBy !== null) { _groups = {}; $.each(newSuggestions, function(index, value) { if(_groups[value[cfg.groupBy]] === undefined) { _groups[value[cfg.groupBy]] = {title: value[cfg.groupBy], items: [value]}; } else { _groups[value[cfg.groupBy]].items.push(value); } }); } return newSuggestions; }, /** * Update the helper text * @private */ _updateHelper: function(html) { ms.helper.html(html); if(!ms.helper.is(":visible")) { ms.helper.fadeIn(); } } }; var handlers = { /** * Triggered when blurring out of the component * @private */ _onBlur: function() { ms.container.removeClass('ms-ctn-bootstrap-focus'); ms.collapse(); _hasFocus = false; if(ms.getRawValue() !== '' && cfg.allowFreeEntries === true){ var obj = {}; obj[cfg.displayField] = obj[cfg.valueField] = ms.getRawValue(); ms.addToSelection(obj); } self._renderSelection(); if(ms.isValid() === false) { ms.container.addClass('ms-ctn-invalid'); } if(ms.input.val() === '' && _selection.length === 0) { ms.input.addClass(cfg.emptyTextCls); ms.input.val(cfg.emptyText); } else if(ms.input.val() !== '' && cfg.allowFreeEntries === false) { ms.empty(); self._updateHelper(''); } if(ms.input.is(":focus")) { $(ms).trigger('blur', [ms]); } }, /** * Triggered when hovering an element in the combo * @param e * @private */ _onComboItemMouseOver: function(e) { ms.combobox.children().removeClass('ms-res-item-active'); $(e.currentTarget).addClass('ms-res-item-active'); }, /** * Triggered when an item is chosen from the list * @param e * @private */ _onComboItemSelected: function(e) { self._selectItem($(e.currentTarget)); }, /** * Triggered when focusing on the container div. Will focus on the input field instead. * @private */ _onFocus: function() { ms.input.focus(); }, /** * Triggered when clicking on the input text field * @private */ _onInputClick: function(){ if (ms.isDisabled() === false && _hasFocus) { 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) { _hasFocus = true; ms.container.addClass('ms-ctn-bootstrap-focus'); ms.container.removeClass(cfg.invalidCls); if(ms.input.val() === cfg.emptyText) { ms.empty(); } 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)); } self._renderSelection(); $(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:first'), freeInput = ms.input.val() !== cfg.emptyText ? ms.input.val() : ''; $(ms).trigger('keydown', [ms, e]); if(e.keyCode === 9 && (cfg.useTabKey === false || (cfg.useTabKey === true && active.length === 0 && ms.input.val().length === 0))) { handlers._onBlur(); return; } switch(e.keyCode) { case 8: //backspace if(freeInput.length === 0 && ms.getSelectedItems().length > 0 && cfg.selectionPosition === 'inner') { _selection.pop(); self._renderSelection(); $(ms).trigger('selectionchange', [ms, ms.getSelectedItems()]); ms.input.focus(); e.preventDefault(); } break; case 188: // comma if(e.shiftKey) break; // Shift + , = < on some keyboards case 9: // tab case 13: // enter e.preventDefault(); break; case 17: // ctrl _ctrlDown = true; break; case 40: // down e.preventDefault(); self._moveSelectedRow("down"); break; case 38: // up 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 && ms.input.val() !== cfg.emptyText && (!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 === 27 && cfg.expanded) { ms.combobox.height(0); } // ignore a bunch of keys if((e.keyCode === 9 && cfg.useTabKey === false) || (e.keyCode > 13 && e.keyCode < 32)) { if(e.keyCode === 17){ _ctrlDown = false; } return; } switch(e.keyCode) { case 40:case 38: // up, down e.preventDefault(); break; case 13:case 9:case 188:// enter, tab, comma // Shift + comma = < on English keyboard if(e.keyCode !== 188 || (cfg.useCommaKey === true && !e.shiftKey)) { e.preventDefault(); if(cfg.expanded === true){ // if a selection is performed, select it and reset field selected = ms.combobox.find('.ms-res-item-active: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; 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')); }, /** * 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)); } } } } }; // 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.selected){ options.value.push(child.value); } } }); } var def = {}; // set values from DOM container element $.each(this.attributes, function(i, att){ def[att.name] = att.value; }); var field = new MagicSuggest(this, $.extend(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);