// Restore focus across the out-of-band sidebar swap. // // htmx does restore focus after a swap, but only for the element it saved // around the MAIN swap target. The sidebar is replaced out-of-band, so a // focused filter control is destroyed outside that window and focus falls // to
, which drops a keyboard user out of the sidebar on every // single filter change. Verified in the browser, not assumed. (function () { var pending = null; document.body.addEventListener('htmx:beforeRequest', function () { var el = document.activeElement; pending = el && el.id && el.closest('#discover-sidebar') ? el.id : null; }); document.body.addEventListener('htmx:afterSettle', function () { if (!pending) return; var el = document.getElementById(pending); pending = null; // Only if it actually went away and came back; a surviving element // kept its focus already. if (el && el !== document.activeElement) el.focus({ preventScroll: true }); }); })(); // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is // reachable by name from any depth without knowing where it sits. (function() { var input = document.getElementById('tag-search'); var list = document.getElementById('tag-suggest-list'); if (!input || !list) return; var timer = null; var active = -1; function close() { list.hidden = true; list.innerHTML = ''; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); active = -1; } // Focus stays in the input for a combobox; aria-activedescendant is what // tells assistive tech which option is current. Without it the listbox // role is a claim the widget does not honour. function setActive(items, index) { active = index; items.forEach(function(el, i) { var on = i === index; el.classList.toggle('highlighted', on); el.setAttribute('aria-selected', on ? 'true' : 'false'); }); if (index >= 0 && items[index]) { input.setAttribute('aria-activedescendant', items[index].id); } else { input.removeAttribute('aria-activedescendant'); } } function choose(slug) { // Adding a tag is the same operation the drill-down checkbox performs, // so route it through the form rather than a bespoke request. var form = document.getElementById('discover-form'); if (!form) return; var existing = form.querySelector('input[data-facet="tag"][value="' + CSS.escape(slug) + '"]'); if (!existing) { var el = document.createElement('input'); el.type = 'hidden'; el.name = 'tag'; el.value = slug; el.className = 'discover-filter'; el.dataset.facet = 'tag'; form.appendChild(el); } input.value = ''; close(); htmx.trigger(form, 'tag-added'); } function render(items) { if (!items.length) { close(); return; } list.innerHTML = ''; items.forEach(function(item, i) { var li = document.createElement('li'); li.id = 'tag-suggest-option-' + i; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', i === active ? 'true' : 'false'); li.className = 'suggestion-item tag-suggest-item' + (i === active ? ' highlighted' : ''); li.dataset.slug = item.slug; var label = document.createElement('span'); label.className = 'tag-suggest-label'; label.textContent = item.label; li.appendChild(label); if (item.context) { var ctx = document.createElement('span'); ctx.className = 'suggestion-category tag-suggest-context'; ctx.textContent = item.context; li.appendChild(ctx); } li.addEventListener('mousedown', function(e) { e.preventDefault(); choose(item.slug); }); list.appendChild(li); }); list.hidden = false; input.setAttribute('aria-expanded', 'true'); } input.addEventListener('input', function() { var q = input.value.trim(); clearTimeout(timer); if (q.length < 2) { close(); return; } timer = setTimeout(function() { fetch('/discover/tag-suggest?q=' + encodeURIComponent(q)) .then(function(r) { return r.ok ? r.json() : []; }) .then(function(items) { active = -1; render(items); }) .catch(function() { close(); }); }, 150); }); input.addEventListener('keydown', function(e) { var items = list.querySelectorAll('.tag-suggest-item'); if (e.key === 'Escape') { close(); return; } if (!items.length) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); var next = active + (e.key === 'ArrowDown' ? 1 : -1); if (next < 0) next = items.length - 1; if (next >= items.length) next = 0; setActive(items, next); } else if (e.key === 'Enter') { e.preventDefault(); var pick = active >= 0 ? items[active] : items[0]; if (pick) choose(pick.dataset.slug); } }); document.addEventListener('click', function(e) { if (!list.contains(e.target) && e.target !== input) close(); }); })(); // View toggle (list vs grid) with localStorage persistence. // Re-applied after HTMX swaps because new content replaces the container. (function() { function applyView(view) { var container = document.getElementById('results-container-inner'); if (container) { container.className = 'results-container results-' + view; } document.querySelectorAll('.view-btn').forEach(function(btn) { btn.classList.toggle('is-selected', btn.dataset.view === view); }); // The column headers label the list layout; in grid view they label nothing. var header = document.querySelector('.table-header'); if (header) { header.classList.toggle('is-hidden', view !== 'list'); } } // Load saved preference on page load document.addEventListener('DOMContentLoaded', function() { var saved = safeStorageGet('discoverViewPref') || 'grid'; applyView(saved); }); // Handle view button clicks document.querySelectorAll('.view-btn').forEach(function(btn) { btn.addEventListener('click', function() { var view = btn.dataset.view; applyView(view); safeStorageSet('discoverViewPref', view); }); }); // Re-apply view preference after HTMX swaps new content document.body.addEventListener('htmx:afterSwap', function(evt) { if (evt.detail.target.id === 'results-container') { var saved = safeStorageGet('discoverViewPref') || 'grid'; applyView(saved); } }); })(); // Search suggestions autocomplete (function() { var input = document.getElementById('search-input'); var box = document.getElementById('search-suggestions'); var timer = null; var selectedIdx = -1; input.addEventListener('input', function() { clearTimeout(timer); var q = input.value.trim(); if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; } timer = setTimeout(function() { fetch('/discover/suggestions?q=' + encodeURIComponent(q)) .then(function(r) { return r.json(); }) .then(function(items) { if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; } selectedIdx = -1; box.innerHTML = items.map(function(s, i) { return '' + '' + escapeHtml(s.label) + '' + '' + escapeHtml(s.category) + ''; }).join(''); box.style.display = 'block'; }); }, 200); }); input.addEventListener('keydown', function(e) { var items = box.querySelectorAll('.suggestion-item'); if (!items.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); } else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); } else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); } else if (e.key === 'Escape') { box.style.display = 'none'; } }); function updateHighlight(items) { items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); }); } document.addEventListener('click', function(e) { if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; } }); })();