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

  1. Set the flag before anything else. static formAssociated = true is what makes attachInternals() legal and the four callbacks fire.

  2. Attach internals once, in the constructor. A second call throws. Store the handle; it is the only route to value, validity and ARIA.

  3. Delegate focus. delegatesFocus: true is what makes a <label for> click, an error-summary link and a first-invalid focus() land on the inner control rather than on the host.

  4. Publish the value on connect, not in the constructor. There is no owning form yet in the constructor, so an early setFormValue is discarded silently.

  5. Normalise on the way out. setFormValue is the boundary between display formatting and the payload. Strip separators, coerce, and send null for empty so the field is absent rather than blank.

  6. Implement all four callbacks. Reset, disable and restore each have a distinct, silent failure if omitted.

Build order, and the failure each step prevents Setting the static formAssociated flag is what makes attachInternals legal; without it the constructor throws with a message that does not explain why. Attaching internals in the constructor gives the single handle used for value, validity and ARIA, and calling it twice throws. Attaching the shadow root with delegatesFocus is what makes label clicks and programmatic focus reach the inner control. Publishing the value in connectedCallback rather than the constructor matters because there is no owning form until the element is connected. And the four form callbacks each cover one silent failure: reset leaving the control populated, an ancestor fieldset having no effect, back-navigation losing the value, and a disabled control blocking a submit. 1 · the flag formAssociated = true 2 · internals once, in the constructor 3 · delegate focus labels and links reach the input 4 · publish on connect — a form exists now 5 · the four callbacks, and the silent failure each one prevents formResetCallback — reset leaves the control populated · formDisabledCallback — an ancestor fieldset does nothing formStateRestoreCallback — back-navigation loses the value · clearing validity when disabled — a hidden control blocks submit Every one of those failures is invisible in a component test and obvious the first time a real form is reset. What setFormValue accepts, and what each produces Passing a string contributes one name and value pair using the host element name attribute. Passing null contributes nothing at all, which is how an empty control is made absent from the payload rather than present and blank. Passing a File contributes it as a file entry, exactly as a file input would. Passing a FormData contributes every entry it holds, which is how one element supplies several pairs. Passing an empty string is different from passing null: it submits a blank value. Argument Contributes a string one pair, under the host&#39;s name attribute null nothing — the field is absent, not blank an empty string one pair with a blank value a File a file entry, as a file input would a FormData every entry it holds — several pairs The null versus empty-string distinction is worth being deliberate about: one omits the key, the other sends it blank.

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.

The order the four callbacks fire in, in real use Connected fires when the element enters the document, which is when the value is first published. State restore fires only on a back-navigation or an autofill path, before the reader interacts. Disabled fires whenever the element or an ancestor fieldset changes its disabled state, including at mount. Reset fires whenever the owning form is reset, at any point. Only the first is guaranteed; the others depend on what the reader does. The order the four callbacks fire in, in real use connected the element joins the document — publish now restore back-navigation or autofill, before use disabled the element or an ancestor fieldset reset the owning form is reset, at any point Only the first is guaranteed to fire. The rest are conditional, which is why omitting them fails silently rather than loudly.

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. connectedCallback is the earliest safe point.
  • A hidden mirror input. It fixes FormData and nothing else, and it adds a second entry to form.elements so every field iteration sees the control twice.
  • Omitting delegatesFocus. A label click and a summary link both call focus() 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 valueMissing blocks a submit nobody can unblock.
  • Setting aria-invalid on the host. It takes the attribute away from consumers, who can then no longer override it. internals.ariaInvalid writes the same state at a lower precedence.

Related

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.