← All stories

COMPONENT · ui-form

ui-form

Component Tier 1 (primitive) Used wherever a user submits something

Forms are where most of the buyer-trust failures hide. A misclicked submit that double-charges. A network blip that loses 5 minutes of typing. An error message that doesn't say which field. A submit button that looks clickable while a request is in flight. ui-form is the one place we encode the answers to all of those once.

Component contract

Every form in the Aperture admin is implemented as <UiForm>. The linter scans the React source and flags any data-test attribute claimed as a ui-form instance that isn't rendered through <UiForm>. There is no per-page hand-rolled form path — submit lifecycle, idempotency-key wiring, and error rendering are component responsibility, not caller responsibility.

Props:

  • onSubmit: (data) => Promise<void> — async; the form awaits this and manages busy state during the call.
  • schema: ZodSchema — validation runs both client-side (before submit) and against the response (server validation errors map back to fields).
  • idempotencyKey?: string | "auto" — when set, the form attaches an Idempotency-Key header to its submit. auto derives the key from form content + a per-mount nonce so retries dedupe but distinct fills don't.
  • busyOverlay?: boolean — defaults true; freezes inputs while submitting.
  • errorRenderer?: "inline" | "summary" | "both" — defaults both; per-field inline errors PLUS a summary at the top of the form when there are 2+ errors.
  • submitOn?: "click" | "enter" | "both" — defaults both. Enter submits when focus is in a single-line input; never from a textarea.

Interaction surface

  1. User fills fields.

    Each field tracks dirty state. The form's overall dirty state is true if any field is dirty. The submit button's disabled state is computed: enabled when the form is dirty AND passes client-side schema validation. Disabled-but-dirty (validation failing) still allows click — it triggers showing the errors. Disabled-and-pristine (nothing typed yet) is fully unclickable.

  2. User submits — Enter key inside a single-line input, click the submit button, or Cmd/Ctrl+Enter from anywhere.

    The form runs client-side validation. If validation fails: show errors (per errorRenderer), focus moves to the first error, the request is NOT made. If validation passes: enter busy state, attach Idempotency-Key header if configured, await onSubmit.

  3. During the request: busy state.

    Submit button shows a spinner and the label changes to a busy variant ("Saving…", "Creating event…"). Inputs become readonly (not disabled — that loses focus visibility). The form root has data-busy=true for CSS to style. Cancel-button is shown if onCancel was provided AND the request has been in flight > 250ms; it aborts the request via AbortController.

  4. Server response: success.

    onSubmit resolves. The form clears busy state. Caller is responsible for navigation or toast (the form doesn't show its own success message — that's caller-decided UX).

  5. Server response: validation error (4xx with field-mapped errors).

    The form parses the error response (expects shape { error: { code, message, fields: { fieldName: messageText } } }) and renders per-field inline errors PLUS a summary if 2+ fields. Focus moves to first errored field. Form returns to non-busy state with input preserved.

  6. Server response: non-field error (network, 5xx, 402, 403).

    Form-level error banner appears at top of the form (the form-error-banner attribute). Banner includes a request id (parsed from X-Request-Id response header) when present, copyable for support. Form returns to non-busy state with input preserved. Caller's onSubmit can throw a typed error that the form maps to a friendlier banner message.

Failure modes

Double-submit on retry

Trigger: user clicks submit; network is slow; user clicks again; the original request was actually successful but the response was lost.

The form sends both requests with the same Idempotency-Key header (when configured). The server dedupes. The user sees one success. Without idempotency, the second click would create a duplicate. The harness simulates this by resolving the first request's response after the second click and asserts the server saw both clicks but only one create operation occurred.

Recovery: Server-side dedup. Form's job is just to send the same key.

Enter inside a textarea submits the form (regression)

Trigger: user is composing a multi-line description and presses Enter to start a new line.

Enter inside a <textarea> must NOT submit. Only Enter inside <input> single-line submits. Cmd/Ctrl+Enter submits from anywhere including textareas (explicit intent). The harness asserts: focus textarea, press Enter, assert no submit fired AND a newline was inserted.

Recovery: Component fix — keyboard handler must gate by target element type.

Submit during busy state

Trigger: user clicks submit, request is in flight, user clicks submit again before the response arrives.

Second click is a no-op. Submit button is visually busy AND aria-disabled="true" AND has data-test=ui-form-submit with data-busy=true. The harness asserts: click while busy, observe one network request total, no UI flicker.

Recovery: Component fix — busy-state guard.

Focus does not move to first error on validation fail

Trigger: user submits with multiple invalid fields. Errors render but focus stays on the submit button.

Screen-reader users would not hear which field is wrong; sighted keyboard users have to tab back to find the error. The component must call focus() on the first errored field after rendering errors, with preventScroll: false so the field is also scrolled into view.

Recovery: Component fix — focus first error.

Server validation error not mapped to fields

Trigger: server returns 400 with fields: { name: "Already exists" }. The form shows only the form-level error banner; the name field has no inline error.

The component must parse the response shape and route field errors to the matching field's error slot. If the server returns a field name that doesn't exist in the form, the error renders in the summary at top with the field name visible ("Field 'foo': bar"), so we don't silently lose information.

Recovery: Component fix — error response parser.

Form input lost on transient network failure

Trigger: user submits; connection drops; the submit fails. User retries.

Input must be preserved across the failed submit. The form does NOT reset on submit failure. Input is preserved in component state, NOT in localStorage (per ui-modal's "no half-finished draft persistence" pattern — that's for caller to opt into via a separate mechanism if they want it).

Recovery: Component already preserves input across failure. The harness asserts post-fail input matches pre-fail input.

Idempotency key changes between retries

Trigger: idempotencyKey="auto"; user submits, fails, submits again. The auto-generated key is different on the retry.

The auto-key is computed once per form mount and reused across retries until the form is unmounted (typically: until navigation away, or until the form is reset). Editing a field between retries does NOT regenerate the key — the user is retrying THIS submission. The harness asserts: two submits in a row send the same Idempotency-Key header.

Recovery: Component fix — auto-key cached at mount.

Accessibility

  • The form root is a <form> element with optional aria-labelledby pointing at a heading.
  • Each field's error has aria-live="polite" so announcement happens on render.
  • Each field uses aria-invalid="true" and aria-describedby referencing its error message id when in error state.
  • Required fields have an asterisk visual marker AND aria-required="true"; an explanation of the asterisk is in the form's accessible name or a per-form helper text.
  • Submit button has type="submit"; cancel has type="button".
  • Busy state announces "Submitting…" via aria-live="assertive" on the submit button's accessible name.
  • Pass axe-core with no serious or critical violations.

Stable test attributes

Component-level contract. Every <UiForm> instance MUST expose these.

Visibility teeth. Each attribute must be present AND effectively visible when the form is rendered. Hiding without removal is treated identically to removal — both require the loosening token.

data-testWherePurpose
ui-formThe <form> rootComponent identity marker; the dialog/page also carries the caller's instance id
ui-form-error-summaryTop of ui-formSummary error region; visible only when 2+ field errors OR a form-level error
ui-form-error-bannerTop of ui-formForm-level (non-field) error banner for network/500/quota/etc.
ui-form-submitInside ui-formThe submit button; carries data-busy, aria-disabled during submission
ui-form-cancelInside ui-formCancel button; appears only after >250ms of busy state
ui-form-busy-overlayInside ui-formVisible during submission when busyOverlay=true; aria-hidden when not busy

Agent test plan

Standalone probes run against a fixture page at /admin-test/ui-form-fixture exposing several variants of the form (with and without idempotencyKey, with and without busyOverlay, with multi-step). Inherited probes run against any branch instance whose usesComponents declares ui-form.

Probe list
- enter-in-input-submits: focus a single-line input, press Enter, assert one submit
- enter-in-textarea-no-submit: focus a textarea, press Enter, assert NO submit AND newline inserted
- cmd-enter-submits-anywhere: focus any field, press Cmd+Enter, assert one submit
- click-during-busy-no-op: click submit while data-busy=true, assert exactly one network call total
- focus-first-error-on-fail: submit with 3 invalid fields, assert document.activeElement is the first errored field
- error-banner-on-non-field: stub onSubmit to throw NetworkError, assert ui-form-error-banner visible with retry hint
- request-id-in-banner: stub onSubmit response with X-Request-Id header, assert banner shows that id
- input-preserved-across-fail: fill name, submit fail, assert name still equals filled value
- field-error-rendering: stub validation error with fields:{name:"taken"}, assert inline error sibling of [name=name] shows "taken"
- summary-when-2-plus-errors: stub validation error with 2 field errors, assert ui-form-error-summary visible
- summary-hidden-with-1-error: stub validation error with 1 field error, assert ui-form-error-summary absent (or aria-hidden)
- idempotency-key-stable-across-retries: submit twice in sequence with auto-key, assert both requests share Idempotency-Key
- idempotency-key-distinct-after-mount: unmount and remount form, submit, assert different Idempotency-Key
- cancel-aborts-after-250ms: trigger long-running submit, after 300ms assert ui-form-cancel visible, click it, assert AbortController fired
- submit-disabled-when-pristine: do not type, assert submit button has aria-disabled=true
- submit-enabled-when-dirty-valid: type valid input, assert submit aria-disabled=false