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.

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'] }),
})

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.

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

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: phones, cards, dates.
@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)
{{! edit through a changeset; the model changes on submit }}
<Form @model={{changeset @user UserValidations}}
  @adapter={{changesetAdapter}} @validator={{changesetValidator}} as |f|>
  <f.Field @for="email" @label="Email" />
</Form>
{{! options straight from a real-enums class }}
<f.Field @for="state" @control="radio" @label="State"
  @options={{enumOptions OrderState except=(array OrderState.Cancelled)}} />

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.