1/** 2 * Audiobook search â front-end enhancement for the learningally/audiobook-search block. 3 * 4 * Two independent enhancers, both no-jQuery and progressive: 5 * 6 * 1. AudiobookSearch â upgrades [data-la-audiobook-search] with live filtering 7 * and AJAX "Load more" pagination via the `la_load_books` endpoint. 8 * Generic over filters: any control with [data-la-filter="<key>"] inside the 9 * widget is sent as that filter, so new filters need no JS changes. 10 * 11 * 2. BuildingsFinder â turns any <input data-la-buildings> into an accessible 12 * combobox backed by the `la_get_buildings` school finder. Opt-in: no markup 13 * uses it yet, but the endpoint is wired and ready. 14 * 15 * Config (ajaxUrl + i18n) is localized as window.LA_AUDIOBOOK_SEARCH. The endpoints 16 * are public reads of public catalog data, so there is no nonce (nonces are 17 * per-user and break under full-page caching). 18 */ 19(function () { 20 'use strict'; 21 22 var CFG = window.LA_AUDIOBOOK_SEARCH || {}; 23 var AJAX = CFG.ajaxUrl || window.ajaxurl || ''; 24 var I18N = CFG.i18n || {}; 25 var uidSeq = 0; 26 27 function uid(prefix) { 28 uidSeq += 1; 29 return prefix + uidSeq; 30 } 31 32 function fmt(tpl, n) { 33 return String(tpl || '%d').replace('%d', n); 34 } 35 36 function toArray(nodeList) { 37 return Array.prototype.slice.call(nodeList); 38 } 39 40 function ready(fn) { 41 if (document.readyState !== 'loading') { 42 fn(); 43 } else { 44 document.addEventListener('DOMContentLoaded', fn); 45 } 46 } 47 48 // âââââââââââââââââââââââââââââââââââââââââââââ AudiobookSearch ââ 49 50 function AudiobookSearch(root) { 51 this.root = root; 52 this.form = root.querySelector('.la-audiobook-search__form'); 53 this.queryInput = root.querySelector('[name="q"]'); 54 this.results = root.querySelector('.la-audiobook-search__results'); 55 this.count = root.querySelector('.la-audiobook-search__count'); 56 this.status = root.querySelector('.la-audiobook-search__status'); 57 this.empty = root.querySelector('.la-audiobook-search__empty'); 58 this.featured = root.querySelector('[data-la-featured]'); 59 this.errorRegion = root.querySelector('[data-la-error]'); 60 this.loadMore = root.querySelector('.la-audiobook-search__load-more'); 61 this.page = parseInt(root.getAttribute('data-page'), 10) || 1; 62 this.maxPages = parseInt(root.getAttribute('data-max-pages'), 10) || 0; 63 this.busy = false; 64 65 if (this.form && this.results) { 66 this.bind(); 67 } 68 } 69 70 AudiobookSearch.prototype.bind = function () { 71 var self = this; 72 73 this.form.addEventListener('submit', function (e) { 74 // An empty query is legitimate here â it's how you browse the whole 75 // catalog, or a category/grade range on its own. 76 e.preventDefault(); 77 self.run(1, false); 78 }); 79 80 this.filterControls().forEach(function (ctrl) { 81 ctrl.addEventListener('change', function () { 82 self.run(1, false); 83 }); 84 }); 85 86 87 if (this.loadMore) { 88 this.loadMore.addEventListener('click', function () { 89 self.run(self.page + 1, true); 90 }); 91 } 92 93 94 this.updateLoadMore(); 95 }; 96 97 AudiobookSearch.prototype.filterControls = function () { 98 return toArray(this.root.querySelectorAll('[data-la-filter]')); 99 }; 100 101 // "Clear grades" is only meaningful while a grade bound is set. 102 103 AudiobookSearch.prototype.collectArgs = function (page) { 104 var args = { q: this.queryInput ? this.queryInput.value : '', page: page }; 105 this.filterControls().forEach(function (ctrl) { 106 var key = ctrl.getAttribute('data-la-filter'); 107 if (key && ctrl.value !== '') { 108 args[key] = ctrl.value; 109 } 110 }); 111 return args; 112 }; 113 114 AudiobookSearch.prototype.run = function (page, append) { 115 if (this.busy || !AJAX) { 116 return; 117 } 118 this.busy = true; 119 this.setLoading(true); 120 121 // Real results replace the default featured covers. 122 if (this.featured) { 123 this.featured.hidden = true; 124 } 125 126 var args = this.collectArgs(page); 127 var body = new URLSearchParams(); 128 body.append('action', 'la_load_books'); 129 Object.keys(args).forEach(function (key) { 130 body.append('args[' + key + ']', args[key]); 131 }); 132 133 var self = this; 134 fetch(AJAX, { 135 method: 'POST', 136 headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, 137 body: body.toString(), 138 credentials: 'same-origin' 139 }) 140 .then(function (resp) { return resp.json(); }) 141 .then(function (json) { 142 if (!json || !json.success || !json.data) { 143 throw new Error('Unexpected response'); 144 } 145 self.render(json.data, append); 146 }) 147 .catch(function () { 148 // Errors are urgent â announce assertively, not via the polite count. 149 if (self.errorRegion) { 150 self.errorRegion.textContent = I18N.error || 'Something went wrong.'; 151 } 152 }) 153 .then(function () { 154 self.busy = false;
155 self.setLoading(false); 156 }); 157 }; 158 159 AudiobookSearch.prototype.render = function (data, append) { 160 // A successful response clears any prior error. 161 if (this.errorRegion) { 162 this.errorRegion.textContent = ''; 163 } 164 this.page = parseInt(data.page, 10) || 1; 165 this.maxPages = parseInt(data.max_num_pages, 10) || 0; 166 var html = data.html || ''; 167 var total = parseInt(data.total, 10) || 0; 168 169 var firstNew = null; 170 if (append) { 171 var startIndex = this.results.children.length; 172 this.results.insertAdjacentHTML('beforeend', html); 173 firstNew = this.results.children[startIndex] || null; 174 } else { 175 this.results.innerHTML = html; 176 } 177 178 if (this.count) { 179 if (total === 0) { 180 this.count.textContent = I18N.noResults || fmt(I18N.foundMany, 0); 181 } else { 182 this.count.textContent = total === 1 183 ? fmt(I18N.foundOne, total) 184 : fmt(I18N.foundMany, total); 185 } 186 } 187 188 // Toggle the no-results message (replace mode only). 189 if (!append && this.empty) { 190 this.empty.hidden = total > 0; 191 } 192 193 this.setStatus(''); 194 this.updateLoadMore(); 195 196 // After "Load more", move focus to the first new result so keyboard/AT 197 // users land on the content they just requested (not the aria-hidden status). 198 if (append && firstNew) { 199 firstNew.setAttribute('tabindex', '-1'); 200 firstNew.focus(); 201 } 202 }; 203 204 AudiobookSearch.prototype.updateLoadMore = function () { 205 if (!this.loadMore) { 206 return; 207 } 208 var more = this.maxPages > this.page; 209 this.loadMore.hidden = !more; 210 this.loadMore.disabled = !more; 211 }; 212 213 AudiobookSearch.prototype.setLoading = function (on) { 214 this.root.setAttribute('aria-busy', on ? 'true' : 'false'); 215 if (on) { 216 this.setStatus(I18N.loading || ''); 217 if (this.loadMore) { 218 this.loadMore.disabled = true; 219 } 220 } else { 221 this.updateLoadMore(); 222 } 223 }; 224 225 AudiobookSearch.prototype.setStatus = function (text) { 226 if (this.status) { 227 this.status.textContent = text; 228 } 229 }; 230 231 // âââââââââââââââââââââââââââââââââââââââââââââ BuildingsFinder ââ 232 233 function BuildingsFinder(input) { 234 this.input = input; 235 this.min = parseInt(input.getAttribute('data-la-buildings-min'), 10) || 2; 236 this.target = null; 237 var targetSel = input.getAttribute('data-la-buildings-target'); 238 if (targetSel) { 239 this.target = document.querySelector(targetSel); 240 } 241 242 this.listbox = document.createElement('ul'); 243 this.listbox.className = 'la-audiobook-search__listbox'; 244 this.listbox.id = uid('la-buildings-list-'); 245 this.listbox.setAttribute('role', 'listbox'); 246 this.listbox.hidden = true; 247 248 input.setAttribute('role', 'combobox'); 249 input.setAttribute('aria-autocomplete', 'list'); 250 input.setAttribute('aria-expanded', 'false'); 251 input.setAttribute('aria-controls', this.listbox.id); 252 input.setAttribute('autocomplete', 'off'); 253 254 var wrap = input.parentNode; 255 wrap.classList.add('la-has-combobox'); 256 wrap.insertBefore(this.listbox, input.nextSibling); 257 258 this.rows = []; 259 this.active = -1; 260 this.debounce = null; 261 this.bind(); 262 } 263 264 BuildingsFinder.prototype.bind = function () { 265 var self = this; 266 267 this.input.addEventListener('input', function () { 268 window.clearTimeout(self.debounce); 269 var value = self.input.value.trim(); 270 if (value.length < self.min) { 271 self.close(); 272 return; 273 } 274 self.debounce = window.setTimeout(function () { self.fetch(value); }, 250); 275 }); 276 277 this.input.addEventListener('keydown', function (e) { 278 if (self.listbox.hidden) { 279 return; 280 } 281 if (e.key === 'ArrowDown') { 282 e.preventDefault(); 283 self.move(1); 284 } else if (e.key === 'ArrowUp') { 285 e.preventDefault(); 286 self.move(-1); 287 } else if (e.key === 'Enter') { 288 if (self.active >= 0) { 289 e.preventDefault(); 290 self.select(self.active); 291 } 292 } else if (e.key === 'Escape') { 293 self.close(); 294 } 295 }); 296 297 this.input.addEventListener('blur', function () { 298 // Delay so a click on an option registers before the list closes. 299 window.setTimeout(function () { self.close(); }, 150); 300 }); 301 }; 302 303 BuildingsFinder.prototype.fetch = function (value) { 304 if (!AJAX) { 305 return; 306 } 307 var url = AJAX + '?action=la_get_buildings&q=' + encodeURIComponent(value); 308 var self = this; 309 fetch(url, { credentials: 'same-origin' }) 310 .then(function (resp) { return resp.json(); }) 311 .then(function (rows) { 312 self.rows = Array.isArray(rows) ? rows : []; 313 self.renderOptions(); 314 }) 315 .catch(function () { self.close(); }); 316 }; 317 318 BuildingsFinder.prototype.label = function (row) { 319 var name = row.name || row.label || row.Name || ''; 320 var parts = [row.city || row.City, row.state || row.State].filter(
320Boolean); 321 return parts.length ? name + ' â ' + parts.join(', ') : name; 322 }; 323 324 BuildingsFinder.prototype.renderOptions = function () { 325 var self = this; 326 this.listbox.innerHTML = ''; 327 this.active = -1; 328 329 if (this.rows.length === 0) { 330 this.close(); 331 return; 332 } 333 334 this.rows.forEach(function (row, i) { 335 var li = document.createElement('li'); 336 li.id = self.listbox.id + '-opt-' + i; 337 li.setAttribute('role', 'option'); 338 li.setAttribute('aria-selected', 'false'); 339 li.textContent = self.label(row); 340 li.addEventListener('mousedown', function (e) { 341 // mousedown (not click) so it fires before the input's blur. 342 e.preventDefault(); 343 self.select(i); 344 }); 345 self.listbox.appendChild(li); 346 }); 347 348 this.listbox.hidden = false; 349 this.input.setAttribute('aria-expanded', 'true'); 350 }; 351 352 BuildingsFinder.prototype.move = function (delta) { 353 var options = this.listbox.children; 354 if (!options.length) { 355 return; 356 } 357 if (this.active >= 0) { 358 options[this.active].classList.remove('is-active'); 359 options[this.active].setAttribute('aria-selected', 'false'); 360 } 361 this.active = (this.active + delta + options.length) % options.length; 362 var opt = options[this.active]; 363 opt.classList.add('is-active'); 364 opt.setAttribute('aria-selected', 'true'); 365 this.input.setAttribute('aria-activedescendant', opt.id); 366 opt.scrollIntoView({ block: 'nearest' }); 367 }; 368 369 BuildingsFinder.prototype.select = function (index) { 370 var row = this.rows[index]; 371 if (!row) { 372 return; 373 } 374 this.input.value = this.label(row); 375 if (this.target) { 376 this.target.value = row.id || row.Id || row.ID || ''; 377 } 378 this.close(); 379 }; 380 381 BuildingsFinder.prototype.close = function () { 382 this.listbox.hidden = true; 383 this.active = -1; 384 this.input.setAttribute('aria-expanded', 'false'); 385 this.input.removeAttribute('aria-activedescendant'); 386 }; 387 388 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââ boot ââ 389 390 ready(function () { 391 toArray(document.querySelectorAll('[data-la-audiobook-search]')).forEach(function (el) { 392 new AudiobookSearch(el); 393 }); 394 toArray(document.querySelectorAll('input[data-la-buildings]')).forEach(function (el) { 395 new BuildingsFinder(el); 396 }); 397 }); 398})();
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.