Exclusifbêta

Rewind <mv-rewind>

Une machine à remonter le temps pour les formulaires : englobe n’importe quelle région, enregistre chaque modification (saisie groupée, cases à cocher, selects, contenteditable) dans un arbre d’annulation étiqueté (« Adresse modifiée », « Mode de livraison : Express »), affiché sous forme de frise compacte. Glisser le long de la frise rembobine les champs en direct, avec un halo fantôme sur tout ce qui diffère de l’état actuel et la valeur actuelle en étiquette ; « Restaurer cet état » valide, et les versions abandonnées restent accessibles sous forme de branches. Ctrl/⌘ Z et Maj Z, persistance de session facultative.

CatégorieFormulaires
TypeWeb Component (<mv-rewind>)
Statutbêta
KitDes formulaires qui ne perdent personne
Keywordsexclusive, form, undo, redo, history, time-travel, undo-tree, branching, scrubber, snapshot, restore, checkout, draft

When to use

  • A long form, checkout or draft editor should let users undo and browse past states safely
  • Users need to recover a value they changed earlier without losing their current edits
  • Form edits should survive a reload through session persistence

Avoid when

  • The form is short enough that native field undo is sufficient
  • The need is scrubbing through media time rather than form history → use Timeline Scrubber instead

Installation

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

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["rewind"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/rewind/rewind.js, components/rewind/rewind.css.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-rewind>
  <form>
    <label>Address <input name="address" value="12 Main St"></label>
    <label>Shipping <select name="shipping"><option>Standard</option><option>Express</option></select></label>
  </form>
</mv-rewind>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

<div id="rw-demo" style="width:min(100%,44rem);margin-inline:auto">
  <style>
    #rw-demo .rw-title { display:flex; align-items:baseline; justify-content:space-between; gap:1rem; margin:0 0 .25rem }
    #rw-demo .rw-title h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #rw-demo .rw-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    #rw-demo form { display:grid; gap:1rem; padding:1.125rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #rw-demo .rw-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:.75rem 1rem }
    #rw-demo .rw-grid > * { grid-column:span 6 }
    @media (min-width:34rem) {
      #rw-demo .rw-grid > .c3 { grid-column:span 3 } #rw-demo .rw-grid > .c2 { grid-column:span 2 } #rw-demo .rw-grid > .c4 { grid-column:span 4 }
    }
    #rw-demo fieldset { margin:0; padding:0; border:0; min-width:0 }
    #rw-demo legend { padding:0; margin-bottom:.6rem; font-size:.75rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--mv-fg-muted) }
    #rw-demo .rw-ship { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem }
    #rw-demo .rw-foot { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; padding-top:.25rem }
    #rw-demo .rw-foot strong { font-size:1.125rem; font-variant-numeric:tabular-nums }
    #rw-demo .rw-hint { margin:.6rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
  </style>

  <div class="rw-title"><h3>Complete your order</h3><span>Nomad Studio · 2 items</span></div>

  <mv-rewind id="rw-checkout" label="Rewind" placement="top">
    <form onsubmit="event.preventDefault()" aria-label="Order">
      <fieldset>
        <legend>Shipping</legend>
        <div class="rw-grid">
          <div class="mv-field c3"><label class="mv-label" for="rw-first">First name</label><input class="mv-input" id="rw-first" name="first_name" autocomplete="given-name"></div>
          <div class="mv-field c3"><label class="mv-label" for="rw-last">Last name</label><input class="mv-input" id="rw-last" name="last_name" autocomplete="family-name"></div>
          <div class="mv-field"><label class="mv-label" for="rw-street">Address</label><input class="mv-input" id="rw-street" name="address" autocomplete="street-address" data-rewind-label="Address changed"></div>
          <div class="mv-field c2"><label class="mv-label" for="rw-zip">ZIP code</label><input class="mv-input" id="rw-zip" name="zip" inputmode="numeric" autocomplete="postal-code"></div>
          <div class="mv-field c4"><label class="mv-label" for="rw-city">City</label><input class="mv-input" id="rw-city" name="city" autocomplete="address-level2"></div>
        </div>
      </fieldset>

      <fieldset>
        <legend>Shipping method</legend>
        <div class="rw-ship">
          <label class="mv-choice-card"><input type="radio" class="mv-radio" name="shipping" value="standard"><span class="mv-choice-text"><span class="mv-choice-title">Standard</span><span class="mv-choice-description">3-5 days · free</span></span></label>
          <label class="mv-choice-card"><input type="radio" class="mv-radio" name="shipping" value="express"><span class="mv-choice-text"><span class="mv-choice-title">Express</span><span class="mv-choice-description">Tomorrow by 1 p.m. · $9.90</span></span></label>
          <label class="mv-choice-card"><input type="radio" class="mv-radio" name="shipping" value="pickup"><span class="mv-choice-text"><span class="mv-choice-title">Pickup point</span><span class="mv-choice-description">48 hours · $3.90</span></span></label>
        </div>
      </fieldset>

      <div class="rw-grid">
        <label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="gift"><span class="mv-choice-text"><span class="mv-choice-title">Gift wrap</span><span class="mv-choice-description">Recycled paper and a handwritten card · $4</span></span></label>
        <div class="mv-field"><label class="mv-label" for="rw-msg">Card message</label><textarea class="mv-textarea" id="rw-msg" name="message" rows="2"></textarea></div>
      </div>

      <div class="rw-foot">
        <span>Total <strong id="rw-total">$129.90</strong></span>
        <button class="mv-button" type="submit">Pay now</button>
      </div>
    </form>
  </mv-rewind>
  <p class="rw-hint">Drag along the timeline to see the form as it was · <kbd class="mv-kbd">Ctrl</kbd> <kbd class="mv-kbd">Z</kbd> undoes, <kbd class="mv-kbd">Ctrl</kbd> <kbd class="mv-kbd">Shift</kbd> <kbd class="mv-kbd">Z</kbd> redoes</p>

  <script type="module">
    const rw = document.getElementById("rw-checkout");
    await customElements.whenDefined("mv-rewind");
    const f = rw.querySelector("form");
    const set = (name, v) => {
      const els = f.elements[name];
      if (els instanceof RadioNodeList) for (const r of els) r.checked = r.value === v;
      else if (els.type === "checkbox") els.checked = v;
      else els.value = v;
    };
    const min = (m) => Date.now() - m * 60000;
    const total = () => {
      const ship = { standard: 0, express: 9.9, pickup: 3.9 }[f.elements.shipping.value] ?? 0;
      const t = 129.9 + ship + (f.elements.gift.checked ? 4 : 0);
      document.getElementById("rw-total").textContent = t.toLocaleString("en-US", { style: "currency", currency: "USD" });
    };
    f.addEventListener("input", total);
    f.addEventListener("change", total);

    // A realistic past session, so the timeline has a story on first sight.
    [["first_name", "Emma"], ["last_name", "Nakamura"], ["address", "12 Maple Street"], ["zip", "11201"], ["city", "Brooklyn"], ["shipping", "standard"]].forEach(([n, v]) => set(n, v));
    rw.clear({ time: min(19) });
    const step = (name, v, m) => { set(name, v); rw.snapshot({ time: min(m) }); };
    step("shipping", "express", 17);
    step("address", "48 Atlantic Avenue", 15);
    step("zip", "11217", 14);
    step("gift", true, 9);
    step("message", "Happy birthday, Paul! Love, Emma", 8);
    step("shipping", "pickup", 5);
    step("address", "5 Court Street", 4);
    rw.undo(); rw.undo();                       // changed her mind…
    step("shipping", "standard", 1);            // …a new branch starts here
    total();

    rw.addEventListener("mv-restore", (e) => console.info("mv-restore", e.detail.reason, e.detail.label));
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
labelstringHistoryTitle shown in the bar.
placementbottom | topbottomPosition of the bar relative to the wrapped content.
debouncenumber (ms)700Quiet time before typing is recorded (checkboxes, radio buttons and selects are recorded immediately; leaving a field forces a record).
mergenumber (ms)4000Successive edits to the same field within this window form a single entry; going back to the previous value removes the entry.
maxnumber120Maximum number of entries kept (the oldest are pruned).
keyboardregion | document | offregionCtrl/⌘ Z undoes, Ctrl/⌘ Shift Z or Ctrl Y redoes: when focus is inside the region, anywhere on the page (except other fields), or never. Inside the region, the component’s history replaces native per-field undo.
persiststringKeeps the history in sessionStorage under this key (empty value: element id or page path) and replays it on reload.
initial-labelstringInitial stateLabel of the first entry.
data-rewind-labelstringOn a field or a group (fieldset…): fixed entry label, “{value}” is replaced with the new value. Otherwise: “Label: “value””.
data-rewind-namestringOn a field: short name used in automatic labels.
data-rewind-ignorebooleanOn a field or a container: never recorded.

Properties

NameTypeDescription
history{ label, time, current }[]Entries on the displayed line (read-only).
previewingbooleanTrue while previewing the past.

Methods

NameDescription
undo() / redo()Steps back / forward one entry (values written with input and change events).
preview(index)Previews entry number index (0 = the oldest), without committing anything.
restore(index?)Commits the current preview (or entry index).
cancel()Leaves the preview and returns to the present.
snapshot({ label?, time? })Records an entry now (time lets you rebuild an existing history).
clear({ label?, time? })Forgets the history and starts again from the current state.

Events

NameDescription
mv-snapshotNew entry. detail: { label, count }.
mv-previewAn entry is previewed. detail: { label, time, differences: string[] }.
mv-restoreA past state became the current state. detail: { reason: "restore" | "undo" | "redo", label, time, state }.

Content structure

NameDescription
(content)Any region: form, fieldset, contenteditable area. Fields added later are picked up.

CSS classes

NameDescription
mv-rewind-barGenerated bar: title, timeline, undo / redo, preview tray.
mv-rewind-track / -tick / -thumb / -branchTimeline (role slider), entry ticks, thumb and branches for abandoned versions.
mv-rewind-halo / -nowGhost halo set on a field that differs from the present, and its “Current” tag.

CSS variables

NameDefaultDescription
--mv-rewind-accentvar(--mv-accent)Color of the present (fill, thumb).
--mv-rewind-pastvar(--mv-warning)Color of the past: halos, preview, the portion that would be undone.
--mv-rewind-bgBar background.

Accessibility

The timeline is a role="slider": arrows, Home/End and Page Up/Down browse the history in preview, Enter restores, Escape returns to the present; aria-valuetext gives the label and age (“Address changed, 15 minutes ago”). During a preview, the region is inert (nothing can be changed by mistake) and a live region announces how many fields differ; restore, undo and redo are announced too. Branches are real, labeled buttons. Restored values go through native setters and fire input/change (React, Vue… stay in sync). Passwords, credit card fields (autocomplete cc-*), one-time codes and files are never recorded. Reduced motion: no thumb glide or field shake. No loops: everything is event-driven.

Cette page a été traduite par IA. Signaler un problème de traduction