Repository navigation
22 Extending fields
A field is an element that holds a value and belongs in a form. ful.Field carries the whole form contract: form association, validity, the error live region, focus delegation, the label chrome, and the disabled, readonly and required claims. A field you write implements one method and its value semantics.
Start with Extending elements for the element machinery itself; everything there applies here too.
| your field is | extend | you implement |
|---|---|---|
a native input with a twist (another type, extra bounds, value normalisation) |
ful.Input |
_type(), and the accessors you add |
| a control of your own making (a rating, a colour picker, a map point) | ful.Field |
_build(conf) and the value pair |
| not a form participant at all | ftl.ParsedElement |
render, and see Extending elements
|
When the field is a native <input> with a different type, ful.Input already renders the label, the affix slots, the control group, the error region and the keystroke filtering, and ful.Field under it submits the form on Enter for any control the platform would submit from. Say which type, and add what you need:
class InputMonth extends ful.Input {
static observed = ['min', 'max']; // joins what Input and Field already observe
_type() {
return 'month';
}
get min() { return this._input.min || null; }
set min(v) { this._input.min = v ?? ''; }
get max() { return this._input.max || null; }
set max(v) { this._input.max = v ?? ''; }
}
ftl.registry.plugin(new ful.Plugin()).defineElement('ful-input-month', InputMonth).configure();That is the whole subclass. value, placeholder, the claims and the slots are inherited; this._input is the inner control the base built. This is exactly how ful-input-local-date and ful-input-instant are written.
Declaration order is application order. The base's attributes are applied before the subclass's, and within your own list, top to bottom. ful-input-local-time relies on it: it declares ['step', 'min', 'max'] so that step lands first, because on a time input the bounds are snapped to the step grid.
If your field's value needs normalising, whether trimming, coercing or formatting, override the value pair. Everything goes through it: form extraction, values = assignment, the change detail and formResetCallback.
You do not need a subclass at all just to change the type: <ful-input type="url"> already works. Subclass when you add attributes, normalise values, or wrap a different control.
_build(conf) builds your dom and returns the pieces the base drives. It does not call back into the base; the base acts on what you return.
class Rating extends ful.Field {
static slots = true;
static observed = ['value:number'];
static attributes = ['stars:number'];
static template = `
<label>{{{{ slots.default }}}}</label>
{{{{ slots.info }}}}
<ful-control-group>
<ful-control data-ref="stars" role="radiogroup"></ful-control>
</ful-control-group>
<ful-field-error></ful-field-error>`;
#stars;
#value = null;
_build({ slots }) {
const fragment = this.template().withOverlay({ slots }).render();
this.#stars = fragment.querySelector('[data-ref=stars]');
const count = this.declared('stars') ?? 5;
for (let i = 1; i <= count; ++i) {
const star = document.createElement('button');
star.type = 'button';
star.dataset.star = String(i);
star.setAttribute('role', 'radio');
star.textContent = '★';
this.#stars.append(star);
}
this.#stars.addEventListener('click', (evt) => {
const star = evt.target.closest('[data-star]');
if (!star || !this._interactive()) {
return;
}
this.value = Number(star.dataset.star);
this._notifyChange();
});
return {
fragment,
control: this.#stars.firstElementChild,
error: fragment.querySelector('ful-field-error'),
label: fragment.querySelector('label'),
described: this,
announces: this.#stars,
freeze: this.#stars,
};
}
get value() {
return this.#value;
}
set value(v) {
this.#value = v ?? null;
for (const star of this.#stars.children) {
const on = this.#value !== null && Number(star.dataset.star) <= this.#value;
star.setAttribute('aria-checked', String(on));
star.classList.toggle('picked', on);
}
}
}<ful-rating name="score" stars="5" value="3" required>How did it go?</ful-rating>The field is themed by the structural tags alone. The control group's border, the focus ring, the required marker, the invalid ring and the error message all come from ful.css. Only the stars themselves need css, and it is written against tokens rather than literal colours:
ful-rating [data-star] {
background: none;
border: 0;
cursor: pointer;
font-size: 1.4rem;
color: var(--ful-muted-color);
}
ful-rating [data-star].picked {
color: var(--ful-accent-ink);
}--ful-accent-ink rather than --ful-active-bg: the *-ink tokens are the ones meant to be read as text, and they already resolve differently in the dark scheme.

| piece | required | what the base does with it |
|---|---|---|
fragment |
✔ | mounted on the host |
control |
✔ | the focus target; focus() forwards to it, and the aria and the claims reach it |
error |
✔ | the field's live region; setCustomValidity renders into it |
label |
names the control through ariaLabelledByElements, and focuses it on click |
|
described |
carries aria-describedby for the error, where no single control should |
|
claims |
a wrapper the claims act on, instead of the control | |
announces |
the element whose role accepts aria-readonly and aria-required; null for a field whose control has no such role |
|
freeze |
for a field with no usable native readOnly: the base refuses gestures inside it while the claim holds |
|
also |
further controls that mirror disabled and readOnly
|
Only fragment, control and error are structural. The rest say how this field differs from a plain input, and each exists for a reason worth knowing.
announces exists because roles are picky about which ARIA attributes they accept. aria-required on a <fieldset> is invalid and gets dropped, so ful-radio-group announces on its own host, which carries radiogroup. A field whose control accepts neither, like the boolean filter's <button>, passes null and announces nowhere, rather than emitting an attribute the role refuses.
freeze exists because readOnly is only meaningful on a text control. A rating, a checkbox, a file field or a filter menu has nothing to make read-only. The base installs one capturing click handler on the frozen piece and cancels the gesture while the claim holds, which leaves the control focusable and readable. That is deliberate and it is why this is not inert: a readonly field must still be reachable by a screen reader.
described exists for fields with no single control worth pointing aria-describedby at. A radio group describes the host instead.
_build may be async: ful-select awaits its prefetch. The base wires the pieces when the promise settles.
get value() { return this.#value; }
set value(v) { this.#value = v ?? null; /* paint */ }-
nullis empty. Neverundefined: the whole library answersnullfor "nothing here", and forms rely on it. -
_notifyChange()publishes the host'schange: bubbling, not cancelable,{ value }in the detail. Call it for gestures the user made. A field with more to say passes extras, the wayful-selectadds the labelledentrybeside the keys, but the detail always carriesvalue, soel.value === evt.detail.valueholds everywhere. -
setCustomValidity(msg)is inherited: it sets the custom error, marksaria-invalidon the announcing element, and renders the message into yourerrorpiece. -
formResetCallback()is inherited and restores the declaredvalue.
A composite field that owns a whole subtree can override setCustomValidity(msg, context): the form's error mapping hands it the part of the problem's context below its own name ('' for an exact match), so it can route the message to an inner control.
Chrome is not a form control. A badge, a dropzone, a menu button or a label click has to ask before acting:
if (!this._interactive()) {
return;
}_interactive() answers both the effective disabled state, through matches(':disabled') so a disabled <fieldset> ancestry counts, and the readonly claim. Native controls get this from the platform; your own chrome does not.
Attributes.forward('input-', this, this.#input);Any input-* attribute on the host lands on the inner control with the prefix stripped, so an author tunes it without knowing your internals:
<ful-input-month input-inputmode="numeric" input-autocomplete="bday-month">Pick your own prefix if your field wraps something else: ful-form uses form-, ful-table uses table-.
Extending ful.Field meets most of these for free. They stay the contract however you meet them.
| convention | how |
|---|---|
| form participation |
ParsedElement attaches the internals and Field uses them; your inner control carries form="" so the host is the only form citizen |
| label | the default slot rendered as a real <label> or <legend>, returned as the label piece |
| slots |
default for the label, info, and before/after inside a ful-affix
|
| addressing | Attributes.forward('input-', …) |
value |
a plain property; null when empty |
change |
stop the inner control's native change, republish with _notifyChange()
|
| validation |
required announces through the announces piece; setCustomValidity renders into error
|
| claims | the attribute is the author's alone; freeze/claims/also say how your field mirrors them |
| reset | inherited formResetCallback
|
| gesture guards | every chrome handler asks _interactive()
|
| focus | inherited: focus() forwards to the control piece |
Reuse the structural tags ful-control-group, ful-affix, ful-control, ful-field-error and ful-icon, and your field is themed, focus-ringed, disabled-dimmed and dark-mode aware with no css of your own.
One thing the structural tags do not bring is the pre-render guard, which is a tag list: add class="ful-input" to your host, or one rule of your own, so your field stays invisible until it has rendered rather than showing a bare label. See Theming.
When you do write css: build on the --ful-* tokens rather than literal colours, namespace your own under --ful-<yourfield>-*, mark internal parts with data-ref, and put your rules in a layer after ful's (or outside any layer, which beats all of them). See Theming.
ftl.registry
.plugin(new ful.Plugin({ language: 'en', translations: { 'rating.cleared': 'No rating' } }))
.defineElement('ful-rating', Rating)
.configure();Your field's messages join through the plugin's translations overlay and resolve with #l10n:t('rating.cleared') in your template, exactly like the built-in ones. See Localization.
ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project