Form Integration

Custom elements can render inside a <form>, but that does not automatically make them form controls. A normal custom element is ignored by FormData, native constraint validation, fieldset disabled state, and form reset behavior unless it opts into the browser's Form-Associated Custom Elements API.

@beforesemicolon/web-component supports that native API directly. It exposes the element's ElementInternals instance as this.internals, while you use standard browser callbacks such as formAssociatedCallback(), formDisabledCallback(), formResetCallback(), and formStateRestoreCallback().

The Default Problem

Start with a component that wraps a native input:

javascript
1import { WebComponent, html } from '@beforesemicolon/web-component'2 3class TextField extends WebComponent {4    static observedAttributes = ['value', 'placeholder', 'name']5 6    value = ''7    placeholder = ''8    name = ''9 10    handleInput = (event) => {11        this.value = event.target.value12        this.dispatch('change', { value: this.value })13    }14 15    render() {16        return html`17            <input18                type="text"19                value="${this.props.value}"20                placeholder="${this.props.placeholder}"21                oninput="${this.handleInput}"22            />23        `24    }25}26 27customElements.define('text-field', TextField)

You can place it in a form:

html
1<form id="profile-form">2    <text-field name="firstName" placeholder="First name"></text-field>3    <button type="submit">Save</button>4</form>

But submitting the form will not include firstName:

javascript
1const form = document.querySelector('#profile-form')2 3form.addEventListener('submit', (event) => {4    event.preventDefault()5 6    const data = new FormData(event.currentTarget)7 8    console.log(Object.fromEntries(data)) // {}9})

The browser sees the custom element as an element, not as a successful form control with a submitted value.

Enable Form Association

Add static formAssociated = true so the browser treats the custom element as form-associated:

javascript
1class TextField extends WebComponent {2    static formAssociated = true3    static observedAttributes = ['value', 'placeholder', 'name']4}

This is native custom element behavior, not a WebComponent-specific abstraction. It allows the element to be associated with a parent form and receive native form callbacks.

Form association alone does not submit a value. It only lets the custom element participate in the form system. You still need this.internals.setFormValue().

ElementInternals

this.internals exposes the native ElementInternals object created with attachInternals().

typescript
1get internals(): ElementInternals

Use it to communicate with the parent form:

Register the Submitted Value

Call setFormValue() whenever the component value changes. The submitted field name comes from the custom element's own name attribute.

javascript
1handleInput = (event) => {2    const value = event.target.value3 4    this.value = value5    this.internals.setFormValue(value)6    this.dispatch('change', { value })7}

Now the same form produces useful data:

javascript
1console.log(Object.fromEntries(new FormData(form)))2// { firstName: "Ada" }

You can also register an initial value when the browser associates the element with a form:

javascript
1formAssociatedCallback() {2    this.internals.setFormValue(this.props.value())3}

Validation

A form-associated custom element can use native validation instead of inventing a parallel error system. A common pattern is to delegate validity to the internal native control, then report that validity through ElementInternals.

javascript
1validate(report = false) {2    const input = this.refs.input?.[0]3 4    if (!input) return5 6    const validity = input.validity7    const message = validity.valid ? '' : this.props.error()8 9    this.internals.setValidity(10        validity,11        message,12        validity.valid ? undefined : input13    )14 15    if (report) {16        this.internals.reportValidity()17    }18}

setValidity() accepts the same validity flags exposed by native form controls. Passing an empty or valid ValidityState clears the error. Passing an anchor element lets the browser position native validation UI near the relevant internal control.

Native Form Callbacks

Form-associated custom elements use native callback names. WebComponent does not wrap these because they are part of the browser platform.

formAssociatedCallback(form)

Called when the browser associates or disassociates the element with a form. Use this to register the initial submitted value and validity.

javascript
1formAssociatedCallback() {2    this.syncValue(this.props.value(), false)3}

formDisabledCallback(disabled)

Called when the element becomes disabled because its own disabled attribute changed or because an ancestor <fieldset> changed disabled state.

javascript
1formDisabledCallback(disabled) {2    this.disabled = disabled3}

If your template passes this.props.disabled into an internal input, assigning this.disabled updates the prop and keeps the rendered control in sync.

formResetCallback()

Called when the parent form resets. Use it to restore the component's default value, clear validation state, and update the native form value.

javascript
1formResetCallback() {2    this.syncValue('', false)3}

formStateRestoreCallback(state, mode)

Called when the browser restores form state, for example after navigation or autocomplete. Use it to restore the component's visible value and submitted value.

javascript
1formStateRestoreCallback(state, mode) {2    if (mode === 'restore' || mode === 'autocomplete') {3        this.syncValue(String(state ?? ''), false)4    }5}

Complete Text Field

This example keeps the custom element API small while integrating with native form submission, validation, reset, fieldset disabled state, and restore behavior.

javascript
1import { WebComponent, html } from '@beforesemicolon/web-component'2 3class TextField extends WebComponent {4    static formAssociated = true5    static observedAttributes = [6        'value',7        'placeholder',8        'name',9        'pattern',10        'disabled',11        'required',12        'error',13    ]14 15    value = ''16    placeholder = ''17    name = ''18    pattern = ''19    disabled = false20    required = false21    error = 'Invalid field value.'22 23    formAssociatedCallback() {24        this.syncValue(this.props.value(), false)25    }26 27    formDisabledCallback(disabled) {28        this.disabled = disabled29    }30 31    formResetCallback() {32        this.syncValue('', false)33    }34 35    formStateRestoreCallback(state, mode) {36        if (mode === 'restore' || mode === 'autocomplete') {37            this.syncValue(String(state ?? ''), false)38        }39    }40 41    syncValue(value, report = true) {42        this.value = value43        this.internals.setFormValue(value)44 45        const input = this.refs.input?.[0]46 47        if (input) {48            const validity = input.validity49 50            this.internals.setValidity(51                validity,52                validity.valid ? '' : this.props.error(),53                validity.valid ? undefined : input54            )55 56            if (report) {57                this.internals.reportValidity()58            }59        }60 61        this.dispatch('change', { value })62    }63 64    handleInput = (event) => {65        this.syncValue(event.target.value)66    }67 68    render() {69        const { error, ...inputAttrs } = this.props70 71        return html`72            <input73                ${inputAttrs}74                ref="input"75                part="input"76                type="text"77                oninput="${this.handleInput}"78            />79        `80    }81}82 83customElements.define('text-field', TextField)

Use it like a normal form field:

html
1<form id="profile-form">2    <text-field3        name="firstName"4        placeholder="First name"5        pattern="[A-Za-z]+"6        required7        error="First name can only contain letters."8    ></text-field>9 10    <button type="reset">Reset</button>11    <button type="submit">Save</button>12</form>

Practical Rules

edit this doc