Unsaved Changes Guard
stimeo--dirty-form
Remembers what changed in a form and stops you leaving with unsaved edits.
Remembers the form's values as you found them and watches for changes. While something has changed it holds back both closing the page and moving on through Turbo, and it stops holding back once the values return to how they were. How you are asked is up to you: the browser's own prompt, or your own confirmation dialog. With several forms on a page you are still only asked once. When a submission succeeds, the values that were sent count as saved, while anything typed while it was in flight stays marked as changed so it cannot be thrown away by accident. It draws no dialog of its own and saves nothing for you.
Keyboard
This component has no keyboard interactions of its own.
<%# Dirty-form demo: edit either field and its form gets data-dirty (shown as a badge).
When both forms are dirty, one Turbo visit dispatches both guard events and opens
one native confirm using the first eligible form in live DOM order. "Save" calls
markClean for that form; this catalog has no server, so it stands in for a successful
save. The library owns data-dirty and navigation coordination; the demo only styles
the visible state. %>
<div class="dirty-form-demo">
<form
class="dirty-form"
data-controller="stimeo--dirty-form"
data-stimeo--dirty-form-message-value="<%= t('components.dirty_form.demo.first_message') %>"
aria-labelledby="dirty-form-first-title"
>
<h3 id="dirty-form-first-title" class="dirty-form__title">
<%= t("components.dirty_form.demo.first_title") %>
</h3>
<label class="dirty-form__field">
<span><%= t("components.dirty_form.demo.first_label") %></span>
<input type="text" name="title" value="<%= t('components.dirty_form.demo.first_value') %>">
</label>
<div class="dirty-form__bar">
<span class="dirty-form__badge"><%= t("components.dirty_form.demo.unsaved") %></span>
<button type="button" class="demo-trigger" data-action="click->stimeo--dirty-form#markClean">
<%= t("components.dirty_form.demo.save") %>
</button>
</div>
</form>
<form
class="dirty-form"
data-controller="stimeo--dirty-form"
data-stimeo--dirty-form-message-value="<%= t('components.dirty_form.demo.second_message') %>"
aria-labelledby="dirty-form-second-title"
>
<h3 id="dirty-form-second-title" class="dirty-form__title">
<%= t("components.dirty_form.demo.second_title") %>
</h3>
<label class="dirty-form__field">
<span><%= t("components.dirty_form.demo.second_label") %></span>
<input type="text" name="summary" value="<%= t('components.dirty_form.demo.second_value') %>">
</label>
<div class="dirty-form__bar">
<span class="dirty-form__badge"><%= t("components.dirty_form.demo.unsaved") %></span>
<button type="button" class="demo-trigger" data-action="click->stimeo--dirty-form#markClean">
<%= t("components.dirty_form.demo.save") %>
</button>
</div>
</form>
</div>
/*
* Presentation-only styles for the dirty-form demo.
* The library toggles data-dirty on the form; this CSS reveals the "unsaved" badge
* while that hook is present.
*/
.dirty-form-demo {
display: grid;
gap: 1rem;
max-width: 28rem;
}
.dirty-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.dirty-form__title {
margin: 0;
font-size: 1rem;
}
.dirty-form__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dirty-form__field input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font: inherit;
}
.dirty-form__bar {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* The badge appears only while the form is dirty. */
.dirty-form__badge {
display: none;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent-700);
}
.dirty-form[data-dirty] .dirty-form__badge {
display: inline-block;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--dirty-form"
Values
| Name | Description | Attribute |
|---|---|---|
message
|
Confirmation message used for the native confirm on a Turbo visit. |
data-stimeo--dirty-form-message-value |
confirmBridge
|
Block the Turbo visit (defer the prompt to a Confirm Bridge) instead of confirm. |
data-stimeo--dirty-form-confirm-bridge-value |
Actions
| Name | Action |
|---|---|
markClean
|
stimeo--dirty-form#markClean |
acceptRestore
|
stimeo--dirty-form#acceptRestore |
Events
| Name | Description | Event |
|---|---|---|
dirty
|
Fires when the dirty state flips, with detail.dirty. |
stimeo--dirty-form:dirty |
guard
|
Cancelable; fires for each dirty, non-submitting form on a Turbo visit. preventDefault blocks the visit. |
stimeo--dirty-form:guard |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-dirty |
Form (root) | Present while the form differs from its connect baseline. |