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.
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
// 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.
// 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:
// 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).
// 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.
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.
Related
- Components — the type in full, view-only vs behavioural.
- Sub-components &
HasX— discovery, the boot queue, and the contract. - Writing a new
HasXtrait — the trait anatomy in depth. - The Globals registry — name, moniker, and the export-alias rule.