PageSourceSearch

https://ilias.phnmsbern.ch/Customizing/global/plugins/Services/UIC…/src/tiny-autocomplete.js?version=9_23

js phnmsbern.ch collected 2026-09-24 12:50:22 UTC 19,675 bytes, 704 lines download raw bytes

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.