From 9fba5041a0ee79b551df81441fdac4f75bcaded2 Mon Sep 17 00:00:00 2001 From: rabuzarus <> Date: Sat, 30 Jan 2016 16:42:46 +0100 Subject: [PATCH] rework autocomplete: update query-textcomplete --- .../jquery.textcomplete.js | 281 +++++++++++++----- 1 file changed, 202 insertions(+), 79 deletions(-) diff --git a/library/jquery-textcomplete/jquery.textcomplete.js b/library/jquery-textcomplete/jquery.textcomplete.js index 3df84f3b4..200310175 100644 --- a/library/jquery-textcomplete/jquery.textcomplete.js +++ b/library/jquery-textcomplete/jquery.textcomplete.js @@ -1,3 +1,16 @@ +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === "object" && module.exports) { + var $ = require('jquery'); + module.exports = factory($); + } else { + // Browser globals + factory(jQuery); + } +}(function (jQuery) { + /*! * jQuery.textcomplete * @@ -17,13 +30,18 @@ if (typeof jQuery === 'undefined') { if (console.warn) { console.warn(message); } }; + var id = 1; + $.fn.textcomplete = function (strategies, option) { var args = Array.prototype.slice.call(arguments); return this.each(function () { + var self = this; var $this = $(this); var completer = $this.data('textComplete'); if (!completer) { - completer = new $.fn.textcomplete.Completer(this, option || {}); + option || (option = {}); + option._oid = id++; // unique object id + completer = new $.fn.textcomplete.Completer(this, option); $this.data('textComplete', completer); } if (typeof strategies === 'string') { @@ -45,7 +63,10 @@ if (typeof jQuery === 'undefined') { } }); }); - completer.register($.fn.textcomplete.Strategy.parse(strategies)); + completer.register($.fn.textcomplete.Strategy.parse(strategies, { + el: self, + $el: $this + })); } }); }; @@ -115,6 +136,10 @@ if (typeof jQuery === 'undefined') { return Object.prototype.toString.call(obj) === '[object String]'; }; + var isFunction = function (obj) { + return Object.prototype.toString.call(obj) === '[object Function]'; + }; + var uniqueId = 0; function Completer(element, option) { @@ -224,8 +249,10 @@ if (typeof jQuery === 'undefined') { // // value - The selected element of the array callbacked from search func. // strategy - The Strategy object. - select: function (value, strategy) { - this.adapter.select(value, strategy); + // e - Click or keydown event object. + select: function (value, strategy, e) { + this._term = null; + this.adapter.select(value, strategy, e); this.fire('change').fire('textComplete:select', value, strategy); this.adapter.focus(); }, @@ -248,8 +275,9 @@ if (typeof jQuery === 'undefined') { var strategy = this.strategies[i]; var context = strategy.context(text); if (context || context === '') { + var matchRegexp = isFunction(strategy.match) ? strategy.match(text) : strategy.match; if (isString(context)) { text = context; } - var match = text.match(strategy.match); + var match = text.match(matchRegexp); if (match) { return [strategy, match[strategy.index], match]; } } } @@ -262,14 +290,14 @@ if (typeof jQuery === 'undefined') { strategy.search(term, function (data, stillSearching) { if (!self.dropdown.shown) { self.dropdown.activate(); - self.dropdown.setPosition(self.adapter.getCaretPosition()); } if (self._clearAtNext) { // The first callback in the current lock. self.dropdown.clear(); self._clearAtNext = false; } - self.dropdown.render(self._zip(data, strategy)); + self.dropdown.setPosition(self.adapter.getCaretPosition()); + self.dropdown.render(self._zip(data, strategy, term)); if (!stillSearching) { // The last callback in the current lock. free(); @@ -284,9 +312,9 @@ if (typeof jQuery === 'undefined') { // // this._zip(['a', 'b'], 's'); // //=> [{ value: 'a', strategy: 's' }, { value: 'b', strategy: 's' }] - _zip: function (data, strategy) { + _zip: function (data, strategy, term) { return $.map(data, function (value) { - return { value: value, strategy: strategy }; + return { value: value, strategy: strategy, term: term }; }); } }); @@ -297,6 +325,8 @@ if (typeof jQuery === 'undefined') { +function ($) { 'use strict'; + var $window = $(window); + var include = function (zippedData, datum) { var i, elem; var idProperty = datum.strategy.idProperty @@ -320,6 +350,16 @@ if (typeof jQuery === 'undefined') { }); }); + var commands = { + SKIP_DEFAULT: 0, + KEY_UP: 1, + KEY_DOWN: 2, + KEY_ENTER: 3, + KEY_PAGEUP: 4, + KEY_PAGEDOWN: 5, + KEY_ESCAPE: 6 + }; + // Dropdown view // ============= @@ -327,7 +367,7 @@ if (typeof jQuery === 'undefined') { // // element - Textarea or contenteditable element. function Dropdown(element, completer, option) { - this.$el = Dropdown.findOrCreateElement(option); + this.$el = Dropdown.createElement(option); this.completer = completer; this.id = completer.id + 'dropdown'; this._data = []; // zipped data. @@ -338,7 +378,7 @@ if (typeof jQuery === 'undefined') { if (option.listPosition) { this.setPosition = option.listPosition; } if (option.height) { this.$el.height(option.height); } var self = this; - $.each(['maxCount', 'placement', 'footer', 'header', 'className'], function (_i, name) { + $.each(['maxCount', 'placement', 'footer', 'header', 'noResultsMessage', 'className'], function (_i, name) { if (option[name] != null) { self[name] = option[name]; } }); this._bindEvents(element); @@ -349,18 +389,19 @@ if (typeof jQuery === 'undefined') { // Class methods // ------------- - findOrCreateElement: function (option) { + createElement: function (option) { var $parent = option.appendTo; if (!($parent instanceof $)) { $parent = $($parent); } - var $el = $parent.children('.dropdown-menu') - if (!$el.length) { - $el = $('').css({ + var $el = $('') + .addClass('dropdown-menu textcomplete-dropdown') + .attr('id', 'textcomplete-dropdown-' + option._oid) + .css({ display: 'none', left: 0, position: 'absolute', zIndex: option.zIndex - }).appendTo($parent); - } + }) + .appendTo($parent); return $el; } }); @@ -403,23 +444,26 @@ if (typeof jQuery === 'undefined') { this._renderFooter(unzippedData); if (contentsHtml) { this._renderContents(contentsHtml); + this._fitToBottom(); this._activateIndexedItem(); } this._setScroll(); + } else if (this.noResultsMessage) { + this._renderNoResultsMessage(unzippedData); } else if (this.shown) { this.deactivate(); } }, - setPosition: function (position) { - this.$el.css(this._applyPlacement(position)); + setPosition: function (pos) { + this.$el.css(this._applyPlacement(pos)); // Make the dropdown fixed if the input is also fixed // This can't be done during init, as textcomplete may be used on multiple elements on the same page // Because the same dropdown is reused behind the scenes, we need to recheck every time the dropdown is showed var position = 'absolute'; // Check if input or one of its parents has positioning we need to care about - this.$inputEl.add(this.$inputEl.parents()).each(function() { + this.$inputEl.add(this.$inputEl.parents()).each(function() { if($(this).css('position') === 'absolute') // The element has absolute positioning, so it's all OK return false; if($(this).css('position') === 'fixed') { @@ -436,7 +480,7 @@ if (typeof jQuery === 'undefined') { this.$el.html(''); this.data = []; this._index = 0; - this._$header = this._$footer = null; + this._$header = this._$footer = this._$noResultsMessage = null; }, activate: function () { @@ -481,19 +525,25 @@ if (typeof jQuery === 'undefined') { return e.keyCode === 34; // PAGEDOWN }, + isEscape: function (e) { + return e.keyCode === 27; // ESCAPE + }, + // Private properties // ------------------ _data: null, // Currently shown zipped data. _index: null, _$header: null, + _$noResultsMessage: null, _$footer: null, // Private methods // --------------- _bindEvents: function () { - this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this)) + this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this)); + this.$el.on('touchstart.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this)); this.$el.on('mouseover.' + this.id, '.textcomplete-item', $.proxy(this._onMouseover, this)); this.$inputEl.on('keydown.' + this.id, $.proxy(this._onKeydown, this)); }, @@ -506,11 +556,16 @@ if (typeof jQuery === 'undefined') { $el = $el.closest('.textcomplete-item'); } var datum = this.data[parseInt($el.data('index'), 10)]; - this.completer.select(datum.value, datum.strategy); + this.completer.select(datum.value, datum.strategy, e); var self = this; // Deactive at next tick to allow other event handlers to know whether // the dropdown has been shown or not. - setTimeout(function () { self.deactivate(); }, 0); + setTimeout(function () { + self.deactivate(); + if (e.type === 'touchstart') { + self.$inputEl.focus(); + } + }, 0); }, // Activate hovered item. @@ -526,21 +581,58 @@ if (typeof jQuery === 'undefined') { _onKeydown: function (e) { if (!this.shown) { return; } + + var command; + + if ($.isFunction(this.option.onKeydown)) { + command = this.option.onKeydown(e, commands); + } + + if (command == null) { + command = this._defaultKeydown(e); + } + + switch (command) { + case commands.KEY_UP: + e.preventDefault(); + this._up(); + break; + case commands.KEY_DOWN: + e.preventDefault(); + this._down(); + break; + case commands.KEY_ENTER: + e.preventDefault(); + this._enter(e); + break; + case commands.KEY_PAGEUP: + e.preventDefault(); + this._pageup(); + break; + case commands.KEY_PAGEDOWN: + e.preventDefault(); + this._pagedown(); + break; + case commands.KEY_ESCAPE: + e.preventDefault(); + this.deactivate(); + break; + } + }, + + _defaultKeydown: function (e) { if (this.isUp(e)) { - e.preventDefault(); - this._up(); + return commands.KEY_UP; } else if (this.isDown(e)) { - e.preventDefault(); - this._down(); + return commands.KEY_DOWN; } else if (this.isEnter(e)) { - e.preventDefault(); - this._enter(); + return commands.KEY_ENTER; } else if (this.isPageup(e)) { - e.preventDefault(); - this._pageup(); + return commands.KEY_PAGEUP; } else if (this.isPagedown(e)) { - e.preventDefault(); - this._pagedown(); + return commands.KEY_PAGEDOWN; + } else if (this.isEscape(e)) { + return commands.KEY_ESCAPE; } }, @@ -564,10 +656,10 @@ if (typeof jQuery === 'undefined') { this._setScroll(); }, - _enter: function () { + _enter: function (e) { var datum = this.data[parseInt(this._getActiveElement().data('index'), 10)]; - this.completer.select(datum.value, datum.strategy); - this._setScroll(); + this.completer.select(datum.value, datum.strategy, e); + this.deactivate(); }, _pageup: function () { @@ -630,7 +722,7 @@ if (typeof jQuery === 'undefined') { index = this.data.length; this.data.push(datum); html += '
  • '; - html += datum.strategy.template(datum.value); + html += datum.strategy.template(datum.value, datum.term); html += '
  • '; } return html; @@ -656,6 +748,16 @@ if (typeof jQuery === 'undefined') { } }, + _renderNoResultsMessage: function (unzippedData) { + if (this.noResultsMessage) { + if (!this._$noResultsMessage) { + this._$noResultsMessage = $('
  • ').appendTo(this.$el); + } + var html = $.isFunction(this.noResultsMessage) ? this.noResultsMessage(unzippedData) : this.noResultsMessage; + this._$noResultsMessage.html(html); + } + }, + _renderContents: function (html) { if (this._$footer) { this._$footer.before(html); @@ -664,7 +766,15 @@ if (typeof jQuery === 'undefined') { } }, - _applyPlacement: function (position) { + _fitToBottom: function() { + var windowScrollBottom = $window.scrollTop() + $window.height(); + var height = this.$el.height(); + if ((this.$el.position().top + height) > windowScrollBottom) { + this.$el.offset({top: windowScrollBottom - height}); + } + }, + + _applyPlacement: function (position) { // If the 'placement' option set to 'top', move the position above the element. if (this.placement.indexOf('top') !== -1) { // Overwrite the position object to set the 'bottom' property instead of the top. @@ -688,6 +798,7 @@ if (typeof jQuery === 'undefined') { }); $.fn.textcomplete.Dropdown = Dropdown; + $.extend($.fn.textcomplete, commands); }(jQuery); +function ($) { @@ -713,9 +824,12 @@ if (typeof jQuery === 'undefined') { if (this.cache) { this.search = memoize(this.search); } } - Strategy.parse = function (optionsArray) { - return $.map(optionsArray, function (options) { - return new Strategy(options); + Strategy.parse = function (strategiesArray, params) { + return $.map(strategiesArray, function (strategy) { + var strategyObj = new Strategy(strategy); + strategyObj.el = params.el; + strategyObj.$el = params.$el; + return strategyObj; }); }; @@ -848,6 +962,7 @@ if (typeof jQuery === 'undefined') { // Suppress searching if it returns true. _skipSearch: function (clickEvent) { switch (clickEvent.keyCode) { + case 13: // ENTER case 40: // DOWN case 38: // UP return true; @@ -894,17 +1009,19 @@ if (typeof jQuery === 'undefined') { // -------------- // Update the textarea with the given value and strategy. - select: function (value, strategy) { + select: function (value, strategy, e) { var pre = this.getTextFromHeadToCaret(); var post = this.el.value.substring(this.el.selectionEnd); - var newSubstr = strategy.replace(value); - if ($.isArray(newSubstr)) { - post = newSubstr[1] + post; - newSubstr = newSubstr[0]; + var newSubstr = strategy.replace(value, e); + if (typeof newSubstr !== 'undefined') { + if ($.isArray(newSubstr)) { + post = newSubstr[1] + post; + newSubstr = newSubstr[0]; + } + pre = pre.replace(strategy.match, newSubstr); + this.$el.val(pre + post); + this.el.selectionStart = this.el.selectionEnd = pre.length; } - pre = pre.replace(strategy.match, newSubstr); - this.$el.val(pre + post); - this.el.selectionStart = this.el.selectionEnd = pre.length; }, // Private methods @@ -981,22 +1098,24 @@ if (typeof jQuery === 'undefined') { // Public methods // -------------- - select: function (value, strategy) { + select: function (value, strategy, e) { var pre = this.getTextFromHeadToCaret(); var post = this.el.value.substring(pre.length); - var newSubstr = strategy.replace(value); - if ($.isArray(newSubstr)) { - post = newSubstr[1] + post; - newSubstr = newSubstr[0]; + var newSubstr = strategy.replace(value, e); + if (typeof newSubstr !== 'undefined') { + if ($.isArray(newSubstr)) { + post = newSubstr[1] + post; + newSubstr = newSubstr[0]; + } + pre = pre.replace(strategy.match, newSubstr); + this.$el.val(pre + post); + this.el.focus(); + var range = this.el.createTextRange(); + range.collapse(true); + range.moveEnd('character', pre.length); + range.moveStart('character', pre.length); + range.select(); } - pre = pre.replace(strategy.match, newSubstr); - this.$el.val(pre + post); - this.el.focus(); - var range = this.el.createTextRange(); - range.collapse(true); - range.moveEnd('character', pre.length); - range.moveStart('character', pre.length); - range.select(); }, getTextFromHeadToCaret: function () { @@ -1032,7 +1151,7 @@ if (typeof jQuery === 'undefined') { // Update the content with the given value and strategy. // When an dropdown item is selected, it is executed. - select: function (value, strategy) { + select: function (value, strategy, e) { var pre = this.getTextFromHeadToCaret(); var sel = window.getSelection() var range = sel.getRangeAt(0); @@ -1040,20 +1159,22 @@ if (typeof jQuery === 'undefined') { selection.selectNodeContents(range.startContainer); var content = selection.toString(); var post = content.substring(range.startOffset); - var newSubstr = strategy.replace(value); - if ($.isArray(newSubstr)) { - post = newSubstr[1] + post; - newSubstr = newSubstr[0]; + var newSubstr = strategy.replace(value, e); + if (typeof newSubstr !== 'undefined') { + if ($.isArray(newSubstr)) { + post = newSubstr[1] + post; + newSubstr = newSubstr[0]; + } + pre = pre.replace(strategy.match, newSubstr); + range.selectNodeContents(range.startContainer); + range.deleteContents(); + var node = document.createTextNode(pre + post); + range.insertNode(node); + range.setStart(node, pre.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); } - pre = pre.replace(strategy.match, newSubstr); - range.selectNodeContents(range.startContainer); - range.deleteContents(); - var node = document.createTextNode(pre + post); - range.insertNode(node); - range.setStart(node, pre.length); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); }, // Private methods @@ -1079,8 +1200,7 @@ if (typeof jQuery === 'undefined') { position.left -= this.$el.offset().left; position.top += $node.height() - this.$el.offset().top; position.lineHeight = $node.height(); - var dir = this.$el.attr('dir') || this.$el.css('direction'); - if (dir === 'rtl') { position.left -= this.listView.$el.width(); } + $node.remove(); return position; }, @@ -1102,3 +1222,6 @@ if (typeof jQuery === 'undefined') { $.fn.textcomplete.ContentEditable = ContentEditable; }(jQuery); + +return jQuery; +}));