Skip to content

21 Extending elements

Roberto Ferranti edited this page Nov 3, 2026 · 5 revisions

Extending: your own elements

ftl.ParsedElement is the base every fml component is built on, and nothing about it is private to the library. This page walks through building a component of your own; Web components is the reference for the statics, the mappers, the lifecycle, reflection, slots, templates and the registry. Extending fields covers the extra contract a form field signs, and Extending loaders covers replacing where a select, table or form gets its data.

The smallest component

class HelloBox extends ftl.ParsedElement {
    static attributes = ['who'];
    static template = `<p>hello, <b>{{ who }}</b></p>`;

    render() {
        this.replaceChildren(this.template().withOverlay({ who: this.declared('who') }).render());
    }
}

ftl.registry.defineElement('hello-box', HelloBox).configure();
<hello-box who="world"></hello-box>

who is declared as configuration: it is read once, when the element upgrades, which is exactly right for something the render bakes in and never revisits. Making it observed instead would promise that writing el.who later does something, and it would not, until you add a setter that re-renders. Declare the tier you actually implement.

Two other things are already true: the element renders after the parser has seen its markup, and it joins the page's single ftl:ready.

Declaring what the element has

Everything an element exposes is declared in statics:

class Gauge extends ftl.ParsedElement {
    static slots = true;                          // read the light-dom children
    static attributes = ['unit', 'scale:number']; // configuration: read once
    static observed = ['value:number'];           // live: attribute ⇄ property
    static config = { icon: 'gauge' };            // constants the template reads
    static template = `
        <ful-control-group>
            <ful-icon data-tpl-name="config.icon"></ful-icon>
            <output data-ref="out"></output>
            <ful-affix>{{{{ slots.default }}}}</ful-affix>
        </ful-control-group>`;
}

observed is for what changes while the page runs; attributes is for what says how the element is built, and a configuration attribute written after the upgrade does nothing. Web components has every static, the two attribute tiers and the mapper types.

Rendering

render(c) receives { slots } and nothing else. Its job is to build the dom that the property setters will drive:

#out;

render({ slots }) {
    const fragment = this.template().withOverlay({ slots }).render();
    this.#out = fragment.querySelector('[data-ref=out]');
    this.replaceChildren(fragment);
}

get value() {
    return this.#value;
}
set value(v) {
    this.#value = v;
    this.#out.textContent = `${v} ${this.declared('unit') ?? ''}`;
    this.reflectTo('value', v);
}

The base applies the observed attributes onto the properties as soon as render returns, in declaration order, so every setter runs against a dom that exists. That is why set value can use this.#out without checking for it.

If the render itself needs a declared value, to choose a template or to decide a structure, read it with declared(name):

render({ slots }) {
    const compact = this.declared('scale') < 10;
    const fragment = this.template(compact ? 'compact' : 'default').withOverlay({ slots }).render();
    …
}

render may be async. The element's properties do not go live until the returned promise settles, and a rejection leaves the element shut rather than half-live.

The setter ends with reflectTo('value', v), which writes the attribute back through the declared mapper without the write coming back through the observer: see Reflection. A class with more than one template names them in static templates and asks for one with this.template('row'), and a class reading its light-dom children declares static slots = true: see Templates in components and Slots.

Registering

Define your elements on the same registry as the plugin, before configure(), so the page has one queue and one ftl:ready. Definitions made before configure() are deferred, so the order of your imports never matters:

ftl.registry
    .plugin(new ful.Plugin({ language: 'it' }))
    .defineElement('hello-box', HelloBox)
    .defineElement('my-gauge', Gauge)
    .configure();

The element internals

The base attaches the platform's ElementInternals for every element and exposes them as this.internals, so your component never calls attachInternals() itself. The platform allows exactly one call per element and throws on a second, and form association follows the element definition rather than whoever attached, so declaring static formAssociated = true still gives you the form apis on the base's object.

Two rules follow, and both fail quietly if you break them:

  • Never call attachInternals(). It throws NotSupportedError, because the base already did.
  • Never declare an internals member. A class field with no initializer assigns undefined, and derived fields initialize after super() returns, so the declaration would wipe what the base assigned. The symptom appears later, wherever something reads it.

The base uses the internals for one thing of its own: it adds a rendered custom state when the render completes. That is a fact about any element, so you can style on it, and it is what ful's stylesheet hides its own elements until. A component of your own is not in that list, so see Theming for the two ways to join it.

Resolving collaborators

Do not reach for the singleton. An element resolves through the registry that defined it:

render() {
    const http = this.component('http-client');        // whatever the plugin registered
    const mapper = this.component(this.declared('response-mapper'));
}

This is what lets a new Registry() host its own elements, and what keeps two registries on one page from resolving each other's templates and components. The waits below are narrower: Rendering.waitFor and Rendering.waitForChildren only see the upgrades queued on ftl.registry, so an element defined by another registry is awaited with that registry's whenUpgraded(el) or ready().

Reading attributes the element does not declare

declared(name) answers for the declared vocabulary only; asking it for anything else throws and tells you so. Attributes that belong to someone else, such as a custom loader's own configuration, are read the platform's way:

const endpoint = el.getAttribute('my-endpoint');

Waiting for elements

await ftl.registry.ready();              // the whole page, never rejects
await ftl.Rendering.waitFor(el);         // el and its descendants, rejects if an upgrade threw
await ftl.Rendering.waitForChildren(el); // its descendants only, nested components included
ftl.registry.whenUpgraded(el);           // el alone, or undefined when it is not queued

el.rendered answers whether the render completed, which is the moment the properties became live.

call waits for on failure
registry.ready() the whole page, once the queue reaches its fixed point after DOMContentLoaded never rejects: it means the queue drained, and a failed component is left to its own report
Rendering.waitFor(el) every queued upgrade inside el, el included rejects with the first upgrade that threw
Rendering.waitForChildren(el) the same, excluding el itself the same
registry.whenUpgraded(el) that one element rejects with its own failure, and answers undefined when the element is not queued

All of them follow components that appear during a render, because the queue drains to a fixed point rather than in one pass. Waiting on a table waits for the fields its rows create, without naming them.

Connect first, then wait

The waits are over what is queued, and an element is queued by its connectedCallback. A subtree with nothing queued has nothing to wait for, so the wait resolves at once:

const el = document.createElement('my-widget');
const waited = ftl.Rendering.waitFor(el);   // nothing is queued yet
host.append(el);                            // this is what queues it
await waited;                               // resolved already, el.rendered is false

Swapping the two middle lines is all it takes, and it is the natural way to write it anyway:

host.append(el);
await ftl.Rendering.waitFor(el);            // el.rendered is true

This works because a defined custom element's connectedCallback runs synchronously during insertion. By the time append returns, the element is queued, so appending and then waiting in the same turn is reliable, innerHTML included.

When a render fails

A throw inside render propagates: the element stays un-upgraded, its properties never go live, and Rendering.waitFor(el) rejects with the error. ftl:ready still fires for the rest of the page, and the failure is left free to reach the console and your error reporter, since the queue attaches no handler of its own, deliberately.

Failures raised while evaluating a template carry a RenderError chain that names the directive, the expression and the node at each nesting level:

Error evaluating data-tpl-each="rows" in `<ul>`
Caused by: Error evaluating data-tpl-if="row.isVisible()" in `<li>`
Caused by: Method missing "isVisible"

A chain keeps its three innermost frames, the ones that name the failure site, and marks itself truncated when it dropped the outer context. A frame names its node by the open tag, with the attributes the node still carries when it fails; the directive being evaluated has already been taken off the tag, which is why the frames above show <ul> and <li>. The failing node is on e.node, live in the fragment being built, and its full markup on e.html. The subtree is not in the message, but the open tag is, and an attribute an earlier directive already set on the node carries its evaluated value.

A worked example

A disclosure component with a named template, a slot, a live attribute and a configuration attribute:

class Callout extends ftl.ParsedElement {
    static slots = true;
    static attributes = ['tone'];            // decided when written, never changes
    static observed = ['open:presence'];     // toggles while the page runs
    static template = `
        <ful-control-group>
            <button type="button" data-ref="toggle" aria-expanded="false">
                <ful-icon data-tpl-name="tone == 'warning' ? 'alert' : 'info-circle-fill'"></ful-icon>
                {{{{ slots.summary }}}}
            </button>
            <div data-ref="body" hidden>{{{{ slots.default }}}}</div>
        </ful-control-group>`;

    #toggle;
    #body;

    render({ slots }) {
        const tone = this.declared('tone');
        const fragment = this.template().withOverlay({ slots, tone }).render();
        this.#toggle = fragment.querySelector('[data-ref=toggle]');
        this.#body = fragment.querySelector('[data-ref=body]');
        this.#toggle.addEventListener('click', () => {
            this.open = !this.open;
            this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { open: this.open } }));
        });
        this.replaceChildren(fragment);
    }

    get open() {
        return this.hasAttribute('open');
    }
    set open(v) {
        this.#body.hidden = !v;
        this.#toggle.setAttribute('aria-expanded', String(!!v));
        this.reflectTo('open', v);
    }
}
<x-callout tone="warning">
    <template slot="summary">Heads up</template>
    <p>The details go here.</p>
</x-callout>

The body is a plain div: ful-note would not do, since its css keeps it hidden unless it is an open popover, and a button right before a ful-note is styled as the round info button. It reuses the other structural tags, so it is themed, focus-ringed and dark-mode aware with no css of its own. See Theming for the tags and tokens, and Extending fields when the thing you are building takes a value and belongs in a form.

Clone this wiki locally