Form Participation API Explained
Web applications often maintain state in JS objects that have no direct DOM representation. Such applications may want such state to be submittable.
Existing form elements map one field name to many values. People often build custom controls precisely because those controls hold more complex values that would be better represented as many names to many values. Subclassing existing form elements don’t get you this.
And inheriting from HTMLInputElement is insane (not because inheriting is insane, but because HTMLInputElement is insane), so that’s not really how we want author-defined objects to become submittable.
Given the above it is not recommended that, we should try to solve the “how authors can participate in form submission” problem by enabling the subclassing of existing form elements. Instead, we should define a protocol implementable by any JS object, which allows that JS object to expose names and values to the form validation and submission processes.
The ‘formdata’ event enables any objects to provide form data. It helps avoid creating <input type=hidden> representing application state, or making submittable custom elements.
The Form Participation API enables objects other than built-in form control elements to participate in form submission, form reset, form validation, and so on.
Goal
- Arbitrary objects can participate in form submission
- Autonomous custom elements can associate with a form in a way same as built-in form control elements.
Non-goal
- Provide ability to imitate built-in form control elements perfectly. e.g. Create <input>-equivalent element with autonomous custom element.
API Proposal
There are two sets of API.
Proposal A – Generic Form Participation API which supports both of custom elements and non-elements
Proposal B – Form Participation API specific to custom elements.
API Proposal A – Event-based participation
Sample code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
// ======== Non-element example ======== class ShoppingCartApp { constructor() { document.querySelector(‘#cart-form’). addEventListener(‘formdata’, this.onFormData.bind(this)); // this.onFormData() is called on form submission and // on “new FormData(document.querySelector(‘#cart-form’))”. } onFormData(event) { event.formData.append(‘customer-id’, this.customerId_); event.formData.append(‘affiliate-code’, this.affiliateCode_); } } // ======== Custom element example ======== class MyControl extends HTMLElement { constructor() { this.onFormData_ = event => { let name = getAttribute(‘name’); if (!hasAttribute('disabled') && name) event.formData.append(getAttribute('name'), this.value); }; this.onReset_ = event => { if (!event.defaultPrevented && event.target == this.form_) this.value = ''; }; } connectedCallback() { for (let parent = parentElement(); parent; parent = parent.parentElement()) { if (parent.tagName == 'FORM') { parent.adEventListener(‘formdata’, this.onFormData_); this.form_ = parent; // Listen on document because this should not reset if other // listeners called preventDefault(). // This is an incomplete way to support form reset because // another listener after invoking this might cancel. document.addEventListener(‘reset’, this.onReset_); break; } } // It's not easy to implement 'form' content attribute behavior // because form element with 'form' content attribute value as // ID might not exist yet. } disconnectedCallback() { if (this.form_) { this.form_.removeEventListener(‘formdata’, this.onFormData_); document.remvoeEventListener(‘reset’, this.onReset_); } } get value() { ... } set value(v) { ... } } customElements.define(‘my-control’, MyControl); // <my-control> will participate in form submission and form reset // if it is connected as a descendant of a <form>. |
API Details – Addition to HTMLFormElement
‘formdata’ event This event is dispatched synchronously on a form element when ‘construct the entry list’ algorithm for it is invoked. The event bubbles, and is not cancelable. The event object has formData IDL attribute, of which interface is FormData. Event listeners may add entries to the formData attribute.
1 2 3 |
interface FormDataEvent : Event { readonly attribute FormData formData; }; |
ISSUE:
The formData object must be identical in all event listeners. So an event listener can read entries set by other event listeners if we use FormData interface. If we’d like to avoid such peeping, we should introduce new interface which is a kind of write-only FormData or add write-only mode to FormData.
RESOLUTION:
We don’t think we need to introduce such write-only interface. Preventing such peeping doesn’t make much sense because anyone can access any entries through ‘new FormData(form)’
ISSUE:
Should the event be dispatch before iterating controls, or after iterating controls? If we allow the peeping, dispatching after the iteration would be useful.
RESOLUTION:
Due to the above RESOLUTION, we should dispatch it after the iteration.
Feature Detection
Check existence of HTMLFormElement.prototype.onformdata or window.FormDataEvent.
API Proposal B – For custom elements
Proposal A is enough in many cases. However it’s not easy to support ‘form’ content attribute behavior and <label>/<fieldset> association with the approach of Proposal A. Proposal B is an alternative API specific to autonomous custom elements. It provides an easy way to participate in forms.
Sample code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
class MyControl extends HTMLElement { constructor() { this.form_ = null; this.disabled_ = false; this.onReset_ = event => { if (!event.defaultPrevented && event.target == this.form_) this.value = ''; }; // Do something if <label> is clicked. addEventListener(‘click’, () => { … }); // Don’t need to register ‘formdata’ event handler. } // New lifecycle callback. This is called just after the constructor. // ‘primitives’ is an instance of HTMLElementPrimities. createdCallback(primitives) { this.primitives_ = primitives; } formAssociatedCallback(form) { this.form_ = form; } get form() { return this.form_; } // Suppose that this is called whenever the value is updated. onInput() { if (!this.disabled_ && hasAttribute(‘required’) && this.value.length <= 0) setCustomValidity(‘Please fill in this field.’); else setCustomValidity(‘’); } disabledStateChangedCallback(disabled) { this.disabled_ = disabled; // Do something. e.g. adding/removing ‘disabled’ content attributes // to/from form controls in this shadow tree. if (this.disabled_) ... } get value() { ... } // Must provide ‘value’ setter. set value(v) { … // Must inform the value to the User-Agent. this.primitives_.setFormControlValue(v) } } customElements.define(‘my-control’, MyControl); |
API Details – Define form-associated custom elements
customElements.define(name, constructor) If the prototype of the specified constructor has one of ‘formAssociatedCallback’ and ‘disabledStateChangedCallback’, the defined element is marked as a form-associated custom element, and the prototype must provide ‘value’ setter too.
ISSUE: Should we introduce new option to define() like:
customElements.define(‘my-control’, MyControl, { formAssociated: true });
The defined elements will have the following capabilities:
- UA handles them as form-associated elements.
Parsers automatically associate them with a <form> like built-in form-associated elements, and DOM mutation functions such as appendChild() associate form-associated custom elements with a <form>, and calls formAssociatedCallback later.
- UA handles them as listed elements
form.elements, form.length, fieldset.elements contain form-associated custom elements.
- UA handles them as submittable elements
In constructing the entry list algorithm, UA creates an entry with name attribute value of the form-associated custom element, and the value set by setFormControlValue() of HTMLElementPrimitives interface. Authors don’t need to register ‘formdata’ event handlers. setCustomValidity() affects form validation.
- UA handles them as labelable elements
<label> can search them for a labeled control.
API Details – Functions which form-associated custom elements should provide
void createdCallback(HTMLElementPrimities primitives) If the custom element has a property named ‘createdCallback’ and it’s callable, it is called after the constructor. UA creates an HTMLElementPrimitives instance for this custom element, and passes it as an argument of createdCallback callback.
HTMLElementPrimitives interface provides API to support implementing elements.
1 2 3 |
interface HTMLElementPrimitives { void setFormControlValue(FormDataEntryValue? value, sequence<object>? entrySource = null); }; |
setFormControlValue() is used to tell state of form-associated custom elements to UA. setFormControlValue() should be called whenever the value of a form-associated custom element is updated. If the element has non-empty name content attribute, the specified value is appended to entry list in form submission. The specified value is used for UA autofilling
If a form-associated custom element don’t want to use name content attribute, or want to submit multiple entries, it should specify entrySource argument. entrySource is a sequence of objects, and The length of the sequence must be multiples of 2 Objects at 2n index (0, 2, 4, 6, …) must be nullable DOMString Objects at 2n+1 index (1, 3, 5, 7, …) must be nullable FormDataEntryValue If entrySource is specified, each pairs in entrySource will be added to entry list in form submission if entrySource[2n] is not empty.
ISSUE: Should we introduce new interface for a pair of DOMString and FormDataEntryValue? ‘[name1, value1, name2, value2, …]’ is simpler than ‘[new FormDataEntry(name1, value1), new FormDataEntry(name2, valeu2), …]’
For example, a form-associated custom element contains three editable fields, and we want to submit cc-cardno=value1 cc-expire=value2 and cc-cvc=value3 if name content attribute value is ‘cc’. We should run code like the following whenever any value is updated or name content attribute is updated:
1 2 3 4 5 6 7 8 9 10 11 12 |
var n = getAttribute(‘name’); var value1 = this._shadow.getElementById(‘cardno’).value; var value2 = this._shadow.getElementById(‘expire’).value; var value3 = this._shadow.getElementById(‘cvc’).value; var composedValue = value1 + ‘,’ + value2 + ‘,’ + value3 if (n) { this._primitives.setFormControlValue(composedValue, [n + ‘-cardno’, value1, n + ‘-expire’, value2, n + ‘-cvc’, value3]); } else { this._primitives.setFormControlValue(composedValue); } // ‘value’ setter for this element is expected to split the new value // by ‘,’, and set the split values to the three internal fields. |
NOTE: We should not pass an HTMLELementPrimitives instance via the constructor instead of new callback
createdCallback
because it would make ‘new MyControl()’ not doable. Also, ‘connectedCallback’ is not suitable. ‘new FormData(form)’ for <form> in an orphan tree should collect values of form-associated custom elements.
The purpose of HTMLElementPrimitives interface is to provide API which custom element implementations can call, but it’s difficult for custom element users to call.
void formAssociatedCallback(HTMLFormElement? form)
If the custom element has a property named ‘formAssociatedCallback’ and it’s callable, it is called on CEReaction timing after UA associated the element with a form element, or disassociated the element from a form element.
void disabledStateChangedCallback(boolean disabled)
If the custom element has a property named ‘disabledStateChangedCallback’ and it’s callable, it is called on CEReaction timing after an ancestor <fieldset>’s disabled state is changed or ‘disabled’ content attribute of this element is added or removed. The argument ‘disabled’ represents new disabled state of the element.
set value(v)
A form-associated custom element implementation must provide a value setter. UA’s input-assist features such as form autofilling may call this setter.
Feature Detection
Check existence of HTMLElementPrimitives.prototype.setFormControlValue.
Changes to other API
Move setCustomValidity(), validationMessage, and reportValidity() to HTMLElement If the context object is neither a listed element nor an autonomous custom element, InvalidStateError is thrown for setCustomValidity() and reportValidity(), and validationMessage returns an empty string. For autonomous custom elements, setCustomValidity() with non-empty string makes element’s validity state ‘invalid’.
Considered alternatives
Implement one of Proposal A and Proposal B, or both?
Though Proposal A can handle both of elements and non-elements, it’s not the best API for elements, and it’s not easy to support some form-related features. For example, it’s very difficult to support <fieldset> and <label> association with the Proposal A approach.
If we don’t support non-element participants, we can drop Proposal A and implement only Proposal B with synchronous FormData callback. If we’d like to minimize complexity, we can drop Proposal B because Proposal A can support both of element and non-element participants.
Alternatives of Proposal B
Authors need to tell “this element will participate in a form” to UA before the element is connected to a document tree In order to participate in a form by markup / DOM structure. The current Proposal B is one of ways to tell it, and it uses customElements.define(). Possible alternatives would be: If an element has a ShadowRoot, and the ShadowRoot has specific callback functions, the element is a participant. Introduce specific content attribute to the element. e.g. <my-control participant=…> A custom element with name content attribute is a participant. Introduce explicit function like document.makeElementParticipatable(element) Should form-associated custom element provide ‘form’ IDL attribute? Should we introduce new base class like HTMLFormControlElement?
TODO or not TODO
Support form-related CSS selectors
1 2 3 4 5 6 7 8 9 10 11 12 13 |
:enabled, :disabled, :checked, :indeterminate, :default, :valid, :invalid, :in-range, :out-of-range, :required, :optional, :read-only, :read-write |
This needs an API to request style re-computation explicitly as well as a callback to query element status. e.g.
sequence<DOMString> matchedPseudoClassCallback()
This callback is called just before starting style computation. The return value is a sequence of pseudo class names to be matched. e.g. [‘:invalid’, ‘:out-of-range’]
document.invalidateStyle(Element)
A custom element implementation calls this when pseudo class state is changed.