<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>
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.
<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.