Forms and validation
Design a complete correction and submission task, from a usable draft to an evidenced outcome.
Choose the right form boundary
Start with what the user is trying to change and what evidence confirms the change. Group related inputs, use the correct control types, and show constraints before submission. Avoid asking for data that the task does not need.
Use a real form for one submission operation. For local teaching examples, expose explicit simulated outcomes. For a product operation, verify the actual response and retained record before showing success.
Anatomy of a complete task
| Layer | Responsibility |
|---|---|
| Draft | User-editable values; preserve spelling, line breaks, pasted content and composition |
| Validation | Native/registered constraints for correction; authoritative server rules for acceptance |
| Payload | Declared fields, units, ownership and serialization. Inspect native FormData or registered values intentionally |
| Pending snapshot | The exact request awaiting an outcome; duplicate guard and cancellation semantics |
| Retained result | Confirmed revision/result separate from draft; explicit edit and recovery actions |
| Evidence / permission | Source, qualified review and permission records remain separate from a successful UI interaction |
Working local submission, rejection and restoration
Nothing has been saved. This is a local form simulation.
'use client';
import { useRef, useState } from 'react';
import { Button, Field, FieldControl, FieldDescription, FieldError, FieldLabel, Form, Input } from '@mobtranslate/ui';
export function ValidatedFormExample() {
const [title, setTitle] = useState('');
const [email, setEmail] = useState('');
const [pending, setPending] = useState<{title: string; email: string} | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [result, setResult] = useState('Nothing has been saved. This is a local form simulation.');
const titleInput = useRef<HTMLInputElement>(null);
const retained = useRef<{title: string; email: string} | null>(null);
function resolve(outcome: 'success' | 'failure' | 'cancel') {
if (!pending) return;
if (outcome === 'success') { retained.current = pending; setResult(`Retained local record: ${pending.title}, ${pending.email}. No server write or email occurred.`); }
if (outcome === 'failure') { setErrors({title:'Simulated rejection. Edit the local title and retry.'}); setResult('Local submission failed. Both draft values remain available.'); }
if (outcome === 'cancel') setResult('Local submission cancelled. Both draft values remain available.');
setPending(null); titleInput.current?.focus();
}
return <div data-form-lab className="space-y-4">
<Form errors={errors} aria-busy={Boolean(pending)} onFormSubmit={values => {
if (pending) return;
setErrors({}); setPending({title:String(values.title),email:String(values.email)}); setResult('Local submission pending. Choose an explicit local outcome.');
}}>
<Field name="title" validate={value => typeof value === 'string' && value.trim().length >= 3 ? null : 'Use at least three non-space code units for the local title.'}>
<FieldLabel>Local record title (required)</FieldLabel>
<FieldControl ref={titleInput} data-form-title value={title} onValueChange={setTitle} readOnly={Boolean(pending)} required render={<Input />} />
<FieldDescription>English-only example; entered values stay in this page.</FieldDescription><FieldError />
</Field>
<Field name="email"><FieldLabel>Local contact email (required)</FieldLabel><FieldControl data-form-email type="email" required value={email} onValueChange={setEmail} readOnly={Boolean(pending)} autoComplete="email" render={<Input />} /><FieldDescription>Format validation does not prove ownership or delivery.</FieldDescription><FieldError match="valueMissing">Enter an email for this local example.</FieldError><FieldError match="typeMismatch">Use a valid email format for this local example.</FieldError></Field>
<div className="flex flex-wrap gap-3"><Button type="submit" disabled={Boolean(pending)}>Submit local record</Button><Button type="button" variant="outline" disabled={Boolean(pending)} onClick={() => { setTitle(retained.current?.title ?? ''); setEmail(retained.current?.email ?? ''); setErrors({}); setResult(retained.current ? 'Restored the retained local values.' : 'Cleared the unsaved local values.'); titleInput.current?.focus(); }}>Restore retained draft</Button></div>
</Form>
{pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={() => resolve('success')}>Complete local form</Button><Button type="button" variant="outline" onClick={() => resolve('failure')}>Fail local form</Button><Button type="button" variant="outline" onClick={() => resolve('cancel')}>Cancel local form</Button></div>}
<p role="status">{result}</p>
</div>;
}
Implementation choices
- Compose FieldControl render={<Input />} for registered inputs. Native Textarea requires explicit label/help/error relationships and a real validation strategy. Fieldset groups related controls under a legend.
- Validate at an appropriate boundary. Show correction text, preserve the rest of the draft and focus the affected control or linked error summary. Avoid eager errors during composition.
- Snapshot values before pending state. Keep a stable local requested/retained distinction; handle general failures as well as field rejection.
- For async product tasks, explicitly manage request identity, obsolete responses, retries and uncertainty after cancellation. The page-memory demo has no network request.
- Use named submit versus type=button actions. Controlled restore/reset needs state updates, not only native reset. Keep independent recovery reachable when a control disappears.
Responsive, international and accessible behavior
- Keep logical reading/focus order at a single narrow column. Use meaningful legends, visible labels and reachable error/cancel actions at 320 CSS pixels and zoom.
- Use correct autoComplete/inputMode hints; allow paste and password managers. Autofill and mobile keyboard tests require the actual browser/device.
- Localize instructions and format values by actual locale. Preserve content language and bidi context separately; never manufacture language content or source authority.
- Avoid announcing each keystroke assertively. Verify error/status announcements and retained input with a named screen-reader/browser pair.
Failure review and acceptance
| Check | Observable evidence |
|---|---|
| Blank / invalid | No operation starts; linked correction message and useful focus |
| Valid / pending | Captured values, retained read-only draft and duplicate guard |
| Failure / cancel | Truthful outcome, preserved draft, available retry and bounded focus recovery |
| Success / restore | Confirmed retained result and explicit route back to it |
| Disabled / read-only | Actual editing and serialization behavior matches the task |
| Source / permission | Independent evidence remains visible; successful form validation is not an authority gate |
Verification and release scope
- Verify the actual label/control connection, description/error IDs, invalid state, focus order and corrected input with pointer and keyboard. Read the submitted payload rather than inferring it from visible text.
- Test blank, populated, invalid, disabled, read-only and pending/recovery states. Check both themes, long labels, user text spacing, 320px reflow, reduced motion, forced colors and zoom.
- Run the named input-family browser checks and source/type/build checks. Inspect mobile keyboards, autofill, password managers and a named screen-reader/browser pair separately. DOM tests do not prove their behavior.
- Client validation supports correction; it does not establish authorization, consent, source authority, language quality, identity, security or a successful server write.