<?php
/**
* snippet.php — `setMode()` + `MODE_CHANGED` design / preview toggle.
*
* Drop alongside `dist/` + `themes/default/`, run a PHP server.
* Clicks on the segmented control (Design / Preview) call
* `builder.setMode()`; the MODE_CHANGED listener owns repainting
* the segmented control + status pill.
*
* IDs `c8DesignBtn` / `c8PreviewBtn` / `c8ModePill` match the live
* demo on /examples/api/3-set-mode-toggle/ AND Steps 1-3 of the
* walkthrough (BUILDER.md RULE H).
*/
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>setMode + MODE_CHANGED — design / preview toggle</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: 12px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
.my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
/* Segmented control. */
.my-segmented { display: inline-flex; padding: 2px; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 8px; }
.my-segmented__btn { font: inherit; font-size: 12px; padding: 4px 12px; background: transparent; color: #6b7280; border: 0; border-radius: 6px; cursor: pointer; transition: background-color 120ms ease, color 120ms ease; }
.my-segmented__btn:hover { color: #111827; }
.my-segmented__btn:focus-visible { outline: 2px solid #4338ca; outline-offset: 2px; }
.my-segmented__btn.is-active { background: #fff; color: #111827; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); }
#c8ModePill { font-size: 11px; padding: 3px 10px; border-radius: 999px; background: #e0e7ff; color: #4338ca; font-weight: 600; letter-spacing: 0.02em; min-width: 88px; text-align: center; }
#c8ModePill[data-mode="preview"] { background: #f3f4f6; color: #6b7280; }
.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; }
</style>
</head>
<body>
<header class="my-header">
<span class="my-header__title">setMode toggle — Design / Preview</span>
<span class="my-segmented" role="group" aria-label="Editor mode">
<button type="button" id="c8DesignBtn" class="my-segmented__btn" aria-pressed="false">Design</button>
<button type="button" id="c8PreviewBtn" class="my-segmented__btn" aria-pressed="false">Preview</button>
</span>
<span id="c8ModePill" aria-live="polite" data-mode="preview">—</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>
const builder = new Builder({
mainContainer: '#MyCanvas',
widgetsContainer: '#MyWidgets',
settingsContainer: '#MySettings',
});
const designBtn = document.getElementById('c8DesignBtn');
const previewBtn = document.getElementById('c8PreviewBtn');
const pill = document.getElementById('c8ModePill');
// The listener owns repaint — UI is a function of the engine's
// current mode, not a side-effect of the click handler.
function render(mode) {
designBtn.classList.toggle('is-active', mode === 'design');
previewBtn.classList.toggle('is-active', mode === 'preview');
designBtn.setAttribute('aria-pressed', mode === 'design' ? 'true' : 'false');
previewBtn.setAttribute('aria-pressed', mode === 'preview' ? 'true' : 'false');
pill.textContent = mode === 'design' ? 'Editing' : 'Previewing';
pill.dataset.mode = mode;
}
builder.events.on(Builder.EVENTS.MODE_CHANGED, ({ to }) => render(to));
// Click handlers stay one-line. Idempotent calls (clicking the
// already-active button) still fire MODE_CHANGED — the listener
// re-renders, no special-casing needed.
designBtn.addEventListener('click', () => builder.setMode('design'));
previewBtn.addEventListener('click', () => builder.setMode('preview'));
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
// Engine boots in 'preview'. Flip to 'design' for editable canvas;
// the MODE_CHANGED listener catches the first event and renders
// initial UI state through the same path subsequent toggles use.
builder.setMode('design');
</script>
</body>
</html>
setMode() + MODE_CHANGED — design / preview toggle
Two API calls (`setMode('design')` · `setMode('preview')`) plus the `MODE_CHANGED` event subscriber give you a segmented toggle that stays in sync with the engine. Click handlers fire the API; the event listener is the single source of truth for the UI state.
Walkthrough
-
Subscribe to MODE_CHANGED before flipping the mode
The engine fires
Builder.EVENTS.MODE_CHANGEDon everysetMode(mode)call with payload{from, to}— even when the new mode equals the current one (idempotent calls still fire so subscribers see every intent). Subscribe BEFORE you callsetMode()the first time and your UI gets bootstrapped through the same event path that subsequent toggles use.The listener is the ONE place the UI reads the active mode. Render the segmented control + status pill from the event payload — never from the local handler that triggered the mode change. Result: any caller that flips mode (your click handler, a keyboard shortcut, a server push, a unit test) updates the UI without touching DOM-mutation code.
JavaScriptconst designBtn = document.getElementById('c8DesignBtn'); const previewBtn = document.getElementById('c8PreviewBtn'); const pill = document.getElementById('c8ModePill'); function render(mode) { designBtn.classList.toggle('is-active', mode === 'design'); previewBtn.classList.toggle('is-active', mode === 'preview'); designBtn.setAttribute('aria-pressed', mode === 'design' ? 'true' : 'false'); previewBtn.setAttribute('aria-pressed', mode === 'preview' ? 'true' : 'false'); pill.textContent = mode === 'design' ? 'Editing' : 'Previewing'; pill.dataset.mode = mode; } builder.events.on(Builder.EVENTS.MODE_CHANGED, ({ from, to }) => render(to)); -
Wire the two buttons — each calls setMode(targetMode)
Click handlers stay one-line. They call
setMode()with the mode that button represents and let the listener handle UI repaint.setMode()is idempotent — clicking the already-active button still firesMODE_CHANGED, the listener still runs, the render is a no-op. No special-casing needed.The engine boots in
'preview'mode by default (the same default every cookbook entry that wants editable-from-boot has to override). CallingsetMode('design')right afterload()flips into editing AND firesMODE_CHANGED({from: 'preview', to: 'design'})— the listener wired in Step 1 catches it and renders the initial UI state. No imperativerender(builder.getMode())call needed at the bootstrap site.JavaScriptdesignBtn.addEventListener('click', () => builder.setMode('design')); previewBtn.addEventListener('click', () => builder.setMode('preview')); // Engine boots in 'preview'. Flipping to 'design' here ALSO // triggers the MODE_CHANGED listener — the UI bootstraps through // the same code path every subsequent toggle uses. builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL); builder.setMode('design'); -
Single-source-of-truth: never mutate UI inside the click handler
The anti-pattern is putting
render('design')directly inside the design-button click handler. Any other site that callssetMode()(a keyboard shortcut, a server-pushed mode, a unit test, an embedded automation script) flips the engine's mode but leaves the UI stale. The listener-driven pattern keeps the engine and the UI consistent regardless of who fired the API.Programmatic mode flips work for free:
builder.setMode('preview')from the JS console mirrors clicking the button — segmented control highlights the right side, status pill swaps text, no mediation needed.builder.getMode()returns the current mode if you ever need a snapshot read (e.g. while building the initial DOM before the listener subscribes).JavaScript// ❌ Anti-pattern — UI mutates inline, drifts out of sync // when ANY other site calls setMode(). // designBtn.addEventListener('click', () => { // builder.setMode('design'); // designBtn.classList.add('is-active'); // duplicates listener work // previewBtn.classList.remove('is-active'); // breaks if next call is preview // }); // ✅ Pattern — UI is a function of mode. Listener owns repaint. designBtn.addEventListener('click', () => builder.setMode('design')); previewBtn.addEventListener('click', () => builder.setMode('preview')); builder.events.on(Builder.EVENTS.MODE_CHANGED, ({ to }) => render(to)); // Programmatic flip works the same: // builder.setMode('preview'); // -> listener fires -> render('preview')
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 subscribe before flipping? The engine boots in 'preview'. If you call builder.setMode('design') after load() to make the canvas editable, that single call fires MODE_CHANGED({from: 'preview', to: 'design'}) — the same event your toggle clicks fire. Subscribing FIRST means the bootstrap repaint flows through the same listener as every subsequent toggle. One render path, zero special-casing.
Idempotent fires. Calling setMode(currentMode) still emits MODE_CHANGED (same engine source: src/includes/Builder.js setMode() comment locks this contract). If your subscriber needs to short-circuit on no-op flips, do it inside the listener: if (payload.from === payload.to) return;.
Reactive enable/disable for other chrome. Once you have a MODE_CHANGED listener, anything else can hook into it: grey-out save/export buttons in preview, hide the widgets palette in preview, swap a "Save" button for a "Send test" button. The cookbook entry cookbook/4-rich-3col-header uses MODE_CHANGED to drive 5 in-wrapper-header buttons; the pattern in this api/3 example is the minimal kernel that scales up to that.
Programmatic flips work for free. A keyboard shortcut binding (Cmd+P → builder.setMode('preview')), a server-pushed state via WebSocket, a unit test, a browser dev-tools call — every entry point that flips mode goes through the same listener. The segmented control stays in sync without any per-caller wiring.
Boot-time read with getMode(). If you need to render initial UI state BEFORE attaching the listener (e.g. on a server-side hydration boundary), call builder.getMode() for a snapshot. Most apps don't need this — subscribe first, fire setMode once, and the listener bootstraps the UI through the canonical path.