1/** 2 * @file 3 * Smart Search Autocomplete â overlay UI + results-page autocomplete. 4 * 5 * Depends on: core/drupal, core/jquery, core/drupalSettings, core/once. 6 */ 7 8(function ($, Drupal, drupalSettings, once) { 9 'use strict'; 10 11 // =========================================================================== 12 // Helper: shared autocomplete fetch logic 13 // =========================================================================== 14 15 function makeFetcher(autocompleteUrl) { 16 var xhr = null; 17 return function fetch(keyword, onSuccess, onEmpty) { 18 if (xhr) { xhr.abort(); xhr = null; } 19 if (!keyword || keyword.length < 2) { onEmpty(); return; } 20 xhr = $.ajax({ 21 url: autocompleteUrl, 22 data: { q: keyword }, 23 dataType: 'json', 24 success: function (data) { onSuccess(data); }, 25 error: function (jqXHR) { if (jqXHR.statusText !== 'abort') { onEmpty(); } }, 26 }); 27 }; 28 } 29 30 function highlightKeyword(text, keyword) { 31 if (!keyword) { return Drupal.checkPlain(text); } 32 // Highlight each word independently so a title that only partially matches 33 // the query (the other words matched in body/summary) still shows the 34 // words that ARE present highlighted, and non-matching titles show as-is. 35 var result = Drupal.checkPlain(text); 36 keyword.trim().split(/\s+/).forEach(function (word) { 37 if (!word) { return; } 38 var escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 39 result = result.replace(new RegExp('(' + escaped + ')', 'gi'), '<mark>$1</mark>'); 40 }); 41 return result; 42 } 43 44 // =========================================================================== 45 // Behavior 1: Overlay block 46 // =========================================================================== 47 48 Drupal.behaviors.ssautoOverlay = { 49 attach: function attach(context, settings) { 50 var autocompleteUrl = (settings.ssauto && settings.ssauto.autocompleteUrl) || '/api/ssauto/autocomplete'; 51 var searchPageUrl = (settings.ssauto && settings.ssauto.searchPageUrl) || '/smart-search'; 52 53 once('ssauto-overlay', '.ssauto-wrapper', context).forEach(function (wrapper) { 54 var $wrapper = $(wrapper); 55 var $trigger = $wrapper.find('.ssauto-trigger'); 56 var $overlay = $wrapper.find('.ssauto-overlay'); 57 var $backdrop = $wrapper.find('.ssauto-backdrop'); 58 var $closeBtn = $wrapper.find('.ssauto-close'); 59 var $input = $wrapper.find('.ssauto-input'); 60 var $submitBtn = $wrapper.find('.ssauto-submit'); 61 var $suggestions = $wrapper.find('.ssauto-suggestions'); 62 63 // Apply theme class before teleporting so the modifier is present 64 // when the overlay is first painted (avoids a flash of wrong theme). 65 var theme = (settings.ssauto && settings.ssauto.theme) || 'dark'; 66 $overlay.toggleClass('ssauto-overlay--light', theme === 'light'); 67 68 // Teleport overlay + backdrop to <body> so they escape any parent 69 // CSS transform / overflow that would clip position:fixed children. 70 $('body').append($backdrop).append($overlay); 71 72 var isOpen = false; 73 var activeIndex = -1; 74 var currentItems = []; 75 var debounceTimer = null; 76 var fetch = makeFetcher(autocompleteUrl); 77 78 // Keep overlay top aligned with Drupal toolbar (displace API). 79 function updateOverlayTop() { 80 var offset = parseInt($('body').css('padding-top') || 0, 10); 81 $overlay.css('top', offset > 0 ? offset + 'px' : ''); 82 } 83 updateOverlayTop(); 84 $(document).on('drupalViewportOffsetChange.ssauto', updateOverlayTop); 85 86 // ----------------------------------------------------------------------- 87 // Open / close 88 // ----------------------------------------------------------------------- 89 function openOverlay() { 90 updateOverlayTop(); 91 isOpen = true; 92 $overlay.removeAttr('hidden').addClass('is-open'); 93 $trigger.addClass('is-active').attr('aria-expanded', 'true'); 94 $('body').addClass('ssauto-active'); 95 $input.trigger('focus'); 96 } 97 98 function closeOverlay() { 99 isOpen = false;
100 $overlay.removeClass('is-open'); 101 $trigger.removeClass('is-active').attr('aria-expanded', 'false'); 102 $('body').removeClass('ssauto-active'); 103 $input.val(''); 104 clearSuggestions(); 105 setTimeout(function () { if (!isOpen) { $overlay.attr('hidden', ''); } }, 560); 106 } 107 108 // ----------------------------------------------------------------------- 109 // Suggestions 110 // ----------------------------------------------------------------------- 111 function clearSuggestions() { 112 $suggestions.empty(); 113 activeIndex = -1; 114 currentItems = []; 115 $input.attr('aria-activedescendant', ''); 116 } 117 118 function renderSuggestions(items, keyword) { 119 clearSuggestions(); 120 if (!items.length) { return; } 121 currentItems = items; 122 var fragment = document.createDocumentFragment(); 123 items.forEach(function (item, idx) { 124 var li = document.createElement('li'); 125 li.className = 'ssauto-suggestion'; 126 li.setAttribute('role', 'option'); 127 li.setAttribute('id', 'ssauto-option-' + idx); 128 li.setAttribute('aria-selected', 'false'); 129 li.innerHTML = 130 '<span class="ssauto-suggestion__body">' + 131 '<span class="ssauto-suggestion__text">' + highlightKeyword(item.label, keyword) + '</span>' + 132 (item.date ? '<span class="ssauto-suggestion__date">' + Drupal.checkPlain(item.date) + '</span>' : '') + 133 '</span>' + 134 '<svg class="ssauto-suggestion__arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 135 '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>' + 136 '</svg>'; 137 li.addEventListener('click', function () { window.location.href = item.url; }); 138 fragment.appendChild(li); 139 }); 140 $suggestions[0].appendChild(fragment); 141 } 142 143 function showLoading() { 144 $suggestions.html( 145 '<li class="ssauto-loading" aria-live="polite">' + 146 '<span class="ssauto-loading__dot"></span>' + 147 '<span class="ssauto-loading__dot"></span>' + 148 '<span class="ssauto-loading__dot"></span>' + 149 '</li>' 150 ); 151 } 152 153 // ----------------------------------------------------------------------- 154 // Keyboard navigation 155 // ----------------------------------------------------------------------- 156 function setActiveIndex(idx) { 157 var $items = $suggestions.find('.ssauto-suggestion'); 158 $items.removeClass('is-active').attr('aria-selected', 'false'); 159 if (idx < 0 || idx >= $items.length) { 160 activeIndex = -1; 161 $input.attr('aria-activedescendant', ''); 162 return; 163 } 164 activeIndex = idx; 165 $items.eq(idx).addClass('is-active').attr('aria-selected', 'true'); 166 $input.attr('aria-activedescendant', 'ssauto-option-' + idx); 167 if (currentItems[idx]) { $input.val(currentItems[idx].value); } 168 } 169 170 // ----------------------------------------------------------------------- 171 // Events 172 // ----------------------------------------------------------------------- 173 $trigger.on('click', function (e) { e.stopPropagation(); isOpen ? closeOverlay() : openOverlay(); }); 174 $closeBtn.on('click', closeOverlay); 175 $backdrop.on('click', closeOverlay); 176 177 $(document).on('keydown.ssauto', function (e) { if (e.key === 'Escape' && isOpen) { closeOverlay(); } }); 178 179 $input.on('input', function () { 180 clearTimeout(debounceTimer); 181 var keyword = $input.val().trim(); 182 if (!keyword) { clearSuggestions(); return; } 183 debounceTimer = setTimeout(function () { 184 showLoading(); 185 fetch(keyword, function (data) { renderSuggestions(data, keyword); }, clearSuggestions); 186 }, 250); 187 }); 188 189 $input.on('keydown', function (e) { 190 var $items = $suggestions.find('.ssauto-suggestion'); 191 var count = $items.length; 192 switch (e.key) { 193 case 'ArrowDown': e.preventDefault(); setActiveIndex(activeIndex < count - 1 ? activeIndex + 1 : 0); break; 194 case 'ArrowUp': e.preventDefault(); setActiveIndex(activeIndex > 0 ? activeIndex - 1 : count - 1); break; 195 case 'Enter': 196 e.preventDefault(); 197 if (activeIndex >= 0 && currentItems[activeIndex]) { 198 window.location.href = currentItems[activeIndex].url; 199 } else { 200 var q = $input.val().trim(); 201 if (q) { window.location.href = searchPageUrl + '?q=' + encodeURIComponent(q); } 202 } 203 break; 204 case 'Escape': closeOverlay(); break; 205 } 206 }); 207 208 $submitBtn.on('click', function () { 209 var q = $input.val().trim(); 210 if (q) { window.location.href = searchPageUrl + '?q=' + encodeURIComponent(q); } 211 }); 212 213 // ----------------------------------------------------------------------- 214 // ----------------------------------------------------------------------- 215 // Theme-nav integration â single click to open overlay. 216 // 217 // Problem: many themes place this block inside a collapsed
217search drawer 218 // (display:none / height:0). The user's first click hits the theme's own 219 // toggle icon (outside our block), which expands the drawer. Only then 220 // can they reach our trigger â resulting in 2 clicks to open search. 221 // 222 // Solution (requires no theme class names): 223 // 1. Walk up from .ssauto-wrapper to find the nearest hidden ancestor. 224 // 2. Hide our trigger (it is trapped inside the collapsed container). 225 // 3. Intercept ANY click on the *visible* sibling area of that ancestor 226 // (i.e. the theme's toggle icon) and open our overlay directly â so 227 // the very first click on the theme's icon opens the ssauto overlay. 228 // ----------------------------------------------------------------------- 229 (function () { 230 // Detect hidden/collapsed elements regardless of which CSS technique the 231 // theme uses: display:none, visibility:hidden, height/offsetHeight=0, 232 // opacity:0, OR pointer-events:none (common in modern slide-in drawers). 233 function isCollapsed(el) { 234 if (!el || el === document.documentElement) { return false; } 235 var s = window.getComputedStyle(el); 236 return s.display === 'none' 237 || s.visibility === 'hidden' 238 || el.offsetHeight === 0 239 || parseFloat(s.opacity) === 0 240 || s.pointerEvents === 'none'; 241 } 242 243 // Walk up from the wrapper to find the nearest hidden ancestor. 244 var hiddenAncestor = null; 245 var node = wrapper.parentNode; 246 while (node && node !== document.body) { 247 if (isCollapsed(node)) { hiddenAncestor = node; break; } 248 node = node.parentNode; 249 } 250 251 if (!hiddenAncestor) { 252 // Wrapper is directly visible (e.g. placed in a primary-nav region). 253 // Drupal renders blocks as <div> (block-level), which pushes them to 254 // a new row below menu items. Add a class so CSS can flip the whole 255 // block chain to inline-flex and align the trigger with nav items. 256 $wrapper.closest('.block').addClass('ssauto-block--inline'); 257 return; 258 } 259 260 // ----------------------------------------------------------------------- 261 // Determine whether hiddenAncestor is a SEARCH drawer or a NAV drawer. 262 // 263 // Search drawer (e.g. thex "Search Box" region): 264 // Class name contains "search". We hide the trigger and intercept the 265 // visible toggle's click â opening our overlay in one click. 266 // 267 // Nav / menu drawer (e.g. thex mobile primary-menu-wrapper): 268 // Class name does NOT contain "search". We leave the trigger visible 269 // inside the drawer. When the user opens the mobile menu they see the 270 // trigger and tap it â two taps total (hamburger + trigger), which is 271 // the normal UX for in-menu search. We must NOT intercept the hamburger 272 // click here or the overlay would open on every menu-open action. 273 // ----------------------------------------------------------------------- 274 var ancestorCls = (hiddenAncestor.className || '').toLowerCase(); 275 var isSearchDrawer = /search/.test(ancestorCls); 276 277 if (!isSearchDrawer) { 278 // Nav/menu drawer â reposition the trigger responsively. 279 // 280 // Mobile (â¤991px): 281 // Insert trigger BEFORE the mobile-menu toggle div (.mobile-menu) 282 // inside its parent (.header-right, which is display:flex). 283 // The trigger appears as a flex sibling of the hamburger icon, 284 // visible in the header without opening the mobile menu first. 285 // 286 // Desktop (â¥992px): 287 // Move trigger back into the block wrapper and activate 288 // inline-nav mode so it sits on the primary-nav row. 289 var $blockEl = $wrapper.closest('.block'); 290 var $mobileToggle = $(hiddenAncestor.parentNode); // .mobile-menu 291 var mql = window.matchMedia('(max-width: 991px)'); 292 var bpTimer; 293 294 function placeByBreakpoint() { 295 if (mql.matches) { 296 // Mobile: insert trigger immediately before .mobile-menu. 297 // margin-left:auto consumes all remaining flex space to the 298 // left, pushing the trigger (and the hamburger after it) to 299 // the RIGHT end of .header-right â keeping them adjacent 300 // regardless of the container's justify-content value. 301 $mobileToggle.before( 302 $trigger.detach().show().css('margin-left', 'auto') 303 ); 304 $blockEl.removeClass('ssauto-block--inline'); 305 } else { 306 // Desktop: trigger returns into .ssauto-wrapper for inline mode. 307 $wrapper.append( 308 $trigger.detach().show().css('margin-left', '') 309 ); 310 $blockEl.addClass('ssauto-block--inline'); 311 } 312 } 313 314 mql.addEventListener('change', function () { 315 clearTimeout(bpTimer); 316 bpTimer = setTimeout(placeByBreakpoint, 80); 317 }); 318 placeByBreakpoint(); // run on attach 319 return; 320 } 321 322 // Search drawer: hide trigger (trapped behind the theme's toggle icon) 323 // and intercept that icon's click to open our overlay directly. 324 $trigger.hide(); 325 326 // Use capture phase so we fire before any stopPropagation() in the 327 // theme's own bubble-phase handler. 328 hiddenAncestor.parentNode.addEventListener('click', function (e) { 329 // Ignore clicks that come from inside the collapsed container. 330 if ($(e.target).closest(hiddenAncestor).length) { return; } 331 // Ignore our own overlay / backdrop (teleported to body level). 332 if ($(e.target).closest('.ssauto-overlay, .ssauto-backdrop').length) { return; } 333 isOpen ? closeOverlay() : openOverlay(); 334 }, true /* capture: fires before theme's stopPropagation */); 335 }()); 336 337 }); 338 }, 339 }; 340 341 // =========================================================================== 342 // Behavior 2: Autocomplete on /smart-search results page input 343 // =========================================================================== 344 345 Drupal.behaviors.ssautoResultsAutocomplete = { 346 attach: function attach(context, settings) { 347 var autocompleteUrl = (settings.ssauto && settings.ssauto.autocompleteUrl) || '/api/ssauto/autocomplete'; 348 349 once('ssauto-results-ac', '.ssauto-results-form__input', context).forEach(function (input) { 350 var $input = $(input); 351 var $row = $input.closest('.ssauto-results-form__row'); 352 // Append dropdown inside the row so it can use position:absolute top:100%. 353 var $dropdown = $('<ul class="ssauto-results-ac-dropdown" role="listbox"></ul>'); 354 $row.css('position', 'relative').append($dropdown); 355 var debounceTimer = null; 356 var fetch = makeFetcher(autocompleteUrl); 357 358 function clearDropdown() { $dropdown.empty().hide(); } 359 360 function renderDropdown(items) { 361 clearDropdown(); 362 if (!items.length) { return; }
363 items.forEach(function (item) { 364 var $li = $('<li class="ssauto-results-ac-item" role="option" tabindex="-1"></li>'); 365 $li.html( 366 '<span class="ssauto-results-ac-item__label">' + Drupal.checkPlain(item.label) + '</span>' + 367 (item.date ? '<span class="ssauto-results-ac-item__date">' + Drupal.checkPlain(item.date) + '</span>' : '') 368 ); 369 $li.on('mousedown', function (e) { 370 e.preventDefault(); // prevent input blur before click 371 window.location.href = item.url; 372 }); 373 $dropdown.append($li); 374 }); 375 $dropdown.show(); 376 } 377 378 $input.on('input', function () { 379 clearTimeout(debounceTimer); 380 var keyword = $input.val().trim(); 381 if (!keyword) { clearDropdown(); return; } 382 debounceTimer = setTimeout(function () { 383 fetch(keyword, renderDropdown, clearDropdown); 384 }, 250); 385 }); 386 387 $input.on('blur', function () { 388 setTimeout(clearDropdown, 150); 389 }); 390 391 $input.on('keydown', function (e) { 392 if (e.key === 'Escape') { clearDropdown(); } 393 }); 394 }); 395 }, 396 }; 397 398}(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.