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, '&') 293 .replace(/</g, '<') 294 .replace(/>/g, '>') 295 .replace(/"/g, '"'); 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.