Skip to content

05 Web components

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

Web components

ftl.ParsedElement and ftl.registry are the authoring layer: declare a class, register it, and the registry upgrades matching elements once the document has parsed them. This page is the reference for the component model; Extending elements walks through building one, and Concepts explains why it is shaped this way.

A component

class Quote extends ftl.ParsedElement {
    static attributes = ['author'];   // configuration: read once, at the upgrade
    static slots = true;
    static template = `
        <blockquote>
            <p>{{{{ slots.default }}}}</p>
            <footer data-tpl-if="author">by {{ author }}</footer>
        </blockquote>
    `;

    render({ slots }) {
        const author = this.declared('author');
        this.template().withOverlay({ slots, author }).renderTo(this);
    }
}

ftl.registry.defineElement('my-quote', Quote).configure();
<my-quote author="Ada">Simplicity is <b>prerequisite</b> for reliability.</my-quote>

Statics

Static Purpose
observed array of 'name' or 'name:type'. Live attributes: each forwards to a property after the render, and reflectTo writes it back
attributes same shape. Configuration: read once at the upgrade, answered by declared(name) for the element's life, never applied to a property
template the default template, as an html string
templates { [name]: html } named templates, merged with template as default
slots true to extract the light-dom children as slots before rendering
mappers { [type]: { unmarshal, marshal } } attribute types of the class's own
config a value the class's templates read as config

observed and attributes compose along the inheritance chain, base first, so a subclass inherits what its parent declares and adds to it without repeating the list. A subclass's entry for a name overrides its ancestors', which lets a leaf refine a type or move a name's position. The other statics are read from the class the way JavaScript reads any static, so a subclass inherits them and replaces one by declaring it.

The registry reads the statics when it defines the element: a page that replaces static config does so before the definition.

The two attribute tiers

Which of the two lists an attribute goes in is the first decision about any component.

Observed attributes are live. After the render, writing the attribute sets the property and writing the property reflects the attribute. value, disabled and current are observed: they change while the page runs. A hyphenated attribute drives the camelCase property, by the rule dataset applies: max-files drives maxFiles.

Configuration attributes are read once, when the element upgrades, and never again. loader, action, pages, placement and k-type are configuration: they say how the element is built, not what it currently holds. Writing one after the upgrade does nothing: the element does not rebuild itself. A ful-select's and a ful-table's src and method are observed even though they configure the loader, because a page points them at another url while it runs: writing one builds a new loader and reloads.

Both tiers are read through one accessor, declared(name). For a configuration attribute it answers the value read at the upgrade, for the element's life. For an observed attribute it answers the value the base will apply while the render is still running, and the live attribute afterwards. Asking it for a name the class does not declare throws; an attribute that belongs to someone else is read with getAttribute.

Attribute mappers

The :type suffix chooses how attribute strings become property values and back:

Type unmarshal marshal
string (default) the string, or null when absent String(value)
number Number(str), null when absent String(value)
presence true when present, false when absent '' when truthy, removed when falsy
bool str === 'true' 'true' / 'false'
json JSON.parse(str), null when absent JSON.stringify(value)
csv split on ,, trimmed, empties dropped; [] when absent value.join(',')

Every built-in marshal maps a nullish value to null, which removes the attribute. A type is resolved when the element is defined, and an unknown one throws unsupported attribute type.

Custom types: registry.defineMapper('offset', { unmarshal, marshal }) for every later definition on that registry, or the class's own static mappers, which win over the registry's for that class. Both functions receive (value, name, el).

Lifecycle

flowchart TD
    A["element connects"] --> B["enqueued on the upgrade queue"]
    B --> C{"parser past the element?"}
    C -- "no (waits)" --> C
    C -- yes --> D["slots extracted,<br/>declared attributes read"]
    D --> E["await render({ slots })"]
    E --> E2["the base applies the observed values<br/>to the properties, in declaration order"]
    E2 --> F{"queue settled<br/>after DOMContentLoaded?"}
    F -- no --> F
    F -- yes --> G["document receives ftl:ready"]
Loading
  1. The element connects: connectedCallback enqueues it on the upgrade queue of the registry that defined it. It does not render.
  2. The queue waits until the parser is past the element (Nodes.waitParsed), so its light-dom children and slots are complete.
  3. upgrade() runs once, however often the element is connected: the slots are extracted, every declared attribute is read through its mapper, and render({ slots }) is awaited. The render builds the dom; a value it needs while building is read with declared(name). The slots are undefined for a class that does not declare static slots = true.
  4. The base then applies the observed values onto the properties, in the composed declaration order, a base class's before a subclass's, so every setter runs against a dom that exists. Configuration attributes are not applied to anything.
  5. Only then do the properties go live: from that point an attribute write forwards to its property's setter through attributeChangedCallback, and the element gains the rendered custom state, which el.rendered answers and css reads as :state(rendered). An attribute written while the render was still pending lands in the values the base applies. Writing a property before the render is not supported, with one exception: a field's value, which answers and takes a value before the render (see ful fields).

A render that throws leaves the element half-built: the properties never go live and rendered stays unset, so a later attribute write cannot reach setters that assume pieces the failed render never created. See When a render fails.

When the whole queue has settled after DOMContentLoaded, document receives ftl:ready. A failing component does not hold readiness back. Waiting for elements lists the calls that await one subtree instead.

Knowing when the parser is past an element

connectedCallback fires as soon as the opening tag is seen, which is too early: the element's children are its input, and they do not exist yet. The queue has to know when the closing tag has been parsed, and the platform offers no event for that.

The test it uses instead follows from how the parser works. Nodes are appended in document order, so an element is complete once it, or any of its ancestors, has a next sibling: something after it exists, therefore the parser has already moved past its closing tag.

<section>
    <div>
        <my-el></my-el>   ← complete once anything follows it, its div, or its section
    </div>
</section>

The test is one-directional. A true is reliable. A false only means "not known yet", because an element that is the last node in the document never gains a following sibling at all. That is why the wait uses DOMContentLoaded as a deadline rather than trusting the test alone.

In practice it settles immediately for almost everything. Formatted markup puts a whitespace text node between an element and its parent's closing tag, and that node is itself a following sibling, so the element is complete the moment its own markup ends. Whitespace-free markup has no such node, and there an element upgrades once the parser reaches a later sibling of any of its ancestors. Only the last such chain in the document, with nothing after it but closing tags, waits for the deadline. Nothing breaks either way; the difference is whether an element upgrades during the parse or at the end of it.

An element inserted once the parser has finished the document does not wait on the test: it upgrades at once, even as the last child of body, which is where Dialog.ask() appends its dialog.

The upgrade queue

A component's render can connect further components: a table renders rows holding fields, and those fields call connectedCallback while the table is still rendering. They enqueue into a queue that is already being drained.

So draining is a fixed point rather than a pass: take everything currently queued, wait for all of it, then look again, and stop only when nothing is left.

flowchart LR
    A["take what is queued"] --> B["await all of it"]
    B --> C{"anything new?"}
    C -- yes --> A
    C -- "no" --> D["settled"]
Loading

ftl:ready is that fixed point being reached after DOMContentLoaded, not the first batch finishing. A component that fails counts as drained: its rejection belongs to whoever awaited that element, while the page still becomes ready.

Reflection

Writing an attribute from inside its own property setter would come straight back through the observer. reflectTo is the way back:

set value(v) {
    this.#value = v;
    this.reflectTo('value', v);   // attribute in sync, no echo
}

It marshals through the type the attribute was declared with, so a setter never encodes its own type: a presence attribute becomes "" or is removed, a csv is joined, a number is stringified. A value the attribute already carries is not written at all, and the observer is muted for that one attribute while the write happens. marshal(name, value) and unmarshal(name, str) expose the same conversions, and both throw for an undeclared name.

Slots

With static slots = true, the light-dom children are taken apart into slots before rendering, leaving the host empty:

<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>`;
  • a child with slot="name" lands in slots.name, and children sharing a name are gathered in document order. A <template slot> contributes its content, a <script type="text/html" slot> contributes its text parsed as html, and any other element contributes itself.
  • everything else, a child with an empty slot included, lands in slots.default, which is always present.
  • each slot is a DocumentFragment. render() decides where it goes, usually through {{{{ slots.name }}}}, the Node interpolation, which accepts a Node and throws on a string, so slotted content cannot be turned into markup by accident.

Templates in components

this.template(name?) returns one of the class's compiled templates, bound to the defining registry's modules and data, with static config overlaid as config. Without a name it answers the default one, static template. Asking for a name the class did not declare throws and names it.

Templates are immutable, so composition is chaining:

async render(c) {
    this.template().withOverlay(c).renderTo(this);
    this.template('row').withOverlay(row).appendTo(this.#list);
}

The registry

One registry per page, ftl.registry, holds the element definitions, the expression modules, the data overlays, the named components (loaders, http clients, mappers), the attribute mappers and the upgrade queue.

ftl.registry
    .plugin(new ful.Plugin({ language: 'en' }))
    .defineElement('my-widget', MyWidget)
    .defineModule('fmt', { money: (v) => `€${v.toFixed(2)}` })
    .configure();
Method Effect
defineElement(tag, klass) register an element: deferred until configure(), immediate after
defineModule(name, value) merge an expression module, called as #name:fn; an empty name merges a whole map, whose keys are called as #fn
defineModules(modules) replace all modules
defineComponent(name, value) store a component for registry.component(name): loaders, clients, response mappers, the page's revision
component(name) the component stored under the name, undefined when none is
defineData(...data) / defineOverlay(...data) replace / append to the data stack the templates evaluate over, a later entry winning a shared name
defineMapper(k, v) register a custom attribute mapper type
plugin(p) p.configure(registry), which is how ful installs itself
configure() define every deferred element; later defineElement calls define immediately
evaluator() the ExpressionEvaluator holding the current modules and data stack
ready() resolves once this registry's ftl:ready has been dispatched; never rejects
whenUpgraded(el) the pending upgrade of one element, undefined when it is not queued
pending() the elements whose upgrade is still pending, in queue order
settle(accept) waits for the queued upgrades accept(el) answers true for, those enqueued meanwhile included, and rejects with the first that failed: what Rendering.waitFor is built on

Definitions made before configure() are deferred, so import order never matters. An element resolves its templates and its components through the registry that defined it, and this.component(name) is how an element asks. A new Registry() can host its own elements with its own upgrade queue and dispatches its own ftl:ready on the document; the event does not say which registry sent it. Rendering.waitFor and Rendering.waitForChildren wait on ftl.registry alone, so an element defined by another registry is awaited through that registry's whenUpgraded(el) and ready().

Fragments, attributes, slots, nodes and rendering

Five namespaces on the ftl global, each one the dom operations the library itself runs on:

  • Fragments: fromHtml(...html), toHtml(fragment) (which moves the nodes out, leaving a fragment empty), isBlank(fragment), from(...nodes), fromChildNodes(el).
  • Attributes: forward(prefix, from, to) (copy prefix-prefixed attributes, stripped; prefix-class adds classes), set(el, attr, value) (nullish removes), defaultValue(el, k, v) (writes only where the attribute is absent, and answers the value it holds afterwards), uid(prefix).
  • LightSlots: from(el), the slot extraction above.
  • Nodes: isParsed(el), waitParsed(el), waitDomContentLoaded(doc), queryChildren(el, sel), queryChildrenAll(el, sel).
  • Rendering: waitFor(el), waitForChildren(el).

Attributes.forward('input-', this, innerInput) is the convention every ful field uses to let authors address the inner control: <ful-input input-inputmode="numeric" input-maxlength="10">. It reaches what the host does not name itself; an attribute the field declares, placeholder among them, is set on the host and the prefixed form of it does not survive (see fields).

Clone this wiki locally