PageSourceSearch

https://learningally.org/wp-content/plugins/learningally-core/inc/blocks/audiobook-search/view.js?m=1786395992

js learningally.org collected 2026-09-24 08:52:11 UTC 11,764 bytes, 398 lines download raw bytes

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.