Skip to main content

max / makenotwork

10.0 KB · 229 lines History Blame Raw
1 // Restore focus across the out-of-band sidebar swap.
2 //
3 // htmx does restore focus after a swap, but only for the element it saved
4 // around the MAIN swap target. The sidebar is replaced out-of-band, so a
5 // focused filter control is destroyed outside that window and focus falls
6 // to <body>, which drops a keyboard user out of the sidebar on every
7 // single filter change. Verified in the browser, not assumed.
8 (function () {
9 var pending = null;
10 document.body.addEventListener('htmx:beforeRequest', function () {
11 var el = document.activeElement;
12 pending = el && el.id && el.closest('#discover-sidebar') ? el.id : null;
13 });
14 document.body.addEventListener('htmx:afterSettle', function () {
15 if (!pending) return;
16 var el = document.getElementById(pending);
17 pending = null;
18 // Only if it actually went away and came back; a surviving element
19 // kept its focus already.
20 if (el && el !== document.activeElement) el.focus({ preventScroll: true });
21 });
22 })();
23
24 // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is
25 // reachable by name from any depth without knowing where it sits.
26 (function() {
27 var input = document.getElementById('tag-search');
28 var list = document.getElementById('tag-suggest-list');
29 if (!input || !list) return;
30 var timer = null;
31 var active = -1;
32
33 function close() {
34 list.hidden = true;
35 list.innerHTML = '';
36 input.setAttribute('aria-expanded', 'false');
37 input.removeAttribute('aria-activedescendant');
38 active = -1;
39 }
40
41 // Focus stays in the input for a combobox; aria-activedescendant is what
42 // tells assistive tech which option is current. Without it the listbox
43 // role is a claim the widget does not honour.
44 function setActive(items, index) {
45 active = index;
46 items.forEach(function(el, i) {
47 var on = i === index;
48 el.classList.toggle('highlighted', on);
49 el.setAttribute('aria-selected', on ? 'true' : 'false');
50 });
51 if (index >= 0 && items[index]) {
52 input.setAttribute('aria-activedescendant', items[index].id);
53 } else {
54 input.removeAttribute('aria-activedescendant');
55 }
56 }
57
58 function choose(slug) {
59 // Adding a tag is the same operation the drill-down checkbox performs,
60 // so route it through the form rather than a bespoke request.
61 var form = document.getElementById('discover-form');
62 if (!form) return;
63 var existing = form.querySelector('input[data-facet="tag"][value="' + CSS.escape(slug) + '"]');
64 if (!existing) {
65 var el = document.createElement('input');
66 el.type = 'hidden';
67 el.name = 'tag';
68 el.value = slug;
69 el.className = 'discover-filter';
70 el.dataset.facet = 'tag';
71 form.appendChild(el);
72 }
73 input.value = '';
74 close();
75 htmx.trigger(form, 'tag-added');
76 }
77
78 function render(items) {
79 if (!items.length) { close(); return; }
80 list.innerHTML = '';
81 items.forEach(function(item, i) {
82 var li = document.createElement('li');
83 li.id = 'tag-suggest-option-' + i;
84 li.setAttribute('role', 'option');
85 li.setAttribute('aria-selected', i === active ? 'true' : 'false');
86 li.className = 'suggestion-item tag-suggest-item' + (i === active ? ' highlighted' : '');
87 li.dataset.slug = item.slug;
88 var label = document.createElement('span');
89 label.className = 'tag-suggest-label';
90 label.textContent = item.label;
91 li.appendChild(label);
92 if (item.context) {
93 var ctx = document.createElement('span');
94 ctx.className = 'suggestion-category tag-suggest-context';
95 ctx.textContent = item.context;
96 li.appendChild(ctx);
97 }
98 li.addEventListener('mousedown', function(e) {
99 e.preventDefault();
100 choose(item.slug);
101 });
102 list.appendChild(li);
103 });
104 list.hidden = false;
105 input.setAttribute('aria-expanded', 'true');
106 }
107
108 input.addEventListener('input', function() {
109 var q = input.value.trim();
110 clearTimeout(timer);
111 if (q.length < 2) { close(); return; }
112 timer = setTimeout(function() {
113 fetch('/discover/tag-suggest?q=' + encodeURIComponent(q))
114 .then(function(r) { return r.ok ? r.json() : []; })
115 .then(function(items) { active = -1; render(items); })
116 .catch(function() { close(); });
117 }, 150);
118 });
119
120 input.addEventListener('keydown', function(e) {
121 var items = list.querySelectorAll('.tag-suggest-item');
122 if (e.key === 'Escape') { close(); return; }
123 if (!items.length) return;
124 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
125 e.preventDefault();
126 var next = active + (e.key === 'ArrowDown' ? 1 : -1);
127 if (next < 0) next = items.length - 1;
128 if (next >= items.length) next = 0;
129 setActive(items, next);
130 } else if (e.key === 'Enter') {
131 e.preventDefault();
132 var pick = active >= 0 ? items[active] : items[0];
133 if (pick) choose(pick.dataset.slug);
134 }
135 });
136
137 document.addEventListener('click', function(e) {
138 if (!list.contains(e.target) && e.target !== input) close();
139 });
140 })();
141
142 // View toggle (list vs grid) with localStorage persistence.
143 // Re-applied after HTMX swaps because new content replaces the container.
144 (function() {
145 function applyView(view) {
146 var container = document.getElementById('results-container-inner');
147 if (container) {
148 container.className = 'results-container results-' + view;
149 }
150 document.querySelectorAll('.view-btn').forEach(function(btn) {
151 btn.classList.toggle('is-selected', btn.dataset.view === view);
152 });
153 // The column headers label the list layout; in grid view they label nothing.
154 var header = document.querySelector('.table-header');
155 if (header) {
156 header.classList.toggle('is-hidden', view !== 'list');
157 }
158 }
159
160 // Load saved preference on page load
161 document.addEventListener('DOMContentLoaded', function() {
162 var saved = safeStorageGet('discoverViewPref') || 'grid';
163 applyView(saved);
164 });
165
166 // Handle view button clicks
167 document.querySelectorAll('.view-btn').forEach(function(btn) {
168 btn.addEventListener('click', function() {
169 var view = btn.dataset.view;
170 applyView(view);
171 safeStorageSet('discoverViewPref', view);
172 });
173 });
174
175 // Re-apply view preference after HTMX swaps new content
176 document.body.addEventListener('htmx:afterSwap', function(evt) {
177 if (evt.detail.target.id === 'results-container') {
178 var saved = safeStorageGet('discoverViewPref') || 'grid';
179 applyView(saved);
180 }
181 });
182 })();
183
184 // Search suggestions autocomplete
185 (function() {
186 var input = document.getElementById('search-input');
187 var box = document.getElementById('search-suggestions');
188 var timer = null;
189 var selectedIdx = -1;
190
191 input.addEventListener('input', function() {
192 clearTimeout(timer);
193 var q = input.value.trim();
194 if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; }
195 timer = setTimeout(function() {
196 fetch('/discover/suggestions?q=' + encodeURIComponent(q))
197 .then(function(r) { return r.json(); })
198 .then(function(items) {
199 if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; }
200 selectedIdx = -1;
201 box.innerHTML = items.map(function(s, i) {
202 return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">'
203 + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>'
204 + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>';
205 }).join('');
206 box.style.display = 'block';
207 });
208 }, 200);
209 });
210
211 input.addEventListener('keydown', function(e) {
212 var items = box.querySelectorAll('.suggestion-item');
213 if (!items.length) return;
214 if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); }
215 else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); }
216 else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); }
217 else if (e.key === 'Escape') { box.style.display = 'none'; }
218 });
219
220 function updateHighlight(items) {
221 items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); });
222 }
223
224
225 document.addEventListener('click', function(e) {
226 if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; }
227 });
228 })();
229