Skip to content

22 Extending fields

Roberto Ferranti edited this page Sep 13, 2026 · 12 revisions

Extending: your own 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.

Which base

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

Extending ful.Input

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.

Extending ful.Field

_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.

the rating field: a label with a required marker, five stars inside a control group, an invalid ring and the error message below

The pieces

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.

Value, change, validity

get value() { return this.#value; }
set value(v) { this.#value = v ?? null; /* paint */ }
  • null is empty. Never undefined: the whole library answers null for "nothing here", and forms rely on it.
  • _notifyChange() publishes the host's change: bubbling, not cancelable, { value } in the detail. Call it for gestures the user made. A field with more to say passes extras, the way ful-select adds the labelled entry beside the keys, but the detail always carries value, so el.value === evt.detail.value holds everywhere.
  • setCustomValidity(msg) is inherited: it sets the custom error, marks aria-invalid on the announcing element, and renders the message into your error piece.
  • formResetCallback() is inherited and restores the declared value.

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.

Guarding gestures

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.

Addressing the inner control

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-.

The checklist

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

Styling

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.

Registering

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.

Clone this wiki locally