Form Control
Markup exposes WebComponent that allows you to create reactive web components with a simple API.
Another special capability of WebComponent is allow you to create form components that well integrate with HTML Form.
To illustrate that, lets look at a simple custom input field.
1class TextField extends WebComponent {2 // define the attributes the component should react to3 static observedAttributes = [4 'value',5 'placeholder',6 'pattern',7 'disabled',8 'required',9 'error',10 ]11 12 // define attributes default values13 placeholder = ''14 value = ''15 pattern = ''16 required = false17 disabled = false18 error = 'Invalid field value.'19 20 handleChange = (value) => {21 this.value = value22 // dispatch a change event with the input field value23 this.dispatch('change', { value })24 }25 26 // render the component content27 render() {28 const { error, ...inputAttrs } = this.props29 30 return html`31 <input32 ${inputAttrs}33 part="text-input"34 type="text"35 ref="input"36 onchange="${(event) => this.handleChange(event.target.value)}"37 />38 `39 }40}41 42// add your web component to the customElements registry43customElements.define('text-field', TextField)With that we can render our custom text field inside a simple form with a reset and submit button.
1<form id="sample-form" method="POST">2 <text-field3 placeholder="Enter first name"4 name="firstName"5 pattern="[a-z]+"6 error="Invalid first name. May only contain letters and no space."7 ></text-field>8 <text-field9 placeholder="Enter last name"10 name="lastName"11 pattern="[a-z\s]+"12 error="Invalid last name. May only contain letters separated by space."13 ></text-field>14 <button type="reset">reset</button>15 <button type="submit">submit</button>16</form>The problem
Even though we have a custom text field for the form, it does not integrate well with the form, to illustrate that let's handle a submit event on the form and see what the form sees.
1<!-- add a onsubmit event handler -->2<form id="sample-form" method="POST" onsubmit="handleSubmit(event)">...</form>1// catch the submit event and read the form data2function handleSubmit(event) {3 event.preventDefault()4 5 const formData = new FormData(event.target)6 7 // log form entries and fields of the form8 console.log(Object.fromEntries(formData), [...event.target])9}When we click the submit button this is what we see:
1{} (2) [button, button]The form does not see the fields, just the buttons and consequently form entries is just an empty object. So let's fix that!
formAssociated
The first thing we can do is tell HTML this element is should be associated with a form.
1class TextField extends WebComponent {2 ...3 4 // mark the component as form associated5 static formAssociated = true;6 7 ...8}The formAssociated is not something related to Markup. It is just native option for web components.
With this simple change, let's click the submit button again and look at the logs.
1{} (4) [text-field, text-field, button, button]As you can see now, the form sees our text-field web component. However, the form does not know about the value of these fields. Let's look at how we can fix that.
internals
Markup WebComponent exposes ElementInternals via the internals property you can use to communicate the value and validity state of your component.
To illustrate that, let's register the component value on mount.
1class TextField extends WebComponent {2 ...3 4 formAssociatedCallback(form) {5 // register value received from props6 this.internals.setFormValue(this.props.value(), false);7 }8}With this change, we are registering our TextField value as soon as it gets notified that its been associated with a form. You can read about formassociatedcallback when you look into Form Lifecycles.
With that, let's click the submit button again an look at the logs.
1{firstName: '', lastName: ''} (4) [text-field, text-field, button, button]As you can see, the form data catches the properties firstName and lastName which are the name we gave to the TextField when we rendered them.
The ElementInternals you access via internals property allows you to do more things like checking, setting, and reporting validity.
We can illustrate that by calling setFormValue and setValidity whenever there is a value change right before we dispatch the change event.
1class TextField extends WebComponent {2 ...3 4 handleChange = (value, report = true) => {5 this.internals.setFormValue(value);6 this.value = value;7 8 const [inputField] = this.refs['input'];9 10 const validity = inputField.validity11 this.internals.setValidity(validity, validity.valid ? undefined : this.props.error(), inputField);12 report && this.internals.reportValidity();13 14 this.dispatch('change', {value});15 }16 17 ...18}Above we are setting the value and grabbing the input field reference to get the validity state via the validity property. The validity changes depending on the value of the required and the pattern attributes.
You can learn more about ElementInternals APIs you can explore to know how to enhance your components even more.
Form Lifecycles
Markup WebComponent is like any native web components which means you can access the form callback lifecycles.
formAssociatedCallback
This lifecycle is called when the browser associates or disassociates the element with a form element.
For example, we can use this to call setFormValue to register the initial value that the component was rendered with.
1class TextField extends WebComponent {2 ...3 4 formAssociatedCallback(form) {5 this.handleChange(this.props.value(), false);6 }7 8 ...9}formDisabledCallback
This lifecycle is called when:
- The
disabledattribute is added/removed from the component element - The
disabledattribute is added/removed from a fieldset the component is inside of.
We can use this to directly disable the input field and handle anything in our component that should put out component in a disabled mode.
1class TextField extends WebComponent {2 ...3 4 formDisabledCallback(disabled) {5 this.disabled = disabled;6 }7 8 ...9}formResetCallback
This lifecycle is called when form is reset.
This can be illustrated by clicking the reset button in our form example. We can then go ahead reset out input field value along with any form field value and validity state.
1class TextField extends WebComponent {2 ...3 4 formResetCallback(form) {5 this.handleChange('', false)6 }7 8 ...9}formStateRestoreCallback
This lifecycle is called in one of two circumstances:
- When the browser restores the state of the element (for example, after a navigation, or when the browser restarts). The mode argument is "restore" in this case.
- When the browser's input-assist features such as form auto-filling sets a value. The mode argument is "autocomplete" in this case.
We can use this in our TextField example to grab the value the form was restored with to update the form value and validity of our component.
1class TextField extends WebComponent {2 ...3 4 formStateRestoreCallback(state, mode) {5 if (mode == 'restore') {6 // expects a state parameter in the form 'controlMode/value'7 const [controlMode, value] = state.split('/');8 this.handleChange(value, false)9 }10 }11 12 ...13}Full example
We can now see our TextField full code with additional improvements.
As you will see, it was not much to create our custom input field that we can use in any HTML form.
1class TextField extends WebComponent {2 static observedAttributes = [3 'value',4 'placeholder',5 'disabled',6 'pattern',7 'error',8 'required',9 ]10 static formAssociated = true11 12 stylesheet = `13 input {14 border: 1px solid #444;15 padding: 8px 10px;16 border-radius: 3px;17 min-width: 150px;18 }19 20 input:user-valid {21 border-color: #090;22 }23 24 input:user-invalid {25 border-color: #900;26 }27 `28 29 placeholder = ''30 value = ''31 pattern = ''32 disabled = false33 required = false34 error = 'Invalid field value'35 36 formAssociatedCallback(form) {37 this.handleChange(this.props.value(), false)38 }39 40 formDisabledCallback(disabled) {41 this.disabled = disabled42 }43 44 formResetCallback() {45 this.handleChange('', false)46 }47 48 formStateRestoreCallback(state, mode) {49 if (mode == 'restore') {50 const [controlMode, value] = state.split('/')51 this.handleChange(value, false)52 }53 }54 55 handleChange = (value, report = true) => {56 this.internals.setFormValue(value)57 this.value = value58 59 const [inputField] = this.refs['input']60 61 const validity = inputField.validity62 this.internals.setValidity(63 validity,64 validity.valid ? undefined : this.props.error(),65 inputField66 )67 report && this.internals.reportValidity()68 69 this.dispatch('change', { value })70 }71 72 render() {73 const { error, ...inputAttrs } = this.props74 75 return html`76 <input77 ${inputAttrs}78 part="text-input"79 type="text"80 ref="input"81 onchange="${(event) => this.handleChange(event.target.value)}"82 />83 `84 }85}86 87customElements.define('text-field', TextField)