<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. 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.
-
Save
clean→dirty→submitting→clean -
Invalid
dirty→invalid→ (fix) →dirty - Save fails
submitting→dirty -
Reset
dirtyorinvalid→clean
| 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.
@ember-formwright/power-calendar
date-picker and date-range, stored as ISO dates.
@ember-formwright/maskito/phone
Masked per country as you type, stored as E.164.
@ember-formwright/file-upload
Drop zones, file lists, and uploads with progress.
@ember-formwright/tiptap
A Tiptap editor with a toolbar, stored as HTML or JSON.
// 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.