Skip to content

21 Extending elements

Roberto Ferranti edited this page Sep 12, 2026 · 6 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 covers building a component of your own; 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. The registry composes them along the inheritance chain, base first.

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>`;
}

Choose the tier deliberately. observed is for what changes while the page runs; attributes is for what says how the element is built. A configuration attribute written after the upgrade does nothing, and that is the contract, not an oversight. The full table of mapper types is in Concepts.

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

Writing back to attributes

set value(v) {
    this.#value = v;
    this.reflectTo('value', v);   // marshals through the declared mapper
}

reflectTo is the only way back to an observed attribute. It marshals through the type you declared (presence becomes "" or removal, csv joins, number stringifies), skips the write when the attribute already holds that value, and keeps the write from coming back through the observer. A setter never encodes its own type.

Templates

static template  = `<p>one unnamed template</p>`;
static templates = { row: `<tr>…</tr>`, empty: `<tr><td>…</td></tr>` };

this.template() answers the default one, this.template('row') a named one. Each call returns a Template already bound to the defining registry's scope, so #l10n:t('…') and your own modules resolve inside it. Asking for a name the class did not declare throws and says so.

Templates are immutable, so composition is chaining:

this.template('row').withOverlay({ rows }).renderTo(this.#body);

Slots

With static slots = true, the element's light-dom children are read once at the upgrade and handed to render as slots:

<my-panel>
    <template slot="header"><h2>Title</h2></template>
    <p>the default slot</p>
</my-panel>
static template = `
    <header>{{{{ slots.header }}}}</header>
    <section>{{{{ slots.default }}}}</section>`;

{{{{ }}}} is the Node interpolation: it accepts a Node and throws on a string, so slotted content cannot be turned into markup by accident. Named slots come from <template slot="x"> (its content is taken) or any [slot=x] child; everything else becomes slots.default.

Registering

Define your elements on the same registry as the plugin, before configure(), so the page has one queue and one ftl:ready:

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

Definitions made before configure() are deferred, so the order of your imports never matters.

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 makes a new Registry() a working host for its own elements, and what keeps two registries on one page from crossing wires.

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);        // this element, rejects if its upgrade threw
await ftl.Rendering.waitForChildren(el);// its children, nested components included

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

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.visible" 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. The failing node is on e.node and its markup on e.html; neither is in the message, so a crash report never carries rendered page data by accident.

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>
            <ful-note data-ref="body" hidden>{{{{ slots.default }}}}</ful-note>
        </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>

It reuses the 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