Theme Framework How the LeaseLeads theme frontend works

Writing a new HasX trait

Teach a host to discover and lazy-load a new behavioural component type.

On this page 8

When you add a new behavioural Component, no host can use it until a matching HasX trait exists. The trait is what lets a host find the Component in its subtree and lazy-load its asset. This page walks the whole edit, based on the real HasImage / HasButtons traits in utilities/Traits/AutoInitializableComponent.js.

Say you've built a Tabs Component (selector .tabs, class registered as Tabs). Here's how you make it discoverable.

1. Write the trait

Every HasX is a class built on the shared AutoLoadsComponent base and stamped through Traits. It contributes exactly two members: a getter and a boot* method.

js
const HasTabs = new Traits(class {

  get tabsComponents() {
    const cls = Globals.get('Tabs')

    if ( ! cls ) {
      return []
    }

    const els = Array.from(this.element.querySelectorAll('.tabs'))

    if ( this.element.matches('.tabs') ) {
      els.unshift(this.element)
    }

    return els.map(el => cls.instance(el))
  }

  async bootTabsComponents() {
    if ( ! this.element.querySelector('.tabs') && ! this.element.matches('.tabs') ) {
      return
    }

    return this.componentProcessor.convert({
      selector: '.tabs',
      name: 'Tabs',
      loader: () => import('../../components/Tabs/Tabs.js'),
    })
  }

}, [AutoLoadsComponent])

Both halves follow a rigid convention — copy an existing trait and rename.

The getter: get xComponents()

  • querySelectorAll over the whole subtree, plus a this.element.matches check for the host itself. Discovery is deliberately flat, not recursive — the host reaches every matching element beneath it in one query, regardless of nesting.
  • Globals.get('Tabs') first, bail to [] if absent. The class may not be loaded yet at the moment a getter is read. Returning [] keeps callers safe until the asset resolves. See The Globals registry.
  • cls.instance(el) returns the one instance per element from Instantiable's registry — never a fresh construction.
Important

The getter's name must end in ComponentstabsComponents, not tabs. That suffix is exactly what autoInitializeComponents() scans the prototype for. Name it anything else and the Component loads but never inits.

The boot method: bootXComponents()

  • Presence-gated. It returns early unless a .tabs is actually present, so nothing is queued for a host that doesn't contain the Component.
  • this.componentProcessor.convert(...) queues the asset on the host's shared Processor — the one AutoLoadsComponent lazily creates and every HasX on the host reuses. selector, name, and loader mirror a global loader entry.
  • Named boot*, so the lifecycle collects it into the host's boot phase automatically. All the host's boot* calls queue their assets, then afterBootLoadComponents() drains the processor once — which is why boot order between traits never matters.

2. Export it

Add the trait to the export block at the bottom of utilities/Traits/AutoInitializableComponent.js:

js
export {
  HasBackground,
  HasImage,
  HasButtons,
  // …
  HasTabs,
}

3. Mix it into the host

In the host Module (or Layout, or Template), add the trait to its Traits list:

js
import { HasTabs } from '../../utilities/Traits/AutoInitializableComponent.js'

const Final = new Traits(Intro, [
  AutoInitializes,
  HasBackground,
  HasTabs,          // ← now Intro can discover .tabs in its subtree
])

export { Final as Intro }

That single line is the whole contract. new Traits stamps tabsComponents and bootTabsComponents onto the host as own-properties, so the lifecycle collector finds them on the flattened prototype.

4. Confirm the host reaches init

A behavioural host already calls autoInitializeComponents() in its init():

js
async init() {
  // …hand-ordered work…
  await this.autoInitializeComponents()
  await super.init()
}

autoInitializeComponents() walks every get *Components() getter on the prototype chain and inits each discovered instance, skipping any already initialized. Because your getter is named tabsComponents, it's swept up here for free — there's nothing to register. If the host has no init() of its own, it inherits one that still runs the sweep.

Note

Why the split into two conventions. The boot* method queues the download; the *Components getter feeds the init sweep. Separating "load the asset" from "init the instance" is what lets a host lazy-load a Component during boot and then order its init precisely during init — the load is presence-gated and parallel, the init is controlled and idempotent. See Sub-components & HasX.

The failure mode

Forget step 3 and the Component still renders — its PHP view is unaffected — but its asset never loads and it never inits. It's dead markup. When a behavioural Component appears on the page but does nothing, the first thing to check is whether its host mixes the matching HasX.