rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1/* busy.js — the busy rule. First-party, dependency-free, and on by
2 default: link it and every submit button in every form that goes
3 somewhere is covered, with no attribute to remember, because a button
4 that changes something should say so while it works, and a form that
5 double-submits on a second click is a bug on every screen. It is
6 app-owned from the moment it is vendored; leave it unlinked to opt the
7 whole app out.
8
9 What a submitted button does: its label gives way to a spinner (the
10 label stays, beside the spinner, under reduced motion or forced
11 colours — tokens.css), it goes aria-busy and then disabled, and its
12 form refuses a second submit. The spinner shows for at least
13 HOLD_MS: a response faster than that is held back until then, so a
14 quick save reads as a deliberate beat rather than a flicker. Going
15 Back to the page hands every busy form back, and a submit still being
16 held when the page is left is dropped rather than sent later. Left
17 means a navigation has started, not that it has finished. A form
18 pow.js protects is pow's to hold: this file arms only pow's re-send
19 and never holds it.
20
21 Vocabulary:
22 data-busy="false" on a <form> or on one submit button: opt OUT
23 of the busy rule. The rule is the default, so
24 any other value — data-busy on its own
25 included — changes nothing
26 data-busy-label="…" on the form or on the button: replacement
27 button text while it works
28 data-live-form on a <form>: live.js's to send, and to show
29 busy, whenever it claims the submit (marking
30 the event e.liveForm); this file arms it only
31 when live.js lets it go natively
32
33 A script that handles a submit itself (a fetch, say) cancels it in a
34 listener on the form or the document, as it would anyway: those run
35 before this file's window listener, which then sees the cancelled
36 submit and leaves it — and its feedback — to that script. Two edges
37 of the hold, on purpose: a handler that stops the submit event's
38 propagation keeps it from the window, so that submit goes at once,
39 unheld; and a form removed from the page during the hold (a polled
40 fragment replacing it) takes its held submit with it. */
41(function () {
42 "use strict";
43
44 // How long the spinner shows, at least. Long enough to read as an
45 // answer to the click, short enough never to feel like waiting.
46 var HOLD_MS = 650;
47
48 // ── The busy rule ────────────────────────────────────────────────
49 //
50 // A button that CHANGES something says so while it works; a button
51 // that only reveals something — a disclosure, a dropdown, a tab —
52 // does not. So every submit button in every form is covered by
53 // default, with no attribute to remember: the submitted button gets
54 // aria-busy, a spinner and — once the submission is under way —
55 // disabled, and its form gets aria-busy and a guard against a second
56 // submit. The data-busy vocabulary above opts out of either half.
57 //
58 // The guard is the substance, the spinner is the manners, and neither
59 // is a promise: with scripts off the form submits exactly as it
60 // always did, twice if the visitor clicks twice. Idempotency stays
61 // the server's job. One delegated capture-phase listener, for the
62 // reasons light dismiss gives below: fragments, and never double-bind.
63 //
64 // Two traps this shape exists to avoid:
65 //
66 // - A form the browser REFUSED must not sit there looking busy.
67 // Constraint validation fails before the submit event is fired at
68 // all, so an invalid form never reaches this code; a cancelled
69 // one is handed back in the tick below. (formnovalidate skips
70 // validation and really does submit, which should look like it.)
71 // - Nothing that could change the payload happens while the payload
72 // is being read. The entry list is built before submit fires, but
73 // engines have differed, so aria-busy, the spinner and a
74 // <button>'s TEXT (never submitted — a button submits its value
75 // attribute) go on synchronously, while disabled and an
76 // <input type="submit">'s value (which IS what it submits) wait.
77 //
78 // Every busy element in the DOCUMENT, then the ownership test: a
79 // form="id" submit button is owned by a form it does not live in.
80 function busyOff(form) {
81 form.removeAttribute("aria-busy");
82 document.querySelectorAll('[aria-busy="true"]').forEach(function (b) {
83 if (b.form !== form && !form.contains(b)) return;
84 b.disabled = false;
85 b.removeAttribute("aria-busy");
86 var spin = b.querySelector("[rst-spin]");
87 if (spin) spin.remove();
88 var idle = b.getAttribute("data-idle-label");
89 if (idle === null) return;
90 if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; }
91 b.removeAttribute("data-idle-label");
92 });
93 }
94
95 function busySubmit(e, late) {
96 var form = e.target;
97 if (!form || form.tagName !== "FORM") return;
98 // A swapping form is live.js's whenever live.js claims it: arming it
99 // here too would hand it back a tick after live.js cancels the
100 // submit, clearing live.js's busy state mid-request. Whether this
101 // capture listener runs before live.js's is down to script order, so
102 // it is armed late, from the window listener, and only if live.js
103 // did not claim it (e.liveForm, set in live.js's capture listener)
104 // and nobody cancelled it. One live.js leaves to the browser (a GET,
105 // another origin, a page live.js is not running on) keeps the rule.
106 if (form.hasAttribute("data-live-form") && !late) return;
107 // pow.js owns every submit of a form it protects: it cancels it,
108 // shows its own busy state (this file's vocabulary) while it waits,
109 // and re-sends it marked data-pow-released. Arming here before that
110 // would hand the form back a tick later and wipe pow's feedback, and
111 // a guard armed here would refuse pow's re-send. Arm on the re-send
112 // only.
113 if (form.hasAttribute("data-pow-form") && !form.hasAttribute("data-pow-released")) return;
114 if (form.getAttribute("data-busy") === "false") return;
115 // The button the browser submitted with: the one clicked, or — for
116 // Enter in a field — the default one it implicitly clicked. Only
117 // that one goes busy; every other submit button in the form keeps
118 // its name and its value. An engine with no SubmitEvent.submitter
119 // leaves the buttons alone and keeps the guard, which is the half
120 // that matters.
121 var btn = e.submitter;
122 // A form that opens its result elsewhere, or a submit that closes a
123 // dialog and stays put, has nothing to be busy about and nothing to
124 // clear. The submitter's formmethod beats the form's.
125 //
126 // getAttribute, never the IDL properties. A form is
127 // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a
128 // target date, a method column — ordinary field names) shadows the IDL
129 // attribute with the input itself, so the property form quietly
130 // switches the rule off and hands back the double submit. The busy flag
131 // below is the form's own aria-busy for the same reason: an expando is
132 // shadowable, and under strict mode assigning to a shadowed one throws.
133 var to = (btn && btn.getAttribute("formtarget")) || form.getAttribute("target");
134 if (to && to !== "_self") return;
135 if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") ||
136 form.getAttribute("method"))) return;
137 if (form === releasing) return; // the hold sending the submit it held
138 if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }
139 form.setAttribute("aria-busy", "true");
140 if (!btn || btn.getAttribute("data-busy") === "false") btn = null;
141 var label = btn && (btn.getAttribute("data-busy-label") ||
142 form.getAttribute("data-busy-label"));
143 if (btn) {
144 btn.setAttribute("aria-busy", "true");
145 if (btn.tagName === "BUTTON") {
146 if (label) {
147 btn.setAttribute("data-idle-label", btn.textContent);
148 btn.textContent = label;
149 }
150 // A child element, not a pseudo-element: the shim has to be
151 // able to take it away again. An <input type="submit"> has no
152 // children, which is the one shape this cannot draw a spinner
153 // on. tokens.css stops it rotating under reduced motion.
154 var spin = document.createElement("span");
155 spin.setAttribute("rst-spin", "");
156 spin.setAttribute("aria-hidden", "true");
157 btn.insertBefore(spin, btn.firstChild);
158 }
159 }
160 setTimeout(function () {
161 // Someone downstream cancelled the submit — an app handler doing
162 // the work itself, most likely. Nothing is on its way anywhere,
163 // so hand the form back, guard included, and let whoever took the
164 // job own the feedback too.
165 if (e.defaultPrevented && !held.has(e)) { busyOff(form); return; }
166 if (!btn) return;
167 if (label && btn.tagName === "INPUT") {
168 btn.setAttribute("data-idle-label", btn.value);
169 btn.value = label;
170 }
171 btn.disabled = true;
172 }, 0);
173 }
174
175 document.addEventListener("submit", busySubmit, true);
176
177 // ── The hold ─────────────────────────────────────────────────────
178 //
179 // A spinner can only be promised a minimum time by holding the
180 // submit itself: once a fast response arrives the browser navigates
181 // and the spinner is gone. So the submit the busy rule just armed is
182 // cancelled here and sent again, by requestSubmit, once HOLD_MS have
183 // passed since the click.
184 //
185 // This listener is on the window, in the bubble phase — the last place
186 // a submit event reaches — so every handler on the form and the
187 // document has had its say first. One that cancelled the submit owns
188 // it (e.defaultPrevented, and the busy tick above hands the form
189 // back); so does the guard's own refusal of a second submit. Only a
190 // submit nobody else stopped is held, and only if the busy rule armed
191 // it (aria-busy on the form: not opted out, not a dialog, not a new
192 // window) and a button is showing the spinner.
193 var held = new WeakSet(); // submit events this listener cancelled
194 var pending = new Map(); // form -> timer, for a hold still running
195 var releasing = null; // the form whose held submit is going now
196 window.addEventListener("submit", function (e) {
197 var form = e.target;
198 if (!form || form.tagName !== "FORM") return;
199 if (form === releasing) { releasing = null; return; }
200 if (form.hasAttribute("data-live-form")) {
201 if (e.defaultPrevented || e.liveForm) return;
202 busySubmit(e, true);
203 }
204 // Never hold a pow form: pow has already held it while it solved,
205 // and a second hold is a second wait for nothing. Worse, holding it
206 // here and re-sending later would send it back through pow, which
207 // would hold it again with busy.js's guard still armed, and the form
208 // could never submit.
209 if (form.hasAttribute("data-pow-form")) return;
210 if (e.defaultPrevented || form.getAttribute("aria-busy") !== "true") return;
211 // Only a submit with a spinner to show is held: the hold is for the
212 // spinner. A script's requestSubmit() with no submitter, or a button
213 // that opted out, has nothing on screen to keep up, and holding it
214 // would only be a silent 650ms stall. The form is still guarded.
215 var btn = e.submitter || null;
216 if (!btn || btn.getAttribute("aria-busy") !== "true") return;
217 e.preventDefault();
218 held.add(e);
219 pending.set(form, setTimeout(function () { release(form, btn); }, HOLD_MS));
220 // pagehide fires only once a navigation commits, and a slow
221 // destination leaves the hold running until then: a held submit
222 // released meanwhile replaces the navigation the visitor chose.
223 // beforeunload fires as the navigation starts. Attached only while
224 // something is held, so it keeps no page out of the back-forward
225 // cache.
226 if (pending.size === 1) window.addEventListener("beforeunload", dropHeld);
227 });
228
229 // Send the held submit. The submitter goes with it, so its name and
230 // value are in the payload exactly as a click would have sent them —
231 // which means, for the moment the entry list is built, it has to be
232 // enabled and an <input type="submit"> has to carry its own value
233 // again, not the busy label. The form is re-checked first: fields
234 // stay editable during the hold, and a form that is no longer valid
235 // must be handed back with its errors showing, not left spinning.
236 function release(form, btn) {
237 pending.delete(form);
238 if (pending.size === 0) window.removeEventListener("beforeunload", dropHeld);
239 if (!form.isConnected) return;
240 var skip = form.hasAttribute("novalidate") ||
241 (btn && btn.hasAttribute("formnovalidate"));
242 if (!skip && !form.checkValidity()) {
243 busyOff(form);
244 form.reportValidity();
245 return;
246 }
247 // The submitter may have left the form during the hold (moved,
248 // removed, its form= changed): requestSubmit would throw and leave
249 // the form guarded for good. Hand it back instead.
250 if (btn && (!btn.isConnected || btn.form !== form)) {
251 busyOff(form);
252 return;
253 }
254 var busyValue = null;
255 // Restored, not assumed: a button that opted out of the busy state
256 // (data-busy="false") was never disabled, and must not end up so.
257 var wasDisabled = btn ? btn.disabled : false;
258 if (btn) {
259 btn.disabled = false;
260 var idle = btn.getAttribute("data-idle-label");
261 if (btn.tagName === "INPUT" && idle !== null) {
262 busyValue = btn.value;
263 btn.value = idle;
264 }
265 }
266 releasing = form;
267 try {
268 form.requestSubmit(btn || undefined);
269 } finally {
270 releasing = null;
271 if (btn) {
272 btn.disabled = wasDisabled;
273 if (busyValue !== null) btn.value = busyValue;
274 }
275 }
276 }
277
278 // A navigation started while a submit was held: drop every held
279 // submit and hand the forms back, so a navigation that is then
280 // cancelled (a download, a beforeunload prompt) leaves no form
281 // spinning.
282 function dropHeld() {
283 pending.forEach(function (timer, form) {
284 clearTimeout(timer);
285 busyOff(form);
286 });
287 pending.clear();
288 window.removeEventListener("beforeunload", dropHeld);
289 }
290
291 // Leaving the page — a link, Back — while a submit is held drops it:
292 // the visitor has gone somewhere else, and a timer surviving into the
293 // back/forward cache would otherwise send it on their return.
294 window.addEventListener("pagehide", function () { dropHeld(); });
295
296 // The back/forward cache restores a page's DOM exactly as it was left
297 // — busy buttons still disabled, still wearing the busy label and the
298 // spinner — so a visitor who navigates back finds a dead form. Hand
299 // every busy form back.
300 window.addEventListener("pageshow", function (e) {
301 if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff);
302 });
303})();
304