Skip to main content

max / makenotwork

6.5 KB · 136 lines History Blame Raw
1 // Restore focus across the out-of-band sidebar swap.
2 //
3 // Under htmx 2 this was the whole mechanism: htmx saved focus only around
4 // the MAIN swap target, so a focused filter control destroyed by the
5 // out-of-band sidebar swap dropped a keyboard user to <body> on every
6 // filter change. htmx 4 saves and restores focus per swap task, the
7 // out-of-band ones included, so this is a backstop rather than the fix.
8 //
9 // It is kept because it costs nothing when htmx has already restored, and
10 // because the two now disagree about ordering: htmx 4 swaps the main target
11 // first and the out-of-band elements after, where 2 did the reverse. So the
12 // settle that fires first is the results one, when the sidebar has not been
13 // replaced yet -- which is why `pending` is cleared on a restore rather
14 // than on the first settle after the request.
15 (function () {
16 var pending = null;
17 document.body.addEventListener('htmx:before:request', function () {
18 var el = document.activeElement;
19 pending = el && el.id && el.closest('#discover-sidebar') ? el.id : null;
20 });
21 document.body.addEventListener('htmx:after:settle', function () {
22 if (!pending) return;
23 var el = document.getElementById(pending);
24 // Only if it actually went away and came back; a surviving element
25 // kept its focus already.
26 if (!el || el === document.activeElement) return;
27 el.focus({ preventScroll: true });
28 pending = null;
29 });
30 })();
31
32 // View toggle (list vs grid) with localStorage persistence.
33 // Re-applied after HTMX swaps because new content replaces the container.
34 (function() {
35 function applyView(view) {
36 var container = document.getElementById('results-container-inner');
37 if (container) {
38 container.className = 'results-container results-' + view;
39 }
40 document.querySelectorAll('.view-btn').forEach(function(btn) {
41 btn.classList.toggle('is-selected', btn.dataset.view === view);
42 });
43 // The column headers label the list layout; in grid view they label nothing.
44 var header = document.querySelector('.table-header');
45 if (header) {
46 header.classList.toggle('is-hidden', view !== 'list');
47 }
48 }
49
50 // Load saved preference on page load
51 document.addEventListener('DOMContentLoaded', function() {
52 var saved = safeStorageGet('discoverViewPref') || 'grid';
53 applyView(saved);
54 });
55
56 // Handle view button clicks
57 document.querySelectorAll('.view-btn').forEach(function(btn) {
58 btn.addEventListener('click', function() {
59 var view = btn.dataset.view;
60 applyView(view);
61 safeStorageSet('discoverViewPref', view);
62 });
63 });
64
65 // Re-apply view preference after HTMX swaps new content.
66 //
67 // Settle rather than swap: htmx 4 fires `htmx:after:swap` on the
68 // element that made the request, and a sidebar filter link is inside
69 // the region the out-of-band sidebar swap replaces, so the event has
70 // nothing to bubble through.
71 //
72 // Asked as "did the results region just arrive" rather than "is the
73 // settled element #results-container". The region is swapped whole now
74 // (hx-swap="outerHTML", which is what a described control emits), so
75 // the element the event names is not necessarily the one that was
76 // targeted, and the id test silently stopped firing under an outer
77 // swap: the grid preference reverted to list on every filter click.
78 document.body.addEventListener('htmx:after:settle', function(evt) {
79 var el = evt.target;
80 if (!el || !el.querySelector) return;
81 var arrived = el.id === 'results-container'
82 || el.id === 'results-container-inner'
83 || el.querySelector('#results-container-inner');
84 if (arrived) {
85 var saved = safeStorageGet('discoverViewPref') || 'grid';
86 applyView(saved);
87 }
88 });
89 })();
90
91 // Search suggestions autocomplete
92 (function() {
93 var input = document.getElementById('search-input');
94 var box = document.getElementById('search-suggestions');
95 var timer = null;
96 var selectedIdx = -1;
97
98 input.addEventListener('input', function() {
99 clearTimeout(timer);
100 var q = input.value.trim();
101 if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; }
102 timer = setTimeout(function() {
103 fetch('/discover/suggestions?q=' + encodeURIComponent(q))
104 .then(function(r) { return r.json(); })
105 .then(function(items) {
106 if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; }
107 selectedIdx = -1;
108 box.innerHTML = items.map(function(s, i) {
109 return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">'
110 + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>'
111 + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>';
112 }).join('');
113 box.style.display = 'block';
114 });
115 }, 200);
116 });
117
118 input.addEventListener('keydown', function(e) {
119 var items = box.querySelectorAll('.suggestion-item');
120 if (!items.length) return;
121 if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); }
122 else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); }
123 else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); }
124 else if (e.key === 'Escape') { box.style.display = 'none'; }
125 });
126
127 function updateHighlight(items) {
128 items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); });
129 }
130
131
132 document.addEventListener('click', function(e) {
133 if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; }
134 });
135 })();
136