oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1/* search.js — live search, keyboard navigation, and Enter.
2 *
3 * App-owned, not vendored: rastrillo.js comes from estilo and is pinned
4 * byte-identical by a gate; this file and drop.js are memoria's.
5 *
6 * Same house rules as its neighbours: first-party, dependency-free, and
7 * an enhancement rather than a mechanism. Delete this file and the
8 * search form is still a GET form to "/" that still returns results —
9 * what is lost is that the results arrive as you type and that Enter can
10 * take you straight into the first one.
11 *
12 * Three behaviours, in the order they matter:
13 *
14 * Live. Debounced fetch of /search?q=…, whose response is the same
15 * results template the page renders. The server sends markup,
16 * not JSON, so a snippet is escaped once, on the server, by
17 * the code that already does it — the browser never builds
18 * HTML out of a document's text.
19 *
20 * Enter. Goes to the primed result, if there is one. WHICH result is
21 * primed, and whether anything is primed at all, is the
22 * server's decision (internal/memoria/query): a query that
23 * reads as a question about the corpus primes nothing, because
24 * navigating away from a page someone is still reading is a
25 * worse failure than making them press Enter twice. This file
26 * reads an attribute. It does not re-derive the rule.
27 *
28 * Arrows. Move real DOM focus onto the result links, rather than
29 * mirroring a selection with aria-activedescendant. A focused
30 * link needs no ARIA to be announced, gets the browser's own
31 * focus ring, and Enter on it is just a link being followed.
32 *
33 * THE REGION. Results go into [rst-refresh="results"]. This file never
34 * holds a reference to it — every read re-queries — so anything that
35 * replaces the node cannot leave it writing to a detached one. It keeps
36 * location.search in step with the box, so a reload or a bookmark
37 * carries the query the screen is showing.
38 *
39 * WHAT IS ON SCREEN. The server stamps the query it rendered onto the
40 * region as rst-search-results-for, and that attribute — never the input
41 * — is the answer to "what do these results describe". The box and the
42 * screen disagree more often than is comfortable: during a debounce,
43 * while a fetch is in the air, and after a Back, where the browser
44 * restores the input's value on a schedule of its own that no script
45 * should be relying on. Every Enter checks the two against each other,
46 * and where they differ it runs the search first and acts on the answer,
47 * so a fast typist gets the same document as a slow one. */
48(function () {
49 "use strict";
50
51 var DEBOUNCE_MS = 120;
52 // How long a live zero-result line waits for the person to stop typing.
53 // Matching is whole-token, so "rev" finds nothing on the way to
54 // "revenue"; "No matches" on every partial keystroke was noise (polish
55 // audit §B1). A submitted search is a page render and shows it at once.
56 var PAUSE_MS = 900;
57
58 var form = document.querySelector("form[rst-search]");
59 var input = form && form.querySelector('input[type="search"]');
60 var status = document.querySelector("[rst-search-status]");
61 if (!form || !input) return;
62 if (!window.fetch || !window.AbortController) return;
63 if (!region()) return;
64
65 var timer = null;
66 var pauseTimer = null;
67 var inflight = null;
68 var inflightFor = null;
69
70 // Re-queried, never cached: drop.js replaces this node outright when a
71 // drop settles, and a held reference would have this file writing into
72 // a detached element for the rest of the session.
73 function region() {
74 return document.querySelector('[rst-refresh="results"]');
75 }
76
77 /* rendered is the query the markup on screen was produced from, as the
78 SERVER reported it, or null when that cannot be determined — after a
79 failed fetch, or after drop.js swapped in a region this file has not
80 read. Null is not "no query": it means Enter must not act on what is
81 on screen, and falls through to a real form submit. */
82 function rendered() {
83 var el = region();
84 if (!el || !el.hasAttribute("rst-search-results-for")) return null;
85 return el.getAttribute("rst-search-results-for");
86 }
87
88 function fresh() {
89 var r = rendered();
90 return r !== null && r === input.value.trim();
91 }
92
93 function primedLink() {
94 var el = region();
95 var li = el && el.querySelector("[rst-result-primed]");
96 return li && li.querySelector("[rst-result-title]");
97 }
98
99 function links() {
100 var el = region();
101 if (!el) return [];
102 return Array.prototype.slice.call(
103 el.querySelectorAll("[rst-result-title]")
104 );
105 }
106
107 /* search fetches the results region for q and swaps it in, resolving
108 once the swap has happened so Enter can wait on it.
109
110 It rejects when the answer never arrived or is no longer wanted, and
111 the two are not the same thing: a REJECTION IS NOT A FAILURE. An
112 aborted or superseded fetch was replaced by a newer one that is
113 still coming, and treating it as a failure is how repeatedly
114 pressing Enter used to end up on the search page instead of in the
115 document. Callers check err.stale. */
116 function search(q) {
117 if (inflight) inflight.abort();
118 var ctl = new AbortController();
119 inflight = ctl;
120 inflightFor = q;
121 return fetch("/search?q=" + encodeURIComponent(q), {
122 signal: ctl.signal,
123 credentials: "same-origin",
124 headers: { Accept: "text/html" },
125 })
126 .then(function (res) {
127 if (ctl !== inflight) throw stale();
128 if (!res.ok) throw new Error("search " + res.status);
129 // A session that ran out answers a GET with a 303 to /signin,
130 // which fetch follows and reports as a perfectly good 200. Left
131 // unchecked, the sign-in page goes into the results region — a
132 // whole document nested inside a list, with the stamp below
133 // vouching for it as the answer to what is in the box. drop.js
134 // refuses the same page for the same reason; this is that guard.
135 if (res.redirected || new URL(res.url).pathname !== "/search") {
136 throw new Error("not the results");
137 }
138 return res.text();
139 })
140 .then(function (html) {
141 // A response that lost the race must not overwrite a newer one.
142 // The abort above usually prevents this; this is the check that
143 // holds when it does not.
144 if (ctl !== inflight) throw stale();
145 inflight = inflightFor = null;
146 put(html, q);
147 })
148 .catch(function (err) {
149 if (err && err.stale) throw err;
150 if (err && err.name === "AbortError") throw stale();
151 if (ctl === inflight) {
152 inflight = inflightFor = null;
153 // Not "no results". The markup on screen stays — it is the
154 // last true thing this page had — but it is no longer allowed
155 // to speak for the box, so the stamp comes off and Enter falls
156 // through to a real submit rather than following a stale link.
157 // And the page SAYS so: results that silently describe an
158 // older query are the confident-zero failure wearing a hat.
159 var el = region();
160 if (el) {
161 el.removeAttribute("rst-search-results-for");
162 // Said out loud AND shown. The status region is sr-only, so
163 // announcing alone leaves a sighted reader looking at older
164 // results with nothing on screen admitting it — which is the
165 // confident-zero failure with better manners. The sentence
166 // itself stays in the template; CSS renders the attribute.
167 el.setAttribute("rst-search-stale", "");
168 }
169 announce(el && el.getAttribute("rst-search-stale-note"));
170 }
171 throw err;
172 });
173 }
174
175 function stale() {
176 var e = new Error("superseded");
177 e.stale = true;
178 return e;
179 }
180
181 /* put swaps the markup in and restores the keyboard.
182
183 Replacing innerHTML under a focused link moves focus to the body,
184 which on a keyboard-navigation feature means the arrows and Enter
185 both stop working with nothing on screen saying why. So where focus
186 was inside the region, it is put back on the result at the same
187 index — or on the input when the list has grown shorter than the
188 place the person was standing in. */
189 function put(html, q) {
190 var el = region();
191 if (!el) return;
192 var held = links().indexOf(document.activeElement);
193 el.innerHTML = html;
194 el.setAttribute("rst-search-results-for", q);
195 el.removeAttribute("rst-search-stale");
196 var waiting = holdEmpty(el);
197 if (held >= 0) {
198 var all = links();
199 (all[held] || all[all.length - 1] || input).focus();
200 }
201 // location.search is kept in step so that drop.js's re-fetch of
202 // pathname+search re-renders THIS search, and so that a reload or a
203 // bookmark carries the query the screen is showing. replaceState,
204 // not pushState: a debounce is not a navigation, and forty history
205 // entries for one typed word is a Back button nobody can use.
206 if (window.history && history.replaceState) {
207 var url = q === "" ? location.pathname : location.pathname + "?q=" + encodeURIComponent(q);
208 history.replaceState(null, "", url);
209 }
210 if (!waiting) announce();
211 }
212
213 /* holdEmpty keeps a live zero-result line out of sight until the person
214 pauses. The line is the server's markup and stays in the region —
215 [rst-search-pending] only hides it (memoria.css) — so the stamp above
216 still vouches for what the region holds, and a pause has nothing to
217 fetch: it lifts the attribute and says the line. Anything else in the
218 region (results, the search-failed callout) shows at once. */
219 function holdEmpty(el) {
220 if (pauseTimer) {
221 clearTimeout(pauseTimer);
222 pauseTimer = null;
223 }
224 if (!el.querySelector("[rst-search-empty]")) {
225 el.removeAttribute("rst-search-pending");
226 return false;
227 }
228 el.setAttribute("rst-search-pending", "");
229 pauseTimer = setTimeout(function () {
230 pauseTimer = null;
231 var now = region();
232 if (!now) return;
233 now.removeAttribute("rst-search-pending");
234 announce();
235 }, PAUSE_MS);
236 return true;
237 }
238
239 /* announce repeats the heading the server just sent into the live
240 region, so a screen reader hears the count change. Reusing the
241 server's sentence rather than composing one here keeps every string
242 a person hears in a template, where it is written and reviewed.
243
244 A fragment with no heading — the search-failed callout — falls back
245 to that callout's own words, so silence never stands in for a
246 failure. */
247 function announce(override) {
248 if (!status) return;
249 if (override) {
250 status.textContent = override;
251 return;
252 }
253 var el = region();
254 var said = el && el.querySelector("h2, [rst-callout-body], [rst-search-empty]");
255 status.textContent = said ? said.textContent.trim() : "";
256 }
257
258 function schedule() {
259 if (timer) clearTimeout(timer);
260 var q = input.value.trim();
261 // An in-flight fetch for some OTHER query is now answering a
262 // question nobody is asking. Dropping it here rather than leaving it
263 // to land is the difference between a list that matches the box and
264 // one that quietly describes a keystroke that was taken back.
265 if (inflight && inflightFor !== q) {
266 inflight.abort();
267 inflight = inflightFor = null;
268 }
269 if (rendered() === q) return;
270 timer = setTimeout(function () {
271 timer = null;
272 search(input.value.trim()).catch(function () {});
273 }, DEBOUNCE_MS);
274 }
275
276 /* submit is the fallback for everything this file cannot do
277 confidently: nothing primed, a failed fetch, a state it cannot
278 vouch for. form.submit bypasses the submit event, so it cannot
279 re-enter this handler. */
280 function submit() {
281 form.submit();
282 }
283
284 function follow() {
285 var link = primedLink();
286 if (!link) return submit();
287 window.location.assign(link.href);
288 }
289
290 /* Enter is handled on the input's keydown rather than on the form's
291 submit, because submit cannot tell Enter from a click on the Search
292 button and the two mean different things: Enter means "take me
293 there", a click on a button labelled Search means "search". Reading
294 e.submitter does not separate them — it is the same default button
295 either way. */
296 input.addEventListener("keydown", function (e) {
297 if (e.key !== "Enter" || e.isComposing || e.keyCode === 229) return;
298 if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
299 var q = input.value.trim();
300 if (q === "") return; // the empty search behaves as it always did
301
302 if (fresh()) {
303 if (!primedLink()) return; // a question, or nothing found: submit
304 e.preventDefault();
305 follow();
306 return;
307 }
308 // What is on screen is older than what is in the box. Find out
309 // before deciding, so a fast typist gets the same answer as a slow
310 // one rather than the previous query's first result.
311 e.preventDefault();
312 if (timer) {
313 clearTimeout(timer);
314 timer = null;
315 }
316 search(q).then(follow, function (err) {
317 // A superseded search means a newer one is already on its way and
318 // will do this; only a real failure falls back to the form.
319 if (!err || !err.stale) submit();
320 });
321 });
322
323 input.addEventListener("input", schedule);
324
325 /* A Back that restores the box without re-running the script leaves
326 the input saying one thing and the screen another. The server's
327 stamp is what settles it: where they differ, ask. pageshow rather
328 than load, because it fires for a bfcache restore too, which is the
329 case that has no load at all. */
330 window.addEventListener("pageshow", function () {
331 if (!fresh() && input.value.trim() !== "") schedule();
332 });
333
334 input.addEventListener("keydown", function (e) {
335 if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
336 var all = links();
337 if (!all.length) return;
338 e.preventDefault();
339 (e.key === "ArrowDown" ? all[0] : all[all.length - 1]).focus();
340 });
341
342 /* Keyboard handling for the list is delegated to the document, not
343 bound to the region: the links inside it are replaced on every
344 keystroke, and the region ITSELF is replaced whenever a drop
345 settles, so a listener on either would not survive. */
346 document.addEventListener("keydown", function (e) {
347 var link = e.target.closest && e.target.closest("[rst-result-title]");
348 if (!link || !region() || !region().contains(link)) return;
349
350 if (e.key === "Escape") {
351 e.preventDefault();
352 input.focus();
353 return;
354 }
355 if (e.key === "ArrowDown" || e.key === "ArrowUp") {
356 var all = links();
357 var i = all.indexOf(link);
358 e.preventDefault();
359 if (e.key === "ArrowUp" && i === 0) return input.focus();
360 var next = all[e.key === "ArrowDown" ? i + 1 : i - 1];
361 if (next) next.focus();
362 return;
363 }
364 // Typing while a result is focused means the person has gone back to
365 // searching. Move the caret without swallowing the character.
366 if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
367 input.focus();
368 }
369 });
370})();
371