# 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](/docs/runtime/) it binds
through: 4.8 kB raw for both, measured on its own fixture page.

```ts
import { bindForms } from '@moonarc/core/form';

bindForms(); // every form[data-ma-form], now and after every ClientRouter navigation
```

Call 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

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

1. The browser validates the form as usual. Only a valid form fires `submit`.
2. The listener on the form itself calls `preventDefault()` and reads the
   `FormData` before anything asynchronous happens, and so before VanishInput's
   listener on `document` has emptied the field.
3. While waiting: `data-pending` on the form, `aria-disabled="true"` on its
   submit buttons (not `disabled`, which drops the focus to the page),
   `novalidate` on the form, and the `pending` text. Another submit is
   ignored: prevented and stopped at the form, so nothing listening on
   `document` sees it and nothing is sent twice.
4. 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-duration` plus
   one `--ma-stagger-tight` per character sent instead. Before leaving
   for `data-success`, it waits up to two `--ma-duration` more for what the
   `done` state 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:

```ts
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](/components/vanish-input/) 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-name` input 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.
- **`novalidate` while waiting and after success.** The field VanishInput
  emptied is `required`, 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 sets `novalidate` only while it waits, and after `done` or
  `exists` until 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.