Skip to main content

Form-Associated Custom Elements

What are "FACE" elements?​

Form-Associated Custom Elements (FACE) allow custom elements to behave like native form controls (<input>, <select>, <textarea>). A form-associated element can:

This is implemented via the ElementInternals API (HTML Spec).


The CustomElement factory supports creating form-associated custom elements that automatically participate in HTML forms. This enables React components wrapped as custom elements to seamlessly integrate with native form handling.

Basic Usage​

Enable form association by setting formAssociated: true:

import { CustomElement } from "@webbloqs/proxies";
import { MyFormFields } from "./MyFormFields";

const MyCustomField = CustomElement({
reactComponent: MyFormFields,
formAssociated: true,
properties: {
name: String,
},
});

customElements.define("my-custom-field", MyCustomField);
<form id="my-form">
<my-custom-field name="contact"></my-custom-field>
<button type="submit">Submit</button>
</form>

Important: The name attribute is required for the element to participate in form association. Without a name, the element will not contribute data to the form, even if formAssociated: true is set. The name attribute can be added dynamically after the element is created.

The custom element will automatically:

  • Associate with its parent form (or the form specified by the form attribute)
  • Aggregate values from all named inputs inside its shadow DOM
  • Include those values when the form is submitted or queried via new FormData(form)

Examples​

Simple Form Field​

A React component with a single input that participates in forms:

// EmailField.tsx
export const EmailField = ({ label = "Email" }) => (
<div className="field">
<label>{label}</label>
<input type="email" name="email" />
</div>
);

// EmailFieldElement.ts
export const EmailFieldElement = CustomElement({
reactComponent: EmailField,
formAssociated: true,
properties: {
label: String,
},
});

customElements.define("email-field", EmailFieldElement);
<form>
<email-field name="user" label="Your Email"></email-field>
<button type="submit">Subscribe</button>
</form>

<!-- FormData will contain: { "user-email": "..." } -->

Composite Form Field​

A React component with multiple inputs (e.g., an address field):

// AddressField.tsx
export const AddressField = () => (
<div className="address-field">
<input name="street" placeholder="Street" />
<input name="city" placeholder="City" />
<input name="zip" placeholder="ZIP" />
</div>
);

// AddressFieldElement.ts
export const AddressFieldElement = CustomElement({
reactComponent: AddressField,
formAssociated: true,
});

customElements.define("address-field", AddressFieldElement);
<form>
<address-field name="shipping"></address-field>
<address-field name="billing"></address-field>
<button type="submit">Place Order</button>
</form>

<!-- FormData will contain:
{
"shipping-street": "...",
"shipping-city": "...",
"shipping-zip": "...",
"billing-street": "...",
"billing-city": "...",
"billing-zip": "..."
}
-->

Custom Name Prefix​

By default, child input names are prefixed with the element's name attribute followed by a hyphen (e.g., name="contact" → contact-email, contact-phone).

Override the default prefix with a custom value using the form-name-prefix attribute:

<form>
<payment-field name="card" form-name-prefix="payment_"></payment-field>
</form>

<!-- FormData will contain: { "payment_cardNumber": "...", "payment_expiry": "..." } -->

To disable prefixing entirely and use the child input names as-is, set form-name-prefix to an empty string:

<form>
<simple-field name="data" form-name-prefix=""></simple-field>
</form>

<!-- FormData will contain: { "cardNumber": "...", "expiry": "..." } (no prefix) -->

Note: Even when using an empty prefix, the name attribute is still required for the element to participate in form association. The name attribute identifies the element to the form; form-name-prefix only controls how child input names are formatted in the form data.

Definition-Level Default Prefix​

When the element author knows the right prefix at definition time, set it once in the CustomElement() call instead of requiring a form-name-prefix attribute on every usage. Pass an options object to formAssociated:

const PaymentFieldElement = CustomElement({
reactComponent: PaymentField,
formAssociated: { namePrefix: "payment_" },
});

Use namePrefix: false (or equivalently "") to disable prefixing entirely:

const SimpleFieldElement = CustomElement({
reactComponent: SimpleField,
formAssociated: { namePrefix: false },
});

The prefix is resolved with the following precedence:

  1. form-name-prefix attribute on the element instance (consumers can still override)
  2. namePrefix from the element definition
  3. {name}- derived from the element's name attribute

An empty options object (formAssociated: {}) is equivalent to formAssociated: true.

Note: The name attribute is still required for form participation, regardless of the configured prefix.

Single Wrapped Input​

A common case: a custom element that wraps exactly one input and should behave like a native <input> — submitting a single FormData entry under the element's own name. With the default {name}- prefix, an inner input named after the element would produce a doubled key like contact-contact. Declare namePrefix: false in the definition and pass the element's name through to the inner input:

// EmailInput.tsx - inner input name mirrors the element's name
export const EmailInput = ({ name, label = "Email" }) => (
<div className="field">
<label>{label}</label>
<input type="email" name={name} />
</div>
);

// EmailInputElement.ts
export const EmailInputElement = CustomElement({
reactComponent: EmailInput,
formAssociated: { namePrefix: false },
properties: {
name: { type: String, reflect: true },
label: String,
},
});

customElements.define("email-input", EmailInputElement);
<form>
<email-input name="contact" label="Your Email"></email-input>
<button type="submit">Subscribe</button>
</form>

<!-- FormData will contain: { "contact": "..." } - exactly like a native <input name="contact"> -->
<!-- With plain formAssociated: true it would have been: { "contact-contact": "..." } -->

The same approach serves composite fields that want full control over their FormData keys: declare namePrefix: false in the definition, let the React component derive all inner input names from the outer name prop itself, and simply don't document the form-name-prefix attribute for the element.

Validation​

Form-associated custom elements automatically aggregate validation state from child inputs and prevent form submission when invalid:

// RequiredEmailField.tsx
export const RequiredEmailField = () => (
<div>
<label>Email (required)</label>
<input type="email" name="email" required />
</div>
);

// RequiredEmailFieldElement.ts
export const RequiredEmailFieldElement = CustomElement({
reactComponent: RequiredEmailField,
formAssociated: true,
});

customElements.define("required-email-field", RequiredEmailFieldElement);
<form id="signup-form">
<required-email-field name="user"></required-email-field>
<button type="submit">Submit</button>
</form>

<script>
const field = document.querySelector('required-email-field');

// Check if field is valid
console.log(field.willValidate); // true
console.log(field.validity.valid); // false (if empty)
console.log(field.validationMessage); // "Please fill out this field."

// Validate programmatically
field.checkValidity(); // Returns false and fires 'invalid' event
field.reportValidity(); // Shows browser validation UI
</script>

The custom element exposes standard validation properties:

  • willValidate - Whether the element participates in validation
  • validity - A ValidityState object with flags like valueMissing, typeMismatch, etc.
  • validationMessage - The validation error message
  • checkValidity() - Checks validity and fires invalid event if invalid
  • reportValidity() - Shows validation UI to the user

Styling with CSS Pseudo-Classes​

Form-associated custom elements support native CSS validation pseudo-classes automatically:

<form>
<email-field name="user" required></email-field>
</form>

<style>
/* Style invalid fields */
email-field:invalid {
border: 2px solid red;
}

/* Style valid fields */
email-field:valid {
border: 2px solid green;
}
</style>

These pseudo-classes work automatically when the element uses constraint validation via setValidity(). See MDN :valid and MDN :invalid.

For application-specific states (loading, dirty, etc.), you can use the custom states API:

const field = document.querySelector('my-field') as any;
field.states?.add('loading');
field.states?.delete('loading');
my-field:state(loading) {
opacity: 0.5;
pointer-events: none;
}

Lifecycle Callbacks​

Form-associated custom elements receive lifecycle callbacks for form events:

formAssociatedCallback(form)​

Called when the element is associated with or disassociated from a form:

// Automatically implemented by CustomElement
// You don't need to define this manually
formAssociatedCallback(form: HTMLFormElement | null) {
console.log('Associated with form:', form?.id);
}

formDisabledCallback(disabled)​

Called when the element becomes disabled (via disabled attribute or ancestor fieldset[disabled]):

formDisabledCallback(disabled: boolean) {
console.log('Disabled state changed:', disabled);
// Child inputs are automatically disabled/enabled
}

formResetCallback()​

Called when the associated form is reset:

formResetCallback() {
console.log('Form was reset');
// Form data and validation state are automatically cleared
}

formStateRestoreCallback(state, mode)​

Called when the browser restores state (e.g., page refresh, autofill):

formStateRestoreCallback(state: FormData, mode: 'restore' | 'autocomplete') {
console.log('State restored:', mode);
// Child input values are automatically restored
}

External Form Association​

Associate with a form that's not an ancestor using the form attribute:

<form id="checkout-form">
<button type="submit">Checkout</button>
</form>

<!-- Elsewhere in the DOM -->
<address-field name="delivery" form="checkout-form"></address-field>

Authoring Custom Inputs​

This section provides a complete, copy-ready template for creating form-associated custom elements that wrap React components. The template incorporates lessons learned from production implementations.

Complete Template​

The following template shows how to create a composite form field (like a phone number input with country code selection) that:

  • Wraps a React component as a custom element
  • Participates in HTML form submission via FACE
  • Uses hidden field aggregation for canonical values
  • Handles validation properly
// ═══════════════════════════════════════════════════════════════════════════
// FILE: PhoneNumberField.tsx - The React Component
// ═══════════════════════════════════════════════════════════════════════════
//
// This is a standard React component. It knows nothing about custom elements.
// Key principles:
// 1. Use SIMPLE field names (e.g., "country", "number") - no prefixes!
// FACE handles prefixing via the `formNamePrefix` attribute.
// 2. For composite inputs, use a hidden field to hold the canonical value
// that gets submitted. Display fields are for UX only.
// 3. The component receives props like any React component.
//
import React, { type FC, useState, useMemo, useRef, useEffect } from "react";

/**
* Props for the PhoneNumberField component.
*
* IMPORTANT: Include `name` and `form` in your props interface even though
* they're handled by FACE. This allows consumers to pass them via JSX.
* The `form` prop enables external form association (<element form="form-id">).
*/
export interface PhoneNumberFieldProps {
/** Element name for FACE form participation. Required for FormData. */
name?: string;

/** Form ID for external form association (optional). */
form?: string;

/** Label text displayed above the input. */
label?: string;

/** Whether a valid phone number is required. */
required?: boolean;

/** Whether the field is disabled. */
disabled?: boolean;

/** Initial phone number value. */
defaultValue?: string;
}

export const PhoneNumberField: FC<PhoneNumberFieldProps> = ({
// Note: `name` and `form` are not used directly in the React component.
// They're handled by the custom element wrapper (FACE).
// We include them in props so consumers can pass them via JSX.
label,
required = false,
disabled = false,
defaultValue = "",
}) => {
const [country, setCountry] = useState("CH");
const [inputNumber, setInputNumber] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);

// Compute the canonical E.164 phone number from display values
const canonicalValue = useMemo(() => {
if (!inputNumber) return "";
const countryCode = country === "CH" ? "+41" : country === "DE" ? "+49" : "+33";
const cleaned = inputNumber.replace(/\D/g, "");
return `${countryCode}${cleaned}`;
}, [country, inputNumber]);

// Simple validation
const isValid = useMemo(() => {
if (!required && !inputNumber) return true;
return canonicalValue.length >= 10;
}, [required, inputNumber, canonicalValue]);

// Trigger validation when value changes
useEffect(() => {
if (inputRef.current && inputNumber) {
inputRef.current.setCustomValidity(isValid ? "" : "Invalid phone number");
}
}, [isValid, inputNumber]);

return (
<div className="phone-field">
{label && <label>{label}</label>}

<div className="phone-field__inputs">
{/* Country selector - display only, uses simple name */}
<select
name="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
disabled={disabled}
>
<option value="CH">🇨🇭 +41</option>
<option value="DE">🇩🇪 +49</option>
<option value="FR">🇫🇷 +33</option>
</select>

{/* Number input - display only, uses simple name */}
<input
ref={inputRef}
type="tel"
name="display-number"
value={inputNumber}
onChange={(e) => setInputNumber(e.target.value)}
placeholder="79 123 45 67"
disabled={disabled}
required={required}
/>
</div>

{/*
┌─────────────────────────────────────────────────────────────────────┐
│ HIDDEN FIELD AGGREGATION PATTERN │
│ │
│ This hidden input holds the CANONICAL value that gets submitted. │
│ The display inputs above are for UX only. │
│ │
│ Use this pattern when: │
│ - Multiple display fields contribute to one logical value │
│ - The submitted format differs from the display format │
│ - You need to normalize/transform user input before submission │
│ │
│ The `name` here should match what you want in FormData. │
│ FACE will prefix it: <element name="phone"> → "phone-number" │
└─────────────────────────────────────────────────────────────────────┘
*/}
<input type="hidden" name="number" value={canonicalValue} />
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// FILE: index.ts - The Custom Element Registration
// ═══════════════════════════════════════════════════════════════════════════
//
// This file creates the custom element class and registers it.
// Key principles:
// 1. Use `formAssociated: true` to enable FACE
// 2. Use `Omit<Props, "form">` to preserve FACE's native .form getter
// 3. Use `reflect: true` for `name` so the attribute syncs to the property
// 4. Don't add `form` as a property - it shadows the native FACE getter
//
import { CustomElement } from "@webbloqs/proxies";
import { PhoneNumberField, type PhoneNumberFieldProps } from "./PhoneNumberField";

/*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ CRITICAL: Omit<Props, "form"> │
* │ │
* │ FACE provides a native `.form` getter that returns the associated │
* │ HTMLFormElement. If you add `form: String` to properties, it creates │
* │ a property that SHADOWS this getter, breaking form association. │
* │ │
* │ The `form` ATTRIBUTE still works for external form association │
* │ (<element form="form-id">) - React sets it via JSX attribute handling. │
* └─────────────────────────────────────────────────────────────────────────┘
*/
export const PhoneNumberFieldElement = CustomElement<Omit<PhoneNumberFieldProps, "form">>({
reactComponent: PhoneNumberField,
formAssociated: true,
properties: {
/*
* ┌─────────────────────────────────────────────────────────────────────┐
* │ CRITICAL: name needs `reflect: true` │
* │ │
* │ Without reflection, the attribute doesn't sync to the property, │
* │ which can cause issues with form association and prefixing. │
* └─────────────────────────────────────────────────────────────────────┘
*/
name: { type: String, reflect: true },

// Optional: expose formNamePrefix if you want consumers to customize prefixing
// formNamePrefix: { type: String, reflect: true, attribute: "form-name-prefix" },

// Regular properties - no special handling needed
label: String,
required: Boolean,
disabled: Boolean,
defaultValue: String,
},
});

// Register the custom element
customElements.define("phone-number-field", PhoneNumberFieldElement);

export type { PhoneNumberFieldProps };
export default PhoneNumberFieldElement;
// ═══════════════════════════════════════════════════════════════════════════
// FILE: Usage Example
// ═══════════════════════════════════════════════════════════════════════════
import React from "react";
import "./index"; // Register the custom element

const ContactForm = () => {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
console.log(Object.fromEntries(formData));
// Output: { "contact-number": "+41791234567", "contact-country": "CH", ... }
};

return (
<form onSubmit={handleSubmit}>
{/* The custom element in JSX */}
<phone-number-field
name="contact"
label="Phone Number"
required
/>
<button type="submit">Submit</button>
</form>
);
};

Hidden Field Aggregation Pattern​

When your component has multiple display inputs that contribute to a single logical value, use a hidden field to hold the canonical submitted value:

ScenarioDisplay FieldsHidden Field Value
Phone numberCountry dropdown + number inputE.164 format: +41791234567
Date pickerDay/month/year selectsISO format: 2024-03-15
Address autocompleteSearch input + suggestionsStructured JSON or ID
Currency inputAmount + currency selectorNormalized: 1234.56 USD

When to use this pattern:

  • The submitted format differs from the display format
  • Multiple inputs contribute to one logical value
  • You need validation/normalization before submission

When NOT to use:

  • Each input represents independent data (use multi-field approach instead)
  • The display format matches the submission format

Overridable Field Names (fieldNames Pattern)​

A composite field hardcodes its inner input names (country, number, street, ...), so consumers can only influence the FormData keys through the prefix. When consumers need control over the exact keys — e.g. a backend expects telefon instead of phone-number — expose the inner names as an overridable fieldNames prop.

Four pieces, building on the phone number template above:

// props/default-field-names.ts - single source of truth for the inner names
export const DEFAULT_FIELD_NAMES = {
country: "country",
number: "number",
};
// PhoneNumberFieldProps.ts - the prop type derives its keys from the constant
export interface PhoneNumberFieldProps {
// ...
/**
* Overridable inner input names. Keys come from DEFAULT_FIELD_NAMES.
* Combine with form-name-prefix="" to control the exact FormData keys.
*/
fieldNames?: Partial<Record<keyof typeof DEFAULT_FIELD_NAMES, string>>;
}
// PhoneNumberField.tsx - resolve each name once, use everywhere
const countryFieldName = fieldNames?.country ?? DEFAULT_FIELD_NAMES.country;
const numberFieldName = fieldNames?.number ?? DEFAULT_FIELD_NAMES.number;

// <select name={countryFieldName} ...>
// <input type="hidden" name={numberFieldName} value={canonicalValue} />
// index.ts - declare it on the element; Object properties are JSON-deserialized from the attribute
export const PhoneNumberFieldElement = CustomElement<PhoneNumberFieldProps>({
reactComponent: PhoneNumberField,
formAssociated: true,
properties: {
name: { type: String, reflect: true },
fieldNames: { type: Object, attribute: "field-names" },
// ...
},
});

Together with prefix control, consumers now decide the complete FormData shape per usage:

<form>
<phone-number-field
name="phone"
form-name-prefix=""
field-names='{"number": "telefon", "country": ""}'
></phone-number-field>
</form>

<!-- FormData will contain exactly: { "telefon": "+41791234567" } -->

Two details worth knowing:

  • Empty string removes a field: "country": "" renders the input without a usable name, and unnamed inputs are excluded from FormData. Use this to drop display-only fields from submission.
  • Definition-level alternative: when the element should always submit flat keys, set formAssociated: { namePrefix: false } in the definition (see Definition-Level Default Prefix) so consumers only pass field-names when they need to rename.

Gotchas & Troubleshooting​

These are non-obvious issues discovered during production implementations:

Don't Add form as a Property​

Problem: Adding form: String to the properties object creates a property that shadows FACE's native .form getter, which returns the associated HTMLFormElement.

// ❌ WRONG - shadows the native .form getter
export const MyElement = CustomElement<MyProps>({
formAssociated: true,
properties: {
name: { type: String, reflect: true },
form: String, // This breaks .form getter!
},
});

// ✅ CORRECT - preserve native .form getter
export const MyElement = CustomElement<Omit<MyProps, "form">>({
formAssociated: true,
properties: {
name: { type: String, reflect: true },
// Don't include form here
},
});

The form attribute still works for external form association - React passes it as an attribute via JSX, and FACE handles it natively.

The name Property Needs reflect: true​

Problem: Without reflect: true, the name attribute doesn't properly sync to the property, causing form association issues.

// ❌ WRONG - name won't reflect
properties: {
name: String,
}

// ✅ CORRECT - name reflects to attribute
properties: {
name: { type: String, reflect: true },
}

Don't Prefix Field Names in React​

Problem: You might be tempted to prefix child field names like ${groupName}_${fieldName} inside your React component. This duplicates FACE's built-in prefixing.

// ❌ WRONG - manual prefixing in React
const AddressField = ({ name }) => (
<div>
<input name={`${name}_street`} /> {/* Results in "address-address_street" */}
<input name={`${name}_city`} />
</div>
);

// ✅ CORRECT - let FACE handle prefixing
const AddressField = () => (
<div>
<input name="street" /> {/* FACE prefixes to "address-street" */}
<input name="city" />
</div>
);

Solution: Use simple field names in React. FACE automatically prefixes them with {name}- (or your custom form-name-prefix).

Nested webbloqs Fields Are Read via getFormData()​

When your component renders webbloqs form elements (wb-text-field, wb-radio-field, wb-toggle, ...), the wrapper reads their values through their public getFormData() method (see Nested FACE Elements and getFormData()). Things to know:

  • Keys come from the inner element's own name. webbloqs elements key their entries by their own name attribute. Pass the wrapper's name through to the inner element and use formAssociated: { namePrefix: false } for native-like keys — otherwise the default prefix produces doubled keys like contact-contact.
  • Diverging keys are appended verbatim. If a nested element's getFormData() returns keys that differ from its name attribute, they are prefixed and appended as-is and can collide with sibling inputs.
  • State restoration is string-only. formStateRestoreCallback restores string values by [name] lookup. File entries and the inner UI state of webbloqs elements are not restored.
  • Nested proxy FACE hosts are read via an internal hook. A CustomElement-based FACE host nested inside another one is read through the documented-internal __faceFormData() hook (the setFormValue state itself is write-only), and it re-aggregates its parent host automatically after its own aggregation. No component-level forwarding (manual updateFormData() calls on the parent) is needed anymore.

When Aggregation Runs​

setFormValue is fed from an aggregation of the child inputs, triggered per change source:

  • User input: input/change events aggregate synchronously — like native inputs, the form's FormData is up to date for listeners of the same event (React has already committed its discrete-event state when the FACE listener runs).
  • Prop changes: any reactive property change re-aggregates after React's commit (a macrotask) — controlled values that change without any DOM event are picked up too.
  • Structural changes: a MutationObserver (childList/subtree + name/checked/disabled/ required attributes) schedules a debounced aggregation when inputs appear, disappear, or change identity.
  • Nested FACE hosts re-aggregate their parent host automatically (debounced microtask).
  • Announced publishes: a child that dispatches formvaluechange (after its own ElementInternals.setFormValue()) or formvaliditychange (after its own setValidity()) re-aggregates the host eagerly, batched through the same debounced microtask as structural changes. This is a generic convention, not a webbloqs-specific hook: both events are plain Events (bubbles: true, composed: true, no detail, no wb- prefix), and any form-associated custom element that implements it is heard the same way. Because both events are composed, they cross the child's own shadow boundary and reach the host the same way input/change already do.
  • Disconnected elements skip pending aggregations instead of publishing an empty value.

If you must read a prop-driven aggregate synchronously in a test, await a macrotask first.

None of the triggers above see a direct DOM property write on a shadow descendant that publishes through neither a DOM event nor the announcement above (for example a wb-hidden-field-style element with no live .value reflection: hiddenFieldRef.current.value = "x" fires no event, changes no observed attribute, touches no host prop, and announces nothing). The host closes that gap at read time instead of adding a further eager trigger:

  • new FormData(form) and native submission both fire the platform's formdata event while building the form's entry list; the host re-aggregates in that handler and replaces its stale entries before the entry list is handed back.
  • The host's own checkValidity(), validity, validationMessage, and reportValidity() re-aggregate before returning.

Known gap, narrowed but not closed: form.checkValidity() and native submission's own static-validation step read ElementInternals' cached validity directly, without calling back into the host, and the browser performs that read before any event the host could observe fires. The announced-publish trigger above closes this for a conformant, already-settled child: if formvaluechange/formvaliditychange has already fired and been handled before the read, the host's own ElementInternals is already fresh, and form.checkValidity() (or submission) sees it correctly. Two cases remain open:

  • A child that publishes with no signal at all (no event, no announcement — the wb-hidden-field-style case above) is still only caught at read time, per the mechanisms listed above.
  • Ordering: a conformant element's setFormValue/setValidity calls (and the events that follow them) commonly run one microtask after the property write that triggered them (Lit-based elements publish from updated()). Native submission or a synchronous form.checkValidity() call that runs in that same window — after the write, before the child's own microtask has fired — still reads the stale value. No platform hook closes this; call checkValidity() on the host directly, or await the child's own settle point (e.g. its updateComplete), before relying on native submission's validation step.

Form Reset May Not Clear React State​

Problem: When the form resets, FACE clears the form data, but React component state persists.

Solution: If your component uses controlled inputs with React state, handle the formResetCallback lifecycle or use uncontrolled inputs with defaultValue.

Validation Timing with Async Operations​

Problem: If your component fetches data or validates asynchronously, the validation state may be stale when checked.

Solution: Use setTimeout(..., 0) to defer validation checks until after React re-renders:

useEffect(() => {
setTimeout(() => {
inputRef.current?.setCustomValidity(isValid ? "" : "Invalid");
}, 0);
}, [isValid]);

API Reference​

CustomElement Options​

OptionTypeDefaultDescription
formAssociatedboolean | FormAssociationOptionsfalseEnable form association for the custom element. Pass an options object to set definition-level defaults (an empty object is equivalent to true)

FormAssociationOptions​

OptionTypeDefaultDescription
namePrefixstring | falseundefinedDefinition-level default for the FormData name prefix. Overridden by a per-instance form-name-prefix attribute; falls back to {name}- when unset. Use false (or "") to disable prefixing

Element Properties​

Form-associated custom elements automatically expose these properties:

PropertyTypeDescription
namestringElement name, used as default prefix for form field names
formHTMLFormElement | nullThe associated form element
formNamePrefixstringCustom prefix for child input names
willValidatebooleanWhether the element participates in constraint validation
validityValidityStateValidity state object with validation flags
validationMessagestringValidation error message
labelsNodeList | nullAssociated <label> elements
statesCustomStateSetCustom state set for app-specific CSS :state() pseudo-classes (e.g., :state(loading))
valuestring | nullThe primary child control's FormData entry: the child whose prefixed name equals the host's name (reachable with namePrefix: false), else the single named child; null for composites (use formData) and while disabled (excluded from form data). Assigning writes the primary child through the native setter and re-aggregates; writes are allowed while disabled (native parity). Not installed when the definition declares a value prop — a declared prop keeps plain Lit/React semantics
formDataFormDataFresh aggregate of the child inputs (prefixed keys), computed per read. Not installed when the definition declares a formData prop

Element Methods​

MethodReturnsDescription
checkValidity()booleanChecks validity, fires invalid event if invalid
reportValidity()booleanChecks validity and shows browser validation UI
setCustomValidity(message)voidNative parity: a non-empty message makes the element invalid (customError) until cleared with "". The custom message wins over child validation messages; cleared by form reset; suppressed while disabled

Element Attributes​

AttributeDescription
nameRequired for form participation. Element name, used as default prefix for form field names
formID of a form to associate with (when not a descendant of the form)
form-name-prefixCustom prefix for child input names. Defaults to the definition's namePrefix option, then {name}-. Set to empty string for unprefixed names
disabledDisables the element. Per HTML spec, disabled elements are excluded from FormData and barred from constraint validation
readonlyMakes the element read-only. Child inputs with readonly are included in FormData but cannot be edited. Per HTML spec, readonly inputs are barred from constraint validation

Lifecycle Callbacks​

Form-associated custom elements receive these lifecycle callbacks:

CallbackParametersDescription
formAssociatedCallbackform: HTMLFormElement | nullCalled when form owner changes
formDisabledCallbackdisabled: booleanCalled when disabled state changes
formResetCallbackNoneCalled when associated form is reset
formStateRestoreCallbackstate: FormData | string, mode: 'restore' | 'autocomplete'Called for autofill/restore. FormData state restores by [name] lookup; string state restores onto the primary child control (File not implemented)

Technical Details​

Why Form-Associated Custom Elements?​

Standard HTML form elements (<input>, <select>, etc.) automatically participate in form submission. However, custom elements with shadow DOM create an encapsulation boundary that prevents inner inputs from being included in the parent form's data.

Form-associated custom elements solve this by using the ElementInternals API to programmatically participate in forms.

How It Works​

  1. Form Association: The custom element declares static formAssociated = true and calls attachInternals() to get an ElementInternals object.

  2. Form Value Reporting: When inputs inside the shadow DOM change, the element aggregates their values into a FormData object and reports it via ElementInternals.setFormValue().

  3. Constraint Validation: The element aggregates validation state from all child inputs using their validity properties and reports the combined state via ElementInternals.setValidity(). This prevents form submission when any child input is invalid.

  4. CSS Pseudo-Classes: The setValidity() call automatically makes the element match native :valid or :invalid CSS pseudo-classes.

  5. Form Participation: The browser includes the element's reported value when:

    • Creating FormData from the form
    • Submitting the form
    • Resetting the form
    • Validating the form

Nested FACE Elements and getFormData()​

A named child inside the shadow DOM can itself be a form-associated custom element (e.g. a webbloqs field). Such elements are detected via their static formAssociated and included in the aggregation. Their values are read in one of two ways:

  1. getFormData() contract (preferred): If the nested element exposes a public getFormData(): FormData method (the webbloqs FormFieldElement contract), aggregation appends all of its entries, each key prefixed with the resolved name prefix. The semantics are native-like: a radio group contributes nothing until an option is checked, a toggle/checkbox only while checked, and entries may be File objects (upload fields), which are passed through as-is.
  2. .value fallback: Nested FACE elements without getFormData() are read via their value property, like a regular input.

Validity flows through the standard fallback chain in both cases: validity property → public checkValidity() (the escape hatch for third-party FACE elements with no public validity getter).

Note: The getFormData() path is a compatibility workaround for webbloqs elements that do not (yet) expose live .value/.checked properties. It can be removed once those elements report their state through standard properties.

Tab Navigation​

FACE elements use delegatesFocus: true on their shadow root. Inner focusable elements (inputs, selects, etc.) become part of the document's natural tab order — Tab and Shift+Tab flow through them as if they were in the light DOM.

  • reportValidity() focuses the first invalid input via the custom focus() override.
  • document.activeElement returns the host element when an inner element has focus (standard delegatesFocus behavior). Use document.activeElement.shadowRoot.activeElement to get the actual focused element inside.

If you need to exclude a FACE element from the tab order, set tabindex="-1" explicitly on the host element in your markup.

Specifications​

This feature implements the Form-Associated Custom Elements specification from the WHATWG HTML Standard.

Key spec references:

Browser Support​

Form-associated custom elements are supported in all modern browsers:

  • Chrome 77+
  • Firefox 93+
  • Safari 16.4+
  • Edge 79+

See Can I Use: Form-associated custom elements for current support status.


Future Enhancements​

The following features are planned for future releases:

  • labels property testing - Comprehensive E2E tests for label association via labels
  • SSR behavior documentation - Document how form-associated custom elements behave during server-side rendering
  • File/string state restoration - Support File and string types in formStateRestoreCallback for complete spec compliance (currently only handles FormData)
  • formStateRestoreCallback testing - E2E tests for browser state restoration (requires complex browser navigation/autofill simulation)
  • Nested fieldsets - Test and document behavior with nested <fieldset disabled> elements
  • Dynamic form attribute changes - Test changing form="a" to form="b" at runtime