</> Ultima

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)

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.

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