Events · 4 of 5

Save click — custom button + 4-state status pipeline

Wire any host-side button to `builder.save()` and surface the full saving lifecycle (idle → saving → saved → error → retry). No engine `save:click` event — the pattern is imperative: button click → save Promise → state pill update.

Walkthrough

  1. Wire your button to `builder.save()` — there is no save:click event

    BuilderJS does NOT emit a save:click event. Save is buyer-driven: any host-side <button> can call builder.save(), which returns a Promise that resolves with the server's JSON response. The button lives anywhere in your page — top app-bar, side panel, modal footer, mobile sheet — outside the engine chrome.

    Pass saveUrl to the constructor; builder.save() POSTs html + data as URL-encoded body and returns the response JSON. For richer payloads (slug, dir, user-id, CSRF token) hand-roll the fetch instead — see 2-hello-world Step 1 for both patterns.

    JavaScript
    const builder = new Builder({
      mainContainer:     '#MyCanvas',
      widgetsContainer:  '#MyWidgets',
      settingsContainer: '#MySettings',
      saveUrl:           '/backend/save.php',  // engine.save() POSTs here
    });
    
    // External button — your HTML, your styling, your placement.
    document.getElementById('c4SaveBtn').addEventListener('click', async () => {
      // builder.save() returns Promise<JSON>. Throws if saveUrl is unset
      // or if fetch rejects (network down).
      const result = await builder.save();
      console.log('saved', result);
    });
  2. Surface the lifecycle: idle → saving → saved → error

    A naked await builder.save() hides the network round-trip from the buyer. The polished pattern surfaces FOUR states on a status pill: idle (default), saving (in flight, with spinner or animated dot), saved (success — fades to idle after 2s), error (failure — sticky until next save). The state machine lives in your handler; the pill renders the current value.

    Why sticky on error? Failure must be impossible to miss. Auto-clearing an error means a buyer can drift away thinking their work is safe when it isn't. Make them click Retry. (The cookbook 4-rich-3col-header pattern follows the same convention for its in-wrapper Save.)

    JavaScript
    const states = {
      idle:   { label: 'Save',          tone: '',        disabled: false },
      saving: { label: 'Saving…',       tone: 'saving',  disabled: true  },
      saved:  { label: 'Saved ✓',          tone: 'saved',   disabled: false },
      error:  { label: 'Retry save',    tone: 'error',   disabled: false },
    };
    
    function setState(name) {
      const { label, tone, disabled } = states[name];
      const btn  = document.getElementById('c4SaveBtn');
      const pill = document.getElementById('c4SaveStatus');
      btn.textContent = label;
      btn.disabled    = disabled;
      pill.dataset.state = name;
      pill.textContent   = name === 'idle'   ? ''
                         : name === 'saving' ? 'Saving your changes…'
                         : name === 'saved'  ? 'All changes saved'
                                              : 'Save failed — click Retry';
    }
  3. The async flow: pre-state, await, post-state, recover

    Wrap the save call in try/catch. On success, flip to saved, then schedule a idle reset after 2 seconds. On error, flip to error with no auto-recovery — buyer clicks Retry to re-enter the cycle. Track the in-flight save with a closure-scoped flag so a second click during saving is ignored (prevents double-POST).

    For a "save on every change" autosave, swap the click handler for a document:changed listener — see events/1-document-changed. The state-pipeline shape transfers identically; only the trigger differs.

    JavaScript
    let inFlight = false;
    let lastSavedAt = 0;
    
    async function handleSave() {
      if (inFlight) return;
      inFlight = true;
      setState('saving');
      try {
        const result = await builder.save();
        if (result.status !== 'success') throw new Error(result.message || 'Save failed');
        lastSavedAt = Date.now();
        setState('saved');
        // Auto-fade to idle after 2 seconds — pure visual polish.
        setTimeout(() => {
          // Only fade if no further save has happened since.
          if (Date.now() - lastSavedAt >= 2000) setState('idle');
        }, 2000);
      } catch (err) {
        console.error('save failed', err);
        setState('error');
      } finally {
        inFlight = false;
      }
    }
    
    document.getElementById('c4SaveBtn').addEventListener('click', handleSave);

Live demo

demo-mini-builder--rich-3col-header
save custom button — 4-state pipeline

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 — external Save button + 4-state status pipeline.
 *
 * Drop alongside `dist/` + `themes/default/` + `backend/save.php`,
 * run a PHP server. Click Save → pill flips to "Saving…" → server
 * round-trip → pill flips to "Saved ✓" → fades back to idle after 2s.
 * Force an error (offline, kill the PHP server, etc) and the pill
 * sticks to "Save failed — click Retry" until the buyer retries.
 *
 * IDs `c4SaveBtn` / `c4SaveStatus` match the live demo on
 * /examples/events/4-save-click-custom-button/ AND Steps 1-3 of the
 * walkthrough (BUILDER.md RULE H, D13.B.parity).
 *
 * NOTE: there is NO `save:click` event on the engine. Save is a
 * host-side intent — any button calls `builder.save()` (or a
 * hand-rolled fetch when the backend needs richer payload like
 * `dir` + `sample`). The "event" framing in the original W6 plan
 * was a misnomer — see W6_EXAMPLES_BIG.md C.4 row + DISCOVERIES
 * D13.C.4 for the pivot rationale.
 */
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>Save click — custom button + 4-state status</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: 10px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }

        #c4SaveStatus {
            font-size: 12px; padding: 4px 12px; border-radius: 999px;
            background: transparent; color: #6b7280;
            transition: background 240ms ease, color 240ms ease;
            min-height: 20px;
        }
        #c4SaveStatus[data-state="saving"] { background: rgba(245, 158, 11, 0.12); color: #b45309; }
        #c4SaveStatus[data-state="saved"]  { background: rgba(16, 185, 129, 0.12); color: #047857; }
        #c4SaveStatus[data-state="error"]  { background: rgba(220, 38, 38, 0.10); color: #b91c1c; }

        @keyframes c4-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
        #c4SaveStatus[data-state="saving"]::before {
            content: ''; display: inline-block; width: 6px; height: 6px;
            border-radius: 50%; background: currentColor;
            margin-right: 6px; vertical-align: middle;
            animation: c4-pulse 900ms ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
            #c4SaveStatus[data-state="saving"]::before { animation: none; }
        }

        #c4SaveBtn {
            padding: 6px 14px; background: #111827; color: #fff;
            border: 1px solid #111827; border-radius: 6px;
            cursor: pointer; font: inherit; font-size: 13px; font-weight: 600;
            transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
        }
        #c4SaveBtn:hover:not(:disabled) { background: #1f2937; }
        #c4SaveBtn:disabled              { opacity: 0.6; cursor: not-allowed; }
        #c4SaveBtn[data-state="saved"]   { background: #10b981; border-color: #10b981; }
        #c4SaveBtn[data-state="error"]   { background: #fff; color: #b91c1c; border-color: #dc2626; }
        #c4SaveBtn[data-state="error"]:hover:not(:disabled) { background: #fef2f2; }

        .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">save custom button — 4-state pipeline</span>
    <span id="c4SaveStatus" data-state="idle" aria-live="polite"></span>
    <button type="button" id="c4SaveBtn" data-state="idle">Save</button>
</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',
        // Optional — only needed if you'll call the engine's built-in
        // `builder.save()` (POSTs html + data only). For richer payloads,
        // hand-roll the fetch as below — your backend likely needs more keys.
        saveUrl: '/backend/save.php',
    });

    const btn  = document.getElementById('c4SaveBtn');
    const pill = document.getElementById('c4SaveStatus');

    const states = {
        idle:   { label: 'Save',          disabled: false, message: '' },
        saving: { label: 'Saving…',       disabled: true,  message: 'Saving your changes…' },
        saved:  { label: 'Saved ✓',          disabled: false, message: 'All changes saved' },
        error:  { label: 'Retry save',    disabled: false, message: 'Save failed — click Retry' },
    };

    function setState(name) {
        const { label, disabled, message } = states[name];
        btn.textContent  = label;
        btn.disabled     = disabled;
        btn.dataset.state  = name;
        pill.dataset.state = name;
        pill.textContent   = message;
    }
    setState('idle');

    let inFlight = false;
    let lastSavedAt = 0;

    // Hand-rolled fetch — adds dir + sample so /backend/save.php knows
    // where to write. builder.save() would only POST html + data;
    // for a real backend you almost always need more keys.
    async function persist() {
        const fd = new FormData();
        fd.append('dir',    '_c4_demo');
        fd.append('sample', 'sample/SaveCustomButton');
        fd.append('html',   builder.getHtml());
        fd.append('data',   JSON.stringify(builder.getData()));
        const res = await fetch('/backend/save.php', { method: 'POST', body: fd });
        return res.json();
    }

    async function handleSave() {
        if (inFlight) return;          // double-POST guard
        inFlight = true;
        setState('saving');
        try {
            const result = await persist();
            if (!result || result.status !== 'success') {
                throw new Error((result && result.message) || 'Save failed');
            }
            lastSavedAt = Date.now();
            setState('saved');
            // Auto-fade ONLY on success — error state is sticky until retry.
            setTimeout(() => {
                if (Date.now() - lastSavedAt >= 2000) setState('idle');
            }, 2000);
        } catch (err) {
            console.warn('save failed', err);
            setState('error');
        } finally {
            inFlight = false;
        }
    }

    btn.addEventListener('click', handleSave);

    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>

</body>
</html>

Notes

Why imperative, not event-driven? Save is a host-side intent — the buyer decides WHEN to persist (a button click, a keyboard chord, a debounced timer, a route-change blocker). The engine emits document:changed on every mutation but does NOT emit save:click — that name was always a host-side concept, never an engine event. builder.save() is the imperative entry point; wire it however your UX dictates.

Sticky-error UX rule. The error state does NOT auto-fade. A naked spinner that disappears + a pill that says "Saved ✓" + a server that 500'd silently = a buyer thinking their work is safe when it isn't. Make failure impossible to miss: red pill, red button border, "Retry save" label. Buyer clicks Retry, the cycle re-enters at saving. The 2-second auto-fade only applies to the success state.

Double-POST guard. A second click during saving is silently ignored (the closure-scoped inFlight flag). Without this, a flaky network user mashes the button and the server sees N concurrent POSTs for the same edit — a race condition waiting to corrupt your DB. inFlight is the cheapest debounce that actually matters.

Compose with autosave. Wire BOTH a click handler AND a document:changed listener for the canonical SaaS UX: instant manual save (button) PLUS soft autosave (every 1.5s of inactivity). Both call handleSave(); the inFlight flag handles overlap. events/1-document-changed covers the autosave debounce; this example covers the manual button. Together they're the full pattern.

Production checklist. Add CSRF tokens to the POST body (the validator's regex won't catch a missing token). Rate-limit per dir+sample pair. Route auth through your session layer. For ungraceful disconnects (laptop closed mid-save), persist a draft to localStorage on every document:changed and reconcile on next mount.