Skip to content

Form Associated Custom Elements (FACE) — Full Form Participation for AgnosticUI Form Input Components #274

@roblevintennis

Description

@roblevintennis

Currently, AgnosticUI form components (e.g. AgInput, AgCheckbox) include <label> inside the shadow DOM as a pragmatic a11y solution for the label association problem. However, this does not address full form participation — meaning these components don't yet natively integrate with a parent <form> element for value submission, constraint validation, required/disabled handling, and reset() support.

The goal is to investigate and implement Form Associated Custom Elements (FACE) via static formAssociated = true and the ElementInternals API (attachInternals()) across relevant form components.

What full implementation would unlock:

  • Component values submitted with parent <form> natively
  • Constraint validation (required, custom validity messages)
  • disabled and readonly participation
  • form.reset() support

Known complexity:

  • ElementInternals requires manual wiring of behaviors that native inputs get for free — it's not automatic even after setting formAssociated
  • Built-in form behaviors like required, disabled, and constraint validation have to be explicitly implemented via ElementInternals rather than inherited naturally
  • The mental model mismatch: developers expect web components to "just work" like native inputs inside forms, but you're essentially reimplementing native form control behavior from scratch
  • Note: browser support is no longer a concern — as of January 2026 FACE has ~95% global coverage including Safari (supported since 16.4). No polyfill strategy needed.

Cheat sheet:

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('my-input')
export class MyInput extends LitElement {
  static formAssociated = true;
  private _internals: ElementInternals;

  constructor() {
    super();
    this._internals = this.attachInternals();
  }

  @property()
  value = '';

  // Sync value to parent form whenever internal state changes
  handleInput(e: Event) {
    this.value = (e.target as HTMLInputElement).value;
    this._internals.setFormValue(this.value);
  }

  // Called automatically when parent form is reset
  formResetCallback() {
    this.value = '';
    this._internals.setFormValue('');
  }

  // Custom validity example
  validate() {
    if (!this.value) {
      // This is bothersome. We'll need to investigate how to invert control to consumer. If not full IOC we need
      // to think through what control CAN we provide to consumers. Obviously, this would break i18n locale control
      // and takes away flexibility the consumer developer likely wants.
      this._internals.setValidity({ valueMissing: true }, 'This field is required');
    } else {
      this._internals.setValidity({});
    }
  }

  // Custom State Set for CSS pseudo-classes e.g. :--checked, :--loading
  setCustomState(state: string, active: boolean) {
    active ? this._internals.states.add(state) : this._internals.states.delete(state);
  }

  // Allow access to parent form; one of several boiler-plate get/set to provide (name, type, etc.)  some of which will be
  // component specific e.g. a Checkbox would need: get checked() { return this.hasAttribute('checked'); }
  // and also set checked(flag), click handling, and so on.
  get form() { return this._internals.form; }

  render() {
    return html`<input .value=${this.value} @input=${this.handleInput}>`;
  }
}

Scope: AgInput only. Other components tracked in the planning doc
this issue produces. Playbook is documented but not fully implemented.

Acceptance criteria:

  • Capture implementation notes as work progresses in v2/lib/src/components/FACE-NOTES.md — this file will be used to derive a future article on refactoring FACE into web components.
  • Investigate IOC options for consumer-controlled validation messages (e.g. locale strings). Document at least two approaches with tradeoffs in the implementation notes file. Do not implement — flag for follow-up issue.
  • AgInput participates fully in parent form submission
  • Constraint validation works (required, minlength, custom validity messages)
  • inline validation messages are accessible to screenreaders via proper use of using WAI-ARIA attributes
  • disabled propagates correctly
  • form.reset() restores default value
  • Cross-browser tested including Safari
  • Document all other components requiring FACE in v2/lib/src/components/FACE-PLANNING.md (e.g. AgCheckbox, AgSelect, AgRadio) so additional GitHub Issues can be created and work scoped.
  • A Form Association Playbook is planned but not implemented as part of this
    issue. Deliverable is PROMPT files only (following the pattern in
    v2/playbooks/README.md) — no generated react-example/, vue-example/, or
    lit-example/ output. The Playbook should demonstrate looping through form
    controls to validate an entire form using the FACE-enabled components
    produced by this and follow-up issues.

###STOP###

The LLM should ignore the following and it's not necessary for the LLM to visit these links as they're more for background information on how FACE works.

Research:

Metadata

Metadata

Labels

A11yAccessibilityenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions