# Form Patterns — Copy-Paste Reference ## Contents - Complete field markup - Common autocomplete values - Validation timing (blur, then input) - Error summary after failed submit - Submit button pending state - Multi-step form skeleton - Gotchas ## Complete field markup ```html

We only use this for receipts.

``` The error node exists from the start (hidden) so `aria-describedby` stays stable. ## Common autocomplete values | Field | type | autocomplete | |---|---|---| | Email | email | email | | Phone | tel | tel | | First / last name | text | given-name / family-name | | Street / city / postal | text | address-line1 / address-level2 / postal-code | | Country | (select) | country-name | | Card number / expiry / CVC | text | cc-number / cc-exp / cc-csc | | New / current password | password | new-password / current-password | | One-time code | text | one-time-code | ## Validation timing (blur, then input) ```js const messages = { valueMissing: (label) => `Enter your ${label.toLowerCase()}.`, typeMismatch: () => 'Enter an email address with an @, like name@example.com.', tooShort: (label, input) => `${label} must be at least ${input.minLength} characters.`, }; function validate(input) { const label = input.labels[0].textContent; const errEl = document.getElementById(input.getAttribute('aria-describedby') .split(' ').find(id => id.endsWith('-error'))); let msg = ''; for (const key of Object.keys(messages)) { if (input.validity[key]) { msg = messages[key](label, input); break; } } input.setAttribute('aria-invalid', msg ? 'true' : 'false'); errEl.textContent = msg; errEl.hidden = !msg; return !msg; } document.querySelectorAll('input, select, textarea').forEach((input) => { input.addEventListener('blur', () => validate(input), { once: false }); input.addEventListener('input', () => { // re-validate live only after the field has already errored if (input.getAttribute('aria-invalid') === 'true') validate(input); }); }); ``` Pair with `
` so these messages replace the browser bubbles. ## Error summary after failed submit ```js form.addEventListener('submit', (e) => { const fields = [...form.querySelectorAll('input, select, textarea')]; const invalid = fields.filter((f) => !validate(f)); if (invalid.length) { e.preventDefault(); const summary = document.getElementById('form-alert'); summary.innerHTML = `

Fix ${invalid.length} error(s):

'; invalid[0].focus(); } }); ``` ```html ``` ## Submit button pending state ```js form.addEventListener('submit', async (e) => { e.preventDefault(); const btn = form.querySelector('button[type="submit"]'); btn.disabled = true; btn.dataset.label = btn.textContent; btn.textContent = 'Creating account…'; try { await submit(new FormData(form)); } catch (err) { showServerErrors(err); // role="alert" summary; inputs keep their values } finally { btn.disabled = false; btn.textContent = btn.dataset.label; } }); ``` Disable only during the request — a permanently disabled submit hides *why* the form can't be sent. ## Multi-step form skeleton ```html

Step 1 of 3

Your details
``` Rules encoded above: steps are fieldsets shown one at a time (data survives because nothing unmounts), per-step validation runs on "Continue", the last step is a read-only review, and the progress line is announced on change. ## Gotchas - `
` disables browser bubbles but NOT the `validity` API — exactly what you want for custom messages. - Disabled inputs are skipped on submit AND invisible to autofill/screen readers in some browsers — prefer `readonly` for locked-but-submitted values. - `pattern` is anchored (implicit `^…$`) and silently fails on typos in the regex; always pair with a `title`/error message that states the format. - iOS zooms into inputs with font-size <16px — keep inputs ≥16px. - Numeric codes (postal, OTP): use `inputmode="numeric" pattern="[0-9]*"`, NOT `type="number"` (which strips leading zeros and adds spinners). - Autofocus on page load disorients screen-reader users — reserve `autofocus` for single-purpose pages (search, login).