Skip to content

22 Extending fields

Roberto Ferranti edited this page Nov 3, 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.

The field model itself, what a field promises to its form and its author, is explained on ful fields; this page is how to implement it. Start with Extending elements for the element machinery; 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-date declares ['step', 'min', 'max'] so that step lands first, and ful-input-local-time inherits that list and relies on it, 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. The inherited pair already works before the render; an override reading this._input answers from _heldValue() and holds a write with _holdValue(value) while there is no this._input yet, as ful-input-instant does (see Value, change, validity).

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" tabindex="-1"></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,
            error: fragment.querySelector('ful-field-error'),
            label: fragment.querySelector('label'),
            announces: this.#stars,
            freeze: this.#stars,
            also: [...this.#stars.children],
        };
    }

    get value() {
        return this.#stars ? this.#value : (this._heldValue() ?? null);
    }
    set value(v) {
        if (!this.#stars) {
            this._holdValue(v);
            return;
        }
        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 control is the radiogroup, not a star. A star is a button, which a label can point for at, and a click on the label would then press the first star and set the rating to 1. The radiogroup is not labelable, so the base names it through aria-labelledby and a click on the label only focuses it, which its tabindex="-1" allows without adding a tab stop. Being the control, it also carries the field's aria-describedby, and also hands the disabled claim on to the star buttons.

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, with for and id where it can and aria-labelledby where it cannot
described carries aria-describedby, 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 cancels the default action of clicks 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 calls preventDefault() on the click while the claim holds, which leaves the control focusable and readable. It does not stop the event, so the field's own click handlers still run: each checks _interactive() first, as the rating's does. That is deliberate and it is why this is not inert: a readonly field must still be reachable by a screen reader.

label is wired the way a plain form wires one wherever the platform allows it: the base gives your control an id and points the label's for at it. That needs control to be a labelable element, which means button, input, meter, output, progress, select or textarea. A composite control, a ful-control carrying role="radiogroup" for instance, cannot be the target of a for, so the base gives the label an id and sets aria-labelledby on the control instead, and adds a click handler that focuses it, aria-labelledby carrying the name but not the label's click.

Either way the association is a real attribute in the dom rather than an ARIA element property. That is deliberate: a property reflects to nothing, so an audit tool, the browser's autofill and anything else reading the markup sees an unlabelled control, and on a browser without ARIA element reflection the assignment is a silent expando and the field has no accessible name at all.

described exists for fields with no single control worth pointing aria-describedby at. A radio group describes the host instead. Everything the field is ever described by lands there: its error region, and whatever describedBy is handed.

_build may be async: the base wires the pieces when the promise settles.

Descriptions

A field owns the aria-describedby of its control, and describedBy(el) is how anything else adds to it:

field.describedBy(note);   //true once the field has taken it

The field mints an id for the element if it has none, and reads the error region last: the descriptions it has been handed come first and the current error after them. It takes a description before its own render as readily as after, so a caller never has to know whether it upgraded first: content slotted into a field is a custom element of its own and may render on either side of the field, which happens in both directions in practice.

Content standing inside a field finds it with describable(el), which answers the nearest ancestor that accepts a description, so neither end has to name the other:

const field = ful.describable(this);
if (field?.describedBy(this.note)) {
    //the content is delivered: this marker need not be reachable on its own
}

ful-tooltip is the library's own user of this, under its describes attribute. Point the reference at the element carrying the words, not at a wrapper around it: a hidden element joins a description only where it is named directly, and a popover closed until someone opens it is exactly that.

Value, change, validity

get value() { return this.#stars ? this.#value : (this._heldValue() ?? null); }
set value(v) {
    if (!this.#stars) {
        this._holdValue(v);
        return;
    }
    this.#value = v ?? null; /* paint */
}
  • The pair works before the render, as on the library's fields: until _build has made the pieces, the setter hands the value to _holdValue(value) and returns, and the getter shapes what _heldValue() answers, which is the value last held or else the declared value attribute read through its mapper. Once the field has rendered the base writes the held value through your setter, over the declared one. Holding leaves the value attribute alone, so a reset still restores it.
  • null is empty, never undefined, as on every field.
  • _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 value the value attribute declares, through your setter. A field whose value is not attribute backed overrides it.

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.

Composite fields

A field whose value lives in fields of its own, a ful-select or a ful-input it renders, meets the upgrade order. The base applies the declared attributes to your setters as soon as your render resolves, and the inner fields upgrade later, so a setter writing into them would write before they exist. Wait for them at the end of the render:

async render(conf) {
    await super.render(conf);
    await ftl.Rendering.waitForChildren(this);
}

super.render has adopted the fragment by then, so the inner fields are in the document and queued; waitForChildren resolves once every element below the host has upgraded, and rejects with the first that failed.

The pieces such a field hands the base are elements it rendered itself, native controls or wrappers of its own, and an inner ful field is not one: the base writes readOnly onto the claim pieces, which a ful field does not have, puts aria-* on announces, whose host role takes none, and names control through a label for, which would name the inner field's host instead of its control. So a composite built from ful fields wires them this way:

  • answer the inner field as control: focus() forwards to it, and it forwards to its own control;
  • let the inner field render the label, from the slot you pass into it, and answer label: null and announces: null, so the inner field names and announces its own control;
  • forward the claims in the setters, calling super first so the host keeps its attributes and its :disabled:
_build({ slots }) {
    const fragment = this.template().withOverlay({ slots }).render();
    this.#select = fragment.querySelector('ful-select');
    return { fragment, control: this.#select, error: fragment.querySelector('ful-field-error'), label: null, announces: null };
}
get readonly() { return super.readonly; }
set readonly(v) {
    super.readonly = v;
    this.#select.readonly = v;
}

disabled and required follow the same shape. The composite's own error region shows a problem routed to it; the inner control is not marked aria-invalid with it, which is the one thing this wiring does not carry.

A setter that should behave differently while the base applies the declared state, not fetching defaults over a value that is about to arrive, for instance, reads this.rendered: it is false while those declared values are applied and turns true just after, so it is the "still upgrading" signal and no flag of your own is needed.

set department(v) {
    this.#department = v;
    if (this.rendered) {
        this.#loadDefaults();
    }
}

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">. ful fields has what the forwarding reaches and what it does not. 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 element the form sees
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
descriptions inherited: describedBy(el) adds to the control's aria-describedby, the error region reading last
criterion, for a filter only not inherited: implement it, or extend one of the exported filter bases. See below

A filter's criterion

A field that is a table filter answers one thing more. criterion is what the filter is currently filtering on, so a page can draw a chip per active filter without reading the operands back out of the controls, which does not work for a field whose control is empty by design. It is null when the filter contributes nothing, and otherwise { label, operator, operands }, the operands being the words the reader chose rather than the keys the request carries. It is called criterion and not description because a field already carries an accessible description, through describedBy.

Nothing enforces it. CompareFilter, BooleanFilter and InFilter are all exported, so a filter extending one answers it already; a filter built on ful.Field or ful.Select directly answers undefined, and a page collecting criteria drops it silently, which looks exactly like a filter that is not set. Implement it where you write your own:

class RatingFilter extends ful.Field {
    get criterion() {
        const stars = this.value;
        return stars == null
            ? null
            : { label: this.querySelector(':scope > label')?.textContent.trim() ?? null,
                operator: 'GTE',
                operands: [`${stars} stars`] };
    }
}

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