0.0.8 • Published 6 months ago

@sirpepe/forma v0.0.8

Weekly downloads
-
License
-
Repository
github
Last release
6 months ago

forma

A slightly unhinged approach to custom form-associated elements (specifically inputs) based upon @sirpepe/ornament. Assuming that you intend to build a new form control by abstracting over existing form controls (eg. to constrain an <input type="number"> to integers, to build a bad YYYY-MM-DD date picker from three <select>, or simply to attach some CSS for your UI library) while using shadow DOM it works like this:

  1. Build your basic custom form control (class, lifecycle handling, attributes) with @sirpepe/ornament
  2. Place all the form-associated elements that you use to compose your custom form control in a <form> element in the shadow tree with novalidate set to true. Use whatever client-side rendering technology you prefer.
  3. Apply the @forma() decorator to the component class. This automagically adds all public behaviors that form controls need (getters, setters, constraint validation) and sets up the data flow explained in the flowchart below
  4. Augment your class with a few data transformation methods as needed. A form control can submit strings, Blobs or FormData (= multiple strings and/or blobs), but its value content attribute can only be a string, while its value IDL attribute can be anything. This library turns your inner form element into an internal FormData change on every change from which you can derive the every other value... and the other way around works too

Forma's main insight is that transforming a form controls' various states to/from FormData is a useful generalization that requires the component authors to only build a form and provide some data transformation functions.

The following table describes the available data transformations. Note that the term "value state" refers to the internal FormData object that gets built from the inner form element and represents the form control's state:

Data transformationUse caseSignatureDefault
VALUE_STATE_TO_SUBMISSION_STATEDerive the submission state from the value state(valueState: FormData) => FormData \| string \| BlobStringifies the first entry in the value state
SUBMISSION_STATE_TO_VALUE_STATEDerive a value state from a submission state (eg. for form resets)(submissionState: FormData \| string \| Blob) => FormDataUse the submission state as the first entry in the value state, with the nested form's first first form-associated descendant's name as the key.
VALUE_STATE_TO_ATTRIBUTE_VALUESerialize a value state to an attribute value(valueState: FormData) => string \| nullStringifies the first entry in the value state
ATTRIBUTE_VALUE_TO_VALUE_STATEDerive a value state from an attribute value (eg. getter and content attribute value)(attributeValue: string \| null) => FormDataUse the attribute value as the first entry in the value state, with the nested form's first first form-associated descendant's name as the key.

forma explained as a flowchart

This process works thanks to the following assumptions/conventions:

  1. <form> elements can always be serialized to FormData (this is guaranteed by web standards)
  2. FormData can be converted into the appropriate data for form submission, resets, and attribute handling (this is the component author's job)
  3. Given a FormData object, the inner form can be kept in sync by looping over the form data's entries and updating the matching elements in the inner form, going by name and/or order (this requires the component's shadow DOM to be largely static)

An example using preact:

// Wrapper component over a native input. Useful for pattern libraries or simple
// abstractions over exiting elements, like this input for integers built on top
// of a regular input[type=number]. The component only fixes some attributes of
// the native input (step, type) and exposes other attributes (min, max) through
// the abstraction. This results in a proper form-associated custom element with
// all form APIs, automatic internal state management (dirty flag for value,
// disabled state, form reset etc.), form validation and everything else.

import { define, reactive, connected, attr, int } from "@sirpepe/ornament";
import { render } from "preact";
import { forma } from "./forma.js";

@define("integer-input") // Component registration
@forma() // Form decorator
export class IntegerInput extends HTMLElement {
  #shadow = this.attachShadow({ mode: "closed", delegatesFocus: true });

  // Regular content attributes, defined via Ornament. These are the public API
  // for this element, plus all regular form APIs which get injected by the
  // @forma() decorator
  @attr(int({ nullable: true })) accessor min = null;
  @attr(int({ nullable: true })) accessor max = null;

  // This is the unhinged part: the element(s) that we abstract over live inside
  // a _nested form_ in the shadow DOM. The Idea behind that is that forms, not
  // matter how simple or complicated can be serialized to FormData, which can
  // in turn be turned into the value state and/or submission states that
  // form-associated elements must express. The element's overall validation
  // state can also be composed form the elements that make up the inner form.
  // "change" events on nested form elements are intercepted and trigger
  // re-computation if the elements value, submission and validity states.
  // If { sync: true } is passed to @forma(), the process of composing the value
  // and submission state from the inner form runs in reverse if the containing
  // element's value/disabled/readOnly state is changed eg. via JS.
  @connected()
  @reactive()
  render() {
    render(
      <form noValidate={true}>
        <input
          name="input"
          step="1"
          type="number"
          min={this.min ?? ""}
          max={this.max ?? ""}
        />
      </form>,
      this.#shadow,
    );
  }

  // Describes how to map attribute values (as in content attribute values) to
  // FormData (or subclasses thereof) objects. For more complex use cases, up to
  // for different serialization/deserialization methods can be defined, with
  // reasonable defaults covering many simple use cases (such as most of this).
  // ATTRIBUTE_VALUE_TO_VALUE_STATE only needs defining because it needs to
  // apply parseInt to its input, rather than just stringifying it.
  [forma.ATTRIBUTE_VALUE_TO_VALUE_STATE](value) {
    const fd = new FormData();
    fd.append("input", Number.parseInt(value, 10) || 0);
    return fd;
  }
}

// That's it! <integer-input> is now available as a full-blown form-associated
// custom element with support for all APIs that one expects from form controls.
// The element is submittable, can be programmed using JS like any other input,
// participates in constraint validation, supports attributes such as `name` and
// `required`, has proper dirty state tracking, and can be be implemented in
// whatever way you like, as long as the element has an inner form and a few
// data transformation methods.

Check out more examples in demo/components!

List of automatically provided form control behaviors

Forma aims to make custom form-associated elements behave identical to built in form controls and therefore implements every API and behavior of standard form controls:

  • Accessors value and defaultValue
  • Getters type, form, labels, willValidate, validity, validationMessage
  • Content attributes name, required, disabled, and readonly with matching accessors name, required, disabled, and readOnly
  • Methods checkValidity, reportValidity, setCustomValidity
  • Always-synchronized state between the inner form, and the host element value state, submission state, and attribute values
  • Constraint validity paricipation via composing a validity state from the inner form's elements
  • Dirty state tracking to properly handle the effects of updating the content attribute value
  • Disabled state handling based on the form control's own disabled attribute and the disabled state of <fieldset> ancestors

Optional auto-sync mode

If you pass { sync: true } when calling @forma() auto-sync mode will be enabled. This manages the following properties on form controls in the inner form as follows:

  • readOnly: set to the same value as the containing form control component
  • required: set to the same value as the containing form control component
  • disabled: set to reflect the containing form control component's disabled state (taking ancestor <fieldset> elements into account)
  • value: set to the matching entry in the value state, if one exists

API summary

APIDescription
@forma(options?)Class decorator for form control components. options?: { sync: boolean = false }
forma.VALUE_STATE_TO_SUBMISSION_STATESymbol; name for a data transformation method
forma.SUBMISSION_STATE_TO_VALUE_STATESymbol; name for a data transformation method
forma.VALUE_STATE_TO_ATTRIBUTE_VALUESymbol; name for a data transformation method
forma.ATTRIBUTE_VALUE_TO_VALUE_STATESymbol; name for a data transformation method
forma.VALUE_STATESymbol; name for a private API that allows access to the form control's value state
forma.DISABLED_STATESymbol; name for a private API that allows access to the form control's disabled state (taking ancestor <fieldset> elements into account)

Caveats

  1. This is currently more of an ongoing experiment than a finished piece of software.
  2. If you don't explicitly need an convention-based way to ease the process of writing form-associated custom elements, than this is probably not the library for you
  3. HTML dislikes nested forms (even when separated by shadow DOM boundaries) to such an extent the compliant parsers (not Firefox) remove nested form tags in their entirety. Therefore myShadowRoot.innerHTML = "<form>...</form>"" won't fly for this approach, while any client-side rendering technology that relies on the DOM without invoking the browser's HTML parser in a context-aware fashion works fine. Use Preact, uhtml, whatever. SSR is also not possible, but this should not matter for form controls.
  4. Be aware that your inner form controls value states will be handled by the library. Better not to touch them!
  5. textarea-like form controls (where the value is provided by the content between the tags) are currently not supported

Troubleshooting

Uncaught TypeError: can't access private field or method: object is not the right class

Depending on when your first render the form, the mixin class may not yet have finished setup. Ensure that the order of decorators is as follows:

@define("my-component")
@forma()
class MyComponent extends HTMLElement {}
0.0.8

6 months ago

0.0.7

6 months ago

0.0.6

6 months ago

0.0.5

6 months ago

0.0.4

6 months ago

0.0.3

6 months ago

0.0.2

6 months ago

0.0.1

6 months ago