Working with Forms
Installation
To install the webbloqs package, run the following command:
pnpm add @webbloqs/react
Usage
To build a form with webbloqs, import Form and some form elements from the @webbloqs/react package.
The components from @webbloqs/react are Web Components, and they rely on the ElementInternals interface to associate form elements with their form.
For this reason you must set a matching formId property on the Form and a matching form property on each contained form element.
import { CheckboxField, DropdownField, Form, Layout, RadioField, TextField } from '@webbloqs/react';
const FORM_ID = "basic-form";
<Form formId={FORM_ID}>
<RadioField
form={FORM_ID}
name="salutation"
options={[
{ label: "Mr", value: "mr", selected: true },
{ label: "Mrs", value: "mrs" },
]}
/>
<Layout distribution="1|1">
<TextField form={FORM_ID} name="firstname" label="First Name" required/>
<TextField form={FORM_ID} name="lastname" label="Last Name" required/>
</Layout>
<DropdownField
form={FORM_ID}
name="country"
label="Country"
options={[
{ label: "Switzerland", value: "ch", checked: true },
{ label: "Germany", value: "de" },
{ label: "France", value: "fr" },
]}
/>
<CheckboxField
form={FORM_ID}
name="newsletter"
options={[{ label: "I want to receive the newsletter", value: "yes" }]}
/>
</Form>;
See this form in action in the webbloqs catalog, and check out Form Elements for the full list of elements.
Form comes with a predefined submit button, its label can be customized as follows:
<Form submitLabel="Create Account">
{/* form elements as above */}
</Form>
Learn about additional options to customize the form's appearance and behaviour in the TODO advanced usage section.
Data Handling and Submission
A Form automatically gathers all associated form values and provides them to the asynchronous handleSubmit callback as a FormData object.
You can determine that your form submission was successful if the promise resolves, in that case the content of the result slot will be displayed.
The standard HTML validation API can be used to validate user input.
To prevent submission you can simply return a rejected Promise in your handleSubmit callback, in that case you may display a custom error message to the user by specifying the content of the error slot.
<Form handleSubmit={async (event: InputEvent, formData: FormData) => {
console.log([...formData.entries()]);
if (formData.get("newsletter") !== "yes") {
return Promise.reject();
}
return true;
}}>
{/* form elements as above */}
<div slot="result">Your account has been created successfully.</div>
<div slot="error">You must accept newsletters.</div>
</Form>
Custom Form Elements (FACE)
Form works with any standards-compliant form-associated custom element (FACE) — not only webbloqs elements. Associate the element with the form (form attribute or DOM containment) and its values are part of getFormData()/handleSubmit via the platform.
Validation also covers FACE members: Form.validate() reports invalid FACE hosts (and plain native controls) alongside webbloqs fields, and an invalid FACE element blocks submission with a visible message instead of failing silently.
For full support, a FACE host should expose the WHATWG-recommended accessors that forward to its ElementInternals: validity, willValidate, validationMessage (and ideally labels and reportValidity()). Hosts without these accessors still block submission — the form then falls back to the native reportValidity() of its internal <form> — but cannot be individually listed in validate() results.
Announcing published state: formvaluechange and formvaliditychange
ElementInternals is private to the element that owns it. A container that aggregates other FACE members — a custom wrapper, a @webbloqs/proxies FACE host — cannot read what a child published to its form owner, and cannot observe it changing: setFormValue() is push-only with no getter, a plain DOM property write fires no event, and checkValidity() reads ElementInternals directly with no event in front of it.
Every webbloqs field therefore fires two plain, non-cancelable Events, { bubbles: true, composed: true }, whenever it republishes state through ElementInternals:
| Event | Fires after | Fires when |
|---|---|---|
formvaluechange | ElementInternals.setFormValue() | the element republishes what it submits |
formvaliditychange | ElementInternals.setValidity() | the element republishes its validity |
Neither event carries a detail payload — a listener re-reads the element (getFormData(), validity, validationMessage) instead of trusting one. Neither fires without ElementInternals (for example during server rendering, where announcing a publish that did not happen would be misleading). Both cross a shadow boundary (composed: true) by design, because a FACE member commonly sits inside an aggregating host's own shadow tree.
The two events are deliberately separate, not one combined event: validity can change while a field's own value does not — setCustomValidity(), a required attribute toggled at runtime, an async validator resolving, or a cross-field rule such as "confirm password" or "end date after start date". A host that only watches for a value change would miss every one of those.
This is a public contract: any form-associated custom element, webbloqs or not, may emit formvaluechange and formvaliditychange — the names carry no wb- prefix on purpose, so an aggregating host can subscribe to them generically.
FACE members in multistep forms
FormStep treats FACE members like webbloqs fields: a required FACE element blocks the "next" navigation until valid, and the step's getFormData() includes its entries.
Because no platform API attributes form entries to elements, step data attribution follows a documented convention: a FACE host's entries are keyed by its name or by its name prefix (the form-name-prefix attribute, else `${name}-`). Entries submitted under unrelated keys remain form-level data — always complete in Form.getFormData(), just not attributable to a single step.
To restore the previous behaviour — steps ignoring everything but webbloqs fields — set legacyFormHandling on the Form:
<Form formId={FORM_ID} legacyFormHandling>
{/* steps validate and collect only webbloqs fields, as before */}
</Form>
Advanced Form Concepts
Multistep Forms
Multistep forms are a common pattern in web applications, allowing users to fill out complex forms in a more manageable way, the webbloqs FormStep component can be used in conjunction with Form to create such a multistep form.
Each FormStep must be identified by a unique id for proper form data collection and can have an optional title. The form will then automatically show a configurable "next" and "previous" button to navigate between the steps.
import { CheckboxField, Form, FormStep, Layout, TextField } from '@webbloqs/react';
const FORM_ID = "conditional-fields";
<Form formId={FORM_ID}>
<FormStep id="user-info" title="User Info">
<Layout distribution="1|1">
<TextField form={FORM_ID} name="firstname" label="First Name" required />
<TextField form={FORM_ID} name="lastname" label="Last Name" required />
</Layout>
</FormStep>
<FormStep id="address-data" title="Address Data">
<TextField form={FORM_ID} name="street" label="Street" required />
<Layout distribution="1|1">
<TextField form={FORM_ID} name="postalcode" label="Postal Code" required />
<TextField form={FORM_ID} name="city" label="City" required />
</Layout>
</FormStep>
</Form>
Prefilling Form Data
Especially in multistep forms, it is often useful to prefill form data from a previous step or from an external source. There are essentially two ways to prefill form data in webbloqs, analogous to how React handles controlled and uncontrolled components.
-
Uncontrolled Input Fields: The recommended way with webbloqs is to use the
defaultValueproperty on inputs, which allows you to set an initial value without controlling the field's state and later retrieve updated values viaFormData.const FORM_ID = "prefill";
<Form formId={FORM_ID} handleSubmit={(event: InputEvent, formData: FormData): Promise<void> => {
console.log([...formData.entries()]);
return Promise.resolve();
}}>
<RadioField
form={FORM_ID}
name="salutation"
label="Salutation"
options={[
{ label: "Mr", value: "mr" },
{ label: "Mrs", value: "mrs" },
]}
defaultValue="mr"
/>
<TextField form={FORM_ID} name="firstname" label="First Name" defaultValue="John" />
<TextField form={FORM_ID} name="lastname" label="Last Name" defaultValue="Doe" />
</Form> -
Controlled Input Fields: In cases where your field values are controlled via React state for any reason you can instead define the
valueproperty on the form field directly to reflect its initial and updated state in the DOM.const [user, setUser] = useState({ salutation: "mr", firstName: "John", lastName: "Doe" });
<Form handleSubmit={() => {
console.log(user);
return Promise.resolve();
}}>
<RadioField
name="salutation"
label="Salutation"
options={[
// Note: The `selected` property is deprecated in favor of defaultValue
{ label: "Mr", value: "mr", selected: user.salutation === "mr" },
{ label: "Mrs", value: "mrs", selected: user.salutation === "mrs" },
]}
onChange={(event) => setUser((prevUser) => ({...prevUser, salutation: event.target.value }))}
/>
<TextField
name="firstname"
label="First Name"
value={user.firstName}
onInput={(event) => setUser((prevUser) => ({...prevUser, firstName: event.target.value }))}
/>
<TextField
name="lastname"
label="Last Name"
value={user.lastName}
onInput={(event) => setUser((prevUser) => ({...prevUser, lastName: event.target.value }))}
/>
</Form>
Conditional Fields
With the condition property on FieldGroup webbloqs provides a mechanism to set field state based on the value of other fields.
Both the condition source and the FieldGroup itself must be part of the same form, wired up via their form property.
| Property | Type | Function |
|---|---|---|
| conditionSourceName | string | Declare the field name for which this condition applies. |
| predicate | function | Provide a function that is called whenever the condition source value is updated. |
| getTarget | function | Optional method to define a different target for the condition than the FieldGroup. |
import { CheckboxField, FieldGroup, Form, TextField } from '@webbloqs/react';
const FORM_ID = "conditional-fields";
<Form formId={FORM_ID}>
<CheckboxField
form={FORM_ID}
name="show-section"
options={[
{
label: "Show section",
value: "on",
},
]}
/>
<FieldGroup
form={FORM_ID}
condition={{
conditionSourceName: "show-section",
predicate: (value, { setVisible }) => setVisible(value === "on"),
}}>
<TextField form={FORM_ID} name="firstname" label="First Name" required />
<TextField form={FORM_ID} name="lastname" label="Last Name" required />
</FieldGroup>
</Form>
As you can see the predicate function receives the current value of the condition source field and a set of methods to control the FieldGroup's behaviour.
The provided methods are as follows:
| Method | Function |
|---|---|
setVisible | Show or hide the whole FieldGroup |
setEnabled | Enable or disable all fields in the FieldGroup |
Validity and disabled
A disabled form field is barred from constraint validation, as a native control is: willValidate is false, the field never makes its form invalid, it shows no validation message, returns no result from validate(), and adds no entry to the form data. required, onValidate, and the length and size checks do not apply to it.
While a field is disabled, its validity.valid is true. This rule holds for every webbloqs field and is stricter than the platform. The HTML Standard lets a disabled control keep its validity states, and native controls differ: in Chrome 155 a disabled, required, empty <input type="text"> reports valid, while a disabled <select>, checkbox and radio keep valueMissing. Code that reads validity on a webbloqs field needs no willValidate check to ignore a disabled field. Code that also reads native controls or other custom elements still needs that check.
A field recomputes its validity when disabled changes, without any user interaction. Enabling an empty required field makes it invalid at once (validity.valueMissing is true) and the form invalid with it. This recomputation is silent: it shows no message and does not mark the field as interacted, so the message appears on the first blur, input or submit attempt, as for a field that was never disabled.
A field is disabled in three ways, and all three behave the same:
| Way | Example |
|---|---|
The field's own disabled | <wb-text-field disabled> |
An ancestor <fieldset> | <fieldset disabled><wb-text-field required /></fieldset> |
A FieldGroup calling setEnabled(false), for example from a condition | see Conditional Fields |
A field that a FieldGroup condition disables or enables follows the same rule whenever the controlling field changes: shown, a required empty field blocks the submit; hidden, it never does.
wb-form and wb-form-step skip a field that any of the three disables: validate() does not list it, and the submit and next buttons do not wait for it. Inside a @webbloqs/proxies host that renders its fields into its own shadow root, disabled on the host reaches the fields in that root.
Custom Field Validation
With the onValidate property on form elements you can easily add custom validation logic to your form fields.
This property accepts a function that receives the current value of the field and returns either a string with your custom error message if the value is invalid or null otherwise.
Note that standard validations like
requiredwill still apply after your custom validation.
import { Form, TextField } from '@webbloqs/react';
const FORM_ID = "custom-validation";
<Form formId={FORM_ID}>
<TextField
form={FORM_ID}
name="phone"
label="Phone"
onValidate={(value: string) => {
return /\d{10}/.test(value) ? null : "No valid swiss phone number.";
}}
required
/>
</Form>
Custom Error Messages
The errorMessage property lets you set a custom error message per field instance. When set, this message replaces the built-in validation message whenever any validation fails — regardless of the validation type (required, too long, pattern mismatch, etc.).
This is useful when the generic messages are insufficient and you need CMS-controlled, field-specific error text.
import { Form, TextField, DropdownField } from '@webbloqs/react';
const FORM_ID = "custom-errors";
<Form formId={FORM_ID}>
<TextField
form={FORM_ID}
name="company-email"
label="Company Email"
type="email"
required
errorMessage="Please provide your company email address."
/>
<DropdownField
form={FORM_ID}
name="department"
label="Department"
required
errorMessage="Please select the department you belong to"
options={[
{ value: "eng", text: "Engineering" },
{ value: "sales", text: "Sales" },
]}
/>
</Form>
The errorMessage property works alongside onValidate. If both are set, onValidate still determines whether the field is valid, but the displayed message is always the custom errorMessage.
As an HTML attribute:
<wb-text-field
name="company-email"
label="Company Email"
type="email"
required
errorMessage="Please enter your company email address"
></wb-text-field>