Repository navigation
05 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.
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>| 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.
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.
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).
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"]
- The element connects:
connectedCallbackenqueues it on the upgrade queue of the registry that defined it. It does not render. - The queue waits until the parser is past the element (
Nodes.waitParsed), so its light-dom children and slots are complete. -
upgrade()runs once, however often the element is connected: the slots are extracted, every declared attribute is read through its mapper, andrender({ slots })is awaited. The render builds the dom; a value it needs while building is read withdeclared(name). The slots areundefinedfor a class that does not declarestatic slots = true. - 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.
- 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 therenderedcustom state, whichel.renderedanswers 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'svalue, 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.
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.
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"]
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.
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.
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 inslots.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
slotincluded, lands inslots.default, which is always present. - each slot is a
DocumentFragment.render()decides where it goes, usually through{{{{ slots.name }}}}, the Node interpolation, which accepts aNodeand throws on a string, so slotted content cannot be turned into markup by accident.
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);
}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().
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)(copyprefix-prefixed attributes, stripped;prefix-classadds 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).
ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project