<?php
/**
* snippet.php — Custom Control by subclassing `ColorPickerControl` +
* wiring it into a custom Element's `getControls()` (CD-1 register +
* inline-control pattern).
*
* Drop alongside `dist/` + `themes/default/`, run a PHP server. Two
* toolbar buttons drive the contract:
* • Insert badge → getData → unshift block w/ BrandBadgeElement → load
* • Reload from JSON → getData → load (proves parse round-trip)
*
* THE CRITICAL UX is the settings panel after canvas-click: the
* BrandColorPickerControl shows a 6-swatch row below the standard hex
* input. Click any swatch → badge color updates instantly.
*
* Class name `BrandBadgeElement` + `BrandColorPickerControl` + button IDs
* `c13InsertBtn` / `c13ReloadBtn` / `c13StatusPill` match the live demo
* on /examples/extensions/3-custom-control/ AND Steps 1-3 of the
* walkthrough (BUILDER.md RULE H + D13.B.parity triple-surface gate).
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';
$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Control — extend ColorPickerControl with brand swatches</title>
<link rel="stylesheet" href="/dist/builder.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
<style>
body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
.my-header { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; flex-wrap: wrap; }
.my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
.my-btn { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; font: inherit; font-size: 12px; font-weight: 600; border: 1px solid #d4d4d8; background: #fff; color: #18181b; border-radius: 6px; cursor: pointer; }
.my-btn--primary { background: #4338ca; color: #fff; border-color: #4338ca; }
.my-btn:hover { border-color: #4338ca; color: #4338ca; }
.my-btn--primary:hover { background: #3730a3; border-color: #3730a3; color: #fff; }
.my-pill { font-size: 11px; padding: 3px 10px; border-radius: 999px; background: #e0e7ff; color: #4338ca; font-weight: 600; }
.my-builder-host { display: grid; grid-template-columns: 200px 1fr 240px; min-height: 0; }
.my-host-slot { background: #f8f9fa; padding: 8px; overflow: auto; font-size: 12px; }
.my-canvas { overflow: auto; background: #fff; }
/* BrandColorPickerControl swatch strip. Settings panel is OUTSIDE the
* iframe canvas, so styling lives in the host stylesheet (the iframe
* boundary rule from D13.C.12 only applies to canvas-rendered DOM). */
.brand-swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-top: 8px; padding: 0 8px; }
.brand-swatch { width: 100%; aspect-ratio: 1; border: 2px solid transparent; border-radius: 6px; cursor: pointer; padding: 0; background-clip: padding-box; transition: all 120ms ease; }
.brand-swatch:hover { transform: scale(1.08); border-color: rgba(255,255,255,.6); box-shadow: 0 2px 8px rgba(0,0,0,.18); }
.brand-swatch:focus-visible { outline: 2px solid #4338ca; outline-offset: 2px; }
</style>
</head>
<body>
<header class="my-header">
<span class="my-header__title">Custom Control — BrandColorPickerControl</span>
<button type="button" class="my-btn my-btn--primary" id="c13InsertBtn">Insert badge</button>
<button type="button" class="my-btn" id="c13ReloadBtn">Reload from JSON</button>
<span class="my-pill" id="c13StatusPill">Registered ✓</span>
</header>
<div class="my-builder-host">
<div id="MyWidgets" class="my-host-slot"></div>
<div id="MyCanvas" class="my-canvas"></div>
<div id="MySettings" class="my-host-slot"></div>
</div>
<script>
window.THEME_JSON = <?= json_encode($bundle->themeJson, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.THEME_TEMPLATES = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.MEDIA_URL = <?= json_encode($bundle->mediaUrl, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="/dist/builder.js"></script>
<script>
// 1) Subclass ColorPickerControl — override render() to call super first,
// then append a brand-swatch strip. Each swatch click routes through
// _notify(hex), the same internal entry the hex input uses on change.
class BrandColorPickerControl extends ColorPickerControl {
static BRAND_SWATCHES = [
{ name: 'Sky', hex: '#0EA5E9' },
{ name: 'Violet', hex: '#8B5CF6' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Emerald', hex: '#10B981' },
{ name: 'Rose', hex: '#EF4444' },
{ name: 'Amber', hex: '#F59E0B' },
];
render() {
super.render(); // standard label + swatch + hex-input row.
const strip = document.createElement('div');
strip.className = 'brand-swatches';
strip.setAttribute('role', 'group');
strip.setAttribute('aria-label', 'Brand colors');
BrandColorPickerControl.BRAND_SWATCHES.forEach(({ name, hex }) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'brand-swatch';
btn.dataset.brandHex = hex;
btn.style.background = hex;
btn.title = `${name} — ${hex}`;
btn.setAttribute('aria-label', `Apply ${name} (${hex})`);
btn.addEventListener('click', (e) => {
e.preventDefault();
this._applyValue(hex); // canonical swatch entry: syncs hex + swatch + callback
});
strip.appendChild(btn);
});
this.domNode.appendChild(strip);
}
}
// 2) Subclass BaseElement — same CD-1 pattern as W6.C.12. getControls()
// returns [TextControl, BrandColorPickerControl]; engine wires them
// into the panel when buyer clicks the badge on canvas.
class BrandBadgeElement extends BaseElement {
constructor(template, text, brandColor) {
super();
this.template = template || 'BrandBadge';
this.text = text || 'New badge';
this.brand_color = brandColor || '#0EA5E9';
this.domNode = null;
this.requiredTemplateKeys = ['text', 'brand_color'];
}
getName() { return 'Brand Badge'; }
getData() {
const base = super.getData();
if (!base.formats) base.formats = {};
return { ...base, text: this.text, brand_color: this.brand_color };
}
static parse(data) {
const inst = new BrandBadgeElement(data.template, data.text, data.brand_color);
return BaseElement.parseFormats(inst, data);
}
_doRender() {
this.domNode.innerHTML = this.renderTemplate({
text: this.text,
brand_color: this.brand_color,
});
}
getControls() {
return [
new TextControl('Label', this.text, {
setText: (v) => { this.text = v; this.render(); },
}),
new BrandColorPickerControl('Brand color', this.brand_color, (hex) => {
this.brand_color = hex;
this.render();
}),
];
}
}
// 3) EJS template body — embedded <style> travels with the markup into
// the sandboxed iframe canvas (D13.C.12 Lesson 74).
const BRAND_BADGE_TEMPLATE = `
<style>
.my-badge { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px; background: #fff; border: 1px solid #e5e7eb; font: 600 14px system-ui, -apple-system, sans-serif; color: #18181b; }
.my-badge__dot { width: 12px; height: 12px; border-radius: 50%; background: var(--badge-brand); flex-shrink: 0; }
.my-badge__text { letter-spacing: -.01em; }
.my-badge__wrap { display: flex; justify-content: center; padding: 24px 16px; background: #f8fafc; }
</style>
<div class="my-badge__wrap">
<div class="my-badge" data-bjs-brand-badge style="--badge-brand: <%- brand_color %>">
<span class="my-badge__dot"></span>
<span class="my-badge__text"><%- text %></span>
</div>
</div>`;
Builder.registerElement('BrandBadgeElement', BrandBadgeElement);
THEME_TEMPLATES.BrandBadge = BRAND_BADGE_TEMPLATE; // survives every load()
const builder = new Builder({
mainContainer: '#MyCanvas',
widgetsContainer: '#MyWidgets',
settingsContainer: '#MySettings',
});
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
// 4) Toolbar handlers. Click the badge on canvas to open the settings
// panel; click any brand swatch to recolor instantly (no save needed).
const HERO_BRAND_BADGE_BLOCK = {
name: 'BlockElement', template: 'Block',
formats: { padding_top: 16, padding_bottom: 16 },
elements: [{
name: 'BrandBadgeElement',
template: 'BrandBadge',
formats: {},
text: '✨ New brand badge',
brand_color: '#0EA5E9',
}],
};
const reload = (data) => builder.load(data, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
document.getElementById('c13InsertBtn').addEventListener('click', () => {
const data = builder.getData();
data.blocks = data.blocks || [];
data.blocks.unshift(JSON.parse(JSON.stringify(HERO_BRAND_BADGE_BLOCK)));
reload(data);
updateStatus();
});
document.getElementById('c13ReloadBtn').addEventListener('click', () => {
reload(builder.getData());
updateStatus();
});
function countBadgesAcrossFrames() {
let n = document.querySelectorAll('[data-bjs-brand-badge]').length;
document.querySelectorAll('iframe').forEach((f) => {
try { if (f.contentDocument) n += f.contentDocument.querySelectorAll('[data-bjs-brand-badge]').length; } catch (_) {}
});
return n;
}
function updateStatus() {
const pill = document.getElementById('c13StatusPill');
if (!pill) return;
const c = countBadgesAcrossFrames();
pill.textContent = c > 0 ? `Registered ✓ · ${c} on canvas` : 'Registered ✓';
}
setInterval(updateStatus, 1000);
updateStatus();
</script>
</body>
</html>
Custom Control — extend ColorPickerControl with brand swatches, wire via getControls()
Three engine touchpoints — subclass an existing `ColorPickerControl` (override `render()` to add brand swatches), instantiate inside a custom Element's `getControls()` (the engine auto-wires the panel on click), and route swatch clicks through `_notify(hex)` (the same callback path the hex input uses). Controls aren't registry-based today; coupling them to an Element via `getControls()` is the canonical buyer-side path.
Walkthrough
-
Subclass ColorPickerControl — override render(), append brand swatches
ColorPickerControlis exposed onwindow.ColorPickerControlbydist/builder.js(src/builder.js:307). Its constructor takes(label, color, callback)+ writes the standard label / swatch / hex-input row intothis.domNode. Subclass extends the render output by callingsuper.render()first (which paints the standard row) then appends a brand-swatch strip viathis.domNode.appendChild(...).Each swatch button is a plain
<button>withdata-brand-hex; click handler routes throughthis._applyValue(hex)— the canonical swatch-click entry point inside ColorPickerControl that syncsthis.color+ the hex input value + the swatch preview + the callback (function-form OR{setValue}/{setColor}object-form). The popover's color-palette swatches use the same_applyValuepath; reusing it means our brand swatches behave byte-identically. Do not callthis._notify(hex)— that's the hex-input change path and skips the hex value sync, leaving the visible hex out-of-step with the swatch preview.Brand swatches live in a static class array. Make it instance-configurable via the constructor if you want per-tenant brand palettes (passed as a 4th constructor arg or via a `setOptions({swatches})` setter).
JavaScriptclass BrandColorPickerControl extends ColorPickerControl { // Brand palette — swap in your tenant's hex codes here. Make this an // instance field if you need per-element / per-tenant palettes. static BRAND_SWATCHES = [ { name: 'Sky', hex: '#0EA5E9' }, { name: 'Violet', hex: '#8B5CF6' }, { name: 'Orange', hex: '#F97316' }, { name: 'Emerald', hex: '#10B981' }, { name: 'Rose', hex: '#EF4444' }, { name: 'Amber', hex: '#F59E0B' }, ]; render() { super.render(); // paints the standard label + swatch + hex-input row. const strip = document.createElement('div'); strip.className = 'brand-swatches'; strip.setAttribute('role', 'group'); strip.setAttribute('aria-label', 'Brand colors'); BrandColorPickerControl.BRAND_SWATCHES.forEach(({ name, hex }) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'brand-swatch'; btn.dataset.brandHex = hex; btn.style.background = hex; btn.title = `${name} — ${hex}`; btn.setAttribute('aria-label', `Apply ${name} (${hex})`); btn.addEventListener('click', (e) => { e.preventDefault(); // Route through the canonical swatch-click entry — _applyValue // syncs this.color + the hex input value + the swatch preview // + the callback. (Don't call _notify — that's the hex-input // change path and leaves the visible hex out-of-step.) this._applyValue(hex); }); strip.appendChild(btn); }); this.domNode.appendChild(strip); } } -
Subclass BaseElement — same pattern as W6.C.12, plus a getControls() override
Element subclassing follows the exact CD-1 pattern from W6.C.12 (`extensions/2-custom-element/`): override
getData()+static parse()+_doRender()+ setthis.template+ register viaBuilder.registerElement. Two element-specific fields:text(badge label) andbrand_color(hex string).getControls()returns the array of Control instances the engine wires into the settings panel when the buyer clicks the badge on canvas.this.hostis the back-reference to the Builder (set by_adopt()after parse, BEFORE render);this.host.renderElementControlsis what re-paints the panel after a control writes back. Settingthis.text = v; this.host.renderElementControls(this)is the canonical setter shape — the panel re-renders with the new value, the canvas re-renders via the same render call inside the callback.Critical (D13.C.12 Lesson 74): the embedded
<style>block lives INSIDE the EJS template body. Engine renders each block into its own sandboxed<iframe>for style isolation; host page CSS does NOT cross the boundary. The control panel is OUTSIDE the iframe (lives in the host's settings container) so its styling can come from the host stylesheet OR from the control's own classes — pick one canonical surface.JavaScriptclass BrandBadgeElement extends BaseElement { constructor(template, text, brandColor) { super(); this.template = template || 'BrandBadge'; this.text = text || 'New badge'; this.brand_color = brandColor || '#0EA5E9'; this.domNode = null; this.requiredTemplateKeys = ['text', 'brand_color']; } getName() { return 'Brand Badge'; } getData() { const base = super.getData(); if (!base.formats) base.formats = {}; return { ...base, text: this.text, brand_color: this.brand_color }; } static parse(data) { const inst = new BrandBadgeElement(data.template, data.text, data.brand_color); return BaseElement.parseFormats(inst, data); } _doRender() { this.domNode.innerHTML = this.renderTemplate({ text: this.text, brand_color: this.brand_color, }); } // The engine calls this when the buyer clicks the badge on canvas. The // returned array is wired into the settings panel; each control owns its // own render + event handlers. getControls() { return [ new TextControl('Label', this.text, { setText: (v) => { this.text = v; this.render(); }, }), new BrandColorPickerControl('Brand color', this.brand_color, (hex) => { // ColorPickerControl supports function-form callback (this branch) + // object-form { setValue } / { setColor }. Function-form is the most // ergonomic for buyer-side custom Elements. this.brand_color = hex; this.render(); }), ]; } } -
Wire it up — register, inject template, hand to load()
Same registration sequence as W6.C.12:
Builder.registerElement(name, klass)mutatesELEMENT_REGISTRY+ writesklassontowindow;builder.templates.BrandBadge = TEMPLATEANDthemeTemplates.BrandBadge = TEMPLATE(both sides becauseload()reassignsthis.templateswholesale, Builder.js:796).The custom Control has NO registration step — it lives as a JS class on
window.BrandColorPickerControl(or as an ESM import) and is instantiated inline insidegetControls(). The engine doesn't need to know about it; it sees a Control instance + asks for itsdomNode+ appends it to the panel. This is the canonical pattern today; a futureBuilder.registerControl(name, klass)would unlock JSON-described custom controls (theme authors could ship custom panels without code) but is out-of-scope for buyer-side host apps that already own JS.The Insert button uses C.10's round-trip pattern (
getData → unshift → load), the Reload button provesparse()rehydrates byte-identically. The CRITICAL UX is the panel after click — try clicking the badge on canvas, then clicking each brand swatch in the settings panel. The badge updates instantly (no save round-trip).JavaScript// EJS template body. Required keys: text + brand_color. Embedded <style> // travels with the markup into the sandboxed iframe canvas (D13.C.12). const BRAND_BADGE_TEMPLATE = ` <style> .my-badge { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px; background: #fff; border: 1px solid #e5e7eb; font: 600 14px system-ui, -apple-system, sans-serif; color: #18181b; } .my-badge__dot { width: 12px; height: 12px; border-radius: 50%; background: var(--badge-brand); flex-shrink: 0; } .my-badge__text { letter-spacing: -.01em; } .my-badge__wrap { display: flex; justify-content: center; padding: 24px 16px; background: #f8fafc; } </style> <div class="my-badge__wrap"> <div class="my-badge" data-bjs-brand-badge style="--badge-brand: <%- brand_color %>"> <span class="my-badge__dot"></span> <span class="my-badge__text"><%- text %></span> </div> </div>`; // Register element (one-time, before any save/load round-trip): Builder.registerElement('BrandBadgeElement', BrandBadgeElement); builder.templates.BrandBadge = BRAND_BADGE_TEMPLATE; themeTemplates.BrandBadge = BRAND_BADGE_TEMPLATE; // survives every load() // Insert: getData → unshift block → load. Click the badge on canvas to // open the settings panel; click any brand swatch to recolor instantly. document.getElementById('c13InsertBtn').addEventListener('click', () => { const data = builder.getData(); data.blocks.unshift({ name: 'BlockElement', template: 'Block', formats: { padding_top: 16, padding_bottom: 16 }, elements: [{ name: 'BrandBadgeElement', template: 'BrandBadge', formats: {}, text: '✨ New brand badge', brand_color: '#0EA5E9' }], }); builder.load(data, themeTemplates, themeConfigData, mediaUrl); }); // Reload — proves BrandBadgeElement.parse rehydrates byte-identically, // including the brand_color field set by the BrandColorPickerControl. document.getElementById('c13ReloadBtn').addEventListener('click', () => { builder.load(builder.getData(), themeTemplates, themeConfigData, mediaUrl); });
The whole snippet
Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.
Notes
Why no Builder.registerControl(name, klass)? Controls today are tightly coupled to the Element they serve — the Element\'s getControls() instantiates them with the right callback wired to a specific element field. JSON saved documents don\'t reference Control class names; they reference Element class names + field values. A future registerControl would unlock JSON-described custom panels (theme authors shipping custom controls without writing JS), but that\'s CD-2 follow-up scope. Buyer-side custom controls today live as JS classes that Element subclasses instantiate.
Where the engine globals live. dist/builder.js exposes window.ColorPickerControl + TextControl + every other Control class (see src/builder.js lines 289-351 for the full list — 30+ controls). Subclassing works straight from a <script> tag; ESM consumers can import { ColorPickerControl, TextControl } from \'builderjs\' instead.
Callback shape — function vs object form. ColorPickerControl accepts both: pass a plain function (hex) => { … } for the simplest path (this example), OR an object { setValue: (hex) => { … } } / { setColor: (hex) => { … } } if you\'re mirroring an existing Element\'s control plumbing. TextControl only accepts the object form { setText: (v) => { … } } (debounced 200ms internally). Be consistent within an Element\'s getControls() for readability.
Calling this.render() vs this.host.renderElementControls(this). Inside a Control callback, calling this.render() on the Element re-paints the canvas WITHOUT re-paneling the settings sidebar (the panel persists across renders). Calling this.host.renderElementControls(this) ALSO re-paints the panel — useful if a control mutation should swap which OTHER controls are visible (e.g. Show/Hide a sub-panel). For a single-color recolor like this example, this.render() is sufficient + cheaper.
Per-tenant brand palettes. Move BRAND_SWATCHES off the static class and onto an instance constructor option: new BrandColorPickerControl(label, color, callback, { swatches: tenantSwatches }). Pass the tenant\'s palette through from the Element\'s getControls() via a closure-captured this.host.tenantContext or a global registry. The control class doesn\'t need to know about tenants — it just renders whatever swatch array it was constructed with.
Where to go next. For a brand-NEW Element type with custom JSON shape + render, see W6.C.12 extensions/2-custom-element/ (CountdownElement). For locale packs that ship translations alongside a custom Element, see W6.C.14 extensions/4-i18n-locale-pack/ (next up). For multi-tenant cookbook (closure-captured tenant context flowing through every control + element + asset upload), see W6.D.4 extensions/5-multi-tenant/.