<?php
/**
* snippet.php — `region:focus` + `region:blur` event subscription with
* a focused-region status strip.
*
* Drop alongside `dist/` + `themes/default/`, run a PHP server.
* Click any element on the canvas → settings panel renders → click
* one of the 4 padding-side inputs → the strip flips to
* "Editing: padding-top (#42)" etc. Tab to the next side → strip
* updates seamlessly (the only-clear-if-matches guard prevents
* flicker).
*
* IDs `c5RegionStrip` match the live demo on
* /examples/events/5-region-focus/ AND Steps 1-3 of the walkthrough
* (BUILDER.md RULE H, D13.B.parity).
*
* NOTE: Builder.EVENTS does NOT currently expose REGION_FOCUS /
* REGION_BLUR constants. Subscription is via raw strings — same
* pattern as `history:change` (see C.3). The events fire reliably
* from `PaddingMarginControl.js:219+225`. When the constants land
* (future Phase A.A scope), buyers swap strings for constants.
*/
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>region:focus / region:blur — focused-region indicator</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; }
#c5RegionStrip {
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
transition: background 240ms ease, color 240ms ease;
min-height: 20px;
line-height: 1.5;
}
#c5RegionStrip:empty::before {
content: 'Select an element on the canvas, then focus a padding side.';
color: #9ca3af;
font-style: italic;
}
#c5RegionStrip[data-region^="padding-"] {
background: #e0e7ff;
color: #4338ca;
}
.my-builder-host { display: grid; grid-template-columns: 220px 1fr 280px; min-height: 0; }
#MyWidgets, #MySettings { background: #f8f9fa; padding: 12px; overflow: auto; }
#MyCanvas { overflow: auto; }
</style>
</head>
<body>
<header class="my-header">
<span class="my-header__title">region:focus / region:blur — padding-side indicator</span>
<span id="c5RegionStrip" data-region="" aria-live="polite" aria-label="Currently focused region"></span>
</header>
<div class="my-builder-host">
<div id="MyWidgets"></div>
<div id="MyCanvas"></div>
<div id="MySettings"></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 strip = document.getElementById('c5RegionStrip');
// Two listeners — same payload shape `{ elementUid, namespace, regionKey }`.
// No Builder.EVENTS.* constants today — raw strings only.
builder.events.on('region:focus', ({ elementUid, regionKey }) => {
if (!regionKey) return;
strip.dataset.region = regionKey;
strip.textContent = `Editing: ${regionKey}` + (elementUid ? ` (#${elementUid})` : '');
});
builder.events.on('region:blur', ({ regionKey }) => {
// Only clear if the blurred region matches the current strip —
// Tab between sides fires blur(prev) then focus(next), so the
// focus handler must win the race for a flicker-free swap.
if (strip.dataset.region === regionKey) {
strip.dataset.region = '';
strip.textContent = '';
}
});
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>
</body>
</html>
region:focus / region:blur — focused-region indicator
Subscribe to region:focus / region:blur and surface the currently-focused padding side as a live status strip. Both events fire from the PaddingMarginControl (settings panel) — payload carries { elementUid, namespace, regionKey } so a host can scope autosave or undo behaviour to the active region.
Walkthrough
-
Subscribe via raw strings — these events lack `Builder.EVENTS.*` constants
Like
history:change(see events/3-history-change),region:focusandregion:blurfire reliably fromPaddingMarginControl.jsbut have NO entries inBuilder.EVENTS. Subscribe via raw strings — same swap-when-the-constant-lands path applies.Both events emit on the
builder.eventsbus. Payload shape:{ elementUid, namespace, regionKey }. TheregionKeyis one ofpadding-top,padding-right,padding-bottom,padding-left— the 4 sides of the padding control. Same shape applies to margin controls when those use the same primitive.JavaScript// Two listeners — symmetrical pair, same payload shape. builder.events.on('region:focus', ({ elementUid, regionKey }) => { const strip = document.getElementById('c5RegionStrip'); strip.dataset.region = regionKey; // 'padding-top' etc. strip.textContent = `Editing: ${regionKey} (#${elementUid})`; }); builder.events.on('region:blur', ({ regionKey }) => { const strip = document.getElementById('c5RegionStrip'); // Only clear if the blurred region matches what's currently shown — // a fast Tab between sides fires blur(top) THEN focus(right) and // we don't want the blur to clear the freshly-shown right region. if (strip.dataset.region === regionKey) { strip.dataset.region = ''; strip.textContent = ''; } }); -
Why "region" and not "hover" — the events fire from a control, not the canvas
The W6 plan originally called this a "currently hovering" status strip — but the events do NOT fire on canvas hover. They fire when the buyer FOCUSES one of the four padding-side
<input>s in the settings panel's padding control. That's a much more specific signal than "hover" but a much more useful one — focus implies intent (the buyer is about to type a number), hover only implies attention.Use cases that earn this event: (1) scoped autosave — debounce per-region instead of per-document so a typing burst on padding-top doesn't collide with one on padding-right; (2) active-region indicator — show the buyer which side they're editing on a tablet where the input field might be off-screen; (3) undo-grouping — group all changes to the same region into a single undo entry. The HistoryManager already does (3) internally — see
src/includes/HistoryManager.js"region:focus" subscription for the canonical pattern.JavaScript// Engine-internal pattern (HistoryManager.js): coalesce region edits // into one undo entry. Your code can do the same for autosave scoping. let activeRegion = null; builder.events.on('region:focus', ({ regionKey }) => { activeRegion = regionKey; }); builder.events.on('region:blur', () => { activeRegion = null; }); // Inside your document:changed listener: builder.events.on(Builder.EVENTS.DOCUMENT_CHANGED, () => { // Use the active region as the autosave bucket key — // a typing burst on padding-top stays scoped to that bucket // even if the buyer Tabs to padding-right mid-burst. scheduleAutosave(activeRegion ?? 'global'); }); -
Empty state + Tab handling — the strip should always read as "ready"
The strip reads empty when no region is focused. CSS-only empty state via
:empty::beforewith a placeholder ("Click a padding side in the settings panel below"). This matches the cookbook 5-sidebar-only floating-panel pattern (D13.C.B.5) — empty surfaces in event-driven UI must read as "ready" not "broken."Tab between sides fires
region:blur(currentSide) →region:focus(nextSide) in rapid succession. Without the "only-clear-if-matches" guard in Step 1, the blur handler would wipe the strip just before the focus handler re-paints it — and depending on browser microtask order the strip would flicker or stay empty entirely. The guard makes the transition seamless.CSS.c5-region-strip { font-size: 11px; padding: 3px 12px; border-radius: 999px; background: var(--demo-bg-elevated, #f3f4f6); color: var(--demo-text-secondary, #4b5563); transition: background 240ms ease, color 240ms ease; min-height: 18px; } .c5-region-strip:empty::before { content: 'Click a padding side in the settings panel below.'; color: var(--demo-text-muted, #9ca3af); font-style: italic; } .c5-region-strip[data-region^="padding-"] { background: var(--demo-accent-soft, #e0e7ff); color: var(--demo-accent, #4338ca); }
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.EVENTS.REGION_FOCUS` constant? Same reason as history:change — the events fire reliably from their emit site (PaddingMarginControl.js:219 + :225) but the constants haven't been registered in Builder.EVENTS. A future Phase A.A scope addition (gated by L1.a design-confirm) will land them; until then, the raw strings are canonical. When the constants ship, your subscription is a 2-character substitution per call site.
What about other "region" events? The same primitive emits region:panel-focus + region:panel-blur with a debounced 120 ms blur — fires when ANY part of the padding panel gains/loses focus, not just one of the 4 side inputs. Useful when you want to detect "the user is interacting with the padding control as a whole" without per-side detail. Same payload minus regionKey.
Engine internal — how the HistoryManager uses these. HistoryManager.js subscribes to region:focus + region:blur to coalesce typing-burst commits into a single undo entry per region. So if you type "16" into padding-top and immediately Tab to padding-right and type "20", the undo stack records 2 entries (one per region), not 4 (one per keystroke). Host-side autosave can do the same scoping — see Step 2.
Plan-vs-reality note. The original W6 plan listed this as a "currently hovering" status strip. The events don't fire on canvas hover — they fire on padding-input focus. Walkthrough corrects the misnomer; the use case still holds (active-region indicator), just scoped to padding-side focus. This is the third events-tier row to surface a plan-vs-reality gap (joining C.1 + C.3 + C.4) — discipline now locked into PIPELINE.md as "verify the engine API surface before mechanical implementation." See events/4 notes for the full discipline.