Forms for Ember 6.12 LTS and 7

Write the form. The wiring writes itself.

One <Form>, one <f.Field>. Labels, ids, errors, aria-invalid, dirty state, reset and nested saves are handled. Bring any validator (zod, valibot, any Standard Schema) and any data layer (WarpDrive, ember-data, ember-changeset, plain objects). Then test it with harnesses that read the way your users do.

npm i @ember-formwright/ember

Add @ember-formwright/test-support for harnesses, and an adapter if you use one.

Why

A field is ten things. You should write one.#

An accessible field needs a label tied to its input, a hint and an error region the input is described by, aria-invalid at the right moment, a value bound one way and written back on change, and dirty state that knows what "unchanged" was. Formwright does all of it from a model path.

By hand, for every field
<label for="email-input">Email</label>
<input
  id="email-input"
  type="email"
  value={{this.user.email}}
  aria-invalid={{if this.errors.email "true"}}
  aria-describedby="email-hint email-errors"
  {{on "input" this.updateEmail}}
/>
<p id="email-hint">We never share it</p>
<ul id="email-errors" aria-live="polite">
  {{#each this.errors.email as |e|}}<li>{{e}}</li>{{/each}}
</ul>
With Formwright
<Form @model={{this.user}} @validator={{userSchema}} as |f|>
  <f.Field
    @for="email"
    @control="email"
    @label="Email"
    @hint="We never share it"
  />
  <f.Submit />
</Form>

Any validator

Pass a zod, valibot or ArkType schema, anything that speaks Standard Schema, or a plain function. Issue paths land on the right field.

Any data layer

Plain objects and @tracked classes work out of the box, with reset built in. Adapters teach it WarpDrive and ember-data (save, rollback, server errors) and ember-changeset (edits buffered until a valid submit).

Accessible by default

Labels, legends for groups, aria-describedby, live error regions, and focus on the first invalid field. Every test runs axe.

Inline editing

Click a value to edit it in place, or in a native popover anchored with CSS. Confirm with ✔, cancel with Esc. No positioning library.

Usage

Strict mode, named args, nothing to register#

Import Form into a .gts template. Fields, nested forms and buttons come from the form itself, already bound to it.

import { Form } from '@ember-formwright/ember'
import '@ember-formwright/ember/theme.css' // optional

<template>
  <Form @model={{@order}} @name="checkout" @onSubmit={{@save}} as |f|>
    <f.Field @for="email" @control="email" @label="Email" @required={{true}} />
    <f.Field @for="size" @control="select" @label="Size" @options={{SIZES}} />
    <f.Field @for="gift" @control="checkbox" @label="This is a gift" />

    <f.Form @model={{@order.address}} @name="address" @legend="Ship to" as |a|>
      <a.Field @for="city" @label="City" />
    </f.Form>

    <f.Errors />
    <f.Submit>Place order</f.Submit>
    {{#if f.state.isDirty}}Unsaved changes{{/if}}
  </Form>
</template>

Built-in controls: input, email, password, search, tel, url, number, range, color, date, datetime, time, textarea, select, radio, checkbox, checkbox-group and currency (formatted with Intl). Pass any component to @control, or register one by name. Searchable selects, date pickers, phone numbers, file uploads and rich text come from add-on packages, one library each.

Layouts change a field's structure without touching its wiring. Set @layout on a form or a field, or write your own from pre-wired parts:

<Form @model={{@member}} @layout="horizontal" as |f|>
  <f.Field @for="name" @label="Name" />
  <f.Field @for="agree" @control="checkbox" @label="I agree" @layout="checkbox-after" />
  <Actions>
    <:left><f.Submit>Save</f.Submit><f.Reset>Cancel</f.Reset></:left>
    <:right><f.Button @onClick={{this.delete}}>Delete</f.Button></:right>
  </Actions>
</Form>

Read the guide  Every control, option and hook, with live, tested examples.

Types

Typed by your model, down to the nested path#

With Glint, a form is typed by its @model. Every @for must be a real path, nested forms check against their own model, and each field's value and @format take the type at its path. A typo is a build error, not a blank field in production.

import { tracked } from '@glimmer/tracking'
import type { TOC } from '@ember/component/template-only'
import { Form } from '@ember-formwright/ember'

class Address {
  @tracked city = 'Lisbon'
}
class Order {
  @tracked email = ''
  @tracked seats = 1
  address = new Address()
}
const shout = (text: string) => text.toUpperCase()

export const OrderForm: TOC<{ Args: { order: Order } }> = <template>
  <Form @model={{@order}} as |f|>
    <f.Field @for="emial" />
    <f.Field @for="seats" @format={{shout}} />
    <f.Field @for="seats" as |field|>{{field.value}}</f.Field>
    <f.Form @model={{@order.address}} as |a|>
      <a.Field @for="email" />
    </f.Form>
  </Form>
</template>
$ ember-tsc --noEmit

order-form.gts(17,15): error TS2820: Type '"emial"' is not
  assignable to type '"email" | "seats" | "address" |
  "address.city"'. Did you mean '"email"'?

order-form.gts(18,28): error TS2322: Type '(text: string)
  => string' is not assignable to type '(value: number)
  => string'.
    Type 'number' is not assignable to type 'string'.

order-form.gts(21,17): error TS2322: Type '"email"' is not
  assignable to type '"city"'.

Errors and hooks follow the same model: ErrorMap<Order> only takes Order's paths, and @onChange(path, value) types value by path. Models typed loosely (any, or a Record<string, unknown>) accept any path, as before.

Validation

Bring the schema you already have#

Validation runs on submit. After a failed submit it re-runs on every change, so errors clear as they are fixed. Nested forms validate with their root.

import { z } from 'zod'

export const userSchema = z.object({
  email: z.string().email('That email looks off'),
  address: z.object({
    city: z.string().min(1, 'City is required'),
  }),
})
// "address.city" errors show on <f.Field @for="address.city">
// Or a function: return messages by path.
// The empty path is for form-level errors.
const validator = (user: User) => ({
  ...(user.password !== user.confirm && {
    confirm: ['Passwords must match'],
  }),
  ...(user.banned && { '': ['This account is locked'] }),
})

States

One state machine, any data layer#

Every form is clean, dirty, invalid or submitting. Read it as f.state.status, style it with [data-status], assert it with the harness. Plain objects, WarpDrive records and ember-changeset changesets all run the same machine; only where pending changes live differs. Watch it run, step by step.

You Status Default (plain objects) WarpDrive ember-changeset
Edit a field dirty The model changes; the form keeps each field's baseline. The record's attribute changes and it is dirty. The changeset holds the edit; the model is untouched.
Submit, invalid invalid Nothing saves. Errors show on their fields, focus moves to the first, and the form re-validates on every change.
Submit, valid submitting → clean model.save(), then a new baseline. record.save(). changeset.save() applies, then saves the model.
The save fails dirty The model keeps the edit; the baseline does not move. Server errors show on their fields. The model is put back; the edit stays in the changeset.
Reset clean The baseline is written back. record.rollbackAttributes(). changeset.rollback().

Changesets

Edits stay private until they save#

Hand the form an ember-changeset changeset and the model only changes on a valid submit. Changeset validations show on their fields, reset drops the pending edits, and a failed save leaves the model as it was. Set it up in three steps, then watch commit and rollback play out, inline and in a form, with WarpDrive or ember-changeset.

npm i @ember-formwright/changeset ember-changeset
// One validator per field: true, or the message
export const UserValidations = {
  email: (_key: string, value: unknown) =>
    String(value).includes('@') || 'Enter an email',
}
<Form @model={{changeset @user UserValidations}}
  @adapter={{changesetAdapter}} @validator={{changesetValidator}} as |f|>
  <f.Field @for="email" @label="Email" />
  <f.Submit /> <f.Reset />
</Form>

Testing

Harnesses: tests that read like your users#

Describe a form once as a harness class. Decorators declare its fields, each with the harness for its control, and its nested forms. Every harness is created once and looks up the DOM live, so it survives re-renders. Custom controls get their own harness by subclassing FieldHarness, as PowerSelectHarness does.

import {
  FormHarness, InputHarness, SelectHarness,
  CheckboxGroupHarness, CheckboxHarness, field, form,
} from '@ember-formwright/test-support'

export class AddressForm extends FormHarness {
  @field(InputHarness) declare city: InputHarness
}

export class SignupForm extends FormHarness {
  static formName = 'signup'

  @field(InputHarness) declare email: InputHarness
  @field(SelectHarness) declare plan: SelectHarness
  @field(CheckboxGroupHarness) declare notify: CheckboxGroupHarness
  @field(CheckboxHarness) declare terms: CheckboxHarness
  @form(AddressForm) declare address: AddressForm
}
test('signing up', async function (assert) {
  await render(<template><Signup /></template>)
  const signup = new SignupForm()

  await signup.email.fillIn('ada@example.com')
  await signup.plan.select('team')
  await signup.notify.select(['email', 'sms'])
  await signup.address.city.fillIn('Lisbon')
  await signup.submit()

  assert.deepEqual(signup.terms.errors, [
    'Please accept the terms',
  ])
  assert.true(signup.terms.isInvalid)
})
Selector On
data-test-fw-form="signup" The <form>, or a nested form's <fieldset>
data-test-fw-field="address.city" A field, by model path. items[0].name normalizes to items.0.name.
data-test-fw-path="signup.address.city" The same field, from the root form
data-test-fw-type="select" The control a field uses; the harness picks a driver from it
-label -hint -error -option -submit -reset -commit -cancel -display The parts of a field and form

Controls

More controls, one package each#

Each add-on wraps one well-kept library, registers a control name, and ships a harness for tests and a stylesheet that follows the Formwright theme. Install the ones you use. Labels, errors, aria-invalid, reset, inline editing and callouts work as they do for every other field. Shown here from the guide's live examples, next to the model they edit.

// app/app.ts: register what you installed
import { registerPowerCalendarControls } from '@ember-formwright/power-calendar'
import { registerPhoneControl } from '@ember-formwright/maskito/phone'
import { registerFileUploadControls } from '@ember-formwright/file-upload'
import { registerTiptapControl } from '@ember-formwright/tiptap'

registerPowerCalendarControls() // @control="date-picker" and "date-range"
registerPhoneControl() // @control="phone"
registerFileUploadControls() // @control="file" and "files"
registerTiptapControl() // @control="rich-text"
<f.Field @for="stay" @control="date-range" @label="Stay" @min={{today}} />
<f.Field @for="phone" @control="phone" @label="Phone" as |field|>
  <field.Control @country="GB" />
</f.Field>
<f.Field @for="avatar" @control="file" @label="Avatar" as |field|>
  <field.Control @accept="image/*" @upload={{this.uploadAvatar}} />
</f.Field>
<f.Field @for="body" @control="rich-text" @label="Body" @required={{true}} />

Date picker · Phone · File upload · Rich text: every argument, with live, tested examples.

Adapters

Small packages for the rest of your stack#

The core edits any object. Each adapter is a few dozen lines, so it is easy to read, and easy to write your own.

Package What it adds
@ember-formwright/warp-drive WarpDrive and ember-data records: save() on submit, rollbackAttributes() on reset, server errors on their fields, new nested records saved with their parent.
@ember-formwright/changeset ember-changeset: edits stay in the changeset until a valid submit applies and saves them, changeset validations show on their fields, and reset is changeset.rollback(), so the model never sees a half-made edit.
@ember-formwright/real-enums real-enums classes as @options, filtered with enumOptions, stored as members or raw strings, shown with their labels.
@ember-formwright/maskito A masked control powered by Maskito: cards, dates, anything with a shape. And phone, from @ember-formwright/maskito/phone: masked per country, stored as E.164.
@ember-formwright/power-select power-select and power-select-multiple controls on ember-power-select: search, async options, chips, with a harness for tests.
@ember-formwright/power-calendar date-picker and date-range on ember-power-calendar, storing ISO dates or Dates, with no moment or luxon to install.
@ember-formwright/file-upload file and files on ember-file-upload: a drop zone, the chosen files listed, and optional uploads with progress.
@ember-formwright/tiptap A rich-text control on Tiptap, with a toolbar, storing HTML or JSON.
@ember-formwright/layouts horizontal, compact and checkbox-after field layouts, and an Actions bar for the form's buttons. Or write your own: a layout arranges pre-wired parts, so it can't break accessibility or the test selectors.
@ember-formwright/test-support The form harnesses above, with drivers you can extend for custom controls.
// app/app.ts: every form edits WarpDrive records
import { setDefaultAdapter } from '@ember-formwright/ember'
import { warpDriveAdapter } from '@ember-formwright/warp-drive'

setDefaultAdapter(warpDriveAdapter)
{{! options straight from a real-enums class }}
<f.Field @for="state" @control="radio" @label="State"
  @options={{enumOptions OrderState except=(array OrderState.Cancelled)}} />
{{! a searchable select, options from your API }}
<f.Field @for="owner" @control="power-select" @label="Owner" @format={{userName}} as |field|>
  <field.Control @search={{searchUsers}} @searchEnabled={{true}} />
</f.Field>

Demo

Try it#

This is the real addon, built from the repository's test app. Submit it empty, fix the errors one by one, open the inline editor, and watch the model change on the right.

Coming from ember-foxy-forms

Formwright is its successor#

Same idea, rebuilt for modern Ember: a v2 addon in TypeScript with .gts components, named arguments only, and no runtime dependencies beyond ember-modifier. The migration guide maps every old component and argument to its replacement.