| 1 | /* rastrillo.js — the fragment shim. First-party, dependency-free, and |
| 2 | almost entirely inert: the polling section answers only to an opt-in |
| 3 | data attribute, and everything it enhances also works with scripts |
| 4 | disabled (a status page's <noscript> meta refresh). This file is |
| 5 | app-owned from the moment it is scaffolded — edit it like any other |
| 6 | static file. |
| 7 | |
| 8 | Two sections are on by default instead, because neither has a |
| 9 | per-instance decision to make. Light dismiss for the menu idioms keys |
| 10 | off the rst-dropdown / rst-row-menu attributes: a menu that closes on |
| 11 | an outside click on one screen and not the next is worse than either |
| 12 | rule applied everywhere. The busy rule keys off nothing at all — every |
| 13 | submit button in every form that goes somewhere is covered, because |
| 14 | a button that changes something should say so while it works, and a |
| 15 | form that double-submits on a second click is a bug on every screen. |
| 16 | Delete either section to opt the whole app out of it. |
| 17 | |
| 18 | Vocabulary: |
| 19 | data-poll="URL" fetch URL for an HTML fragment, replace this |
| 20 | element with it, and keep going only if the |
| 21 | replacement element itself carries data-poll |
| 22 | data-poll-every="2" seconds between polls (default 2) |
| 23 | data-poll-push="URL" optional, beside data-poll: open an |
| 24 | EventSource to URL and re-fetch the fragment |
| 25 | when the server says so, instead of on a |
| 26 | timer. Any EventSource error downgrades this |
| 27 | element to timer polling for good (once, |
| 28 | no flapping); a browser without EventSource |
| 29 | never leaves the timer path |
| 30 | data-busy="false" on a <form> or on one submit button: opt OUT |
| 31 | of the busy rule below. The rule is the |
| 32 | default, so any other value — data-busy on |
| 33 | its own included — changes nothing |
| 34 | data-busy-label="…" on the form or on the button: replacement |
| 35 | button text while it works |
| 36 | |
| 37 | select.js is a sibling file following exactly these rules, kept |
| 38 | separate so this one stays small enough to read in one sitting. It |
| 39 | answers data-rst-select on a <select>, which field-select emits past |
| 40 | ten options. |
| 41 | |
| 42 | Every poll carries the request header Rastrillo-Fragment: 1, which |
| 43 | marks the request as a shim poll so a handler can tell it apart from |
| 44 | direct navigation. Reserved: the framework's own handlers do not |
| 45 | read it today. |
| 46 | |
| 47 | Menus: an open <details rst-dropdown>, <details rst-row-menu> or a |
| 48 | nested <details rst-menu-group> closes on an outside click and on |
| 49 | Escape, in EITHER spelling, so upgrading this file before running |
| 50 | `rastrillo markup` leaves no dead menus. Exclusivity stays native. |
| 51 | |
| 52 | A polled response may answer 204 with a Rastrillo-Location header |
| 53 | instead of a fragment; the shim navigates there, but only to a local |
| 54 | path. 403 and 404 end the poll for good — the job was swept, or was |
| 55 | never yours. Any other fetch error backs off (doubling to a 30s cap) |
| 56 | and keeps trying — a network blip must not strand a status page. */ |
| 57 | (function () { |
| 58 | "use strict"; |
| 59 | |
| 60 | // The same rule sessions.SafeReturn enforces server-side: a same-site |
| 61 | // absolute path — starts with exactly one "/", no scheme, no |
| 62 | // backslash, no control characters (browsers strip tab/CR/LF before |
| 63 | // parsing, so "/\t/evil.example" would resolve scheme-relative). |
| 64 | // Anything laxer is an open redirect, here driven by a response |
| 65 | // header instead of a form field. |
| 66 | function localPath(to) { |
| 67 | return to.charAt(0) === "/" && to.charAt(1) !== "/" && |
| 68 | to.indexOf("\\") === -1 && !/[\u0000-\u001f\u007f]/.test(to); |
| 69 | } |
| 70 | |
| 71 | function poll(el) { |
| 72 | var base = (parseFloat(el.getAttribute("data-poll-every")) || 2) * 1000; |
| 73 | var wait = base; |
| 74 | var src = null; // open EventSource while pushing; null on the timer path |
| 75 | var busy = false; // a fetch is in flight |
| 76 | var queued = false; // an update landed mid-fetch; tick again after it |
| 77 | function stop() { |
| 78 | if (src) { src.close(); src = null; } |
| 79 | } |
| 80 | // One downgrade, never back: push hands this element to the timer. |
| 81 | function fallback() { |
| 82 | stop(); |
| 83 | schedule(); |
| 84 | } |
| 85 | function tick() { |
| 86 | if (busy) { queued = true; return; } |
| 87 | busy = true; |
| 88 | fetch(el.getAttribute("data-poll"), { headers: { "Rastrillo-Fragment": "1" } }) |
| 89 | .then(function (res) { |
| 90 | // Terminal, not an error to retry: retrying a 404 forever is |
| 91 | // noise, and following a signed-out redirect would swap a |
| 92 | // sign-in page into the fragment's slot. |
| 93 | if (res.status === 403 || res.status === 404) return null; |
| 94 | if (!res.ok && res.status !== 204) throw new Error("status " + res.status); |
| 95 | var to = res.headers.get("Rastrillo-Location"); |
| 96 | if (to) { |
| 97 | if (localPath(to)) window.location.assign(to); |
| 98 | return null; // navigating, or refusing to — either way, done |
| 99 | } |
| 100 | return res.text(); |
| 101 | }) |
| 102 | .then(function (html) { |
| 103 | busy = false; |
| 104 | if (html === null) { stop(); return; } // stopped |
| 105 | wait = base; // a healthy response resets the backoff |
| 106 | var tpl = document.createElement("template"); |
| 107 | tpl.innerHTML = html; |
| 108 | var next = tpl.content.firstElementChild; |
| 109 | if (!next) { stop(); return; } // fragment with no element: stop politely |
| 110 | el.replaceWith(next); |
| 111 | el = next; |
| 112 | if (!el.hasAttribute("data-poll")) { stop(); return; } |
| 113 | if (!src) { schedule(); return; } |
| 114 | // Pushing: a fragment that dropped data-poll-push falls back |
| 115 | // to the timer; otherwise catch up if an update was queued. |
| 116 | if (!el.hasAttribute("data-poll-push")) { fallback(); return; } |
| 117 | if (queued) { queued = false; tick(); } |
| 118 | }) |
| 119 | .catch(function () { |
| 120 | busy = false; |
| 121 | wait = Math.min(wait * 2, 30000); |
| 122 | if (!src) schedule(); // pushing: the next event retries instead |
| 123 | }); |
| 124 | } |
| 125 | function schedule() { setTimeout(tick, wait); } |
| 126 | var push = el.getAttribute("data-poll-push"); |
| 127 | if (push && window.EventSource) { |
| 128 | src = new EventSource(push); |
| 129 | src.addEventListener("update", function () { tick(); }); |
| 130 | src.addEventListener("done", function () { stop(); tick(); }); |
| 131 | src.addEventListener("gone", stop); |
| 132 | src.onerror = fallback; |
| 133 | return; |
| 134 | } |
| 135 | schedule(); |
| 136 | } |
| 137 | |
| 138 | // ── The busy rule ──────────────────────────────────────────────── |
| 139 | // |
| 140 | // A button that CHANGES something says so while it works; a button |
| 141 | // that only reveals something — a disclosure, a dropdown, a tab — |
| 142 | // does not. So every submit button in every form is covered by |
| 143 | // default, with no attribute to remember: the submitted button gets |
| 144 | // aria-busy, a spinner and — once the submission is under way — |
| 145 | // disabled, and its form gets aria-busy and a guard against a second |
| 146 | // submit. The data-busy vocabulary above opts out of either half. |
| 147 | // |
| 148 | // The guard is the substance, the spinner is the manners, and neither |
| 149 | // is a promise: with scripts off the form submits exactly as it |
| 150 | // always did, twice if the visitor clicks twice. Idempotency stays |
| 151 | // the server's job. One delegated capture-phase listener, for the |
| 152 | // reasons light dismiss gives below: fragments, and never double-bind. |
| 153 | // |
| 154 | // Two traps this shape exists to avoid: |
| 155 | // |
| 156 | // - A form the browser REFUSED must not sit there looking busy. |
| 157 | // Constraint validation fails before the submit event is fired at |
| 158 | // all, so an invalid form never reaches this code; a cancelled |
| 159 | // one is handed back in the tick below. (formnovalidate skips |
| 160 | // validation and really does submit, which should look like it.) |
| 161 | // - Nothing that could change the payload happens while the payload |
| 162 | // is being read. The entry list is built before submit fires, but |
| 163 | // engines have differed, so aria-busy, the spinner and a |
| 164 | // <button>'s TEXT (never submitted — a button submits its value |
| 165 | // attribute) go on synchronously, while disabled and an |
| 166 | // <input type="submit">'s value (which IS what it submits) wait. |
| 167 | // |
| 168 | // Every busy element in the DOCUMENT, then the ownership test: a |
| 169 | // form="id" submit button is owned by a form it does not live in. |
| 170 | function busyOff(form) { |
| 171 | form.removeAttribute("aria-busy"); |
| 172 | document.querySelectorAll('[aria-busy="true"]').forEach(function (b) { |
| 173 | if (b.form !== form && !form.contains(b)) return; |
| 174 | b.disabled = false; |
| 175 | b.removeAttribute("aria-busy"); |
| 176 | var spin = b.querySelector("[rst-spin]"); |
| 177 | if (spin) spin.remove(); |
| 178 | var idle = b.getAttribute("data-idle-label"); |
| 179 | if (idle === null) return; |
| 180 | if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; } |
| 181 | b.removeAttribute("data-idle-label"); |
| 182 | }); |
| 183 | } |
| 184 | |
| 185 | function busySubmit(e) { |
| 186 | var form = e.target; |
| 187 | if (!form || form.tagName !== "FORM") return; |
| 188 | if (form.getAttribute("data-busy") === "false") return; |
| 189 | // The button the browser submitted with: the one clicked, or — for |
| 190 | // Enter in a field — the default one it implicitly clicked. Only |
| 191 | // that one goes busy; every other submit button in the form keeps |
| 192 | // its name and its value. An engine with no SubmitEvent.submitter |
| 193 | // leaves the buttons alone and keeps the guard, which is the half |
| 194 | // that matters. |
| 195 | var btn = e.submitter; |
| 196 | // A form that opens its result elsewhere, or a submit that closes a |
| 197 | // dialog and stays put, has nothing to be busy about and nothing to |
| 198 | // clear. The submitter's formmethod beats the form's. |
| 199 | // |
| 200 | // getAttribute, never the IDL properties. A form is |
| 201 | // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a |
| 202 | // target date, a method column — ordinary field names) shadows the IDL |
| 203 | // attribute with the input itself, so the property form quietly |
| 204 | // switches the rule off and hands back the double submit. The busy flag |
| 205 | // below is the form's own aria-busy for the same reason: an expando is |
| 206 | // shadowable, and under strict mode assigning to a shadowed one throws. |
| 207 | var to = form.getAttribute("target"); |
| 208 | if (to && to !== "_self") return; |
| 209 | if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") || |
| 210 | form.getAttribute("method"))) return; |
| 211 | if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; } |
| 212 | form.setAttribute("aria-busy", "true"); |
| 213 | if (!btn || btn.getAttribute("data-busy") === "false") btn = null; |
| 214 | var label = btn && (btn.getAttribute("data-busy-label") || |
| 215 | form.getAttribute("data-busy-label")); |
| 216 | if (btn) { |
| 217 | btn.setAttribute("aria-busy", "true"); |
| 218 | if (btn.tagName === "BUTTON") { |
| 219 | if (label) { |
| 220 | btn.setAttribute("data-idle-label", btn.textContent); |
| 221 | btn.textContent = label; |
| 222 | } |
| 223 | // A child element, not a pseudo-element: the shim has to be |
| 224 | // able to take it away again. An <input type="submit"> has no |
| 225 | // children, which is the one shape this cannot draw a spinner |
| 226 | // on. tokens.css stops it rotating under reduced motion. |
| 227 | var spin = document.createElement("span"); |
| 228 | spin.setAttribute("rst-spin", ""); |
| 229 | spin.setAttribute("aria-hidden", "true"); |
| 230 | btn.insertBefore(spin, btn.firstChild); |
| 231 | } |
| 232 | } |
| 233 | setTimeout(function () { |
| 234 | // Someone downstream cancelled the submit — an app handler doing |
| 235 | // the work itself, most likely. Nothing is on its way anywhere, |
| 236 | // so hand the form back, guard included, and let whoever took the |
| 237 | // job own the feedback too. |
| 238 | if (e.defaultPrevented) { busyOff(form); return; } |
| 239 | if (!btn) return; |
| 240 | if (label && btn.tagName === "INPUT") { |
| 241 | btn.setAttribute("data-idle-label", btn.value); |
| 242 | btn.value = label; |
| 243 | } |
| 244 | btn.disabled = true; |
| 245 | }, 0); |
| 246 | } |
| 247 | |
| 248 | document.addEventListener("submit", busySubmit, true); |
| 249 | |
| 250 | // Light dismiss — the one behaviour the native disclosure genuinely |
| 251 | // cannot do, which is the shim's whole admission rule. A <details> |
| 252 | // menu closes on a second click of its own summary and on nothing |
| 253 | // else: not on a click elsewhere on the page, not on Escape. Both are |
| 254 | // what every menu anywhere else does, and neither is expressible in |
| 255 | // HTML or CSS. |
| 256 | // |
| 257 | // Two delegated listeners on the document, never one per element, so a |
| 258 | // dropdown that arrives inside a polled fragment is covered the moment |
| 259 | // it lands and re-scanning can never double-bind. Capture phase, so a |
| 260 | // page whose own handler stops propagation cannot leave a menu stuck |
| 261 | // open. |
| 262 | // |
| 263 | // The scriptless baseline is untouched: with this file removed, the |
| 264 | // menus still toggle and the native <details name> group still keeps |
| 265 | // one open at a time. |
| 266 | // |
| 267 | // The nested rst-menu-group is in MENUS even though it is never in the |
| 268 | // <details name> group with its parent — the two mechanisms answer |
| 269 | // different questions. The name group decides which menus may be open |
| 270 | // at once, and a submenu must be exempt from it or opening one would |
| 271 | // close the menu around it. Dismissal is not exclusivity: a submenu |
| 272 | // left open behind its closing parent is still open the next time the |
| 273 | // parent opens, which is a menu remembering a state the user has no |
| 274 | // way to see. Listing it here also gives the natural behaviour of |
| 275 | // clicking elsewhere INSIDE the parent closing the submenu — the |
| 276 | // contains(except) test below already draws that line for free. |
| 277 | // |
| 278 | // Shell chrome and the toggle-block are deliberately absent from |
| 279 | // MENUS. A sidebar's disclosure strip and a settings switch are not |
| 280 | // menus; closing them because a click landed elsewhere would fight the |
| 281 | // user rather than help. |
| 282 | var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]"; |
| 283 | MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1"); |
| 284 | |
| 285 | // except is the clicked node: the menu containing it stays open, which |
| 286 | // is what keeps a click on a menu item — or on the summary of a menu |
| 287 | // being opened right now — from closing the thing being used. |
| 288 | function closeMenus(except) { |
| 289 | document.querySelectorAll(MENUS).forEach(function (d) { |
| 290 | if (!except || !d.contains(except)) d.open = false; |
| 291 | }); |
| 292 | } |
| 293 | |
| 294 | function dismissMenus(e) { |
| 295 | if (e.type === "click") { closeMenus(e.target); return; } |
| 296 | if (e.key !== "Escape") return; |
| 297 | // Focus is about to be inside a subtree that is no longer rendered, |
| 298 | // which strands a keyboard user at the top of the document. Hand it |
| 299 | // back to the summary that opened the menu. |
| 300 | var el = document.activeElement; |
| 301 | var host = el && el.closest ? el.closest(MENUS) : null; |
| 302 | // Climb to the OUTERMOST open menu around the focus. closest() finds |
| 303 | // the innermost, which for focus inside a submenu is the submenu — |
| 304 | // and its summary is inside the parent that is about to close too, so |
| 305 | // focusing it would hand focus to something no longer rendered, which |
| 306 | // is no hand-back at all. |
| 307 | while (host && host.parentElement && host.parentElement.closest(MENUS)) { |
| 308 | host = host.parentElement.closest(MENUS); |
| 309 | } |
| 310 | closeMenus(null); |
| 311 | if (host) { |
| 312 | var summary = host.querySelector("summary"); |
| 313 | if (summary) summary.focus(); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | document.addEventListener("click", dismissMenus, true); |
| 318 | document.addEventListener("keydown", dismissMenus, true); |
| 319 | |
| 320 | function scan() { |
| 321 | document.querySelectorAll("[data-poll]").forEach(poll); |
| 322 | } |
| 323 | |
| 324 | // The back/forward cache restores a page's DOM exactly as it was left |
| 325 | // — busy buttons still disabled, still wearing the busy label and the |
| 326 | // spinner — so a visitor who navigates back finds a dead form. Hand |
| 327 | // every busy form back. |
| 328 | window.addEventListener("pageshow", function (e) { |
| 329 | if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff); |
| 330 | }); |
| 331 | |
| 332 | if (document.readyState === "loading") { |
| 333 | document.addEventListener("DOMContentLoaded", scan); |
| 334 | } else { |
| 335 | scan(); |
| 336 | } |
| 337 | })(); |
| 338 | |