Every behavioural thing in the theme — a Module, a Layout, a sub-component — is an
element backed by an instance. Instantiable is the base class that owns that
pairing. It keeps a registry of which element maps to which instance, reads the
element's options on construction, and runs the lifecycle. You subclass it, give it a
selector, and the rest of the framework knows how to find, build, and drive your
class.
A Utility (the standard library — Traits, AssetLoader, Observers) is not
element-backed and does not extend Instantiable. The dividing line is exactly
that: a Component is an element; a Utility is cross-cutting logic that isn't tied
to one.
How you subclass it
Declare a class, give it a static selector, and put your behaviour in lifecycle
methods. That's the whole surface.
class Intro extends Instantiable {
static selector = '.intro'
async init() {
// wire behaviour — runs once the element's declared assets have loaded
}
}
You almost never call new Intro(...) yourself. The framework instantiates your class
for you when its selector is found on the page (see
The AssetLoader pipeline), and hands you back the
same instance every time anyone asks for that element again.
The per-class registry
Each subclass owns a lazy Map of element → instance. Two calls for the same
element return the same object — instances are unique per element, never duplicated.
You never build or read that Map directly; you reach it through the static API:
| Call | What it does |
|---|---|
Intro.make(el) |
Construct (or return the cached) instance, register it, fire created. |
Intro.instance(el) |
Same, but reads as "give me the instance for this element." |
Intro.get(el) |
The registered instance, or undefined — no construction. |
Intro.has(el) |
Whether one is registered. |
Intro.load() |
make() every element matching the static selector. |
Intro.destroy(el) |
Run the instance's destroy and drop it from the registry. |
make is the gate everything funnels through, and it is idempotent — the second
call for an element returns the object the first one built, so reaching for an instance
is always safe:
const intro = Intro.make(el) // constructs and registers, or…
const same = Intro.make(el) // …returns the very same instance — never a duplicate
Why it works this way. The registry is not just internal bookkeeping — it is the
theme's external API. Because make/instance always return the one instance per
element, a script injected from outside the theme can reach a live, already-booted
instance and drive it: components.get('Intro').get(el).open(). See
The Globals registry.
Options come from the element
On construction, Instantiable reads the element's data-* attribute, JSON-parses
it, and stores the result as this.options. This is how PHP passes per-instance
config (Splide settings, flags) into JS without a front-end dev touching the backend.
static parseOptions(element) {
// reads element.dataset by dataKey → moniker → name, JSON.parses it,
// and falls back to the class `defaults`
}
The attribute is keyed by the class's dataKey, then its moniker, then its
name — so <div class="intro" data-intro='{"loop":true}'> lands as
this.options.loop === true. Override static get defaults() to supply a baseline
when the element carries no attribute, and settings(options) to reshape the merged
result.
A data-* attribute is for scalar config. A larger collection (gallery items,
map markers) travels as a data island — a <script type="application/json">
child the host reads with this.element.querySelector(':scope > script'). Reach for
the attribute for flags and knobs; reach for the island for payloads.
A component with no behaviour
The floor is low. A class with only a static selector and nothing else is still
useful — its single job is to pull its colocated .scss into the bundle through the
JS import side-effect. OneFold is exactly this: a CSS-only component, no
lifecycle, no options, just a selector that makes its styles ship when the page needs
them.
Related
- The lifecycle — the
boot/initphasesmakesets in motion. - The Globals registry — how the per-class registry becomes a public API.
- The AssetLoader pipeline — what calls
load()when a selector matches.