Exclusivobeta

Bloodstain <mv-bloodstain>

Recuperação de rascunhos de formulário que lembra exatamente onde você estava: envolva qualquer <form> (ou região com campos) e os valores são salvos automaticamente (com debounce, em localStorage ou sessionStorage) junto com o último campo em foco e a posição do cursor. Quando a sessão morre (travamento, recarregamento, aba fechada, envio com falha), a montagem seguinte mostra uma pequena marca de gota pulsante nesse mesmo campo (“You left off here · 12 min ago”) e um banner compacto, “Recover your unsaved progress? 7 fields · 12 min ago”; Recover restaura tudo com um toque, dispara input/change para que os frameworks fiquem sincronizados, coloca o foco no campo e devolve o cursor ao lugar. Nada é restaurado automaticamente, e digitar num campo salvo sem recuperar substitui o rascunho antigo de vez (forfeit="never" desativa isso). Senhas, dados de cartão, códigos de uso único, campos ocultos e de arquivo nunca são guardados; um envio bem-sucedido ou clear() apaga o rascunho; os rascunhos expiram após um TTL.

CategoriaFormulários
TipoWeb Component (<mv-bloodstain>)
Statusbeta
KitFormulários que não perdem ninguém
Também instalabutton
Keywordsexclusive, culture, form, draft, autosave, recovery, restore, unsaved-changes, crash, reload, localstorage, session-storage, resume, caret, persistence, form-state

When to use

  • A long form (application, checkout address, support ticket) must survive a crash, a reload or a closed tab without losing what was typed
  • A fetch-based submit can fail and the user should find their input, and their place in it, when they come back
  • Users jump between tabs mid-form and resuming at the exact field and caret saves real time
  • You need a draft that never persists secrets such as passwords, card numbers or one-time codes

Avoid when

  • Drafts must follow the user across devices; save them on the server instead of in browser storage
  • The form is a two-field sign-in or search box where recovery adds more noise than value
  • The page must show what changed while the user was away rather than restore their own input → use While Away instead

Instalação

node scripts/add.mjs bloodstain --out ./src/marvelous

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["bloodstain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/bloodstain/bloodstain.js, components/bloodstain/bloodstain.css, components/button/button.css.

Uso

Início rápido, a menor marcação que funciona:

<mv-bloodstain key="support-reply">
  <form>
    <label>Reply <textarea name="reply"></textarea></label>
    <button>Send</button>
  </form>
</mv-bloodstain>

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

<div id="bs-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #bs-demo .bs-grid { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1.25rem; align-items:start }
    #bs-demo .bs-card { min-width:0; padding:1.25rem 1.375rem 1.375rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #bs-demo .bs-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; margin:0 0 1.125rem }
    #bs-demo .bs-head h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
    #bs-demo .bs-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #bs-demo .bs-saved { flex:none; color:var(--mv-fg-subtle); font-size:.75rem; white-space:nowrap; padding-top:.1875rem }
    #bs-demo .bs-saved[data-on] { color:var(--mv-fg-muted) }
    #bs-demo .bs-saved[data-on]::before { content:""; display:inline-block; width:.4375rem; height:.4375rem; margin-inline-end:.375rem; border-radius:50%; background:var(--mv-success); vertical-align:.0625rem }
    #bs-demo .bs-radios { display:flex; gap:.5rem 1.25rem; flex-wrap:wrap; padding-top:.25rem }
    #bs-demo .bs-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; margin-top:1.25rem; padding-top:1rem; border-top:1px solid var(--mv-border) }
    #bs-demo .bs-foot small { color:var(--mv-fg-subtle); font-size:.75rem }
    #bs-demo .bs-side { display:grid; gap:1rem; position:sticky; top:1rem }
    #bs-demo .bs-panel { padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
    #bs-demo .bs-panel h4 { margin:0 0 .625rem; font-size:.8125rem }
    #bs-demo .bs-steps { margin:0 0 1rem; padding-inline-start:1.125rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5; display:grid; gap:.375rem }
    #bs-demo .bs-steps b { color:var(--mv-fg); font-weight:600 }
    #bs-demo .bs-crash { width:100% }
    #bs-demo .bs-chips { display:flex; flex-wrap:wrap; gap:.375rem; margin:0 0 .75rem; padding:0; list-style:none }
    #bs-demo .bs-chips:empty::before { content:"Nothing saved yet"; color:var(--mv-fg-subtle); font-size:.75rem }
    #bs-demo .bs-label { margin:0 0 .375rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
    #bs-demo .bs-loading { display:grid; place-items:center; min-height:34rem; color:var(--mv-fg-muted); font-size:.875rem }
    #bs-demo .bs-done { display:grid; justify-items:center; gap:.5rem; padding:3rem 1rem; text-align:center }
    #bs-demo .bs-done p { margin:0; color:var(--mv-fg-muted); font-size:.875rem }
    #bs-demo [hidden] { display:none }
    @media (max-width:52rem) {
      #bs-demo .bs-grid { grid-template-columns:minmax(0,1fr) }
      #bs-demo .bs-side { position:static }
    }
  </style>

  <div class="bs-grid">
    <div class="bs-card" id="bs-slot"></div>

    <aside class="bs-side" aria-label="Try it">
      <div class="bs-panel">
        <h4>Try it</h4>
        <ol class="bs-steps">
          <li>The last session crashed mid-sentence. Recover the draft in one click, or start typing in a saved field to lose it for good.</li>
          <li>Edit anything, then <b>Simulate a crash</b>: the form is torn down and loads again, empty.</li>
          <li>Your latest draft is offered back, with a mark on the field you were in.</li>
        </ol>
        <button type="button" class="mv-button bs-crash" id="bs-crash">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/></svg>
          Simulate crash
        </button>
      </div>
      <div class="bs-panel">
        <h4>What the draft stores</h4>
        <p class="bs-label">Saved</p>
        <ul class="bs-chips" id="bs-stored"></ul>
        <p class="bs-label">Never stored</p>
        <ul class="bs-chips">
          <li><span class="mv-badge" data-variant="outline">password</span></li>
          <li><span class="mv-badge" data-variant="outline">applicant ID (hidden)</span></li>
        </ul>
      </div>
    </aside>
  </div>

  <template id="bs-template">
    <mv-bloodstain key="mv-demo:job-application" debounce="300">
      <form id="bs-form" novalidate>
        <div class="bs-head">
          <div>
            <h3>Senior Product Designer</h3>
            <p>Remote, United States · Full time</p>
          </div>
          <span class="bs-saved" id="bs-saved" aria-live="polite">Not saved yet</span>
        </div>
        <input type="hidden" name="applicant_id" value="apl_7Q2K9">
        <div class="mv-field-group">
          <div class="mv-field-row">
            <div class="mv-field">
              <label class="mv-label" for="bs-name" data-required>Full name</label>
              <input class="mv-input" id="bs-name" name="name" autocomplete="name" required>
            </div>
            <div class="mv-field">
              <label class="mv-label" for="bs-email" data-required>Email</label>
              <input class="mv-input" id="bs-email" name="email" type="email" autocomplete="email" required>
            </div>
          </div>
          <div class="mv-field-row">
            <div class="mv-field">
              <label class="mv-label" for="bs-phone" data-optional="optional">Phone</label>
              <input class="mv-input" id="bs-phone" name="phone" type="tel" autocomplete="tel">
            </div>
            <div class="mv-field">
              <label class="mv-label" for="bs-site">Portfolio URL</label>
              <input class="mv-input" id="bs-site" name="portfolio" type="url" placeholder="https://">
            </div>
          </div>
          <div class="mv-field-row">
            <div class="mv-field">
              <label class="mv-label" for="bs-notice">Available to start</label>
              <select class="mv-select" id="bs-notice" name="notice">
                <option value="">Select…</option>
                <option value="now">Immediately</option>
                <option value="2w">In 2 weeks</option>
                <option value="1m">In 1 month</option>
                <option value="2m">In 2 months or more</option>
              </select>
            </div>
            <fieldset class="mv-field" style="border:0;margin:0;padding:0;min-width:0">
              <legend class="mv-label">Work arrangement</legend>
              <div class="bs-radios">
                <label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="remote"> Remote</label>
                <label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="hybrid"> Hybrid</label>
                <label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="onsite"> On-site</label>
              </div>
            </fieldset>
          </div>
          <div class="mv-field">
            <label class="mv-label" for="bs-letter">Why this role?</label>
            <textarea class="mv-textarea" id="bs-letter" name="letter" style="--mv-textarea-min-rows:4" placeholder="A few lines about your work and what draws you here…"></textarea>
          </div>
          <div class="mv-field">
            <label class="mv-label" for="bs-pass">Create a password for your candidate account</label>
            <input class="mv-input" id="bs-pass" name="password" type="password" autocomplete="new-password" aria-describedby="bs-pass-hint">
            <p class="mv-field-description" id="bs-pass-hint">Never saved in the draft: you’ll type it again after a crash.</p>
          </div>
          <label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="updates" id="bs-updates"> Let me know about similar roles</label>
        </div>
        <div class="bs-foot">
          <small>Drafts are kept on this device for 24 hours.</small>
          <button type="submit" class="mv-button" id="bs-submit">Submit application</button>
        </div>
      </form>
    </mv-bloodstain>
  </template>

  <script type="module">
    const root = document.getElementById("bs-demo");
    const slot = document.getElementById("bs-slot");
    const tpl = document.getElementById("bs-template");
    const stored = document.getElementById("bs-stored");
    const KEY = "mv-demo:job-application";
    const LABELS = { name: "name", email: "email", phone: "phone", portfolio: "portfolio", notice: "start date", arrangement: "arrangement", letter: "cover note", updates: "updates" };
    let host = null;
    let savedAt = 0;
    let ticker = 0;

    const readDraft = () => { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } };
    const showStored = () => {
      const d = readDraft();
      stored.replaceChildren(...Object.entries(d?.fields ?? {})
        .filter(([, v]) => v !== "" && v !== null && v !== false && !(Array.isArray(v) && !v.length))
        .map(([k]) => { const li = document.createElement("li"); const b = document.createElement("span"); b.className = "mv-badge"; b.dataset.variant = "secondary"; b.textContent = LABELS[k] ?? k; li.append(b); return li; }));
    };
    const tick = () => {
      const s = host?.querySelector("#bs-saved");
      if (!s || !savedAt) return;
      const sec = Math.round((Date.now() - savedAt) / 1000);
      s.textContent = `Draft saved · ${sec < 5 ? "just now" : sec < 60 ? `${sec}s ago` : `${Math.round(sec / 60)} min ago`}`;
      s.dataset.on = "";
    };

    // Listeners live on the stable slot: component events bubble.
    slot.addEventListener("mv-save", (e) => { savedAt = e.detail.t; tick(); showStored(); });
    slot.addEventListener("mv-stain", () => { const s = host?.querySelector("#bs-saved"); if (s) s.textContent = "Unsaved draft found"; });
    slot.addEventListener("mv-forfeit", () => setTimeout(showStored, 400));
    slot.addEventListener("mv-discard", showStored);
    slot.addEventListener("submit", (e) => {
      e.preventDefault();
      const btn = host.querySelector("#bs-submit");
      btn.setAttribute("aria-busy", "true");
      setTimeout(() => {
        host.clear();
        showStored();
        const done = document.createElement("div");
        done.className = "bs-done";
        const badge = Object.assign(document.createElement("span"), { className: "mv-badge", textContent: "Submitted" });
        badge.dataset.variant = "success";
        const title = Object.assign(document.createElement("strong"), { textContent: "Application sent" });
        const text = Object.assign(document.createElement("p"), { textContent: "The saved draft was wiped. Simulate a crash now: nothing comes back." });
        done.append(badge, title, text);
        host.querySelector("form").replaceWith(done);
      }, 700);
    });

    function mount() {
      slot.replaceChildren(tpl.content.cloneNode(true));
      host = slot.querySelector("mv-bloodstain");
      savedAt = 0;
      showStored();
    }

    // A draft left by a session that died 4 minutes ago, in the shape the component writes
    // (no password: it is never stored). The cover note stops mid-sentence, caret at the end.
    function seedDraft() {
      const letter = "Hi Maya,\n\nI’ve spent six years designing B2B tools, most recently leading the billing redesign at a fintech startup. What draws me to this role is";
      const draft = {
        v: 1,
        t: Date.now() - 4 * 60e3,
        reason: "pagehide",
        last: { name: "letter", index: 0, start: letter.length, end: letter.length },
        fields: {
          name: "Amara Okafor",
          email: "[email protected]",
          phone: "+1 415 555 0142",
          portfolio: "https://amaraokafor.design",
          notice: "1m",
          arrangement: "remote",
          letter,
          updates: true,
        },
      };
      try { localStorage.setItem(KEY, JSON.stringify(draft)); } catch {}
    }

    document.getElementById("bs-crash").addEventListener("click", () => {
      if (!host) return;
      host.remove(); // pending edits are flushed on disconnect, like a real tab closing
      const loading = document.createElement("div");
      loading.className = "bs-loading";
      loading.textContent = "Reloading the page…";
      slot.replaceChildren(loading);
      setTimeout(mount, 650);
    });

    // Opens on a recovered draft. A real reload also works: an existing draft is offered as is.
    if (!readDraft()) seedDraft();
    mount();
    clearInterval(ticker);
    ticker = setInterval(() => { if (!root.isConnected) clearInterval(ticker); else tick(); }, 5000);
  </script>
</div>

Referência cultural

Dark Souls, FromSoftware (Hidetaka Miyazaki) (2011, jogo). Quando você morre, tudo o que carregava cai como uma mancha de sangue exatamente onde você caiu; toque nela para recuperar tudo, mas morra de novo antes de alcançá-la e ela some para sempre. Na interface, um rascunho de formulário fica marcado no campo exato onde a sessão morreu e é restaurado com um toque, e editar sem recuperá-lo o substitui para sempre.

API

Attributes

NameTipoDefaultDescription
keystringmv-bloodstain:<pathname>:<form id | name | action>Storage key of the draft. Set it explicitly (per record, e.g. "ticket-4821") whenever the same form appears on several URLs or several times on a page.
storagelocal | sessionlocallocalStorage (survives closing the browser) or sessionStorage (this tab only, gone when it closes).
debouncenumber (ms)400Delay after the last edit before the draft is written. Pending edits are always flushed when the tab is hidden, on pagehide and when the element is removed.
ttltime ("24h", "30m", "7d", ms number)24hAge after which a draft is dropped instead of being offered.
forfeitfield | any | neverfieldWhen an unrecovered draft is lost for good: field = on the first edit of a field that has a saved value; any = on the first edit anywhere in the form; never = only via Discard, submit, clear() or the TTL. The banner states the rule (“Editing a saved field will discard it.”). While a draft awaits recovery, autosave is paused so it is never overwritten by accident.
data-bloodstain-ignoreboolean (on a field or any ancestor)Excludes fields from the draft, in addition to the built-in exclusions: type password / hidden / file / submit / button / reset / image, and autocomplete tokens cc-*, one-time-code, new-password, current-password. Fields without name or id and disabled fields are skipped too.

Properties

NameTipoDescription
stain{ t, reason, fields, last, keys } | nullThe draft awaiting recovery (read-only): save time, reason ("pagehide", your died() reason, or null), saved values by field name, last position { name, index, start, end } and the names of the fields that differ from the screen.
dirtybooleanTrue when the current values differ from the ones the form started with (read-only).
stringsPartial<Record<string, string>>Overrides for visible texts and announcements (title, meta {count} {fields} {ago}, field, fields, hintField, hintAny, hintNever, recover, discard, marker {ago}, describe {ago}, announceFound, announceRecovered, announceDiscarded, announceForfeit). English defaults.

Methods

NameDescription
recover()Restores the draft: writes the values, dispatches bubbling input and change on each changed control, flashes the restored fields, focuses the field where the user left off and restores the caret/selection. Emits mv-recover. Returns false when there is nothing to recover.
discard()Drops the draft without restoring it. Emits mv-discard.
clear()Wipes the saved draft and takes the current values as the new baseline. Call it after a successful fetch-based submit (a native submit that nobody prevented does it automatically).
died(reason)Tells the component the session just died (failed request, error boundary…): flushes the draft now, tagged with reason. Called automatically on pagehide when there are unsaved edits. Emits mv-died; returns true if something was saved.
save()Writes the draft immediately, skipping the debounce.

Events

NameDescription
mv-saveThe draft was written. detail: { count, t, reason } (count = fields that differ from the initial values). Handy for a “Draft saved” indicator.
mv-stainA draft that differs from the screen was found on mount; the banner and mark are shown. detail: { count, fields, t, reason }.
mv-dieddied() was called (or the page was hidden for good with unsaved edits). detail: { reason, saved, count }.
mv-recoverThe draft was restored. detail: { fields, count, element, reason } (element = the field that received focus).
mv-forfeitCancelable. The user edited the form without recovering and the old draft is about to be replaced. detail: { fields, reason, field }. preventDefault() keeps the draft.
mv-discardThe user chose Discard. detail: { fields, reason }.

Content structure

NameDescription
(children)A <form> or any region containing inputs, textareas and selects. Controls are identified by name (or id); radio groups, checkbox groups, multiple selects and repeated names are supported.

CSS classes

NameDescription
mv-bloodstain-bannerRecovery banner inserted at the top of the element: -icon, -copy (-title, -sub with -meta and -hint), -actions (Discard, Recover).
mv-bloodstain-markMark pinned to the top inline-end corner of the field where the user left off (host-relative, scrolls with the form): -drop (teardrop with pulse ring) and -mark-label. Clicking it recovers.
[data-bloodstain-restored]Set for 1.6 s on each restored control: fading outline (the focus ring stays visible).

CSS variables

NameDefaultDescription
--mv-bloodstain-colorvar(--mv-accent)Color of the mark, the banner tint and the restored-field outline.

Accessibility

Nothing is ever restored behind the user’s back. When a draft is found, a polite live region announces it once (“Unsaved progress from 12 minutes ago found: 7 fields. Recover or discard it from the banner at the top of the form.”). The banner is a labelled region placed before the form in the DOM, with two real <button>s (Discard, Recover) reachable with Tab. The visual mark is aria-hidden; instead, the field where the user left off gets an aria-describedby pointing to a hidden description (“You left off here 12 minutes ago…”) so screen reader users hear it when they reach that field, and the reference is removed once the draft is resolved. Recover moves focus to that field, restores the caret or selection and announces “Progress recovered: 7 fields restored.”; Discard announces and moves focus to the first field; a forfeit is announced too. The rule for losing the draft is written in the banner, not only implied. Restored values dispatch real input/change events so framework state and validation stay in sync. Reduced motion (OS or data-motion="reduce"): no pulse, no landing or banner animation; the mark keeps a static halo and restored fields a static outline for 1.6 s. The mark is also clickable with a pointer or touch (a shortcut; the banner stays the accessible path).

Esta página foi traduzida com IA. Informar um problema de tradução