// Restore focus across the out-of-band sidebar swap. // // Under htmx 2 this was the whole mechanism: htmx saved focus only around // the MAIN swap target, so a focused filter control destroyed by the // out-of-band sidebar swap dropped a keyboard user to
on every // filter change. htmx 4 saves and restores focus per swap task, the // out-of-band ones included, so this is a backstop rather than the fix. // // It is kept because it costs nothing when htmx has already restored, and // because the two now disagree about ordering: htmx 4 swaps the main target // first and the out-of-band elements after, where 2 did the reverse. So the // settle that fires first is the results one, when the sidebar has not been // replaced yet -- which is why `pending` is cleared on a restore rather // than on the first settle after the request. (function () { var pending = null; document.body.addEventListener('htmx:before:request', function () { var el = document.activeElement; pending = el && el.id && el.closest('#discover-sidebar') ? el.id : null; }); document.body.addEventListener('htmx:after:settle', function () { if (!pending) return; var el = document.getElementById(pending); // Only if it actually went away and came back; a surviving element // kept its focus already. if (!el || el === document.activeElement) return; el.focus({ preventScroll: true }); pending = null; }); })(); // 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. // // Settle rather than swap: htmx 4 fires `htmx:after:swap` on the // element that made the request, and a sidebar filter link is inside // the region the out-of-band sidebar swap replaces, so the event has // nothing to bubble through. // // Asked as "did the results region just arrive" rather than "is the // settled element #results-container". The region is swapped whole now // (hx-swap="outerHTML", which is what a described control emits), so // the element the event names is not necessarily the one that was // targeted, and the id test silently stopped firing under an outer // swap: the grid preference reverted to list on every filter click. document.body.addEventListener('htmx:after:settle', function(evt) { var el = evt.target; if (!el || !el.querySelector) return; var arrived = el.id === 'results-container' || el.id === 'results-container-inner' || el.querySelector('#results-container-inner'); if (arrived) { 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'; } }); })();