Working with Forms
Espalier form controls participate in the browser's native form lifecycle through ElementInternals. The core pattern is:
- Use
<esp-form>as the form wrapper. - Wrap each control in
<esp-form-item>to get labels and inline validation messaging. - Use form-associated Espalier controls such as
esp-input,esp-textarea,esp-checkbox,esp-switch,esp-radio-button, and the picker components.
Because esp-form renders a real light-DOM <form>, you still get the browser's submission, reset, and accessibility behavior. Espalier adds better labels, error handling, theming, and fetch-based submission when you opt into it.
Basic Structure
esp-form does not provide layout by itself. Pair it with esp-box, grid, or your own CSS:
<esp-form action="/api/profile" method="post">
<esp-box>
<esp-form-item label="First name" field-name="firstName">
<esp-input required></esp-input>
</esp-form-item>
<esp-form-item label="Email address" field-name="email">
<esp-input input-type="email" required></esp-input>
</esp-form-item>
<esp-button button-type="submit" label="Save"></esp-button>
</esp-box>
</esp-form>
Naming Fields
Your form controls need a field name to participate in submission. You can provide that in either of two ways:
- Set
namedirectly on the control. - Set
field-nameonesp-form-itemand let it propagate thenameattribute to the slotted control.
Using field-name often keeps the label and submitted field name together:
<esp-form-item label="Phone number" field-name="phone">
<esp-input input-type="tel" tel-localities="US CA GB"></esp-input>
</esp-form-item>
Replacing or Moving Fields
An esp-form-item always requires exactly one element in its default slot, but
that field does not have to stay there for the component's lifetime. You can
replace it, or move fields between form items, as part of one synchronous DOM
update. The form item releases its name, hint description, accessible label,
validation listener, and help target from the departing field before binding
the arriving one.
When swapping two live fields, complete both sides before yielding back to the browser so neither assignment settles empty:
firstItem.replaceChild(secondField, firstField);
secondItem.appendChild(firstField);
Cleanup is ownership-sensitive. A name or aria-describedby value authored
on the field is restored when it leaves, while only the values added by the
form item are removed. If a default slot settles with no field or with multiple
elements, esp-form-item reports the contract violation from its queued
slotchange reconciliation instead of leaving a partially bound control. That
asynchronous error cannot be caught around the DOM mutation itself, and a
transition that stays empty across an await is invalid even if another field
is appended in a later task.
Hint Text
A hint is persistent helper text on a form item — the first rung of the help ladder, answering most field questions with no interaction at all. Use it for static authored guidance such as an expected format; validation messaging belongs in the error and warning tiers, not the hint.
<esp-form-item label="Employer ID" hint="Use the format XX-XXXXXXX.">
<esp-input></esp-input>
</esp-form-item>
When a field needs more than a sentence, keep the hint short and move the rest into a help document, described in Field Help below.
- By default the hint renders directly below the field. Set
hint-placement="above"to render it between the label and the field instead. - The hint stays visible while an error or warning badge is showing, so the guidance is still there while the user corrects the field.
- The hint is wired to the slotted control via
aria-describedby, so screen readers announce it when the field receives focus. Anyaria-describedbyvalue you set on the control yourself is preserved. - For rich hint content, slot markup into the
hintslot; it replaces thehintattribute text when present:
<esp-form-item label="Employer ID">
<esp-input></esp-input>
<span slot="hint">Use the format <code>XX-XXXXXXX</code>.</span>
</esp-form-item>
Field Help
A hint carries what fits beside the field. Longer guidance — a policy, a worked
example, a table of accepted values — belongs in a help document your
application publishes at a URL. esp-form-item renders a help affordance next
to its label as soon as a document and a topic both resolve, and activating it
opens that topic in the page's flyout.
Two pieces of setup belong to the application shell, not to any one form:
- one
<esp-help-provider>, which fetches and normalizes documents. More than one provider on a page means the same request is serviced twice. - one
<esp-flyout>in the page'sflyoutslot, which presents them.
From there a form only declares where its document lives. Put help-src on the
form and every item under it inherits the document, deriving its own topic from
field-name:
<esp-help-provider></esp-help-provider>
<esp-form help-src="/examples/help/profile/">
<esp-box>
<esp-form-item
label="Display name"
field-name="display-name"
hint="Use the name teammates already call you.">
<esp-input></esp-input>
</esp-form-item>
<esp-form-item label="Time zone" field-name="time-zone">
<esp-pick-one placeholder="Choose a time zone">
<esp-picker-item text="Pacific Time" value="America/Los_Angeles"></esp-picker-item>
<esp-picker-item text="Mountain Time" value="America/Denver"></esp-picker-item>
<esp-picker-item text="Central Time" value="America/Chicago"></esp-picker-item>
<esp-picker-item text="Eastern Time" value="America/New_York"></esp-picker-item>
</esp-pick-one>
</esp-form-item>
</esp-box>
</esp-form>
That example is live. Activating either information button fetches
/examples/help/profile/ and opens the section whose heading id matches the
field name — display-name or time-zone. The flyout opens with just that
topic, aligned with the field and titled by the field's label; View full
document promotes it to the whole document. Activating a pressed button again
closes its help without disturbing anything else on the page.
The affordance is driven by resolution, not by content: a field with no
help-src in scope shows nothing at all, while a field whose heading id has
gone missing from the document still shows its affordance and quietly falls back
to the whole document. Keeping each heading id exactly equal to its
field-name is what holds the two halves together — see
Authoring Help Documents for how
a topic's excerpt is derived from that heading.
Sending One Field Somewhere Else
Use help-url when a field's guidance lives in another document, or when the
heading id cannot mirror the field name. A fragment in help-url is the topic
and wins over the derived anchor. Add help-title-source="document" when the
document's heading — not the field's visible label — should title the flyout:
<esp-form help-src="/examples/help/profile/">
<esp-box>
<esp-form-item
label="Notification email"
field-name="notification-email"
help-url="/examples/help/contact/#contact-email">
<esp-input input-type="email"></esp-input>
</esp-form-item>
<esp-form-item
label="Visibility"
field-name="profile-visibility"
help-title-source="document">
<esp-pick-one placeholder="Choose who can see your profile">
<esp-picker-item text="Everyone" value="everyone"></esp-picker-item>
<esp-picker-item text="My teams" value="teams"></esp-picker-item>
<esp-picker-item text="Hidden" value="hidden"></esp-picker-item>
</esp-pick-one>
</esp-form-item>
</esp-box>
</esp-form>
The first item ignores the inherited document and opens contact-email from the
contact document. The second stays on the profile document but is titled “Who
can see your profile” from the matched heading instead of “Visibility”.
Help For The Whole Form
Not every topic belongs to a field. Use <esp-help-button> for the form,
section, or step itself. It inherits help-src the same way and takes an
explicit anchor, so a form-level topic sits next to the form's heading:
<esp-form help-src="/examples/help/profile/">
<esp-box>
<h3>
Profile
<esp-help-button anchor="profile" label="Help for the profile form"></esp-help-button>
</h3>
<esp-form-item label="Display name" field-name="display-name">
<esp-input></esp-input>
</esp-form-item>
</esp-box>
</esp-form>
Because the button is icon-only, its label is the accessible name and should
name the topic rather than repeat “Help”.
Provider placement, flyout scope in nested pages, the normalization keep-list,
programmatic requestHelp(), and the fallbacks when a fetch fails are all in
the URL Help System guide.
Validation
Espalier controls support native-style constraint validation plus inline error presentation through esp-form-item.
- Use
required,min,max,step, and other normal constraint attributes on the control. - Use
required-messageor other component-specific validation messaging props when you want custom text. - On submit,
esp-formvalidates the controls, focuses the first invalid field, and prevents submission until the errors are resolved.
<esp-form-item label="Password" field-name="password">
<esp-input
input-type="password"
required
required-message="Please enter a password to continue.">
</esp-input>
</esp-form-item>
Standard Submit, Reset, And Buttons
Espalier buttons use the button-type attribute instead of the native type attribute:
<div style="display:flex; gap: var(--esp-size-small);">
<esp-button button-type="submit" label="Submit"></esp-button>
<esp-button button-type="reset" label="Reset" intent="danger"></esp-button>
</div>
Without use-fetch, esp-form behaves like a native form and submits to action using the configured method.
Fetch Submission
If you add use-fetch, Espalier intercepts the submit, performs a fetch(), and dispatches lifecycle events:
esp-form-submitesp-form-submit-responseesp-form-submit-error
Add use-json when the server expects JSON instead of FormData.
<esp-form
action="/api/preferences"
use-fetch
use-json
label="Preferences">
<esp-box>
<esp-form-item label="Display name" field-name="displayName">
<esp-input required></esp-input>
</esp-form-item>
<esp-form-item label="Timezone" field-name="timezone">
<esp-pick-one required placeholder="Choose a timezone">
<esp-picker-item text="Pacific Time" value="America/Los_Angeles"></esp-picker-item>
<esp-picker-item text="Mountain Time" value="America/Denver"></esp-picker-item>
<esp-picker-item text="Central Time" value="America/Chicago"></esp-picker-item>
<esp-picker-item text="Eastern Time" value="America/New_York"></esp-picker-item>
</esp-pick-one>
</esp-form-item>
<esp-button button-type="submit" label="Save preferences"></esp-button>
</esp-box>
</esp-form>
<script>
const form = findByTagName("esp-form")[0];
form.addEventListener("esp-form-submit-response", (ev) => {
console.log("Saved:", ev.detail.ok);
});
form.addEventListener("esp-form-submit-error", (ev) => {
console.error("Request failed:", ev.detail.error);
});
</script>
Dialog Forms
When method="dialog", submitting the form dispatches esp-form-dialog-close-requested, which esp-dialog listens for. This is useful for confirmation dialogs and quick-edit flows:
<esp-dialog>
<esp-box>
<h2>Rename project</h2>
<esp-form method="dialog" label="Rename project">
<esp-form-item label="Project name" field-name="name">
<esp-input required></esp-input>
</esp-form-item>
<div style="display:flex; gap: var(--esp-size-small);">
<esp-button button-type="submit" label="Save"></esp-button>
<esp-button button-type="reset" label="Reset" intent="danger"></esp-button>
</div>
</esp-form>
</esp-box>
</esp-dialog>
A Full Example
<esp-form action="/api/signup" use-fetch use-json label="Sign up">
<esp-box>
<esp-form-item label="Full name" field-name="fullName">
<esp-input required></esp-input>
</esp-form-item>
<esp-form-item label="Email address" field-name="email">
<esp-input input-type="email" required></esp-input>
</esp-form-item>
<esp-form-item label="Favorite color" field-name="favoriteColor">
<esp-pick-one required placeholder="Pick one">
<esp-picker-item text="Red" value="red"></esp-picker-item>
<esp-picker-item text="Green" value="green"></esp-picker-item>
<esp-picker-item text="Blue" value="blue"></esp-picker-item>
</esp-pick-one>
</esp-form-item>
<esp-form-item label="About you" field-name="bio">
<esp-textarea rows="4" placeholder="Tell us a little about yourself"></esp-textarea>
</esp-form-item>
<esp-form-item label="I agree to the terms" field-name="terms">
<esp-checkbox value="agreed" required>
Yes, I agree
</esp-checkbox>
</esp-form-item>
<div style="display:flex; gap: var(--esp-size-small);">
<esp-button button-type="submit" label="Create account"></esp-button>
<esp-button button-type="reset" label="Reset" intent="danger"></esp-button>
</div>
</esp-box>
</esp-form>
Next Steps
- Read Getting Started for the initial install and import story.
- Read Browser Support before depending on newer browser APIs.
- Inspect the component docs for Form, Form Item, Input, and the picker components for API-level details.