Repository navigation
02 Concepts
This page is the model behind everything else: how fml renders, how a component comes alive, what a field promises, how failures travel, and how the styles are layered. Read it once and the rest of the wiki is reference material.
fml is four small libraries that run in the browser as they are. There is no build step, no bundler requirement and no runtime dependency: you load a script tag or import an ES module, and the page works.
flowchart TD
ftl["<b>ftl</b><br/>templates, expressions,<br/>components, registry"]
httpc["<b>httpc</b><br/>request builder,<br/>interceptors, failures"]
ful["<b>ful</b><br/>fields, forms, tables,<br/>disclosures, navigation"]
ce["<b>client-errors</b><br/>uncaught errors → your endpoint"]
ftl --> ful
httpc --> ful
ftl and httpc are independent of each other and of ful. ful is built on both. client-errors stands alone: one script tag that needs nothing else.
Three decisions shape everything that follows.
Components render into the light DOM. There is no shadow root. Your css reaches inside a ful-select, the page's own stylesheet applies, and a field's inner <input> is a real node you can query. The cost is that fml has to manage slotting and style isolation itself; the benefit is that nothing is sealed away from you.
The page author is trusted. Templates, expressions, translations and markup are program source, not user input. Expressions are as powerful as a script tag, by design. Data arriving from a server is passed to them as data and never spliced into them as text. The full statement is in the README's threat model.
Elements upgrade once, after the parser has seen their markup. A component's children are its input, so it cannot render before they exist. The registry queues elements and upgrades each once the parser is past it, which is why there is a ftl:ready event and a Rendering.waitFor rather than a constructor that renders.
A Template is two things held together: a compiled DocumentFragment and the scope it renders in. Both are immutable. Adding data or changing the scope returns a new Template, so one piece of compiled markup can be rendered against any number of scopes.
flowchart LR
html["markup string<br/>or <template>"] -->|"Template.fromHtml"| tpl["Template<br/>(fragment + scope)"]
tpl -->|"withOverlay(data)"| tpl2["Template'<br/>(same fragment, wider scope)"]
tpl2 -->|"render()"| frag["DocumentFragment"]
tpl2 -->|"renderTo(el)"| dom["el's children replaced"]
Rendering walks the fragment once and does two kinds of work.
Interpolations replace text and attribute values:
| form | meaning |
|---|---|
{{ expr }} |
the value as a text node, so markup in it renders as literal text |
{{{ expr }}} |
the value as html, through innerHTML: an explicit opt-in |
{{{{ expr }}}} |
the value as a Node, throwing on a string, so it cannot inject markup |
data-tpl-<attr>="expr" |
sets <attr> to the value; a boolean toggles the attribute |
Commands are data-tpl-* attributes that restructure the tree: if/when gate, with/each open a new scope, class-append/attr-append add to what is there, remove drops a node or its tag, verbatim stops the walk. They run in a fixed order, so each command sees the node as the previous ones left it. The order is the semantics, and it is documented in Templates.
A scope is one value, an ExpressionEvaluator, holding two things:
- the modules a call like
#l10n:t('key')resolves against. - the data stack a bare identifier like
user.nameresolves against, innermost first.
withOverlay(data) pushes a layer and returns a new evaluator; nothing is mutated, so a data-tpl-each can widen the scope for one subtree without affecting the rest of the render. A bare identifier that no layer carries is undefined, so a bare window, document or fetch is not the global: a name is looked up on the stack alone. That is how names resolve, not a sandbox; member access still reaches the page, as the trust boundary above says.
A component is a class extending ParsedElement. What it is is stated declaratively in statics: the attributes it reads, its templates, whether it takes slots, and the constants its templates see. What it does at render time is one method, render({ slots }), which builds the element's dom. The declarations compose along the inheritance chain, so a subclass adds to what its parent declares without repeating it.
The distinction that explains most of a component's surface is between its two kinds of attribute. Observed attributes are live: they change while the page runs, like value or disabled, and after the render writing the attribute sets the property and writing the property reflects the attribute. Configuration attributes, like loader, action or placement, say how the element is built: they are read once when the element upgrades, and writing one afterwards does nothing, because the element does not rebuild itself.
The lifecycle follows from the element's children being its input. connectedCallback does not render: it puts the element on the registry's upgrade queue, and the queue waits until the parser is past the element's closing tag. The element then reads its declared attributes, renders, and only after the render does the base apply the observed attributes onto the properties, so every setter runs against a dom that exists. A render that throws leaves the properties shut. Because a render can create further components, the queue drains to a fixed point, and ftl:ready fires when it reaches one after DOMContentLoaded.
One registry per page, ftl.registry, holds the element definitions, the expression modules, the named components such as loaders and http clients, and the upgrade queue. Definitions made before configure() are deferred, so import order never matters.
Web components is the reference for all of this: the statics, the two attribute tiers, the mapper types, the lifecycle step by step, reflection, slots and the registry's methods. Extending elements walks through writing one.
Every ful-* field is a form-associated custom element wrapping a native control, and the host is the one element the form sees: the inner control is detached from the form, so the host carries the name, appears in form.elements and is what extraction reads. disabled, readonly and required are claims, the author's attributes that nothing else writes, composed with a disabled <fieldset> ancestry the way native controls compose them. Every field answers null for an empty value, publishes a bubbling change with { value } in its detail, and renders its setCustomValidity message into its own error region.
ful fields is the explanation of that model; Extending fields is the same contract from the side of a field you write yourself.
Failures carry structure, not just a message.
flowchart TD
srv["server responds 4xx/5xx"] -->|"failures+json / problem+json"| f["HttpClientError<br/>problems: [{ type, context, reason, details }]"]
f --> form["ful-form catches it"]
form -->|"context names a field"| field["setCustomValidity on that field"]
form -->|"context names nothing"| banner["ful-errors banner"]
A problem's context is a field name, which is what lets a form route each message to the input it belongs to rather than showing every message in one banner. On the transport side the vocabulary is narrow and deliberate: CONNECTION_PROBLEM means the transport refused to deliver, UNEXPECTED_PROBLEM means code in the request chain threw, UNMARSHALING_PROBLEM means the response body could not be read as the requested type, GENERIC_PROBLEM carries a served status whose body did not match the contract.
Render failures are their own shape: a RenderError carries one frame per nesting level, each naming the directive, its expression and the node's open tag, chained through cause from the outermost nesting down to the expression that failed. When a render fails shows a chain and what each frame carries.
ful.css is self-contained: no css framework, no icon font, no javascript.
Everything it ships is inside a cascade layer, and the order is declared first in the file:
@layer ful.theme, ful.components, ful.hidden;The consequence is the whole override contract: any rule your page writes outside a layer beats all of ful's, whatever it selects. A bare ful-badge { padding: 0 } wins over the library's ful-control > ful-badge although its selector is less specific. A page that layers its own styles decides instead by declaring its layer after ful's.
Styles hook structural tags such as ful-control-group, ful-affix and ful-field-error, rather than class names, so a component that renders the same structure is themed for free. They also hook one custom state: the base marks an element rendered when its render completes, and the stylesheet keeps an element invisible until then, so nothing flashes unstyled and nothing appears empty. Colours, spacing and density come from --ful-* custom properties, with light-dark() handling the dark scheme.
| choice | why | what it means |
|---|---|---|
| light DOM, no shadow root | styling, form participation and accessibility all work without piercing anything | your css reaches everything; fml manages slotting itself |
| upgrade after parsing | a component's children are its input | nothing renders synchronously on construction; wait with ftl:ready or Rendering.waitFor
|
| two attribute tiers | most attributes describe how a thing is built, not what it holds | writing a configuration attribute after the upgrade does nothing, on purpose |
| the host is the one element the form sees | a field must submit exactly once | inner controls carry form=""; address them with input-*
|
| claims are the author's alone | to match native disabled semantics exactly |
the property reflects your claim; ask matches(':disabled') for the effective state |
null is the empty value |
one answer to "nothing here", across every field | never test for undefined
|
problems carry a context
|
server errors belong next to the field that caused them | your endpoint's error shape decides how good your form's errors look |
| everything ships in a cascade layer | a consumer must be able to override without matching ful's selectors | your unlayered rules always win |
| expressions are not a sandbox | they are program text, like a script tag | never build an expression out of untrusted data |
- Templates and Expressions: the rendering surface in full.
- Web components: the component reference: statics, mappers, lifecycle, reflection, slots, the registry.
- ful fields: the field model and the protocols every field shares.
-
Http client: the request builder, the interceptors, the
problemscontract. - Theming: the tokens, the structural hooks, the icons.
- Extending elements and Extending fields: building your own.
ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project