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