Events · 5 of 5

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

  1. Subscribe via raw strings — these events lack `Builder.EVENTS.*` constants

    Like history:change (see events/3-history-change), region:focus and region:blur fire reliably from PaddingMarginControl.js but have NO entries in Builder.EVENTS. Subscribe via raw strings — same swap-when-the-constant-lands path applies.

    Both events emit on the builder.events bus. Payload shape: { elementUid, namespace, regionKey }. The regionKey is one of padding-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    = '';
      }
    });
  2. 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');
    });
  3. 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::before with 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);
    }

Live demo

demo-mini-builder--rich-3col-header
region:focus / region:blur — padding-side indicator

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.

<?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>

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.