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.