Theme Framework How the LeaseLeads theme frontend works

Add a behavioural Component

Build a reusable Component with behaviour and wire it into a host Module through its HasX trait.

On this page 5

A Component is a reusable piece rendered inside a Module, never placed on its own. When it carries behaviour it is discovered and lazy-loaded by its host's HasX trait — not by a Global orchestrator. This walkthrough builds a small Copy component (a button that copies text to the clipboard) and wires it into the Notice module.

Important

The contract that trips people up: a behavioural component only boots if its host mixes the matching HasX trait. Render the markup without wiring the trait and the component stays inert — no error, just dead. Step 3 is not optional.

1. Author the component view

Component views live at resources/public/views/components/<name>.php and render through View::component. Scalar config rides along as a data-* option — a JSON attribute keyed by the component's moniker (data-copy), which Instantiable parses into this.options on construction.

php
<?php
// resources/public/views/components/copy.php
/** @var array $args */

use LeaseLeads\Theme\App\Helpers\Settings;

$data = Settings::parseArgs($args['data'] ?? [], [
    'text'  => '',
    'label' => 'Copy',
]);
?>

<button
  class="copy"
  type="button"
  data-copy="<?= esc_attr(wp_json_encode(['text' => $data['text']])); ?>">
  <span class="copy__label"><?= esc_html($data['label']); ?></span>
</button>

2. Write the component JS

The JS is an Instantiable with a static selector. Crucially, a sub-component does not mix AutoInitializes — it never self-inits, so its host keeps ordering control. It reads its config off this.options and toggles a state class rather than setting styles.

js
// resources/public/assets/components/Copy/Copy.js
import "./Copy.scss"
import { Instantiable } from '../../utilities/Instantiable/Instantiable.js'
import { Traits } from '../../utilities/Traits/Traits.js'

/**
 * Copies the configured text to the clipboard and flags success.
 */
class Copy extends Instantiable {
  static selector = '.copy'

  async init() {
    if ( this.isInitialized ) {
      return
    }

    this.element.addEventListener('click', () => this.#copy())

    await super.init()
  }

  async #copy() {
    await navigator.clipboard.writeText(this.options.text ?? '')
    this.element.classList.add('is-copied')
  }
}

const Final = new Traits(Copy, [])

export { Final as Copy }

The export { Final as Copy } alias must equal the name the trait registers in step 3 — that string is how AssetLoader resolves the class (exports['Copy']) and the moniker Globals keys it under.

3. Wire it into the host with a HasX trait

The host discovers the component through a HasX trait that adds two members: a get copyComponents() getter (queries the host's whole subtree) and a bootCopyComponents() that queues the asset on the host's shared processor when a .copy is present. If no trait exists for your component yet, add one to assets/utilities/Traits/AutoInitializableComponent.js, following the family shape:

js
// resources/public/assets/utilities/Traits/AutoInitializableComponent.js
const HasCopy = new Traits(class {

  get copyComponents() {
    const cls = Globals.get('Copy')

    if ( ! cls ) {
      return []
    }

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

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

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

  async bootCopyComponents() {
    if ( ! this.element.querySelector('.copy') && ! this.element.matches('.copy') ) {
      return
    }

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

}, [AutoLoadsComponent])

Then add HasCopy to the file's export { … } block. Writing a trait from scratch — the boot*/getter naming, the AutoLoadsComponent base, the discovery rules — is covered in full in Writing a new HasX trait.

4. Mix the trait onto the host Module

Finally, add HasCopy to the host's Traits list. This is the line that satisfies the contract — it gives the host a copyComponents getter (so autoInitializeComponents() finds and inits every Copy) and a bootCopyComponents() (so the asset is queued and loaded).

js
// resources/public/assets/modules/Notice/Notice.js
import { HasCopy } from '../../utilities/Traits/AutoInitializableComponent.js'

const Final = new Traits(Notice, [
  AutoInitializes,
  FadesIn,
  HasSpacing,
  HasBackground,
  HasContentStack,
  HasCopy,          // ← the host now discovers and boots .copy
  AnimatesOnScroll,
])

With the component rendered in the view (View::component('copy', ['text' => …])) and HasCopy on the host, the boot phase queues the Copy chunk, the init phase runs each instance's init(), and clicks start copying.

Note

Why the trait, not a Deferred entry? Global orchestrators load modules; a host loads its own components. Routing sub-components through HasX keeps discovery scoped to hosts that actually contain them (presence-gated per subtree) and hands the host sole control of init ordering. See Sub-components & HasX.