├── README.md └── jquery-ui.triggeredAutocomplete.js /README.md: -------------------------------------------------------------------------------- 1 | jQuery UI Triggered Autocomplete 2 | ==================== 3 | 4 | This widget lets you search for users to @mention in your posts. It works very much like Facebook and Google+ in that it supports users with spaces in their name. It writes to a hidden field with the user ID's formatted in this way: @[12345] while showing @username in the input box. You can save the encoded string for easier parsing at display time. 5 | 6 | ``` 7 | $('#inputbox').triggeredAutocomplete({ 8 | hidden: '#hidden_inputbox, 9 | source: "/search.php", 10 | trigger: "@", 11 | maxLength: 25 12 | }); 13 | ``` 14 | 15 | You can use a predefined array or json as a source. Example json result: 16 | 17 | ``` 18 | [{"value":"1234","label":"Beef"},{"value":"98765","label":"Chicken"}] 19 | ``` 20 | 21 | To use the hidden field without an ajax call you need to pass an associative array: 22 | 23 | ``` 24 | $('#inputbox').triggeredAutocomplete({ 25 | hidden: '#hidden_inputbox, 26 | source: new Array({ "value": "1234", "label": 'Geech'}, {"value": "5312", "label": "Marf"}) 27 | }); 28 | ``` 29 | 30 | This also supports an optional img to appear beside each result. You just need to pass an img URL for each value and label. Here is the CSS for the image: 31 | 32 | ``` 33 | .ui-menu-item img { padding-right: 10px; width: 32px; height: 32px; } 34 | .ui-menu-item span { color: #444; font-size: 12px; vertical-align: top } 35 | ``` 36 | 37 | If you want editable posts, you need to pass an id_map as an attr tag of the input box. This is also json encoded and is simply an associative array of the included user_id => username pairs in the existing post. This is so when you change the post the original @mentions are preserved in their @[12345] format. 38 | 39 | Demo: http://jsfiddle.net/vq6MH/146/ 40 | 41 | Discussion at Hawkee: http://www.hawkee.com/snippet/9391/ -------------------------------------------------------------------------------- /jquery-ui.triggeredAutocomplete.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************** 2 | /* 3 | * triggeredAutocomplete (jQuery UI autocomplete widget) 4 | * 2012 by Hawkee.com (hawkee@gmail.com) 5 | * 6 | * Version 1.4.5 7 | * 8 | * Requires jQuery 1.7 and jQuery UI 1.8 9 | * 10 | * Dual licensed under MIT or GPLv2 licenses 11 | * http://en.wikipedia.org/wiki/MIT_License 12 | * http://en.wikipedia.org/wiki/GNU_General_Public_License 13 | * 14 | */ 15 | 16 | ;(function ( $, window, document, undefined ) { 17 | $.widget("ui.triggeredAutocomplete", $.extend(true, {}, $.ui.autocomplete.prototype, { 18 | 19 | options: { 20 | trigger: "@", 21 | allowDuplicates: true, 22 | maxLength: 0 23 | }, 24 | 25 | _create:function() { 26 | 27 | var self = this; 28 | this.id_map = new Object(); 29 | this.stopIndex = -1; 30 | this.stopLength = -1; 31 | this.contents = ''; 32 | this.cursorPos = 0; 33 | 34 | /** Fixes some events improperly handled by ui.autocomplete */ 35 | this.element.bind('keydown.autocomplete.fix', function (e) { 36 | switch (e.keyCode) { 37 | case $.ui.keyCode.ESCAPE: 38 | self.close(e); 39 | e.stopImmediatePropagation(); 40 | break; 41 | case $.ui.keyCode.UP: 42 | case $.ui.keyCode.DOWN: 43 | if (!self.menu.element.is(":visible")) { 44 | e.stopImmediatePropagation(); 45 | } 46 | } 47 | }); 48 | 49 | // Check for the id_map as an attribute. This is for editing. 50 | 51 | var id_map_string = this.element.attr('id_map'); 52 | if(id_map_string) this.id_map = jQuery.parseJSON(id_map_string); 53 | 54 | this.ac = $.ui.autocomplete.prototype; 55 | this.ac._create.apply(this, arguments); 56 | 57 | this.updateHidden(); 58 | 59 | // Select function defined via options. 60 | this.options.select = function(event, ui) { 61 | var contents = self.contents; 62 | var cursorPos = self.cursorPos; 63 | 64 | // Save everything following the cursor (in case they went back to add a mention) 65 | // Separate everything before the cursor 66 | // Remove the trigger and search 67 | // Rebuild: start + result + end 68 | 69 | var end = contents.substring(cursorPos, contents.length); 70 | var start = contents.substring(0, cursorPos); 71 | start = start.substring(0, start.lastIndexOf(self.options.trigger)); 72 | 73 | var top = self.element.scrollTop(); 74 | this.value = start + self.options.trigger+ui.item.label+' ' + end; 75 | self.element.scrollTop(top); 76 | 77 | // Create an id map so we can create a hidden version of this string with id's instead of labels. 78 | 79 | self.id_map[ui.item.label] = ui.item.value; 80 | self.updateHidden(); 81 | 82 | /** Places the caret right after the inserted item. */ 83 | var index = start.length + self.options.trigger.length + ui.item.label.length + 2; 84 | if (this.createTextRange) { 85 | var range = this.createTextRange(); 86 | range.move('character', index); 87 | range.select(); 88 | } else if (this.setSelectionRange) { 89 | this.setSelectionRange(index, index); 90 | } 91 | 92 | return false; 93 | }; 94 | 95 | // Don't change the input as you browse the results. 96 | this.options.focus = function(event, ui) { return false; } 97 | this.menu.options.blur = function(event, ui) { return false; } 98 | 99 | // Any changes made need to update the hidden field. 100 | this.element.focus(function() { self.updateHidden(); }); 101 | this.element.change(function() { self.updateHidden(); }); 102 | }, 103 | 104 | // If there is an 'img' then show it beside the label. 105 | 106 | _renderItem: function( ul, item ) { 107 | if(item.img != undefined) { 108 | return $( "
  • " ) 109 | .data( "item.autocomplete", item ) 110 | .append( "" + ""+item.label+"" ) 111 | .appendTo( ul ); 112 | } 113 | else { 114 | return $( "
  • " ) 115 | .data( "item.autocomplete", item ) 116 | .append( $( "" ).text( item.label ) ) 117 | .appendTo( ul ); 118 | } 119 | }, 120 | 121 | // This stops the input box from being cleared when traversing the menu. 122 | 123 | _move: function( direction, event ) { 124 | if ( !this.menu.element.is(":visible") ) { 125 | this.search( null, event ); 126 | return; 127 | } 128 | if ( this.menu.first() && /^previous/.test(direction) || 129 | this.menu.last() && /^next/.test(direction) ) { 130 | this.menu.deactivate(); 131 | return; 132 | } 133 | this.menu[ direction ]( event ); 134 | }, 135 | 136 | search: function(value, event) { 137 | 138 | var contents = this.element.val(); 139 | var cursorPos = this.getCursor(); 140 | this.contents = contents; 141 | this.cursorPos = cursorPos; 142 | 143 | // Include the character before the trigger and check that the trigger is not in the middle of a word 144 | // This avoids trying to match in the middle of email addresses when '@' is used as the trigger 145 | 146 | var check_contents = contents.substring(contents.lastIndexOf(this.options.trigger) - 1, cursorPos); 147 | var regex = new RegExp('\\B\\'+this.options.trigger+'([\\w\\-]+)'); 148 | 149 | if (contents.indexOf(this.options.trigger) >= 0 && check_contents.match(regex)) { 150 | 151 | // Get the characters following the trigger and before the cursor position. 152 | // Get the contents up to the cursortPos first then get the lastIndexOf the trigger to find the search term. 153 | 154 | contents = contents.substring(0, cursorPos); 155 | var term = contents.substring(contents.lastIndexOf(this.options.trigger) + 1, contents.length); 156 | 157 | // Only query the server if we have a term and we haven't received a null response. 158 | // First check the current query to see if it already returned a null response. 159 | 160 | if(this.stopIndex == contents.lastIndexOf(this.options.trigger) && term.length > this.stopLength) { term = ''; } 161 | 162 | if (term.length > 0 && (!this.options.maxLength || term.length <= this.options.maxLength)) { 163 | // Updates the hidden field to check if a name was removed so that we can put them back in the list. 164 | this.updateHidden(); 165 | return this._search(term); 166 | } 167 | else this.close(); 168 | } 169 | }, 170 | 171 | // Slightly altered the default ajax call to stop querying after the search produced no results. 172 | // This is to prevent unnecessary querying. 173 | 174 | _initSource: function() { 175 | var self = this, array, url; 176 | if ( $.isArray(this.options.source) ) { 177 | array = this.options.source; 178 | this.source = function( request, response ) { 179 | response( $.ui.autocomplete.filter(array, request.term) ); 180 | }; 181 | } else if ( typeof this.options.source === "string" ) { 182 | url = this.options.source; 183 | this.source = function( request, response ) { 184 | if ( self.xhr ) { 185 | self.xhr.abort(); 186 | } 187 | self.xhr = $.ajax({ 188 | url: url, 189 | data: request, 190 | dataType: 'json', 191 | success: function(data) { 192 | if(data != null) { 193 | response($.map(data, function(item) { 194 | if (typeof item === "string") { 195 | label = item; 196 | } 197 | else { 198 | label = item.label; 199 | } 200 | // If the item has already been selected don't re-include it. 201 | if(!self.id_map[label] || self.options.allowDuplicates) { 202 | return item 203 | } 204 | })); 205 | self.stopLength = -1; 206 | self.stopIndex = -1; 207 | } 208 | else { 209 | // No results, record length of string and stop querying unless the length decreases 210 | self.stopLength = request.term.length; 211 | self.stopIndex = self.contents.lastIndexOf(self.options.trigger); 212 | self.close(); 213 | } 214 | } 215 | }); 216 | }; 217 | } else { 218 | this.source = this.options.source; 219 | } 220 | }, 221 | 222 | destroy: function() { 223 | $.Widget.prototype.destroy.call(this); 224 | }, 225 | 226 | // Gets the position of the cursor in the input box. 227 | 228 | getCursor: function() { 229 | var i = this.element[0]; 230 | 231 | if(i.selectionStart) { 232 | return i.selectionStart; 233 | } 234 | else if(i.ownerDocument.selection) { 235 | var range = i.ownerDocument.selection.createRange(); 236 | if(!range) return 0; 237 | var textrange = i.createTextRange(); 238 | var textrange2 = textrange.duplicate(); 239 | 240 | textrange.moveToBookmark(range.getBookmark()); 241 | textrange2.setEndPoint('EndToStart', textrange); 242 | return textrange2.text.length; 243 | } 244 | }, 245 | 246 | // Populates the hidden field with the contents of the entry box but with 247 | // ID's instead of usernames. Better for storage. 248 | 249 | updateHidden: function() { 250 | var trigger = this.options.trigger; 251 | var top = this.element.scrollTop(); 252 | var contents = this.element.val(); 253 | for(var key in this.id_map) { 254 | var find = trigger+key; 255 | find = find.replace(/[^a-zA-Z 0-9@]+/g,'\\$&'); 256 | var regex = new RegExp(find, "g"); 257 | var old_contents = contents; 258 | contents = contents.replace(regex, trigger+'['+this.id_map[key]+']'); 259 | if(old_contents == contents) delete this.id_map[key]; 260 | } 261 | $(this.options.hidden).val(contents); 262 | this.element.scrollTop(top); 263 | } 264 | 265 | })); 266 | })( jQuery, window , document ); 267 | --------------------------------------------------------------------------------