/* busy.js — the busy rule. First-party, dependency-free, and on by default: link it and every submit button in every form that goes somewhere is covered, with no attribute to remember, because a button that changes something should say so while it works, and a form that double-submits on a second click is a bug on every screen. It is app-owned from the moment it is vendored; leave it unlinked to opt the whole app out. What a submitted button does: its label gives way to a spinner (the label stays, beside the spinner, under reduced motion or forced colours — tokens.css), it goes aria-busy and then disabled, and its form refuses a second submit. The spinner shows for at least HOLD_MS: a response faster than that is held back until then, so a quick save reads as a deliberate beat rather than a flicker. Going Back to the page hands every busy form back, and a submit still being held when the page is left is dropped rather than sent later. Vocabulary: data-busy="false" on a
or on one submit button: opt OUT of the busy rule. The rule is the default, so any other value — data-busy on its own included — changes nothing data-busy-label="…" on the form or on the button: replacement button text while it works A script that handles a submit itself (a fetch, say) cancels it in a listener on the form or the document, as it would anyway: those run before this file's window listener, which then sees the cancelled submit and leaves it — and its feedback — to that script. Two edges of the hold, on purpose: a handler that stops the submit event's propagation keeps it from the window, so that submit goes at once, unheld; and a form removed from the page during the hold (a polled fragment replacing it) takes its held submit with it. */ (function () { "use strict"; // How long the spinner shows, at least. Long enough to read as an // answer to the click, short enough never to feel like waiting. var HOLD_MS = 650; // ── The busy rule ──────────────────────────────────────────────── // // A button that CHANGES something says so while it works; a button // that only reveals something — a disclosure, a dropdown, a tab — // does not. So every submit button in every form is covered by // default, with no attribute to remember: the submitted button gets // aria-busy, a spinner and — once the submission is under way — // disabled, and its form gets aria-busy and a guard against a second // submit. The data-busy vocabulary above opts out of either half. // // The guard is the substance, the spinner is the manners, and neither // is a promise: with scripts off the form submits exactly as it // always did, twice if the visitor clicks twice. Idempotency stays // the server's job. One delegated capture-phase listener, for the // reasons light dismiss gives below: fragments, and never double-bind. // // Two traps this shape exists to avoid: // // - A form the browser REFUSED must not sit there looking busy. // Constraint validation fails before the submit event is fired at // all, so an invalid form never reaches this code; a cancelled // one is handed back in the tick below. (formnovalidate skips // validation and really does submit, which should look like it.) // - Nothing that could change the payload happens while the payload // is being read. The entry list is built before submit fires, but // engines have differed, so aria-busy, the spinner and a //