PageSourceSearch

https://www.nordfjord.no/modules/custom/common/visitsearch/js/input.js?v=2

js nordfjord.no collected 2026-09-24 22:41:37 UTC 14,370 bytes, 413 lines download raw bytes

1/**
2 * Unified search component with support for full results and suggestions modes
3 */
4
5(function (Vue, Drupal, visitsearch) {
6  'use strict';
7
8  if (typeof Drupal.debounce === "function") {
9
10    // Support multiple instances via data attribute, with fallback to the
11    // single-ID pattern used by existing templates that haven't been updated.
12    var mountTargets = Array.from(document.querySelectorAll('[data-visitsearch-input]'));
13    if (mountTargets.length === 0) {
14      var singleIdElement = document.getElementById('visitsearchInput');
15      if (singleIdElement) {
16        mountTargets = [singleIdElement];
17      }
18    }
19
20    mountTargets.forEach(function (mountElement) {
21
22      var mode = mountElement.getAttribute('data-mode') || 'full';
23      var directNodeNavigation = mountElement.getAttribute('data-direct-node-navigation') === 'true';
24
25      new Vue({
26        el: mountElement,
27        delimiters: ['<%', '%>'],
28
29        data: {
30          abortController: null, // Modern fetch abort controller
31          search: '',
32          mode: mode, // 'full' or 'suggestions' - read from data-mode attribute
33          directNodeNavigation: directNodeNavigation, // Navigate to node directly on suggestion click (suggestions mode only)
34          grouped: visitsearch.settings.grouped || false,
35          groupedMin: visitsearch.settings.grouped_view_min_results,
36          groupOrder: visitsearch.settings.group_sort || false,
37          linkToPage: visitsearch.settings.link_page || false,
38          limit: visitsearch.settings.input_view_limit_result || 0,
39          items: [],
40          suggestions: [], // For suggestions mode
41          totalCount: null,
42          loading: false,
43          showInMobile: false,
44          searchActive: false,
45          isDesktop: false,
46          showfullResult: {},
47          focused: false, // For suggestions dropdown
48          selectedIndex: -1, // For keyboard navigation
49        },
50
51        watch: {
52          'search': function () {
53            this.updateSearch();
54            this.selectedIndex = -1; // Reset keyboard selection on new search
55          },
56        },
57
58        created: function () {
59
60          if (this.grouped) {
61
62            var contentTypes = {};
63            for (var i = 0; i < visitsearch.settings.group_sort.length; i++) {
64              contentTypes[visitsearch.settings.group_sort[i]] = false;
65            }
66            this.showfullResult = contentTypes;
67
68          }
69
70          this.handleWindowResize();
71        },
72        mounted: function () {
73          window.addEventListener('resize', this.handleWindowResize);
74        },
75
76        methods: {
77
78          activateSearch: function () {
79            // For suggestions mode with dialog
80            if (this.mode === 'suggestions' && this.$refs.dialog) {
81              this.$refs.dialog.showModal();
82              this.$nextTick(() => {
83                if (this.$refs.autocomplete) {
84                  this.$refs.autocomplete.focus();
85                }
86              });
87              return;
88            }
89
90            // For full/inline mode
91            var mainElement = document.querySelector('main');
92            if (mainElement) {
93              mainElement.classList.add('blur-filter');
94            }
95            document.body.classList.add('search-overflow');
96            this.searchActive = true;
97
98            this.$nextTick(function () {
99              if (this.$refs.search) {
100                this.$refs.search.focus();
101              }
102            });
103          },
104
105          cancelSearch: function () {
106            // For suggestions mode with dialog
107            if (this.mode === 'suggestions' && this.$refs.dialog) {
108              this.closeSearchDialog();
109              return;
110            }
111
112            // For full/inline mode
113            var mainElement = document.querySelector('main');
114            if (mainElement) {
115              mainElement.classList.remove('blur-filter');
116            }
117            document.body.classList.remove('search-overflow');
118            this.showInMobile = false;
119            this.searchActive = false;
120            this.resetInput();
121          },
122
123          openSearchDialog: function () {
124            this.activateSearch();
125          },
126
127          closeSearchDialog: function () {
128
129            // Cancel any ongoing requests when closing
130            if (this.abortController) {
131              this.abortController.abort();
132              this.abortController = null;
133            }
134
135            if (this.$refs.dialog) {
136              this.$refs.dialog.close();
137              this.resetInput();
138            }
139          },
140
141          resetInput: function () {
142            // Cancel any ongoing requests
143            if (this.abortController) {
144              this.abortController.abort();
145              this.abortController = null;
146            }
147
148            this.search = '';
149            this.items = [];
150            this.suggestions = [];
151            this.loading = false;
152            this.selectedIndex = -1;
153            this.focused = false;
154          },
155
156          updateSearch: Drupal.debounce(function () {
157            var vue = this;
158
159            // Cancel previous request if exists
160            if (vue.abortController) {
161              vue.abortController.abort();
162            }
163
164            if ((vue.search && vue.search.length < 3) || !vue.search) {
165              vue.items = [];
166              vue.suggestions = [];
167              return false;
168            }
169
170            vue.loading = true;
171
172            // Create new AbortController for this request
173            vue.abortController = new AbortController();
174
175            // Determine endpoint and parameters based on mode
176            var endpoint, queryParams;
177
178            if (vue.mode === 'suggestions') {
179              // Suggestions mode: simpler endpoint
180              endpoint = visitsearch.settings.searchUrl + '/suggestions';
181              queryParams = new URLSearchParams({
182                term: vue.search
183              });
184            } else {
185              // Full mode: existing endpoint with all parameters
186              endpoint = visitsearch.settings.searchUrl;
187              queryParams = new URLSearchParams({
188                term: vue.search,
189                grouped: vue.grouped,
190                limit: vue.limit,
191                stats: true
192              });
193            }
194
195            // Fetch with abort signal
196            fetch(endpoint + '?' + queryParams.toString(), {
197              signal: vue.abortController.signal,
198              headers: {
199                'Accept': 'application/json'
200              }
201            })
202              .then(function (response) {
203                if (!response.ok) {
204                  throw new Error('Network response was not ok');
205                }
206                return response.json();
207              })
208              .then(function (data) {
209                vue.loading = false;
210
211                if (vue.mode === 'suggestions') {
212                  // For suggestions mode, populate suggestions array
213                  vue.suggestions = data.items || [];
214                } else {
215                  // For full mode, populate items and totalCount
216                  vue.items = data.items || [];
217                  vue.totalCount = data.totalCount || 0;
218                }
219              })
220              .catch(function (error) {
221                if (error.name === 'AbortError') {
222                  console.log('Search request aborted');
223                } else {
224                  console.error('Search error:', error);
225                  vue.items = [];
226                  vue.suggestions = [];
227                }
228                vue.loading = false;
229              })
230              .finally(function () {
231                vue.abortController = null;
232              });
233          }, 500),
234
235          handleWindowResize: function (event) {
236            // whenever the document is resized, re-set the 'fullHeight' variable
237            if (document.documentElement.clientWidth > 1024) {
238              this.isDesktop = true;
239            } else {
240              this.isDesktop = false;
241            }
242          },
243
244          searchBtn: function () {
245            if (this.isDesktop) {
246              return;
247            } else {
248              this.showInMobile = true;
249              this.searchActive = true;
250            }
251          },
252          outside: function (e) {
253            this.cancelSearch();
254          },
255
256          showRows: function (items, group) {
257            var vue = this;
258            var showFull = vue.showfullResult[group] || false;
259
260            if (showFull == true) {
261              return items;
262            }
263            return items.slice(0, this.groupedMin);
264          },
265          toggle: function (group) {
266            var vue = this;
267            var show = vue.showfullResult[group];
268            vue.showfullResult[group] = !show;
269            this.focusResult(group);
270          },
271          focusResult: function (group) {
272            var vue = this;
273            var collapsed = !vue.showfullResult[group];
274
275            if (collapsed) {
276              console.log("Collapsed");
277            }
278          },
279          translateTitle: function (group) {
280            return drupalSettings.visitsearch.settings.translations[group];
281          },
282
283          // Keyboard navigation methods for suggestions mode
284          navigateDown: function () {
285            if (this.mode !== 'suggestions' || !this.suggestions.length) return;
286
287            if (this.selectedIndex < this.suggestions.length - 1) {
288              this.selectedIndex++;
289              this.scrollToSelected();
290            }
291          },
292
293          navigateUp: function () {
294            if (this.mode !== 'suggestions' || !this.suggestions.length) return;
295
296            if (this.selectedIndex > 0) {
297              this.selectedIndex--;
298              this.scrollToSelected();
299            } else if (this.selectedIndex === 0) {
300              this.selectedIndex = -1; // Back to input
301            }
302          },
303
304          scrollToSelected: function () {
305            this.$nextTick(() => {
306              const listbox = this.$refs.listbox;
307              const selected = listbox ? listbox.querySelector('[aria-selected="true"]') : null;
308
309              if (selected && listbox) {
310                const listboxRect = listbox.getBoundingClientRect();
311                const selectedRect = selected.getBoundingClientRect();
312
313                if (selectedRect.bottom > listboxRect.bottom) {
314                  selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
315                } else if (selectedRect.top < listboxRect.top) {
316                  selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
317                }
318              }
319            });
320          },
321
322          selectCurrentSuggestion: function () {
323            // Handle suggestions selection or perform search
324            if (this.selectedIndex >= 0 && this.suggestions.length > 0 && this.selectedIndex < this.suggestions.length) {
325              // A suggestion is selected, navigate to it
326              this.selectSuggestion(this.suggestions[this.selectedIndex]);
327            } else if (this.search && this.search.length >= 3) {
328              // No suggestion selected but there's valid search text, perform search
329              this.performSearch();
330            }
331          },
332
333          selectSuggestion: function (suggestion) {
334            // In suggestions mode with direct navigation enabled, go straight to the node.
335            // On Enter with no suggestion selected, selectCurrentSuggestion calls performSearch() instead.
336            if (this.mode === 'suggestions' && this.directNodeNavigation && suggestion.id) {
337              window.location.href = '/' + drupalSettings.path.pathPrefix + 'node/' + suggestion.id;
338              return;
339            }
340            this.search = suggestion.title || suggestion.text || suggestion;
341            this.performSearch();
342          },
343
344          performSearch: function () {
345
346            // Navigate to search results page or perform search action
347            if (visitsearch.settings.search_page_url) {
348              window.location.href = visitsearch.settings.search_page_url + '?q=' + encodeURIComponent(this.search);
349            } else {
350              console.error('search_page_url is not configured');
351              // If no URL configured, close the dialog
352              this.closeSearchDialog();
353            }
354          },
355
356          handleInputBlur: function () {
357            // Delay to allow click events on suggestions to fire first
358            setTimeout(() => {
359              this.focused = false;
360            }, 200);
361          },
362
363          hideMenu: function () {
364            if (document.body.classList.contains('show-menu')) {
365              document.body.classList.remove('show-menu');
366              document.body.classList.add('hide-menu');
367              var menuButton = document.getElementById('menu-button');
368              if (menuButton) {
369                menuButton.classList.remove('open');
370              }
371            }
372          }
373
374        },
375
376        directives: {
377          'click-outside': {
378            bind: function (el, binding, vNode) {
379              // Provided expression must evaluate to a function.
380              if (typeof binding.value !== 'function') {
381                const compName = vNode.context.name
382                /*let warn = `[Vue-click-outside:] provided expression '${binding.expression}' is not a function, but has to be`
383                if (compName) { warn += `Found in component '${compName}'` }*/
384
385                console.warn(warn)
386              }
387              // Define Handler and cache it on the element
388              const bubble = binding.modifiers.bubble;
389              const handler = function (e) {
390                if (bubble || (!el.contains(e.target) && el !== e.target)) {
391                  binding.value(e)
392                }
393              }
394              el.__vueClickOutside__ = handler
395
396              // add Event Listeners
397              document.addEventListener('click', handler)
398            },
399
400            unbind: function (el, binding) {
401              // Remove Event Listeners
402              document.removeEventListener('click', el.__vueClickOutside__)
403              el.__vueClickOutside__ = null
404
405            }
406          }
407        }
408
409      });
410
411    });
412  }
413})(Vue, Drupal, drupalSettings.visitsearch);

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.