1/** 2 * Tiny Autocomplete 3 * Small and fast autocomplete plugin for Zepto and jQuery. 4 * Written by Johan Halse, https://twitter.com/hejsna, [email protected] 5 * Contributions by Juha Tauriainen, https://github.com/JuhQ and 6 * Phoebe Bright, https://github.com/phoebebright 7 * License: http://johanhalse.mit-license.org 8 * @version 1.0.0 9 * @return {object} Self 10 */ 11 12var factory = function($, window) { 13 var TinyAutocomplete = function(el, options) { 14 var that = this; // This is just to minify better 15 that.field = $(el); 16 that.el = null; 17 that.json = null; 18 that.items = []; 19 that.selectedItem = null; 20 that.list = $('<ul class="autocomplete-list" />'); 21 that.lastSearch = null; 22 that.options = options; 23 }; 24 25 TinyAutocomplete.prototype = { 26 defaults: { 27 minChars: 2, 28 markAsBold: true, 29 grouped: false, 30 queryProperty: "q", 31 queryParameters: {}, 32 method: "get", 33 scrollOnFocus: "auto", 34 maxItems: 100, 35 maxItemsOnMobile: 3, 36 mobileWidth: 700, 37 keyboardDelay: 300, 38 lastItemTemplate: null, 39 closeOnSelect: true, 40 groupContentName: ".autocomplete-items", 41 groupTemplate: 42 '<li class="autocomplete-group"><span class="autocomplete-group-header">{{title}}</span><ul class="autocomplete-items" /></li>', 43 itemTemplate: '<li class="autocomplete-item">{{title}}</li>', 44 showNoResults: false, 45 noResultsTemplate: 46 '<li class="autocomplete-item">No results for {{title}}</li>', 47 wrapClasses: "autocomplete" 48 }, 49 50 /** 51 * Constructor 52 * @return {null} 53 */ 54 init: function() { 55 this.defaults.templateMethod = this.template; 56 this.settings = $.extend({}, this.defaults, this.options); 57 this.setupSettings(); 58 this.setupMarkup(); 59 this.setupEvents(); 60 61 return this; 62 }, 63 64 /** 65 * Micro-templating utility 66 * @param {string} template Template string with mustache-style curly braces 67 * @param {object} vars Contains values to interpolate with 68 * @return {string} Interpolated string 69 */ 70 template: function(template, vars) { 71 return template.replace(/{{\s*[\w]+\s*}}/g, function(v) { 72 return vars[v.substr(2, v.length - 4)]; 73 }); 74 }, 75 76 /** 77 * Debounce function from http://davidwalsh.name/javascript-debounce-function 78 * @param {function} func function to execute 79 * @param {number} wait delay in milliseconds 80 * @param {boolean} immediate whether to fire on trailing or leading 81 * @return {function} debounced function 82 */ 83 debounce: function(func, wait, immediate) { 84 var timeout; 85 return function() { 86 var context = this, 87 args = arguments; 88 var later = function() { 89 timeout = null; 90 if (!immediate) func.apply(context, args); 91 }; 92 var callNow = immediate && !timeout; 93 clearTimeout(timeout); 94 timeout = setTimeout(later, wait); 95 if (callNow) func.apply(context, args); 96 }; 97 }, 98 99 /** 100 * Tweak settings a bit, since we don't have any private functions 101 * defined at constructor time. 102 * @return {null} 103 */ 104 setupSettings: function() { 105 if (this.settings.scrollOnFocus == "auto") { 106 this.settings.scrollOnFocus = this.isTouchDevice(); 107 } 108 109 // We might be on a mobile device and have little in the way of 110 // vertical real estate to work with. Cap it! This check needs a 111 // bit more intelligence to it. 112 this.settings.maxItemsOnLarge = this.settings.maxItems; 113 if ( 114 window.innerWidth < this.settings.mobileWidth && 115 this.settings.maxItemsOnMobile !== null 116 ) { 117 this.settings.maxItems = Math.min( 118 this.settings.maxItems, 119 this.settings.maxItemsOnMobile 120 ); 121 } 122 123 // Using local data or remote url? 124 if (this.settings.data) { 125 this.request = this.localRequest; 126 } else { 127 this.request = this.remoteRequest; 128 } 129
130 // Set the keyboard delay before search fires 131 if (this.settings.keyboardDelay != null) { 132 this.request = this.debounce(this.request, this.settings.keyboardDelay); 133 } 134 }, 135 136 /** 137 * Register initial callbacks 138 * @return {null} 139 */ 140 setupEvents: function() { 141 this.el.on("keyup", ".autocomplete-field", $.proxy(this.onKeyUp, this)); 142 this.el.on( 143 "keydown", 144 ".autocomplete-field", 145 $.proxy(this.onKeyDown, this) 146 ); 147 this.el.on( 148 "mousedown", 149 ".autocomplete-item", 150 $.proxy(this.onClickItem, this) 151 ); 152 153 this.el.on("blur", ".autocomplete-field", $.proxy(this.closeList, this)); 154 var self = this; 155 // Update maxItems when window size change 156 $(window).resize( 157 this.debounce(function() { 158 if ( 159 window.innerWidth < self.settings.mobileWidth && 160 self.settings.maxItemsOnMobile !== null 161 ) { 162 self.settings.maxItems = Math.min( 163 self.settings.maxItems, 164 self.settings.maxItemsOnMobile 165 ); 166 } else { 167 self.settings.maxItems = self.settings.maxItemsOnLarge; 168 } 169 }, 250) 170 ); 171 172 // Scroll to field if we're on a small device, we need that 173 // screen real estate! 174 if (this.settings.scrollOnFocus) { 175 this.field.on("focus", function() { 176 var h = $(this).offset().top; 177 setTimeout(function() { 178 window.scrollTo(0, h); 179 }, 0); 180 }); 181 } 182 }, 183 184 /** 185 * Wrap field in required markup and set up this.el 186 * @return {null} 187 */ 188 setupMarkup: function() { 189 this.field.addClass("autocomplete-field"); 190 this.field.attr("autocomplete", "off"); 191 this.field.wrap('<div class="' + this.settings.wrapClasses + '" />'); 192 this.el = this.field.parent(); 193 }, 194 195 /** 196 * Fire request to specified url 197 * @param {string} val Value to search for 198 * @return {null} 199 */ 200 remoteRequest: function(val) { 201 this.field.trigger("beforerequest", [this, val]); 202 var data = {}; 203 $.extend(data, this.settings.queryParameters); 204 data[this.settings.queryProperty] = val; 205 $.ajax({ 206 method: this.settings.method, 207 url: this.settings.url, 208 dataType: "json", 209 data: data, 210 success: $.proxy(this.beforeReceiveData, this) 211 }); 212 }, 213 214 /** 215 * Match request in local data instead of remote url 216 * @param {string} val Value to search for 217 * @return {null} 218 */ 219 localRequest: function(val) { 220 if (this.settings.grouped) { 221 this.beforeReceiveData(this.matchLocalPatternGrouped(val)); 222 } else { 223 this.beforeReceiveData(this.matchLocalPatternFlat(val)); 224 } 225 }, 226 227 /** 228 * Match the data in a flat array and return the objects where a 229 * hit was found. 230 * @param {string} val Value to search for 231 * @return {array} Array of hits 232 */ 233 matchLocalPatternFlat: function(val) { 234 return this.matchArray(val, this.settings.data); 235 }, 236 237 /** 238 * Match the data in a grouped array and return the objects where a 239 * hit was found, in the same manner 240 * @param {string} val Value to search for 241 * @return {array} Array of hits 242 */ 243 matchLocalPatternGrouped: function(val) { 244 var r = $.extend(true, [], this.settings.data); 245 246 for (var i = 0; i < r.length; i++) { 247 var a = this.matchArray(val, r[i].data); 248 if (a.length == 0) { 249 r.splice(i, 1); 250 i--; 251 } else { 252 r[i].data = a; 253 } 254 } 255 256 return r; 257 }, 258 259 /** 260 * Return an array of matched values from a source array 261 * @param {string} val String to search for 262 * @param {array} arr Array to search in 263 * @return {array} Matched array 264 */ 265 matchArray: function(val, arr) { 266 var r = []; 267 for (var i = 0; i < arr.length; i++) { 268 for (var j in arr[i]) { 269 if ( 270 (arr[i][j].toLowerCase && 271 arr[i][j].toLowerCase().indexOf(val.toLowerCase()) > -1) || 272 arr[i][j] == val 273 ) { 274 r.push(arr[i]); 275 break; 276 } 277 } 278 } 279 280 return r; 281 }, 282 283 /** 284 * Find DOM object at specified index 285 * @param {number} i Index 286 * @return {object} DOM object 287 */ 288 itemAt: function(i) { 289 if (i == null) { 290 return $(); 291 } 292 293 return this.el.find(".autocomplete-item").eq(i); 294 }, 295 296 /** 297 * Find item number for clicked item 298 * @param {object} o The clicked object 299 * @return {number} Item's id in array 300 */ 301 clickedItemAt: function(o) { 302 for (var i = 0; i < this.items.length; i++) { 303 if ( 304 o == 305 this.el 306 .find(".autocomplete-item") 307 .eq(i) 308 .get(0) 309 ) { 310 return i; 311 } 312 } 313 return null; 314 }, 315 316 /** 317 * Select previous item 318 * @return {null} 319 */ 320 prevItem: function() { 321 this.selectedItem--; 322 if (this.selectedItem < 0) { 323 this.selectedItem = null; 324 } 325 326 this.markSelected(this.selectedItem); 327 }, 328 329 /** 330 * Select next item 331 * @return {null} 332 */ 333 nextItem: function() { 334 if (this.selectedItem == null) { 335 this.selectedItem = -1; 336 } 337 this.selectedItem++; 338 339 var l = this.settings.lastItemTemplate 340 ? this.items.length 341 : this.items.length - 1; 342 if (this.selectedItem >= l) { 343 this.selectedItem = l; 344 } 345 346 this.markSelected(this.selectedItem); 347 }, 348 349 /** 350 * Mark an item as selected 351 * @param {number} i Item's id in items array 352 * @return {null} 353 */ 354 markSelected: function(i) { 355 this.el.find(".active").removeClass("active"); 356 this.itemAt(i).addClass("active"); 357 }, 358 359 /** 360 * Iterate through data, adding <strong> tags for every hit. 361 * Splits strings into words using whitespace and matches each 362 * word separately. 363 * @param {object} v Data to operate on 364 * @param {string} str String to search for 365 * @return {object} Processed data object 366 */ 367 markHitText: function(v, str) { 368 var words = str.split(" "); 369 for (var i in v) { 370 if (typeof v[i] == "string" && i != "template") { 371 var replacements = [str]; 372 for (var j = 0; j < words.length; j++) { 373 var word = words[j].trim().replace(/[^a-ö0-9]/gi, ""); // Remove non-alphanumerics 374 if (word.length > 0) { 375 replacements.push(word); 376 } 377 } 378 v[i] = v[i].replace( 379 new RegExp("(" + replacements.join("|") + ")", "gi"), 380 "<strong>$1</strong>" 381 ); 382 } 383 } 384 return v; 385 }, 386 387 /**
388 * Render groups. This must be done before calling 389 * renderItemsInGroups, since it resets the list markup and 390 * attaches a new this.list object. 391 * @return {null} 392 */ 393 renderGroups: function() { 394 this.list.remove(); 395 this.list = $('<ul class="autocomplete-list" />'); 396 for (var i in this.json) { 397 this.list.append( 398 this.settings.templateMethod( 399 this.settings.groupTemplate, 400 this.json[i] 401 ) 402 ); 403 } 404 405 this.el.append(this.list); 406 }, 407 408 /** 409 * Iterates through items and attaches them to their respective 410 * groups. 411 * @return {null} 412 */ 413 renderItemsInGroups: function() { 414 var v = this.field.val(); 415 for (var i = 0; i < this.json.length; i++) { 416 var group = this.el.find(this.settings.groupContentName).eq(i); 417 for ( 418 var j = 0; 419 j < this.json[i].data.length && j < this.settings.maxItems; 420 j++ 421 ) { 422 var jsonData = $.extend({}, this.json[i].data[j]); 423 // Strongify hits 424 if (this.settings.markAsBold) { 425 jsonData = this.markHitText(jsonData, v); 426 } 427 group.append( 428 this.settings.templateMethod( 429 this.json[i].template || this.settings.itemTemplate, 430 jsonData 431 ) 432 ); 433 } 434 } 435 }, 436 437 /** 438 * If we're not dealing with groups, just attach items to the list. 439 * @return {null} 440 */ 441 renderItemsFlat: function() { 442 this.list.remove(); 443 this.list = $('<ul class="autocomplete-list" />'); 444 var v = this.field.val(); 445 for (var i = 0; i < this.json.length && i < this.settings.maxItems; i++) { 446 var jsonData = $.extend({}, this.json[i]); 447 // Strongify hits 448 if (this.settings.markAsBold) { 449 jsonData = this.markHitText(jsonData, v); 450 } 451 this.list.append( 452 this.settings.templateMethod( 453 this.json[i].template || this.settings.itemTemplate, 454 jsonData 455 ) 456 ); 457 } 458 this.el.append(this.list); 459 }, 460 461 /** 462 * If there's a last "find all results for..." item, this function 463 * gets called. 464 * @return {null} 465 */ 466 renderLastItem: function() { 467 this.list.append( 468 this.settings.templateMethod(this.settings.lastItemTemplate, { 469 title: this.field.val() 470 }) 471 ); 472 }, 473 474 /** 475 * If there's a "no results found for..." item, this function 476 * gets called. 477 * @return {null} 478 */ 479 renderNoResults: function() { 480 this.list.append( 481 this.settings.templateMethod(this.settings.noResultsTemplate, { 482 title: this.field.val() 483 }) 484 ); 485 }, 486 487 /** 488 * Removes list from DOM and resets state. 489 * @return {null} 490 */ 491 closeList: function() { 492 $("html").off("click"); 493 this.list.remove(); 494 this.selectedItem = null; 495 }, 496 497 /** 498 * Since groups aren't selectable, we need to fetch the actual 499 * selectable items and keep them around in our this.items list. 500 * @return {array} List of selectable items 501 */ 502 getItemsFromGroups: function() { 503 var r = []; 504 for (var i in this.json) { 505 for (var j = 0; j < this.json[i].data.length; j++) { 506 if (j < this.settings.maxItems) { 507 r.push(this.json[i].data[j]); 508 } 509 } 510 } 511 512 return r; 513 }, 514 515 /** 516 * Check whether a value has changed. Used to determine whether or 517 * not we fire a new request, since all keypresses generate an 518 * event. 519 * @return {boolean} True if value has changed since last request 520 */ 521 valueHasChanged: function() { 522 if (this.field.val() != this.lastSearch) { 523 this.lastSearch = this.field.val(); 524 return true; 525 } 526 return false; 527 }, 528 529 /** 530 * A naive implementation of touch detection. In this day and age 531 * with Microsoft Surfaces and whatnot, this should probably be 532 * replaced with something more intelligent. But let's do that when 533 * we find the time. 534 * @return {boolean} Whether or not this is a touch device 535 */ 536 isTouchDevice: function() { 537 return !!("ontouchstart" in window); 538 }, 539 540 /** 541 * This is mostly an opportunity to hook into the data served by 542 * server to do your own thing 543 * @param {object} data JSON from server 544 * @param {object} xhr XHR object 545 * @return {null} 546 */ 547 beforeReceiveData: function(data, xhr) { 548 this.json = data; 549 this.field.trigger("receivedata", [this, data, xhr]); 550 this.onReceiveData(this.json); 551 }, 552 553 /** 554 * Data received from server, determine what to do with it and 555 * render everything. 556 * @param {object} data JSON from server 557 * @return {null} 558 */ 559 onReceiveData: function(data) { 560 this.selectedItem = null; 561 if (this.settings.grouped) { 562 // First, render groups 563 this.renderGroups(); 564 565 // Then, render the selectable items 566 this.items = this.getItemsFromGroups(); 567 this.renderItemsInGroups(); 568 } else { 569 this.items = this.json; 570 this.renderItemsFlat(); 571 } 572 573 // If no results, render no results message, if applicable 574 if (!this.items.length) { 575 if (this.settings.showNoResults) { 576 this.renderNoResults(); 577 } 578 } 579 580 // Render last item, if applicable 581 if (this.settings.lastItemTemplate) { 582 this.renderLastItem(); 583 } 584 585 // Click outside should close the list 586 $("html").one("click", $.proxy(this.closeList, this)); 587 }, 588 589 /** 590 * Keyup handler. If value has changed and contains min number of chars, 591 * fire the (debounced) request. 592 * @param {object} e Normalized Event object 593 * @return {null} 594 */ 595 onKeyUp: function(e) { 596 if ( 597 this.field.val().length >= this.settings.minChars && 598 this.valueHasChanged() 599 ) { 600 this.request(this.field.val()); 601 } 602 if (this.field.val() == "") { 603 this.lastSearch = ""; 604 this.closeList(); 605 } 606 }, 607 608 /** 609 * Keydown handler to take care of keyboard navigation. 610 * @param {object} e Normalized event object 611 * @return {null} 612 */ 613 onKeyDown: function(e) { 614 if (e.keyCode == 38) { 615 // Up 616 e.preventDefault(); 617 this.prevItem(); 618 } 619 if (e.keyCode == 40) { 620 // Down 621 e.preventDefault(); 622 this.nextItem(); 623 } 624 if (e.keyCode == 13) { 625 // Enter 626 this.onPressEnter(e); 627 } 628 if (e.keyCode == 27) { 629 // Esc 630 e.preventDefault(); 631 this.closeList(); 632 } 633 }, 634 635 /** 636 * Click handler, handle mouse/touch selection of items. 637 * @param {object} e Normalized event object 638 * @return {null} 639 */ 640 onClickItem: function(e) { 641 var i = this.clickedItemAt(e.currentTarget); 642 this.onSelect(e.currentTarget, this.items[i]); 643 }, 644 645 /** 646 * User has pressed enter, select object or perform search. 647 * @return {null} 648 */ 649 onPressEnter: function(e) { 650 // If field is filled in but no item is selected, we don't block 651 // submit 652 if (this.selectedItem === null) { 653 return true; 654 } 655 656 e.preventDefault(); 657 this.onSelect( 658 this.itemAt(this.selectedItem), 659 this.items[this.selectedItem] 660 ); 661 }, 662 663 /** 664 * User has selected an object with mouse or keyboard - act on it 665 * and fire any user-supplied callback. 666 * @param {object} item DOM object for the field 667 * @param {object} val Item value from server 668 * @return {null} 669 */ 670 onSelect: function(item, val) { 671 if (this.settings.onSelect) { 672 this.settings.onSelect.apply(this.field, [item, val]); 673 } 674 this.lastSearch = this.field.val(); 675 if (this.settings.closeOnSelect) { 676 this.closeList(); 677 } 678 } 679 }; 680 681 TinyAutocomplete.defaults = TinyAutocomplete.prototype.defaults; 682 683 $.fn.tinyAutocomplete = function(settings) { 684 return this.each(function() { 685 if (this.tinyAutocomplete) { 686 // Prevent TinyAutocomplete from creating a new object. Instead, 687 // just update the settings object 688 $.extend(this.tinyAutocomplete.settings, settings); 689 return this; 690 } 691 692 var d = new TinyAutocomplete(this, settings).init(); 693 694 // Expose "tinyAutocomplete.settings" to the cold outside 695 this.tinyAutocomplete = { settings: d.settings }; 696 }); 697 }; 698}; 699 700if (typeof exports !== "undefined") { 701 module.exports = factory; 702} else { 703 factory($, window); 704}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.