Docs · 11 pages
Docs / Patterns
Forms that send
One function turns a plain posting form into a fetch with a pending state, spoken status, a thanks-page record and the value back after a failure. The form still posts with JavaScript off.
A form here is a real form first: method="post" and an action that works
with JavaScript off. bindForms() is the enhancement. It takes over the
submit, posts with fetch, keeps the button focused while it waits, speaks
the result through a status line, and either stays on the page or moves on to
a thanks page. It adds 2.9 kB raw to the runtime it binds
through: 4.8 kB raw for both, measured on its own fixture page.
import { bindForms } from '@moonarc/core/form';
bindForms(); // every form[data-ma-form], now and after every ClientRouter navigationCall it from the script of the section or layout that renders the form. A
second call is a no-op, so two sections on one page cannot bind a form twice.
Forms without data-ma-form, and marked forms whose method is not post, are
left alone.
The markup
<form data-ma-form method="post" action="https://formspree.io/f/abcd1234"
data-ma-form-status="join-status"
data-success="/thanks/"
data-msg-pending="Adding you…" data-msg-done="You're on the list."
data-msg-exists="You're already on the list." data-msg-error="That didn't go through. Try again."
data-msg-429="Too many tries. Wait a minute." data-msg-offline="You're offline. Try again when you're back.">
<label for="join-email">Email</label>
<input id="join-email" name="email" type="email" autocomplete="email" required>
<button>Join</button>
</form>
<p id="join-status" role="status"></p>The status line must be in the page when it loads, not hidden and without
aria-busy: a live region that appears with its text, or is busy, is not
announced. The same text twice in a row is emptied for two frames first, so
it is announced again. It can sit inside the form or anywhere else; the form names it by
id.
| Attribute | What it does |
|---|---|
data-ma-form-status |
Required. The id of the role="status" element. |
data-endpoint |
Where fetch posts. Unset, the form’s action. |
data-encode |
multipart (default: the FormData itself), urlencoded, or json. |
data-success |
A page to go to after success. Unset, the form stays. |
data-carry |
Response keys to hand to that page, space-separated. |
data-ref-param |
A URL parameter (ref) to send along (see Referrals). |
data-ma-form-demo |
No request: success with this JSON as the response. |
data-msg-pending, -done, -exists, -error, -429, -offline |
The status texts. A missing 429 or offline says the error text; a missing exists says done. |
Every request carries Accept: application/json. With json, fields with
the same name keep the last value, and files need multipart.
What a submit does
- The browser validates the form as usual. Only a valid form fires
submit. - The listener on the form itself calls
preventDefault()and reads theFormDatabefore anything asynchronous happens, and so before VanishInput’s listener ondocumenthas emptied the field. - While waiting:
data-pendingon the form,aria-disabled="true"on its submit buttons (notdisabled, which drops the focus to the page),novalidateon the form, and thependingtext. Another submit is ignored: prevented and stopped at the form, so nothing listening ondocumentsees it and nothing is sent twice. - The response decides, then the finite animations inside the form finish
(an endless shimmer on the button is not waited for). A tab in the
background, where animations do not run, waits two
--ma-durationplus one--ma-stagger-tightper character sent instead. Before leaving fordata-success, it waits up to two--ma-durationmore for what thedonestate animates. Each response sets:
| Response | data-state |
Text | Then |
|---|---|---|---|
2xx, and ok is not false in a JSON body |
done |
done |
ma:form-done; to data-success, or stay |
409, or status: 'exists' in the body |
exists |
exists |
stay |
| 429 | error |
429 |
value back, focus on the field |
any other status, or ok: false |
error |
error |
value back, focus on the field |
| no response (offline, blocked) | error |
offline |
value back, focus on the field |
After done or exists without leaving, the form stays as it is (buttons
aria-disabled, focus where it was, the status reading the result), and a
second submit is ignored until a field changes. The first change clears
data-state and the lock, and brings back the form’s own novalidate
setting.
After a failure every text field the submit read is refilled, but only if it
is still empty: whatever the visitor typed in the meantime stays. The focus
goes to the first of them that held text and is on screen, so a hidden
honeypot field before it is passed over. The text
from the service itself (Formspree’s errors[].message, for instance) is
never shown: it cannot be translated, and the status line is in the page’s
language.
Two events bubble from the form: ma:form-done with the parsed response (or
{} when it was not JSON), and ma:form-error with { status }, where status is 0 when
there was no response. exists sends neither; read data-state.
The thanks page
On success with a data-success on the same origin, just before leaving, the
lib writes one record:
sessionStorage['ma:form'] = JSON.stringify({ path: '/thanks/', at: 1758268800000, position: 2401, referral: 'k7Qp2' });path is the page it is going to and at the time, so the page can tell a
fresh arrival from a reload an hour later. The other keys are the ones named
in data-carry, copied from the response only when the value is a number or
a short token (A–Z a–z 0–9 _ -, 1 to 32 characters), as it came: a service
answering "position": "12" leaves the string "12", and the ThankYou
section reads a position of 1 to 15 digits as a number or as a string. An
email address can never pass that test, so it never reaches storage, the URL
or a log. The
record is written even when nothing is carried, because the thanks page’s confetti
needs to know that a form was just sent. Storage that throws (a private
window, blocked site data) is skipped.
A native post writes no record, so a thanks page reached without JavaScript shows no position, no referral and no confetti. Write it to read well without them.
Referrals
data-ref-param="ref" reads ?ref=… from the page’s address. The value is
checked like a carried one. If the form has a field with that name (a hidden
input, as Netlify needs, because it only stores fields present in the HTML),
the lib fills it in when it binds; otherwise the value is appended to the
FormData at submit. Without JavaScript the referral is lost.
Services
The lib does not know service names. A template maps its form.adapter
config onto these attributes:
| Adapter | Without JavaScript | With bindForms() |
|---|---|---|
demo (development only) |
method="dialog": outside a <dialog> a submit goes nowhere, and a static note says nothing is sent |
data-ma-form-demo: a fake success with a sample position and referral |
formspree |
posts to https://formspree.io/f/{id} and shows Formspree’s page |
multipart; a 429 from Formspree says the 429 text |
netlify |
data-netlify, a form-name input, action="/thanks/": Netlify stores it and shows your page |
data-endpoint="/", urlencoded with form-name |
custom |
posts to your endpoint, which answers 303 to /thanks/ |
json by default; answer { ok, status?, position?, referral? } with status created or exists, or 409 for exists |
A demo form is bound whatever its method, never sends a request, and succeeds
once its animations have finished. An empty data-ma-form-demo responds {}.
What happens without JavaScript
Nothing is hidden behind the script. The form posts natively to its action,
the browser validates it, the service answers with its own page or a
redirect. The status line is empty and stays empty. Test your form with
JavaScript off once: that is what a visitor on a slow connection gets before
the script arrives.
Using it with VanishInput
VanishInput blurs the typed text away when a
submit was prevented: it listens on document and reads defaultPrevented.
The lib’s listener is on the form, so it always runs first: it prevents the
default synchronously and reads the value before the field is emptied, and
the vanish plays while the request is out. A failure waits for the vanish to
finish, then puts the text back and focuses the field. An ignored submit
(while waiting, or done without a change) is stopped at the form, so the
vanish never empties a second value that was not sent.
Two exceptions to the form contract
The form controls follow one contract: native elements with your attributes
passed through, native validation, labels, focus rings, 24 px targets. A form
bound by bindForms() departs from it in two places, on purpose:
- Hidden service inputs. A
form-nameinput or Netlify’s referral field are not controls with a label; they are the form’s plumbing, which the contract’s first point (native controls, attributes passed through) does not govern. novalidatewhile waiting and after success. The field VanishInput emptied isrequired, so without it a second click would pop the browser’s “please fill in this field” bubble over a form that was just sent. The lib setsnovalidateonly while it waits, and afterdoneorexistsuntil a field changes; your own value comes back afterwards.
Pages restored from the back/forward cache
A page restored from the back/forward cache (pageshow with persisted)
starts over: no data-state, no lock, an empty status line, your own
novalidate. A form that was submitted is also reset (form.reset()):
every control goes back to its markup, the consent box included, and the
field VanishInput emptied loses its :user-invalid error border, which
would otherwise show with no message beside it. The referral from ?ref= is
filled in again. A form that was never submitted, or that failed and got its
values back, keeps what the visitor typed. With <ClientRouter /> the form
is a new element on every visit and is bound fresh.