carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

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