Guide
Every Ultima component extends, directly or indirectly, ComponentBase.
Components that need declarative data binding extend ReactiveComponent,
which in turn extends ComponentBase.
ComponentBase
Handles loading the external HTML template, optional isolation via Shadow DOM, and resolving paths relative to the component's own module (not to the page that uses it).
constructor(properties, subclassUrl)
properties.templateUrl— path to the template's HTML file, relative to the.jsmodule.properties.shadowDom—trueto isolate in Shadow DOM (mode:'open');falseto render directly as children of the element itself.subclassUrl— always the child class'simport.meta.url.
The loaded event
ComponentBase.LOADED_EVENT ("component-loaded")
fires when the component — and all of its descendants that are also
ComponentBase — finish mounting. It bubbles
(bubbles:true, composed:true) and crosses Shadow DOM boundaries.
event.composedPath()[0], not
event.target. When a descendant component has its own
Shadow Root, the browser retargets the event as it crosses that boundary —
event.target shows up as the listening element itself, even when the
event came from a different descendant. Without this check, a listener can react to loads that
aren't its own — in practice this has already caused real re-render loops in this project.
this.addEventListener(ComponentBase.LOADED_EVENT, (event) => {
if (event.composedPath()[0] !== this) {
return;
}
// only here: the component itself finished loading
});
ReactiveComponent
Adds declarative reactivity via data-* attributes read from the
template — the full list is in the DSL reference. Here, the
concepts behind it.
State
get state() // returns the current state
set state(newState) // writes and re-renders, if anything changed
The data-state HTML attribute is also observed — changing it from
the outside (setAttribute) has the same effect as using the
state setter. Writes that don't change the content (compared by
value, not by reference) are ignored — they don't trigger a re-render or events.
Computed properties and watchers
computed() // -> Object, derived from this.state, merged into every binding
watchers() // -> { "path": (newValue, oldValue) => {...} }
Override computed() to declare values derived from the raw state —
the return value is available to any binding (data-bind,
data-if, data-class,
data-style, data-for) as if it were a
regular piece of state. Override watchers() to react whenever a
specific path's value changes between one render and the next.
Scope
Every nesting level — the component's root, a resolved conditional branch, a list item — has
its own data scope. Inside a list item, the loop variable's name is available as if it were a
regular field, and whatever comes from the parent scope stays accessible (scope chains by
prototype). Two nested reactive components don't share scope: a child receives data explicitly
via data-bind='{"data-state":"path"}'.
List reconciliation
data-for can reconcile by key — when the item has a stable identity
field (declared, or inferred from uuid/id),
existing DOM nodes are reused and moved instead of being destroyed and recreated on every
render, preserving focus, scroll position and nested components' internal state. Full details
and syntax in the Reference.