Adding a widget
A widget is two halves paired by a shared type string: a PHP builder that constructs the node, and a JS renderer that draws it. You register each half under the same type name, and a contract test fails the suite if either half is missing. Adding a widget is four steps.
This page is about adding a widget type to the framework itself or to a package you control. If you just want to build an app from the existing widgets, you want Building an app instead.
1. The PHP builder
A builder extends SystemX\Core\Wire\Node, seeds its props in the constructor, and exposes a static make() plus fluent setters. Each setter mutates props and returns $this. The simplest is Label:
namespace SystemX\Core\Widgets;
use SystemX\Core\Wire\Node;
class Label extends Node
{
public function __construct(string $text)
{
parent::__construct('label', ['text' => $text]);
}
public static function make(string $text): static
{
return new static($text);
}
}A fluent setter is nothing clever:
public function size(int $width, int $height): static
{
$this->props['width'] = $width;
$this->props['height'] = $height;
return $this;
}Interactive widgets and the events allowlist
If your widget round-trips DOM events to PHP, it carries an events prop: the allowlist of which events the client dispatches. Seed it in the constructor and use withEvent() to add more idempotently. Never push onto props['events'] by hand.
public function __construct(string $name)
{
parent::__construct('textfield', [
'name' => $name,
'value' => '',
'events' => ['submit'],
]);
}Keep the shorthand convention consistent: an ->onX() method with a handler routes through ->on($event, $handler), which records the binding and opens the allowlist in one act. Called bare, it falls back to withEvent() and only opens the round trip:
public function onChange(callable|string|null $handler = null): static
{
return $handler === null
? $this->withEvent('change')
: $this->on('change', $handler);
}The serialiser reads only type, id, props and children, so the bindings never reach the wire. Handlers are server-only by construction.
2. The JS renderer
A renderer is an object with create(node, ctx) and update(el, node, ctx), plus an optional destroy(el, ctx). create builds a fresh element. update patches an existing one toward a new node; the morph calls it whenever a slot's type is unchanged, so it must be idempotent and must not stack listeners. destroy, if defined, fires on removal.
Widgets follow one of three interaction patterns. Pick deliberately:
- Delegated (most input widgets). No
addEventListenerat all. A single delegated dispatcher on the desktop surface owns click, change and submit, and readsdata-sx-eventsto know what round-trips. - Programmatic emit (overlay widgets: dialogs, menus). The widget needs an event name the DOM doesn't fire, like
closeorselect, so its renderer attaches its own listeners and callsctx.emit(...). These never stampdata-sx-events. - Display-only (labels, badges, tooltips). No events prop, no listeners, nothing round-trips.
The button renderer is the delegated template:
export const buttonRenderer = {
create(node) {
const el = document.createElement('button');
el.className = 'sx-button';
el.dataset.sxId = node.id ?? '';
el.dataset.sxEvents = (node.props.events ?? ['click']).join(',');
el.textContent = node.props.label;
return el;
},
update(el, node) {
if (el.textContent !== node.props.label) {
el.textContent = node.props.label;
}
el.dataset.sxEvents = (node.props.events ?? ['click']).join(',');
},
};Conventions:
- The element class is
sx-followed by the type. Setdata-sx-idfromnode.id ?? ''. - Interactive widgets stamp
data-sx-eventsfrom the props so the dispatcher knows what round-trips. Input renderers should sync incoming values only when the field isn't focused, so a server frame never clobbers what the user is typing. - Don't stamp
data-sx-typeordata-sx-keyyourself. The registry stamps both centrally, so the morph's type and key matching work even if a renderer forgets.
Rules for renderers that attach their own listeners
The overlay widgets earned these the hard way. Each one was a shipped bug first:
- Read the current node, not the create-time closure. A server re-render changes props, and a listener that closed over
create()'s node serves stale data forever. Stash the live node on the element in bothcreate()andupdate(), and read the stash at interaction time. - Body-host pattern for containers. Children reconcile into a dedicated inner host element. Renderer-owned chrome (a legend, a bubble, a backdrop) is a sibling of that host, never inside the reconciled slot, because positional reconcile matches by index.
- Popups that portal to the body must capture their window and app ids from the in-tree trigger element before portaling. Once mounted on the body there's no window ancestor to derive them from, and the emit misroutes. And don't set a z-index to a
var()in inline style; the CSSOM silently drops it. Put z on a class. create()runs detached. The reconciler appends the element after rendering it, sofocus()is a no-op and events don't bubble to the document yet. Defer connect-dependent work with a microtask and anisConnectedguard.destroy()must be idempotent and must not assume its children are still attached. Teardown walks parent first, so a parent's destroy can run before its children's. Guard everything and make a second call a no-op. And keep self-heal guards on anything living outside your subtree (document listeners, portaled popups) even with a destroy hook, so a missed teardown can never strand a listener.- Stash conventions. Underscore-prefixed element expandos (
_sx...) for non-serialisable handles like traps and listeners;datasetkeys for morph-visible flags. Don't invent a third place.
And on the PHP side of any interactive widget: the event value is client-controlled. Coerce and validate in the handler, as covered in Building an app.
3. The registry pairing
Three edits register the two halves under one type string.
In PHP, add the builder to the WidgetRegistry:
$registry->register('gauge', Gauge::class);In JS, import and register the renderer:
import { gaugeRenderer } from './widgets/gauge.js';
registry.register('gauge', gaugeRenderer);And add the type string to the registered-types.json manifest, which is the JS side's source of truth for the contract test.
A package does the same two register calls under one type name. The framework's own widgets go through the same API; there's no privileged path.
Type naming
Core keeps bare type names (button, label). A third-party widget type should be namespaced vendor.name, like acme.gauge, with its CSS class following as sx-vendor-name. The registry throws on a duplicate type, so a collision fails loudly at boot rather than silently last-wins.
4. The contract test
A pairing test cross-checks the PHP registry against the JS manifest in both directions. A PHP builder with no JS renderer fails the suite, and so does the reverse, so a half-registered widget can't ship. Add a unit test for your builder alongside the existing widget serialisation tests, asserting the seeded props and that the fluent setters land on the wire.
An unknown type at runtime doesn't throw, by the way. It renders a placeholder and warns in the console, so a version mismatch degrades rather than blanking the desktop.
Recap
- A PHP builder extending
Node. - A JS renderer with
createandupdate. - Register both under one type: the registry, the renderer barrel, the manifest.
- The contract test stays green.
The wire envelope itself never changes. A new widget is just a new type with its own props. See the wire protocol for the contract, and Shipping an app as a package for delivering a custom widget's renderer and CSS from your own package.