# Complex form construction — practitioner spec

> A tool-neutral Markdown reference. Paste the contents into any AI assistant (Claude, ChatGPT, Gemini, etc.) as context, or read it directly. For tool-specific versions (Claude Skill, Cursor rule), see the sibling files at https://uxvenkata.com/specs/.

Authoritative rules for building forms that get finished. Written from twelve years of shipping enterprise forms; condensed from the long-form series at https://uxvenkata.com/resources/patterns/complex-form.

This is a rules-and-checklists document, not a tutorial. Apply rules verbatim. When in doubt, prefer the native platform behavior and the simpler control.

---

## 1. Field-type decision tree

Run this top to bottom for every form decision:

1. Does the change take effect immediately, before any submit? → **toggle** (`role="switch"`). Stop.
2. Form submission, user picks **multiple** from a fixed list (≤10 options visible)? → **checkboxes**.
3. Form submission, user picks **exactly one**, 2–5 visible options? → **radios**.
4. Form submission, user picks **exactly one**, 6+ options or hidden? → **native `<select>`**.
5. Form submission, user needs to **search or type to find** an option? → **combobox / autocomplete**.
6. Single binary form choice (consent, agreement, opt-in)? → **one checkbox**.
7. Free text? → **`<input>` / `<textarea>`** per the text-input rules below.

---

## 2. Text inputs

### Always
- Label above the field, visible at all times. Never use placeholder as label.
- One message slot under the field, reserving its line-height even when empty. Helper *or* error, never both at once.
- `font-size: 16px` minimum on iOS Safari (zoom prevention).
- Set `autocomplete` correctly per WHATWG list. Search the list; never write from memory.
- Set `inputmode` for digit-only fields (`numeric`, `decimal`, `tel`, `email`, `url`, `search`).
- Set `enterkeyhint` on each input (`next` for non-final, `done`/`go` for final).
- Required: mark on label + `aria-required="true"`. Asterisk alone is insufficient.

### Never
- `type="number"` for phone, ZIP, credit card, account numbers. Use `type="text" inputmode="numeric"` instead.
- `autocomplete="off"` to suppress autofill. Browsers ignore it. Design around autofill.
- Placeholder as the only label. Disappears on typing, low contrast, untranslated.
- Disabled submit button to indicate invalid form. Leave it enabled; show errors on click.
- Pre-filling or pre-checking legal/consent fields.

### Choosing disabled vs readonly
- **Disabled**: non-interactive AND not submitted with form. Use when field shouldn't exist for this user.
- **Readonly**: non-interactive BUT submitted with form. Use when value is set elsewhere (server, prior step, computed).

---

## 3. Selects, dropdowns, comboboxes

### Defaults
- Native `<select>` is the default for single-select. Restyle the trigger (`appearance: none` + custom chevron); keep the native popup.
- Custom select only justified by: rich option content (image+text), inline section grouping, async-loaded options, type-to-search inside the list.

### If building custom
Must clear all of the following before shipping:
- ARIA roles correctly applied (`combobox` or `listbox` depending on pattern; `aria-expanded`, `aria-activedescendant`, `aria-selected`).
- Full keyboard: arrows, Home/End, type-to-jump (any printable character), Enter to select, Escape to close.
- Focus trap when open; focus returns to trigger on close.
- Click-outside and Escape both close the popup.
- Touch target on the trigger ≥ 44×44 px.
- Full-screen modal or bottom-sheet on mobile for long lists. Never a tiny anchored dropdown.

### Multi-select
- Selected chips outside the dropdown, always visible. Each chip dismissible (X icon + keyboard backspace from input).
- Chips wrap; never horizontal-scroll.
- Selections persist across dropdown open/close.

### Combobox (search-as-you-type)
- Input is instant. Debounce only the fetch (200ms typical). Never debounce the input itself.
- Highlight matched substring in results.
- Allow clearing via visible X and Escape key.
- Empty state designed: recent / suggested / popular before user types.
- Zero-results state designed: tell next step ("Did you mean…", "No matches; try…"). Never blank.

---

## 4. Checkbox / radio / toggle

### Toggle (`role="switch"`)
- Live state. Applied immediately, before any submit.
- Label describes the on-state: "Enable dark mode", not "Dark mode".
- Destructive toggle: flip optimistically + show toast with Undo. Never "are you sure?" pre-confirmation.
- On network failure, visually revert + surface retry. Never leave the toggle showing a state that doesn't match the server.

### Radio
- 2–5 visible options that map to a single form-submitted choice. Above 5, switch to `<select>`.
- Wrap with `<fieldset><legend>`. The legend is the question; each radio is an answer.
- Arrow keys move selection within the group. Tab leaves the group.
- Pre-select a default only when there's a sensible one. "No selection" is a valid initial state.

### Checkbox
- Multi-select from a fixed list (≤10 options) or single binary form choice.
- Each independent. If picking one needs to deselect another, you have radios.
- Indeterminate state exists (`aria-checked="mixed"`) for parent checkboxes in trees. Use it; don't fake it.
- Never pre-check legal/consent checkboxes. GDPR / CCPA / UX all reject pre-checked consent.

---

## 5. Date and time pickers

### Defaults
- Native `<input type="date">` and `<input type="time">` first.
- Custom justified by: range selection (start+end), recurring schedules, multi-date selection, inline embedded calendar.
- "Design system doesn't match the native picker" is not a justification.

### Storage and transport
- Always ISO 8601 (`YYYY-MM-DD` for date, full ISO for date-time). Never depend on user locale for wire format.

### Display
- `Intl.DateTimeFormat(navigator.language, options)`. Never hardcode date format strings.
- 24-hour vs 12-hour follows locale, not user preference.

### Time and timezone
- Store in UTC. Display in user's zone.
- Include timezone in transport when the time matters at the receiver (delivery, meeting).
- For cross-timezone display, show both: "3:00 PM your time · 8:30 PM IST".

### Free-text entry
- Accept typing in the displayed format. Show format hint next to the field.
- Stretch goal: natural-language parsing ("next Friday", "in two weeks") via chrono-node or equivalent.

### Mobile pickers
- iOS wheel and Android modal calendar are platform-grade. Use them.
- Custom mobile pickers: full-screen modal, 44px touch targets, big OK / Cancel buttons.
- Bottom sheets work for compact pickers; not for date ranges (too cramped).

---

## 6. File upload

### Five states to design
1. Empty (drop zone / trigger)
2. Uploading (progress + abortable cancel)
3. Uploaded (filename, size, thumbnail if relevant, Remove)
4. Error (specific cause + Retry, never generic "try again")
5. Multi-file (per-file state + per-file actions)

### Four input paths
- **Drag-drop**: design the dragover hover state on the drop zone.
- **Click**: `<input type="file">` with explicit MIME list in `accept` (e.g. `accept="image/png,image/jpeg,image/webp"`, not `image/*`).
- **Paste**: listen for `paste` event; check clipboard for files. Critical for screenshots.
- **Camera (mobile)**: `accept="image/*" capture="environment"` (rear) or `capture="user"` (front).

### Network handling
- Real progress via XHR progress event or stream upload. Not fake spinners.
- Cancel must call `AbortController.abort()` or `xhr.abort()`. Not just hide UI.
- Network blip: retain file reference; offer Retry. Never make user re-select.
- Partial fail: per-file state + per-file retry.
- Navigate-away mid-upload: `beforeunload` prompt for in-flight uploads.

---

## 7. Validation and error grammar

### When to fire
- **Never** on every keystroke. Tax with no return.
- **On blur**: things checkable locally (email format, number range, required-empty).
- **On submit**: things checkable only server-side (account exists, code valid, payment authorized).

### Where errors appear
- Inline under the field, in the reserved message slot. No layout shift.
- `aria-describedby` on the field points to the error message id.
- `aria-live="polite"` on the error container for non-blocking announcements.

### Error grammar
- Action the user can take: "Add your phone number". Not: "Phone is required".
- Specific to what failed: "That email already has an account. Sign in instead?" Not: "Invalid input".
- Avoid blame: "We couldn't reach the server" not "You're offline".

### On failed submit
- Move focus to the first invalid field.
- Scroll into view if needed.
- Announce error count via live region for screen readers.
- Submit button stays enabled; "submitting…" state is for in-flight only.

---

## 8. Accessibility — non-negotiable

- Every field has `<label for>` or `aria-label`. No exceptions.
- Required marked on label AND `aria-required="true"`.
- Visible focus ring on every interactive element. Never `outline: none` without a visible replacement.
- Touch targets ≥ 44×44 px (Apple HIG) / 48dp (Material).
- Color contrast ≥ 4.5:1 for text, ≥ 3:1 for non-text UI elements.
- Tab order matches visual order. Never use `tabindex` > 0.
- Error associated to field via `aria-describedby`.
- Live regions: `aria-live="polite"` for status; `assertive` only for blocking errors.
- Keyboard-only test passes. Screen-reader test passes (NVDA + VoiceOver minimum).

---

## 9. Autosave and draft recovery

- Long forms (>30s to fill): autosave every 5–30s OR on blur of any field.
- Visible save status: "Saved 2s ago" / "Saving…" / "Save failed — Retry".
- Restore on reload: prompt user, never auto-restore silently.
- **Never persist**: passwords, OTP codes, payment card data, government IDs, sensitive PII.
- Storage choice: localStorage for browser-only drafts; server-side draft endpoint for cross-device.
- Privacy: drafts stored locally must be cleared on logout / on completed submission.

---

## 10. Internationalization

- **Name**: one field, "Full name". Many cultures don't split first/last. Don't force a shape.
- **Address**: free-form lines + country dropdown. Address shape varies by country; let users format their way.
- **Phone**: country code selector + national number. Store E.164.
- **Currency**: `Intl.NumberFormat(locale, { style: "currency", currency })`. Symbol position varies.
- **Date**: ISO 8601 storage, `Intl.DateTimeFormat` display.
- **Numbers**: `1,000.00` (en-US) vs `1.000,00` (de-DE) vs `1 000,00` (fr-FR). Use Intl.
- **RTL**: test in Arabic/Hebrew. Use CSS logical properties (`margin-inline-start`, `padding-block-end`).

---

## 11. Mobile mechanics

### `inputmode` quick reference
- `numeric` — PINs, ZIP codes (no decimal, no minus)
- `decimal` — currency, weights, quantities
- `tel` — phone keypad with `#`, `*`, `+`
- `email` — keyboard with @ visible
- `url` — keyboard with `.com` and `/`
- `search` — Return key labeled "Search"

### iOS zoom prevention
```css
input, textarea, select {
  font-size: 16px;
}
```

### `enterkeyhint`
- `next` — every field except the last
- `done` or `go` — last field before submit
- `search` — search inputs

### Picker preferences
- Date/time: native > custom (mobile native is platform-quality).
- Single-select: native `<select>` > custom (mobile native opens OS picker).
- Justify custom only with a real functional requirement.

---

## 12. Submission feedback

- Loading state inside the submit button (text + spinner). Disable the button only while in-flight, not while form is "invalid".
- Success state: clear feedback + next action ("Order placed · Track it →").
- Network failure: surface specifically + offer retry. Preserve form data.
- Optimistic UI on toggles only; never on submit actions where confirmation matters.

---

## Pre-ship checklist

### Field-level
- [ ] Label above every field. No placeholder-as-label anywhere.
- [ ] `autocomplete` set per WHATWG list for every field.
- [ ] `inputmode` set for digit-only fields.
- [ ] `type="text" inputmode="numeric"` for digit strings (phone, ZIP, credit card) — not `type="number"`.
- [ ] Font-size ≥ 16px on mobile.
- [ ] `enterkeyhint` set on every input.
- [ ] Required marked on label + `aria-required`.
- [ ] Disabled vs readonly chosen correctly per submission needs.
- [ ] Touch targets ≥ 44×44.

### Validation and errors
- [ ] No keystroke-level validation.
- [ ] Blur validation for local checks.
- [ ] Submit validation for server checks.
- [ ] Error messages written as user actions.
- [ ] Focus moves to first invalid field on failed submit.
- [ ] Error message slot reserves space (no layout shift).
- [ ] `aria-describedby` from field to error.

### Accessibility
- [ ] Visible focus ring on every interactive element.
- [ ] 4.5:1 text contrast / 3:1 non-text.
- [ ] Live region for status announcements.
- [ ] Tab order matches visual order.
- [ ] Keyboard-only navigation works.
- [ ] Screen-reader test passed (NVDA + VoiceOver).

### Mobile
- [ ] Tested on real iOS and real Android device.
- [ ] No zoom on input focus.
- [ ] Native pickers used where appropriate.
- [ ] Full-screen / bottom-sheet for custom mobile pickers.
- [ ] Keyboard auto-hides when picker opens.
- [ ] One-handed reachability checked.

### Submission
- [ ] Optimistic UI for toggles only.
- [ ] Loading state inside submit button.
- [ ] Network failure: revert state + error + retry.
- [ ] Navigate-away prompt for unsaved or uploading state.
- [ ] Autosave for long forms (no passwords / OTP / payment / PII).

### Internationalization
- [ ] No hardcoded date / number / currency formats.
- [ ] Address adapts to country selection.
- [ ] Name field works for one-name cultures.
- [ ] RTL layout tested.
- [ ] Currency display uses Intl.

### Privacy & security
- [ ] No autosave of passwords / OTP / payment / sensitive PII.
- [ ] `autocomplete="new-password"` on password creation.
- [ ] `autocomplete="current-password"` on sign-in.
- [ ] `autocomplete="one-time-code"` on OTP fields.
- [ ] PII fields flagged for compliance review.

---

## Anti-patterns — never ship these

| Pattern | Why it's wrong | What to do instead |
| --- | --- | --- |
| Placeholder as label | Vanishes on typing; low contrast; unread by screen readers; doesn't translate. | Static label above the field. |
| `type="number"` for phone / ZIP / credit card | Wrong semantic; adds spinners; breaks paste of formatted values. | `type="text" inputmode="numeric"`. |
| `autocomplete="off"` to suppress autofill | Modern browsers ignore it. | Design around the autofill. |
| Validating on every keystroke | UX tax with no signal until field is complete. | Validate on blur (local) and submit (server). |
| Disabled submit button while form invalid | User can't tell why. | Leave enabled; show error on click. |
| Pre-checked consent / legal | Legal failure + UX failure. | Always unchecked default. |
| Multi-select hidden inside a dropdown | Selections invisible; click marathon to manage. | Chips outside the dropdown. |
| Custom date picker without keyboard typing | Forces 13 clicks for what a power user types in 3 keystrokes. | Allow free-text entry. |
| Fake "uploading" spinner with no real progress | User doesn't know if it's working or hung. | Real bytes from XHR progress event. |
| Toggle that lies about server state on failure | User thinks the setting is saved when it isn't. | Optimistic flip + revert on failure + Retry. |

---

## Source

This spec is the prescriptive companion to the long-form series at:
**https://uxvenkata.com/resources/patterns/complex-form**

When the spec and the articles disagree, the articles describe the reasoning; this spec describes the rule. Apply the rule.
