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égorie | Formulaires |
|---|---|
| Type | Web Component (<mv-rewind>) |
| Statut | bêta |
| Kit | Des formulaires qui ne perdent personne |
| Keywords | exclusive, 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/marvelousAgent 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
| Name | Type | Default | Description |
|---|---|---|---|
label | string | History | Title shown in the bar. |
placement | bottom | top | bottom | Position of the bar relative to the wrapped content. |
debounce | number (ms) | 700 | Quiet time before typing is recorded (checkboxes, radio buttons and selects are recorded immediately; leaving a field forces a record). |
merge | number (ms) | 4000 | Successive edits to the same field within this window form a single entry; going back to the previous value removes the entry. |
max | number | 120 | Maximum number of entries kept (the oldest are pruned). |
keyboard | region | document | off | region | Ctrl/⌘ 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. |
persist | string | Keeps the history in sessionStorage under this key (empty value: element id or page path) and replays it on reload. | |
initial-label | string | Initial state | Label of the first entry. |
data-rewind-label | string | On a field or a group (fieldset…): fixed entry label, “{value}” is replaced with the new value. Otherwise: “Label: “value””. | |
data-rewind-name | string | On a field: short name used in automatic labels. | |
data-rewind-ignore | boolean | On a field or a container: never recorded. |
Properties
| Name | Type | Description |
|---|---|---|
history | { label, time, current }[] | Entries on the displayed line (read-only). |
previewing | boolean | True while previewing the past. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-snapshot | New entry. detail: { label, count }. |
mv-preview | An entry is previewed. detail: { label, time, differences: string[] }. |
mv-restore | A past state became the current state. detail: { reason: "restore" | "undo" | "redo", label, time, state }. |
Content structure
| Name | Description |
|---|---|
(content) | Any region: form, fieldset, contenteditable area. Fields added later are picked up. |
CSS classes
| Name | Description |
|---|---|
mv-rewind-bar | Generated bar: title, timeline, undo / redo, preview tray. |
mv-rewind-track / -tick / -thumb / -branch | Timeline (role slider), entry ticks, thumb and branches for abandoned versions. |
mv-rewind-halo / -now | Ghost halo set on a field that differs from the present, and its “Current” tag. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-rewind-accent | var(--mv-accent) | Color of the present (fill, thumb). |
--mv-rewind-past | var(--mv-warning) | Color of the past: halos, preview, the portion that would be undone. |
--mv-rewind-bg | Bar 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.