Skip to content

Instantly share code, notes, and snippets.

@simonihmig
Last active January 4, 2023 11:05
Show Gist options
  • Save simonihmig/ff39facb915d41f4b27717a10e2196e9 to your computer and use it in GitHub Desktop.
Save simonihmig/ff39facb915d41f4b27717a10e2196e9 to your computer and use it in GitHub Desktop.
Headless Forms

WIP - Ember Headless Forms

Goals

  • 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
  • make it easy to build an opinionated form system on top of it

Component API

<HeadlessForm
  @data={{this.data}}
  @onSubmit={{this.doSomething}}
  @onInvalid={{this.showError}}
  @validateOn="blur"
  as
  form
>
  <form.field @name='firstName' as field>
    <div class='form-row'>
      <field.label class='form-label'>First name</field.label>
      <field.input @type='text' required />
      {{#if field.errors}}
        <ul class='form-errors'>
          {{#each field.errors as |errorMessage|}}
            <li>{{errorMessage}}</li>
          {{/each}}
        </ul>
      {{/if}}
    </div>
  </form.field>

  <form.field @name='lastName' as field>
    <div class='form-row'>
      <field.label class='form-label'>Last name</field.label>
      <field.input @type='text' />
    </div>
  </form.field>

  <form.field @name='gender' as field>
    <div class='form-row'>
      <field.label class='form-label'>Gender</field.label>
      <PowerSelect
        @options={{array "Female" "Male" "Other"}}
        @selected={{field.value}}
        @triggerId={{field.id}}
        @onChange={{field.setValue}}
        as gender
      >
        {{gender}}
      </PowerSelect>
    </div>
  </form.field>

  <form.submitButton class='btn btn-primary'>Submit</form.submitButton>
</HeadlessForm>

<HeadlessForm as form> yields:

  • form.field
  • form.submitButton
  • form.validate - ??? tbd
  • form.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.

Getting Data in and out of the form

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 single Date). We could nevertheless easily construct a FormData 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?

Validations

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.

Custom validations

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

<HeadlessForm
  @data={{this.data}}
  @validate={{withYup(this.yupSchema)}}
>
  ...
</HeadlessForm>
<HeadlessForm
  @data={{this.changeset}}
  @validate={{withChangesetValidations(this.changeset)}}
>
  ...
</HeadlessForm>

When validations are shown

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'

Async support

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment