oficina / estilo Public

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

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