| 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 | |