PageSourceSearch

https://www.csbs.org/modules/contrib/search_autocomplete/js/jquery.autocomplete.js?v=11.4.6

js csbs.org collected 2026-09-24 17:39:40 UTC 15,576 bytes, 473 lines download raw bytes

1/**
2 * @file
3 * SEARCH AUTOCOMPLETE javascript mechanism.
4 */
5
6(function ($, Drupal, drupalSettings, once) {
7
8  'use strict';
9
10  var DOMPurify = window.DOMPurify;
11  if (typeof DOMPurify !== 'undefined') {
12    // Add a hook to keep script tag but sanitize it via Drupal.checkPlain().
13    DOMPurify.addHook('uponSanitizeElement', function (node, data) {
14      if (data.tagName === 'script') {
15        node.outerHTML = Drupal.checkPlain(node.outerHTML);
16        return node;
17      }
18    });
19  }
20
21  var autocomplete;
22
23  // Escape characters in pattern before creating regexp.
24  function escapeRegExp(str) {
25    str = str.trim();
26    return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
27  }
28
29  /**
30   * Helper splitting terms from the autocomplete value.
31   *
32   * @param {String} value
33   *
34   * @return {Array}
35   */
36  function autocompleteSplitValues(value) {
37    // We will match the value against comma-separated terms.
38    var result = [];
39    var quote = false;
40    var current = '';
41    var valueLength = value.length;
42    var i, character;
43
44    for (i = 0; i < valueLength; i++) {
45      character = value.charAt(i);
46      if (character === '"') {
47        current += character;
48        quote = !quote;
49      }
50      else if (character === ',' && !quote) {
51        result.push(current.trim());
52        current = '';
53      }
54      else {
55        current += character;
56      }
57    }
58    if (value.length > 0) {
59      result.push(current.trim());
60    }
61
62    return result;
63  }
64
65  /**
66   * Returns the last value of an multi-value textfield.
67   *
68   * @param {String} terms
69   *
70   * @return {String}
71   */
72  function extractLastTerm(terms) {
73    return autocomplete.splitValues(terms).pop();
74  }
75
76  /**
77   * The search handler is called before a search is performed.
78   *
79   * @param {Object} event
80   *
81   * @return {Boolean}
82   */
83  function searchHandler(event) {
84    var options = autocomplete.options;
85    var term = autocomplete.extractLastTerm(event.target.value);
86    // Abort search if the first character is in firstCharacterBlacklist.
87    if (term.length > 0 && options.firstCharacterBlacklist.indexOf(term[0]) !== -1) {
88      return false;
89    }
90    // Only search when the term is at least the minimum length.
91    return term.length >= options.minLength;
92  }
93
94  /**
95   * jQuery UI autocomplete source callback.
96   *
97   * @param {Object} request
98   * @param {Function} response
99   */
100  function sourceData(request, response) {
101    var elementId = this.element.attr('id');
102
103    // Build empty cache for this element.
104    if (!(elementId in autocomplete.cache)) {
105      autocomplete.cache[elementId] = {};
106    }
107
108    // Retrieve the key for this element.
109    var key = this.element.data('key');
110
111    /**
112     * Filter through the suggestions removing all terms already tagged and
113     * display the available terms to the user.
114     *
115     * @param {Object} suggestions
116     */
117    function showSuggestions(suggestions) {
118      var tagged = autocomplete.splitValues(request.term);
119      for (var i = 0, il = tagged.length; i < il; i++) {
120        var index = suggestions.indexOf(tagged[i]);
121        if (index >= 0) {
122          suggestions.splice(index, 1);
123        }
124      }
125      response(suggestions);
126    }
127
128    /**
129     * Transforms the data object into an array and update autocomplete results.
130     *
131     * @param {Object} data
132     */
133    function sourceCallbackHandler(data) {
134      // Reduce number to limit.
135      const length = data.length;
136      if (key) {
137        data = data.slice(0, autocomplete.options.forms[key].maxSuggestions);
138      }
139
140      // Add no_result or more_results depending on the situation.
141      if (key) {
142        if (data.length) {
143          var moreResults = replaceInObject(autocomplete.options.forms[key].moreResults, '\\[search-phrase\\]', request.term);
144          moreResults = replaceInObject(moreResults, '\\[search-count\\]', length);
145          data.push(moreResults);
146        }
147        else {
148          var noResult = replaceInObject(autocomplete.options.forms[key].noResult, '\\[search-phrase\\]', request.term);
149          data.push(noResult);
150        }
151      }
152
153      // Cache the results.
154      autocomplete.cache[elementId][term] = data;
155
156      // Send the new string array of terms to the jQuery UI list.
157      showSuggestions(data);
158    }
159
160    // Get the desired term and construct the autocomplete URL for it.
161    var term = autocomplete.extractLastTerm(request.term);
162
163    // Check if the term is already cached.
164    if (autocomplete.cache[elementId].hasOwnProperty(term)) {
165      showSuggestions(autocomplete.cache[elementId][term]);
166    }
167    else {
168      var data = {};
169      var path = '';
170      if (key && autocomplete.options.forms[key]) {
171        path = autocomplete.options.forms[key].source;
172        $.each(autocomplete.options.forms[key].filters, function (key, value) {
173          data[value] = term;
174        });
175      }
176      else {
177        path = this.element.attr('data-autocomplete-path');
178        data.q = term;
179      }
180      var options = $.extend({
181        success: sourceCallbackHandler,
182        data: data
183      }, autocomplete.ajax);
184      $.ajax(path, options);
185    }
186  }
187
188  /**
189   * Handles an autocompletefocus event.
190   *
191   * @return {Boolean}
192   */
193  function focusHandler() {
194    return false;
195  }
196
197  /**
198   * Modify HTML.
199   *
200   * @param {String} html
201   * @param {String} mode
202   * @param {RegExp} regex
203   *
204   * @return {String}
205   */
206  function modifyHtml(html, mode, regex) {
207    let regexHtml = /(<("[^"]*"|\'[^\']*\'|[^\'">])*>)/g;
208    switch (mode) {
209      case 'remove_html_tags':
210        html = html.replace(regexHtml, '');
211        break;
212
213      case 'highlight':
214        // Our goal here is to highlight only the text without destroying the
215        // tags in the HTML.
216        // To do this, we first save the tags in the outputted HTML in a
217        // separate variable and mark them so that we can restore them later.
218        // Then we apply the highlighting process, and finally we restore the
219        // tags we saved in the separate variable and we're done.
220        let nullStrings = '';
221        let detachedTags = {};
222        html = html.replace(regexHtml, match => {
223          // The string used for marking should be a special string that a user
224          // would not normally be able to enter in order to restore the tag
225          // correctly.
226          nullStrings += `\0`;
227          let detachedMark = `\v${nullStrings}\v`;
228          detachedTags[detachedMark] = match;
229          return detachedMark;
230        }).replace(regex, '<span class="ui-autocomplete-field-term">$1</span>').replace(/(\v\0+\v)/g, match => detachedTags[match]);
231        break;
232    }
233    return html.replaceAll(/\s+/g, ' ').trim();
234  }
235
236  /**
237   * Handles an autocompleteselect event.
238   *
239   * @param {Object} event
240   * @param {Object} ui
241   *
242   * @return {Boolean}
243   */
244  function selectHandler(event, ui) {
245    var terms = autocomplete.splitValues(event.target.value);
246    // Remove the current input.
247    terms.pop();
248
249    // Trick here to handle encoded characters (see #2936846).
250    const helper = document.createElement('textarea');
251    helper.innerHTML = ui.item.value;
252    ui.item.value = modifyHtml(helper.value, 'remove_html_tags');
253
254    // Add the selected item.
255    if (ui.item.value.search(',') > 0) {
256      terms.push('"' + ui.item.value + '"');
257    }
258    else {
259      terms.push(ui.item.value);
260    }
261    event.target.value = terms.join(', ');
262    var key = $(event.target).data('key');
263
264    // Add our own handling on submission if needed
265    if (key && autocomplete.options.forms[key].autoRedirect && ui.item.link) {
266      // Decode '&' characters in links: #3240117.
267      helper.innerHTML = ui.item.link;
268      document.location.href = helper.value;
269    }
270    else if (key && autocomplete.options.forms[key].autoSubmit && ui.item.value) {
271      $(this).val(ui.item.value);
272      const form = $(this).closest('form');
273      const submit = $('[type="submit"]', form);
274      // If we find a submit input click on it rather then submit the form to
275      // trigger the attached click behavior such as AJAX refresh
276      // (case of an ajax view with expose filters for instance).
277      // @see #2820337
278      if (submit.length === 1) {
279        submit.click();
280      } else {
281        form.submit();
282      }
283    }
284    // Return false to tell jQuery UI that we've filled in the value already.
285    return false;
286  }
287
288  function renderMenu(ul, items) {
289    var that = this;
290    let currentGroup = null;
291    const content = $('<div class="ui-autocomplete-content"></div>')
292    ul.append(content);
293    $.each( items, function( index, item ) {
294      if ('group' in item) {
295        currentGroup = $('<div class="ui-autocomplete-container ui-autocomplete-container-' + item.group.group_id + '"></div>');
296        if (item.group.group_id === 'more_results' || item.group.group_id === 'no_results') {
297          ul.append(currentGroup);
298        } else {
299          content.append(currentGroup)
300        }
301      }
302      that._renderItemData(currentGroup || ul, item);
303    });
304  }
305
306  /**
307   * Override jQuery UI _renderItem function to output HTML by default.
308   *
309   * @param {Object} ul
310   * @param {Object} item
311   *
312   * @return {Object}
313   */
314  function renderItem(ul, item) {
315    var term = this.term;
316    var first = ('group' in item) ? 'first' : '';
317    let innerHTML = '';
318    var regex = new RegExp('(' + escapeRegExp(term) + ')', 'gi');
319
320    // Move everything to fields if none defined.
321    if (!item.fields) {
322      item.fields = [item.label];
323    }
324
325    var helper = document.createElement('textarea');
326    innerHTML = '<div class="ui-autocomplete-fields ' + first + '">';
327    $.each(item.fields, function (key, value) {
328      helper.innerHTML = value;
329      let output = value;
330      if (typeof DOMPurify !== 'undefined') {
331        output = DOMPurify.sanitize(helper.value, {ADD_TAGS: ['script']});
332      }
333      else {
334        let parser = new DOMParser();
335        let doc = parser.parseFromString(helper.value, 'text/html');
336        output = doc.body.textContent;
337      }
338      if (output.indexOf('src=') === -1 && output.indexOf('href=') === -1) {
339        output = modifyHtml(output, 'highlight', regex);
340      }
341      innerHTML += ('<div class="ui-autocomplete-field-' + key + '">' + output + '</div>');
342    });
343    innerHTML += '</div>';
344
345    if ('group' in item) {
346      var groupId = typeof (item.group.group_id) !== 'undefined' ? item.group.group_id : '';
347      var groupName = typeof (item.group.group_name) !== 'undefined' ? item.group.group_name : '';
348      $('<div class="ui-autocomplete-field-group ui-state-disabled ' + groupId + '">' + groupName + '</div>').appendTo(ul);
349    }
350    var isSearchAutoComplete = ul.closest('ul[data-input-ref]').data('input-ref');
351    var uiMenuItemClass = (isSearchAutoComplete ? '' : ' ui-menu-item');
352    var elem = $('<li class="ui-menu-item-' + first + uiMenuItemClass + '"></li>').append('<a>' + innerHTML + '</a>');
353    if (item.value === '') {
354      elem = $('<li class="ui-state-disabled ui-menu-item-' + first + uiMenuItemClass + '">' + item.label + '</li>');
355    }
356    elem.data('item.autocomplete', item).appendTo(ul);
357    return elem;
358  }
359
360  /**
361   * This method resizes the suggestion panel property.
362   */
363  function resizeMenu() {
364    var ul = this.menu.element;
365    ul.outerWidth(Math.max(ul.width('').outerWidth() + 5, this.options.position.of == null ? this.element.outerWidth() : this.options.position.of.outerWidth()));
366    const parent = ul.parent()[0];
367    ul.css({"maxHeight": (window.innerHeight - parent.offsetHeight - parent.getBoundingClientRect().top) + 'px'});
368  }
369
370  /**
371   * This method replaces needle by replacement in stash.
372   */
373  function replaceInObject(stash, needle, replacement) {
374    var regex = new RegExp(needle, 'g');
375    var input = Drupal.checkPlain(replacement);
376    var result = {};
377    $.each(stash, function (index, value) {
378      if (typeof(value) === 'string') {
379        result[index] = value.replace(regex, input);
380      }
381      else {
382        result[index] = value;
383      }
384    });
385    return result;
386  }
387
388  /**
389   * Attaches the autocomplete behavior to all required fields.
390   */
391  Drupal.behaviors.autocomplete = {
392    attach: function (context) {
393      // Act on textfields with the "form-autocomplete" class.
394      var $autocomplete = $(context).find('input.form-autocomplete');
395      // Act also on registered fields
396      $.each(autocomplete.options.forms, function (key, value) {
397        var elem = $(context).find(autocomplete.options.forms[key].selector).data('key', key).addClass('form-autocomplete').attr('data-id', key);
398        $autocomplete = $.merge($autocomplete, elem);
399      });
400
401      $.each($autocomplete, function (_, value) {
402        value = $(value);
403        // Retrieve the key for this element.
404        var key = value.data('key');
405
406        // Run only once on found elements
407        once('autocomplete', value);
408
409        // If present: autocomplete those fields
410        if (value.length) {
411          // Allow options to be overriden per instance.
412          var blacklist = value.attr('data-autocomplete-first-character-blacklist');
413          // Append the autocomplete results to the form.
414          var formId = '#' + $(this).closest('form').attr('id');
415          $.extend(autocomplete.options, {
416            firstCharacterBlacklist: (blacklist) ? blacklist : '',
417            minLength: (typeof key !== 'undefined') ? autocomplete.options.forms[key].minChars : autocomplete.options.minLength,
418            appendTo: (formId) ? formId : 'body',
419          });
420
421          // Use jQuery UI Autocomplete on the textfield.
422          value.autocomplete(autocomplete.options).autocomplete('widget').menu( 'option', 'items', '.ui-autocomplete-container > *' );
423          value.autocomplete(autocomplete.options).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem;
424          value.autocomplete(autocomplete.options).data('ui-autocomplete')._renderMenu = autocomplete.options.renderMenu;
425          value.autocomplete(autocomplete.options).data('ui-autocomplete')._resizeMenu = autocomplete.options.resizeMenu;
426
427          if (key) {
428            // Add theme id to suggestion list.
429            value.autocomplete('widget').attr('data-sa-theme', autocomplete.options.forms[key].theme);
430            // Add unique key (helpfull for styling differently multiple instances on a single form).
431            value.autocomplete('widget').attr('data-input-ref', key);
432          }
433        }
434      });
435    },
436    detach: function (context, settings, trigger) {
437      if (trigger === 'unload') {
438        $(once.remove('autocomplete', 'input.form-autocomplete', context))
439          .autocomplete('destroy');
440      }
441    }
442  };
443
444  /**
445   * Autocomplete object implementation.
446   */
447  autocomplete = {
448    cache: {},
449    // Exposes options to allow overriding by contrib.
450    splitValues: autocompleteSplitValues,
451    extractLastTerm: extractLastTerm,
452    // jQuery UI autocomplete options.
453    options: {
454      source: sourceData,
455      focus: focusHandler,
456      search: searchHandler,
457      select: selectHandler,
458      renderItem: renderItem,
459      renderMenu: renderMenu,
460      resizeMenu: resizeMenu,
461      minLength: 1,
462      // Custom options, used by Drupal.autocomplete.
463      firstCharacterBlacklist: '',
464      forms: drupalSettings.search_autocomplete ? drupalSettings.search_autocomplete : []
465    },
466    ajax: {
467      dataType: 'json'
468    }
469  };
470
471  Drupal.autocomplete = autocomplete;
472
473})(jQuery, Drupal, drupalSettings, once);

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.