Save bar
A sticky bottom bar that shows when a form is edited and hides when the values match again. Try editing the forms below.
The bar
A message on the left, buttons on the right. Discard is a
type="reset" button that restores the original values
natively. Save is type="submit". Both point at the
form with form="id", so the bar can sit outside the
form.
<div class="save-bar" role="region" aria-label="Unsaved changes">
<p>Unsaved changes</p>
<div>
<button type="button" class="btn" data-variant="ghost">Discard</button>
<button type="button" class="btn" data-variant="primary">Save</button>
</div>
</div>
The bar names its form with data-form
data-form is the id of the form the bar watches.
Place the bar after the form and start it with
hidden. The examples on this page use
method="dialog" so saving does not reload the page.
<div class="stack">
<form id="sb-form" class="stack docs-narrow" method="dialog">
<div class="field">
<label for="sb-title">Product title</label>
<input id="sb-title" name="title" type="text" value="T-shirt" />
</div>
<div class="field">
<input id="sb-visible" name="visible" type="checkbox" />
<label for="sb-visible">Visible in store</label>
</div>
</form>
<div class="save-bar" data-form="sb-form" role="region" aria-label="Unsaved changes" hidden>
<p>Unsaved changes</p>
<div>
<button type="reset" form="sb-form" class="btn" data-variant="ghost">Discard</button>
<button type="submit" form="sb-form" class="btn" data-variant="primary">Save</button>
</div>
</div>
</div>
Shows on change, hides when undone, stays at the bottom
The bar uses position: sticky; bottom: 0, so it
sticks to the bottom of its scroll container (or the viewport).
Edit the title and the bar appears. Type "Tote bag" back and it
goes away.
<div class="scroll-area" tabindex="0" aria-label="Product form" style="height: 18rem">
<form id="sb-scroll" class="stack" method="dialog">
<div class="field">
<label for="sb-scroll-title">Product title</label>
<input id="sb-scroll-title" name="title" type="text" value="Tote bag" aria-describedby="sb-scroll-hint" />
<small id="sb-scroll-hint">Change it, then type it back</small>
</div>
<div class="field">
<label for="sb-scroll-meta">Meta description</label>
<textarea id="sb-scroll-meta" name="meta" rows="12"></textarea>
</div>
</form>
<div class="save-bar" data-form="sb-scroll" role="region" aria-label="Unsaved changes" hidden>
<p>Unsaved changes</p>
<div>
<button type="reset" form="sb-scroll" class="btn" data-variant="ghost">Discard</button>
<button type="submit" form="sb-scroll" class="btn" data-variant="primary">Save</button>
</div>
</div>
</div>
Every kind of field is compared
Checkboxes, radios and switches compare their checked state. Selects compare the selected options (multiple too). File inputs compare the chosen files. Other fields compare their value (sliders too). The original value is the one written in the HTML.
<div class="stack">
<form id="sb-kinds" class="field-group" method="dialog">
<div>
<div class="field">
<input id="sb-kinds-public" name="public" type="checkbox" role="switch" checked />
<label for="sb-kinds-public">Is public</label>
</div>
<div class="field">
<input id="sb-kinds-tax" name="tax" type="checkbox" />
<label for="sb-kinds-tax">Includes tax</label>
</div>
</div>
<div>
<div class="field">
<label for="sb-kinds-status">Status</label>
<select id="sb-kinds-status" name="status">
<option>Draft</option>
<option selected>Active</option>
<option>Archived</option>
</select>
</div>
<div class="field">
<label for="sb-kinds-colour">Colour</label>
<select id="sb-kinds-colour" name="colour">
<option value="blue" selected>Blue</option>
<option value="white">White</option>
<option value="grey">Grey</option>
</select>
</div>
</div>
<div>
<div class="field">
<label for="sb-kinds-file">Size chart</label>
<input id="sb-kinds-file" name="chart" type="file" />
</div>
<div class="field">
<label for="sb-kinds-stock">Min stock: <output for="sb-kinds-stock">10</output></label>
<input id="sb-kinds-stock" name="stock" class="slider" type="range" min="0" max="50" value="10" />
</div>
</div>
<fieldset class="field-group" data-gap="sm">
<legend>Shipping</legend>
<div class="field">
<input id="sb-kinds-std" name="ship" type="radio" value="std" checked />
<label for="sb-kinds-std">Standard</label>
</div>
<div class="field">
<input id="sb-kinds-exp" name="ship" type="radio" value="exp" />
<label for="sb-kinds-exp">Express</label>
</div>
</fieldset>
</form>
<div class="save-bar" data-form="sb-kinds" role="region" aria-label="Unsaved changes" hidden>
<p>Unsaved changes</p>
<div>
<button type="reset" form="sb-kinds" class="btn" data-variant="ghost">Discard</button>
<button type="submit" form="sb-kinds" class="btn" data-variant="primary">Save</button>
</div>
</div>
</div>
A bar without hidden stays up
Use this for a new record that has never been saved, or after a
failed save. The script still sets data-dirty but
never hides the bar.
<div class="stack">
<form id="sb-new" class="stack docs-narrow" method="dialog">
<div class="field">
<label for="sb-new-title">Product title</label>
<input id="sb-new-title" name="title" type="text" placeholder="New product" />
</div>
</form>
<div class="save-bar" data-form="sb-new" role="region" aria-label="Unsaved changes">
<p>Unsaved changes</p>
<div>
<button type="reset" form="sb-new" class="btn" data-variant="ghost">Discard</button>
<button type="submit" form="sb-new" class="btn" data-variant="primary">Save</button>
</div>
</div>
</div>
data-warn-on-leave asks before leaving with unsaved changes
Edit this field, then reload or follow a link. The browser asks first (beforeunload). Discard, Save and links inside the bar do not ask.
<div class="stack">
<form id="sb-warn" class="stack docs-narrow" method="dialog">
<div class="field">
<label for="sb-warn-title">Product title</label>
<input id="sb-warn-title" name="title" type="text" value="Timeless Watch" />
</div>
</form>
<div class="save-bar" data-form="sb-warn" data-warn-on-leave role="region" aria-label="Unsaved changes" hidden>
<p>Unsaved changes</p>
<div>
<button type="reset" form="sb-warn" class="btn" data-variant="ghost">Discard</button>
<button type="submit" form="sb-warn" class="btn" data-variant="primary">Save</button>
</div>
</div>
</div>
Events and markClean() after saving without a page load
The form gets tsunami:dirty when it starts to differ
from the saved values and tsunami:clean when it
matches again. After saving with fetch, call
tsunami.saveBar.markClean(form) to make the current
values the saved ones.
<div class="stack docs-narrow">
<form id="sb-events" method="dialog">
<div class="field">
<label for="sb-events-title">Product title</label>
<input id="sb-events-title" name="title" type="text" value="Bollard Lamp" />
</div>
</form>
<div class="stack" data-direction="row" data-gap="sm">
<button type="button" class="btn" data-size="sm" id="sb-events-clean">markClean()</button>
<small id="sb-events-result" aria-live="polite">No changes</small>
</div>
</div>
<div class="save-bar" data-form="sb-events" role="region" aria-label="Unsaved changes" hidden>
<p>Unsaved changes</p>
<div>
<button type="reset" form="sb-events" class="btn" data-variant="ghost">Discard</button>
<button type="submit" form="sb-events" class="btn" data-variant="primary">Save</button>
</div>
</div>
<script>
{
const form = document.getElementById("sb-events");
const result = document.getElementById("sb-events-result");
form.addEventListener("tsunami:dirty", () => {
result.textContent = "dirty: unsaved changes";
});
form.addEventListener("tsunami:clean", () => {
result.textContent = "clean: matches the saved values";
});
document.getElementById("sb-events-clean").addEventListener("click", () => {
window.tsunami.saveBar.markClean(form);
});
}
</script>
API
js/save-bar.js
also works with bars and forms added later. No init call needed.
| Function | Arguments | Description |
|---|---|---|
tsunami.
|
target: .save-bar or form |
Compares the form now, shows or hides the bar, sets
data-dirty and fires an event if the state changed.
Returns true if there are changes. Call it
after a script changes a field without firing
input/change.
|
tsunami.
|
target: .save-bar or form |
Makes the current values the saved ones (updates defaultValue / defaultChecked / defaultSelected, so reset keeps working), then hides the bar. |
| Event | On | Detail |
|---|---|---|
tsunami:dirty |
form (bubbles) |
{ form, bars }
Fired when the form starts to differ from the saved
values.
|
tsunami:clean |
form (bubbles) |
{ form, bars }
Fired when the form matches the saved values again, by
undoing an edit, reset, submit or markClean().
|
How fields are compared: checkbox/radio compare
checked with defaultChecked, select
compares selected with defaultSelected,
file compares the chosen files, input type=hidden compares with
its value when the script first saw the form, and other fields
compare value with defaultValue. A
change in the number of fields (rows added or removed) also counts
as an edit; a script that adds rows must fire an input event on
the form. Not compared: buttons, fieldset, output, fields inside a
dialog, the select search box and
[data-save-bar-ignore]. After submit, the values
count as saved.
Attributes
| Attribute / element | Values | Description |
|---|---|---|
.
|
Sticky bottom bar with a 1px top border | |
.
|
Muted message on the left | |
.
|
Button group on the right | |
data-form |
form id | The form the bar watches. Without it, the script leaves the bar alone. |
hidden |
present / absent | Add it to start hidden and let the script toggle the bar. Without it, the bar is always shown. |
data-warn-on-leave |
present / absent (default absent) | Asks before leaving the page with unsaved changes (beforeunload). Discard, Save and links inside the bar do not ask. |
data-dirty |
true, false |
Set by the script: whether the form has unsaved changes. Use it in CSS or read it from a script. |
role="region" + aria-label |
Lets screen readers find the bar | |
button[type=reset]
|
Discard restores the original values natively; the bar hides itself | |
button[type=submit]
|
Save submits the form from outside it. After submit, the values count as saved. | |
a[href=""] |
Alternative Discard: reloads the page to restore the saved values, without asking first | |
data-save-bar-ignore |
On a form field: leave it out of the comparison (e.g. a filter box that is not saved) |