- focus on reusable behavior that every form should have
- have no opinions on styling or app-specific use cases
- have opinions on form best-practices and a11y
- render as little as possible
- render things for which we have strong opinions (e.g. an input should have an associated
<label>
) - do not render auxiliary markup, classes or anything related to styling
- render things for which we have strong opinions (e.g. an input should have an associated
- make it easy to build an opinionated form system on top of it
<HeadlessForm as form>
yields:
form.field
form.submitButton
form.validate
- ??? tbdform.isValidating
form.isSubmitting
form.isSubmitted
form.isRejected
<form.field as field>
yields:
field.label
- Renders a<label>
.for
is pre-wired to the input's ID.field.input
- Renders an<input>
with an ID and a type of@type
field.textarea
- Renders a<textarea>
field.select
- Renders a<select>
field.errors
field.id
field.value
field.setValue
field.validate
- ??? tbd
Of course, all components pass on ...attributes
properly, so they can receive arbitrary HTML attributes or modifiers.
We pass @data
to <HeadlessForm>
, which is a simple POJO with the keys being the @name
of the used form fields. This data object is internally updated through a simple protocol which all control components follow: they receive their value (the value of the key matching their @name
), and may call @setValue
when the value changes.
The goal is for this protocol to both support native HTML form elements like <input>
, but also "controlled" components, where their internal state does not necessarily match the form state of DOM, or they don't even use native form elements (things like <PowerSelect>
).
By following this protocol, control components can also normalize/denormalize their data. For example a date picker that is implemented by having three <select>
pulldowns for day/month/year could receive and return a canonical Date
object, while splitting that to three strings for its internal representation.
We could have also used
FormData
as the data structure for holding the form's data, but when constructing it directly from the<form>
element, it would only work properly with native elements, and not support controlled components or data normalization (e.g. the date picker example above would give three string-typed entries instead of a singleDate
). We could nevertheless easily construct aFormData
instance from the data POJO, if users prefer that, or provide a helper transformation function.
The @data
passed will serve as the default data, and will also make the form update whenever it changes. But we will never mutate it directly. The only way to get the data out of the form is to listen to the @onSubmit
action, which pass the current data (when valid).
To investigate: will that immutability pattern work for
ember-changeset-validations
, or do we have to mutate the changeset in order to get the new validations defined on it?
Headless forms support native HTML5 validations out of the box. Users can either rely on the browser's default validation UI, or provide their own error markup by setting the novalidate
attribute on the form and rendering field errors using the yielded field.errors
.
This array will be populated by querying the browser's validation API for the given field. Besides built-in attribute-based validations like required
, adding custom validations using the native APIs (setCustomValidity()
) should also work, e.g. by using ember-validity-modifier.
Besides validations based on the native APIs, using custom JavaScript-based validations are also supported, per form or per field. They are provided as functions passed to @validate
, so either to <HeadlessForm>
or <form.field>
Validations should follow the following signature:
type FormValidateCallback = (formData: Record<string, unknown>) => true | Record<string, ValidationError[]> | Promise<true | Record<string, ValidationError[]>>;
type FieldValidateCallback = (fieldValue: unknown, fieldName: string, formData: Record<string, unknown>) => true | ValidationError[] | Promise<true | ValidationError[]>;
interface ValidationError {
type: string;
value: unknown;
message?: string;
}
While this generic API should allow for any kind of validation, we can also provide more convenient wrappers around popular validation libraries, like...
yup:
When validations are evaluated and shown to the user can be specified by passing these arguments to the form component (inspired by react-hook-form:
@validateOn: 'change' | 'blur' | 'submit' = 'submit'
@revalidateOn: 'change' | 'blur' | 'submit' = 'change'
When the callback passed to @onSubmit
returns a promise, its async state is reflected in thse properties yielded by the form component:
form.isValidating
form.isSubmitting
form.isSubmitted
form.isRejected
This can be used to e.g. disable the submit button and/or show a loading spinner while the submission is pending.