PageSourceSearch

https://poakpong.com/modules/custom/ssauto/js/ssauto.js?v=1.5.5

js poakpong.com collected 2026-10-02 10:12:51 UTC 18,573 bytes, 398 lines download raw bytes

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.