Form System

AdiaUI's form system uses UIFormElement and ElementInternals to give custom elements native form participation, constraint validation, and auto-validation UX.

Overview

HTML forms expect native elements (<input>, <select>) to participate in submission, validation, and reset. Custom elements are invisible to forms by default. AdiaUI solves this with UIFormElement, a base class that bridges custom elements into the native form lifecycle via the ElementInternals API.

Any component that extends UIFormElement can be placed inside a <form> and will:

Components Using UIFormElement

The following components extend UIFormElement and participate in forms:

Component Tag Value Type
Inputinput-uiString
Textareatextarea-uiString
Selectselect-uiString
Checkcheck-uiString (on/off)
Switchswitch-uiString (on/off)
Radioradio-uiString
Sliderslider-uiNumber (as string)
Rangerange-uiNumber (as string)
Searchsearch-uiString
Segmentedsegmented-uiString
OTP Inputotp-input-uiString
Calendar Pickercalendar-picker-uiDate string
Color Pickercolor-picker-uiColor string
Uploadupload-uiFile(s)

ElementInternals

ElementInternals is the browser API that lets custom elements act as form controls. AdiaUI's UIFormElement sets static formAssociated = true to opt in, then uses the internals object to:

class UIFormElement extends UIElement { static formAssociated = true; // Provided by the browser when formAssociated is true: // this.internals = this.attachInternals(); get form() { return this.internals.form; } get validity() { return this.internals.validity; } get validationMessage() { return this.internals.validationMessage; } }

Constraint Validation

UIFormElement implements four constraint types that mirror native HTML validation:

Attribute Validity Flag Default Message
required valueMissing "This field is required."
pattern="regex" patternMismatch "Please match the requested format."
minlength="n" tooShort "Please use at least n characters."
maxlength="n" tooLong "Please use no more than n characters."
<form> <input-ui name="email" required pattern="[^@]+@[^@]+" data-msg-required="Email is required" data-msg-pattern="Enter a valid email"> </input-ui> <input-ui name="username" required minlength="3" maxlength="20" data-msg-minlength="Username must be at least 3 characters" data-msg-maxlength="Username cannot exceed 20 characters"> </input-ui> <button-ui type="submit" text="Submit" variant="primary"></button-ui> </form>

Auto-Validation Lifecycle

AdiaUI implements a progressive validation UX that avoids showing errors prematurely. The lifecycle is:

  1. On form submit: The browser fires the invalid event on any field that fails validation. UIFormElement catches this, sets aria-invalid="true", and populates the error attribute with the validation message.
  2. On input (while invalid): If the field currently has an error, every keystroke re-validates. As soon as the value passes, the error is cleared immediately. This gives instant positive feedback.
  3. On blur (if dirty): When the user tabs away from a field they have typed in, validation runs. This catches errors on fields the user has interacted with but not submitted.
  4. On form reset: All validation state is cleared — error attribute removed, aria-invalid removed, dirty flag reset.

The "dirty" flag ensures blur validation only fires after the user has actually interacted with the field. A pristine field that receives focus and then loses it will not show an error.

Custom Error Messages

Override the default validation messages using data-msg-* attributes on any form element:

Attribute Overrides
data-msg-required Message when field is empty and required
data-msg-pattern Message when value doesn't match pattern
data-msg-minlength Message when value is shorter than minlength
data-msg-maxlength Message when value exceeds maxlength
<input-ui name="phone" required pattern="\d{3}-\d{3}-\d{4}" data-msg-required="Phone number is required" data-msg-pattern="Use format: 123-456-7890"> </input-ui>

Manual Control

For cases where you need to validate programmatically or set custom errors (e.g. server-side validation), use these methods:

Method Returns Description
validate() boolean Runs all constraints, surfaces error if invalid, clears if valid
setInvalid(message) void Sets a custom error — useful for async/server validation
setValid() void Clears all validation errors and aria-invalid
syncValue(val) void Updates the form value and re-runs constraint validation
const emailField = document.querySelector('input-ui[name="email"]'); // Programmatic validation if (!emailField.validate()) { console.log(emailField.validationMessage); } // Server-side error async function onSubmit() { const res = await fetch('/api/check-email', { ... }); if (!res.ok) { emailField.setInvalid('This email is already registered'); } } // Clear error after user action emailField.setValid(); // Update form value programmatically emailField.syncValue('new@example.com');

Form Lifecycle Callbacks

UIFormElement implements the standard form-associated custom element callbacks. Subclasses can override the hook methods:

Browser Callback Override Hook When It Fires
formResetCallback() onFormReset() When the parent form is reset
formDisabledCallback(disabled) onFormDisabled(disabled) When a parent fieldset is disabled/enabled
formAssociatedCallback(form) onFormAssociated(form) When the element is associated with a form
formStateRestoreCallback(state, reason) onFormStateRestore(state, reason) When browser restores form state (back/forward navigation)

Live Demo

Try submitting the form below without filling in the fields to see auto-validation in action:

Extending UIFormElement

To create a new form-participating component, extend UIFormElement and call syncValue() whenever the internal value changes:

import { UIFormElement } from '@core/form.js'; class MyRating extends UIFormElement { static properties = { ...UIFormElement.properties, max: { type: Number, default: 5, reflect: true }, }; render() { // Render stars, handle clicks... } #onStarClick(rating) { this.value = String(rating); this.syncValue(this.value); // updates form + runs validation this.dispatch('change', { value: this.value }); } onFormReset() { this.value = ''; // Reset visual state... } } customElements.define('my-rating', MyRating);

Always call syncValue() after changing the component's value. This updates the form's submission data and runs constraint validation so errors clear as soon as the value becomes valid.

Authoring rules