MobTranslate styleguide

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

LayerResponsibility
DraftUser-editable values; preserve spelling, line breaks, pasted content and composition
ValidationNative/registered constraints for correction; authoritative server rules for acceptance
PayloadDeclared fields, units, ownership and serialization. Inspect native FormData or registered values intentionally
Pending snapshotThe exact request awaiting an outcome; duplicate guard and cancellation semantics
Retained resultConfirmed revision/result separate from draft; explicit edit and recovery actions
Evidence / permissionSource, qualified review and permission records remain separate from a successful UI interaction

Working local submission, rejection and restoration

English-only example; entered values stay in this page.

Format validation does not prove ownership or delivery.

Nothing has been saved. This is a local form simulation.

tsx
'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

CheckObservable evidence
Blank / invalidNo operation starts; linked correction message and useful focus
Valid / pendingCaptured values, retained read-only draft and duplicate guard
Failure / cancelTruthful outcome, preserved draft, available retry and bounded focus recovery
Success / restoreConfirmed retained result and explicit route back to it
Disabled / read-onlyActual editing and serialization behavior matches the task
Source / permissionIndependent 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.