The exact problem: a design-system text field built as a custom element submits nothing. new FormData(form) returns every native input and skips it entirely, so the server receives a payload missing a required value that the reader can plainly see on screen.
Context and Prerequisites
The contract is described in web components and form association; this page walks the implementation and the order the pieces must be added in. You need a browser with ElementInternals, and a control whose value is genuinely a single form value — a date-range picker contributing two pairs is a variation covered at the end.
Core Pattern
export class XCurrency extends HTMLElement {
// Without this, attachInternals() throws and none of the callbacks fire.
static formAssociated = true;
static observedAttributes = ['value', 'required', 'disabled'];
#internals = this.attachInternals();
#input: HTMLInputElement;
#touched = false;
constructor() {
super();
const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
root.innerHTML = `<span part="prefix">£</span><input part="input" inputmode="decimal">`;
this.#input = root.querySelector('input')!;
this.#input.addEventListener('input', () => this.#commit(this.#input.value));
this.#input.addEventListener('blur', () => { this.#touched = true; this.#refresh(); });
}
connectedCallback(): void {
// Publish the initial value here, not in the constructor: the element has
// no owning form until it is connected, so an early call is discarded.
this.#commit(this.getAttribute('value') ?? '');
}
attributeChangedCallback(name: string, _old: string | null, next: string | null): void {
if (name === 'value') this.#commit(next ?? '');
if (name === 'disabled') this.#input.disabled = next !== null;
if (name === 'required') this.#refresh();
}
#commit(raw: string): void {
this.#input.value = raw;
// The value the FORM sees. Normalise here so the server never receives the
// display formatting — "1,250.00" becomes "1250.00".
this.#internals.setFormValue(raw.replace(/,/g, '') || null);
this.#refresh();
}
#refresh(): void {
const raw = this.#input.value.replace(/,/g, '');
const required = this.hasAttribute('required');
if (required && raw === '') {
this.#internals.setValidity({ valueMissing: true }, 'Enter an amount', this.#input);
} else if (raw !== '' && !/^\d+(\.\d{1,2})?$/.test(raw)) {
this.#internals.setValidity({ patternMismatch: true },
'Enter an amount, for example 1250.00', this.#input);
} else {
this.#internals.setValidity({});
}
// Reflect for CSS and for tests, without taking aria-invalid from consumers.
const invalid = this.#touched && !this.#internals.validity.valid;
this.toggleAttribute('data-invalid', invalid);
this.#internals.ariaInvalid = invalid ? 'true' : null;
}
formResetCallback(): void {
this.#touched = false;
this.#commit(this.getAttribute('value') ?? '');
}
formDisabledCallback(disabled: boolean): void {
this.#input.disabled = disabled;
// Native disabled controls are exempt from constraint validation; match that,
// or a hidden control blocks a submit the reader cannot unblock.
if (disabled) this.#internals.setValidity({});
else this.#refresh();
}
formStateRestoreCallback(state: string | FormData): void {
this.#commit(typeof state === 'string' ? state : String(state.get(this.name) ?? ''));
}
get name() { return this.getAttribute('name') ?? ''; }
get value() { return this.#input.value; }
set value(v: string) { this.#commit(v); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
}
customElements.define('x-currency', XCurrency);
Step-by-Step Walkthrough
-
Set the flag before anything else.
static formAssociated = trueis what makesattachInternals()legal and the four callbacks fire. -
Attach internals once, in the constructor. A second call throws. Store the handle; it is the only route to value, validity and ARIA.
-
Delegate focus.
delegatesFocus: trueis what makes a<label for>click, an error-summary link and a first-invalidfocus()land on the inner control rather than on the host. -
Publish the value on connect, not in the constructor. There is no owning form yet in the constructor, so an early
setFormValueis discarded silently. -
Normalise on the way out.
setFormValueis the boundary between display formatting and the payload. Strip separators, coerce, and sendnullfor empty so the field is absent rather than blank. -
Implement all four callbacks. Reset, disable and restore each have a distinct, silent failure if omitted.
Failure Modes and Edge Cases
1. Contributing more than one value
A date-range control needs two name/value pairs. Pass a FormData to setFormValue rather than a string:
const fd = new FormData();
fd.append(`${this.name}From`, from);
fd.append(`${this.name}To`, to);
this.#internals.setFormValue(fd); // a string here would submit only one value
Remember that formStateRestoreCallback then receives a FormData too.
2. setFormValue before connection
Called in the constructor, it is discarded with no warning and the first submit is empty. connectedCallback is the earliest safe point.
3. The host takes aria-invalid
Writing this.setAttribute('aria-invalid', …) puts the attribute on the host, where a consumer cannot override it. internals.ariaInvalid writes the same information into the accessibility tree at a lower precedence, which is what a reusable control wants.
4. Validity set while disabled
A disabled native control is exempt from constraint validation. An element that keeps reporting valueMissing while disabled blocks form.checkValidity() on a field nobody can fill.
5. Framework interop
Some frameworks set attributes, others set properties. Implementing the property setter and observing the attribute covers both; implementing only one produces a control that works in exactly one framework.
Verification Checklist
Common Pitfalls
- Publishing the value in the constructor. There is no owning form yet, so the call is discarded and the first submit is empty.
connectedCallbackis the earliest safe point. - A hidden mirror input. It fixes
FormDataand nothing else, and it adds a second entry toform.elementsso every field iteration sees the control twice. - Omitting
delegatesFocus. A label click and a summary link both callfocus()on the host, which does nothing, so both appear broken for no visible reason. - Keeping validity while disabled. Native disabled controls are exempt from constraint validation. A control that keeps reporting
valueMissingblocks a submit nobody can unblock. - Setting
aria-invalidon the host. It takes the attribute away from consumers, who can then no longer override it.internals.ariaInvalidwrites the same state at a lower precedence.
Related
- Web Components and Form Association — the contract this implements
- Validating Inputs Across Shadow DOM Boundaries — labels, describedby and queries across the boundary
- Best Practices for Uncontrolled Form State — reading the values at submit
← Web Components and Form Association
Frequently Asked Questions
Where should attachInternals be called?
In the constructor, exactly once. It throws on a second call and it throws entirely if the class does not declare static formAssociated. What must not happen in the constructor is setFormValue or setValidity: the element has no owning form yet, so those calls are discarded. Attach in the constructor, publish in connectedCallback.
How do I submit more than one value from one element?
Pass a FormData to setFormValue instead of a string, appending one entry per value with distinct names. The pairs then appear in the form’s FormData exactly as if they had been separate inputs. Note that formStateRestoreCallback will hand you a FormData back rather than a string, so the restore path has to handle both shapes.
Does this work inside a framework that manages the DOM?
Yes, with one caveat: some frameworks set properties and others set attributes, so implement both the property accessor and observedAttributes. Beyond that the element behaves as a native input, which means framework form libraries that read FormData or listen for input events need no special support — which is most of the point of using form association rather than a bespoke binding.