PageSourceSearch

https://umbc.edu/wp-content/themes/umbc-theme-2.0/template-parts/search/quick-search.js?ver=1790186983

js umbc.edu collected 2026-09-24 07:39:34 UTC 16,940 bytes, 395 lines download raw bytes

1/**
2 * UMBC Quick Search Widget
3 *
4 * Keyword-routing layer: maps typed terms to specific pages via a lookup table
5 * supplied by PHP (quickSearchRoutes global). Falls through to the full UMBC
6 * Google Search when no route matches or the user explicitly requests it.
7 *
8 * Ranking (highest → lowest):
9 *   1. Manual priority field (lower number = higher rank, 0/empty = lowest)
10 *   2. Exact alias match
11 *   3. Alias starts-with query
12 *   4. Alias contains query
13 *
14 * Accessibility: WCAG 1.3.1, 2.1.1, 2.1.2, 4.1.2
15 *   - role="combobox" on the input wrapper
16 *   - role="listbox" on the dropdown
17 *   - role="option" on each item with unique IDs
18 *   - aria-activedescendant tracks the focused option
19 *   - aria-expanded reflects open/closed state
20 *   - aria-live="polite" region announces result count to screen readers
21 *   - Escape closes, arrows move focus, Enter/click navigates, Tab closes
22 */
23(function () {
24  'use strict';
25
26  var FULL_SEARCH_BASE = '/search/';
27  var MAX_RESULTS = 5;
28  var DEBOUNCE_MS = 200;
29
30  // type → inline SVG [viewBox, path]. Font Awesome Free 6.7.2 solid artwork
31  // embedded directly so icons render without the FA kit script (which ad
32  // blockers commonly block). Mirror of umbc_type_icon_svg() in
33  // quick-search.php; keep the two in sync.
34  var ICON_SVGS = {
35    college: ['0 0 512 512', 'M243.4 2.6l-224 96c-14 6-21.8 21-18.7 35.8S16.8 160 32 160l0 8c0 13.3 10.7 24 24 24l400 0c13.3 0 24-10.7 24-24l0-8c15.2 0 28.3-10.7 31.3-25.6s-4.8-29.9-18.7-35.8l-224-96c-8-3.4-17.2-3.4-25.2 0zM128 224l-64 0 0 196.3c-.6 .3-1.2 .7-1.8 1.1l-48 32c-11.7 7.8-17 22.4-12.9 35.9S17.9 512 32 512l448 0c14.1 0 26.5-9.2 30.6-22.7s-1.1-28.1-12.9-35.9l-48-32c-.6-.4-1.2-.7-1.8-1.1L448 224l-64 0 0 192-40 0 0-192-64 0 0 192-48 0 0-192-64 0 0 192-40 0 0-192zM256 64a32 32 0 1 1 0 64 32 32 0 1 1 0-64z'],
36    department: ['0 0 576 512', 'M208 80c0-26.5 21.5-48 48-48l64 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-8 0 0 40 152 0c30.9 0 56 25.1 56 56l0 32 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-32c0-4.4-3.6-8-8-8l-152 0 0 40 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-40-152 0c-4.4 0-8 3.6-8 8l0 32 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-32c0-30.9 25.1-56 56-56l152 0 0-40-8 0c-26.5 0-48-21.5-48-48l0-64z'],
37    program: ['0 0 640 512', 'M320 32c-8.1 0-16.1 1.4-23.7 4.1L15.8 137.4C6.3 140.9 0 149.9 0 160s6.3 19.1 15.8 22.6l57.9 20.9C57.3 229.3 48 259.8 48 291.9l0 28.1c0 28.4-10.8 57.7-22.3 80.8c-6.5 13-13.9 25.8-22.5 37.6C0 442.7-.9 448.3 .9 453.4s6 8.9 11.2 10.2l64 16c4.2 1.1 8.7 .3 12.4-2s6.3-6.1 7.1-10.4c8.6-42.8 4.3-81.2-2.1-108.7C90.3 344.3 86 329.8 80 316.5l0-24.6c0-30.2 10.2-58.7 27.9-81.5c12.9-15.5 29.6-28 49.2-35.7l157-61.7c8.2-3.2 17.5 .8 20.7 9s-.8 17.5-9 20.7l-157 61.7c-12.4 4.9-23.3 12.4-32.2 21.6l159.6 57.6c7.6 2.7 15.6 4.1 23.7 4.1s16.1-1.4 23.7-4.1L624.2 182.6c9.5-3.4 15.8-12.5 15.8-22.6s-6.3-19.1-15.8-22.6L343.7 36.1C336.1 33.4 328.1 32 320 32zM128 408c0 35.3 86 72 192 72s192-36.7 192-72L496.7 262.6 354.5 314c-11.1 4-22.8 6-34.5 6s-23.5-2-34.5-6L143.3 262.6 128 408z'],
38    page: ['0 0 384 512', 'M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM112 256l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z'],
39    search: ['0 0 512 512', 'M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z']
40  };
41  // Spoken type prefix for screen readers (the icon is decorative).
42  var TYPE_LABELS = {
43    college: 'College or school',
44    department: 'Department',
45    program: 'Program',
46    page: 'Page'
47  };
48
49  function iconHtml(type) {
50    var icon = ICON_SVGS[type] || ICON_SVGS.page;
51    return '<svg class="qs-option-icon" viewBox="' + icon[0] + '" fill="currentColor"' +
52      ' aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">' +
53      '<path d="' + icon[1] + '"/></svg>';
54  }
55
56  /* Detect context-specific refinement tab from current URL path */
57  function getSearchRef() {
58    var path = window.location.pathname;
59    if (path.indexOf('/stories/') !== -1 || path.endsWith('/stories')) return 'stories';
60    if (path.indexOf('/events/') !== -1 || path.endsWith('/events')) return 'events';
61    return null;
62  }
63
64  /* ------------------------------------------------------------------
65   * Scoring — returns a numeric rank for a single route against a query.
66   * Lower score = higher priority in results list.
67   * Returns null if the route does not match at all.
68   * ------------------------------------------------------------------ */
69  function scoreRoute(route, query) {
70    var aliases = route.aliases || [];
71    var bestMatch = null; // null | 'exact' | 'starts' | 'contains'
72
73    for (var i = 0; i < aliases.length; i++) {
74      var alias = aliases[i].toLowerCase().trim();
75      if (alias === query) {
76        bestMatch = 'exact';
77        break; // can't do better
78      }
79      if (alias.indexOf(query) === 0) {
80        if (bestMatch !== 'exact') bestMatch = 'starts';
81      } else if (alias.indexOf(query) !== -1) {
82        if (bestMatch !== 'exact' && bestMatch !== 'starts') bestMatch = 'contains';
83      }
84    }
85
86    if (bestMatch === null) return null;
87
88    // Base score by match type
89    var matchScore = bestMatch === 'exact' ? 0 : bestMatch === 'starts' ? 1 : 2;
90
91    // Priority field: lower number wins. Missing/0 treated as 999 (lowest).
92    var priority = parseInt(route.priority, 10);
93    if (isNaN(priority) || priority <= 0) priority = 999;
94
95    // Combined score: priority is primary sort, match type breaks ties.
96    // Multiply priority by 10 so match type only matters within same priority band.
97    return priority * 10 + matchScore;
98  }
99
100  /* ------------------------------------------------------------------
101   * Filter and rank routes against a query string.
102   * ------------------------------------------------------------------ */
103  function getMatches(query) {
104    if (!query || !window.quickSearchRoutes) return [];
105
106    var scored = [];
107    var routes = window.quickSearchRoutes;
108
109    for (var i = 0; i < routes.length; i++) {
110      var s = scoreRoute(routes[i], query);
111      if (s !== null) {
112        scored.push({ route: routes[i], score: s });
113      }
114    }
115
116    scored.sort(function (a, b) { return a.score - b.score; });
117    return scored.slice(0, MAX_RESULTS).map(function (s) { return s.route; });
118  }
119
120  /* ------------------------------------------------------------------
121   * Initialise a single quick-search widget instance.
122   * widgetEl: the containing element that holds the input and listbox.
123   * ------------------------------------------------------------------ */
124  function initWidget(widgetEl) {
125    var input     = widgetEl.querySelector('.qs-input');
126    var listbox   = widgetEl.querySelector('.qs-listbox');
127    var announcer = widgetEl.querySelector('.qs-announcer');
128
129    if (!input || !listbox || !announcer) return;
130
131    var debounceTimer   = null;
132    var noResultsTimer  = null; // settle timer for the zero-result content-gap signal
133    var lastNoResultTerm = null; // dedup: only report each zero-result term once
134    var activeIndex     = -1; // index of keyboard-highlighted option
135    var currentMatches  = [];
136    var widgetId       = input.id || ('qs-' + Math.random().toString(36).slice(2));
137
138    input.setAttribute('id', widgetId);
139    listbox.setAttribute('id', widgetId + '-listbox');
140    input.setAttribute('aria-controls', widgetId + '-listbox');
141
142    /* ---- helpers ---- */
143
144    function optionId(i) {
145      return widgetId + '-opt-' + i;
146    }
147
148    function closeListbox() {
149      listbox.innerHTML = '';
150      listbox.hidden = true;
151      input.setAttribute('aria-expanded', 'false');
152      input.removeAttribute('aria-activedescendant');
153      activeIndex = -1;
154      currentMatches = [];
155    }
156
157    function setActive(index) {
158      var items = listbox.querySelectorAll('[role="option"]');
159      items.forEach(function (el, i) {
160        var isActive = i === index;
161        el.setAttribute('aria-selected', isActive ? 'true' : 'false');
162        el.classList.toggle('qs-option--active', isActive);
163      });
164      if (index >= 0 && items[index]) {
165        input.setAttribute('aria-activedescendant', items[index].id);
166      } else {
167        input.removeAttribute('aria-activedescendant');
168      }
169      activeIndex = index;
170    }
171
172    // User picked an on-site route — the "kept on site" win.
173    // position = 1-based rank of the clicked result; count = on-site matches shown.
174    function navigate(url, query, label, position, count) {
175      window.dataLayer = window.dataLayer || [];
176      window.dataLayer.push({
177        event: 'quick_search_click',
178        search_term: query,
179        destination: url,
180        result_label: label,
181        result_position: position,
182        result_count: count
183      });
184      window.location.href = url;
185    }
186
187    // User went to the full GCSE search instead. count = on-site matches that
188    // WERE available when they chose Google — had_results separates "we had
189    // nothing" (content gap) from "we had matches but they still left".
190    function navigateFullSearch(query, count) {
191      window.dataLayer = window.dataLayer || [];
192      window.dataLayer.push({
193        event: 'quick_search_fallback',
194        search_term: query,
195        result_count: count,
196        had_results: count > 0
197      });
198      var ref = getSearchRef();
199      var url = FULL_SEARCH_BASE + (ref ? '?ref=' + ref : '') + '#gsc.q=' + encodeURIComponent(query);
200      window.location.href = url;
201    }
202
203    function buildListbox(matches, query) {
204      listbox.innerHTML = '';
205
206      // Content-gap signal: a search that yields zero on-site routes, reported
207      // once it settles (~1.2s after typing stops) and deduped per term — NOT
208      // on every keystroke — so we learn what people expect us to route without
209      // flooding GA4. Captures intent even if the user abandons without clicking.
210      clearTimeout(noResultsTimer);
211      if (matches.length === 0) {
212        noResultsTimer = setTimeout(function () {
213          if (lastNoResultTerm === query) return;            // already reported
214          if (input.value.toLowerCase().trim() !== query) return; // term changed
215          lastNoResultTerm = query;
216          window.dataLayer = window.dataLayer || [];
217          window.dataLayer.push({ event: 'quick_search_no_results', search_term: query });
218        }, 1200);
219      }
220
221      if (matches.length === 0) {
222        // Show a single "search all" option
223        var fallback = document.createElement('li');
224        fallback.setAttribute('role', 'option');
225        fallback.setAttribute('id', optionId(0));
226        fallback.setAttribute('aria-selected', 'false');
227        fallback.className = 'qs-option qs-option--fallback';
228        fallback.innerHTML =
229          iconHtml('search') +
230          '<span class="qs-option-text qs-option-fallback-text">Search all of UMBC for “' + escHtml(query) + '”</span>' +
231          '<span class="qs-option-arrow" aria-hidden="true">→</span>';
232        fallback.addEventListener('mousedown', function (e) {
233          e.preventDefault(); // prevent blur before click fires
234          navigateFullSearch(query, 0);
235        });
236        listbox.appendChild(fallback);
237        announcer.textContent = 'No direct matches. Press Enter to search all of UMBC.';
238      } else {
239        matches.forEach(function (route, i) {
240          var li = document.createElement('li');
241          li.setAttribute('role', 'option');
242          li.setAttribute('id', optionId(i));
243          li.setAttribute('aria-selected', 'false');
244          li.className = 'qs-option';
245          var type = route.type || 'page';
246          li.innerHTML =
247            iconHtml(type) +
248            '<span class="qs-option-text">' +
249              '<span class="qs-option-label">' +
250                '<span class="qs-option-type-sr">' + escHtml(TYPE_LABELS[type] || 'Page') + ': </span>' +
251                escHtml(route.label) +
252              '</span>' +
253              '</span>' +
254            '<span class="qs-option-arrow" aria-hidden="true">→</span>';
255          li.addEventListener('mousedown', function (e) {
256            e.preventDefault();
257            navigate(route.url, query, route.label, i + 1, matches.length);
258          });
259          listbox.appendChild(li);
260        });
261
262        // Always append a "full search" footer option
263        var footer = document.createElement('li');
264        footer.setAttribute('role', 'option');
265        footer.setAttribute('id', optionId(matches.length));
266        footer.setAttribute('aria-selected', 'false');
267        footer.className = 'qs-option qs-option--fallback';
268        footer.innerHTML =
269          iconHtml('search') +
270          '<span class="qs-option-text qs-option-fallback-text">Search all of UMBC for "' + escHtml(query) + '"</span>' +
271          '<span class="qs-option-arrow" aria-hidden="true">→</span>';
272        footer.addEventListener('mousedown', function (e) {
273          e.preventDefault();
274          navigateFullSearch(query, matches.length);
275        });
276        listbox.appendChild(footer);
277
278        var count = matches.length;
279        announcer.textContent =
280          count + (count === 1 ? ' result' : ' results') + ' for ' + query + '. Use arrow keys to navigate.';
281      }
282
283      listbox.hidden = false;
284      input.setAttribute('aria-expanded', 'true');
285
286      currentMatches = matches;
287      activeIndex = -1;
288    }
289
290    function escHtml(str) {
291      return String(str)
292        .replace(/&/g, '&amp;')
293        .replace(/</g, '&lt;')
294        .replace(/>/g, '&gt;')
295        .replace(/"/g, '&quot;');
296    }
297
298    /* ---- event listeners ---- */
299
300    input.addEventListener('input', function () {
301      clearTimeout(debounceTimer);
302      var query = input.value.toLowerCase().trim();
303
304      if (!query) {
305        closeListbox();
306        announcer.textContent = '';
307        return;
308      }
309
310      debounceTimer = setTimeout(function () {
311        var matches = getMatches(query);
312        buildListbox(matches, query);
313      }, DEBOUNCE_MS);
314    });
315
316    input.addEventListener('keydown', function (e) {
317      var query = input.value.toLowerCase().trim();
318      var items = listbox.querySelectorAll('[role="option"]');
319      var total = items.length; // includes fallback footer
320
321      if (listbox.hidden) {
322        // Open on arrow-down if closed
323        if (e.key === 'ArrowDown' && query) {
324          e.preventDefault();
325          var matches = getMatches(query);
326          buildListbox(matches, query);
327          setActive(0);
328        }
329        return;
330      }
331
332      switch (e.key) {
333        case 'ArrowDown':
334          e.preventDefault();
335          setActive(Math.min(activeIndex + 1, total - 1));
336          break;
337
338        case 'ArrowUp':
339          e.preventDefault();
340          if (activeIndex <= 0) {
341            setActive(-1); // return focus to input
342          } else {
343            setActive(activeIndex - 1);
344          }
345          break;
346
347        case 'Enter':
348          e.preventDefault();
349          if (activeIndex >= 0 && items[activeIndex]) {
350            items[activeIndex].dispatchEvent(new MouseEvent('mousedown'));
351          } else if (query) {
352            // No selection — fall through to full search
353            navigateFullSearch(query, currentMatches.length);
354          }
355          break;
356
357        case 'Escape':
358          e.preventDefault();
359          closeListbox();
360          announcer.textContent = 'Search suggestions closed.';
361          break;
362
363        case 'Tab':
364          closeListbox();
365          break;
366      }
367    });
368
369    input.addEventListener('blur', function () {
370      // Small delay so mousedown on an option fires before blur closes the list
371      setTimeout(closeListbox, 150);
372    });
373
374    // Close if user clicks outside the widget
375    document.addEventListener('click', function (e) {
376      if (!widgetEl.contains(e.target)) {
377        closeListbox();
378      }
379    });
380  }
381
382  /* ------------------------------------------------------------------
383   * Boot: initialise every .qs-widget on the page
384   * ------------------------------------------------------------------ */
385  function boot() {
386    var widgets = document.querySelectorAll('.qs-widget');
387    widgets.forEach(initWidget);
388  }
389
390  if (document.readyState === 'loading') {
391    document.addEventListener('DOMContentLoaded', boot);
392  } else {
393    boot();
394  }
395})();

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.