Skip to content

Instantly share code, notes, and snippets.

@bensheldon
Last active June 6, 2025 02:45
Show Gist options
  • Save bensheldon/94483f08e6e2fd48da0b7630d9b583d6 to your computer and use it in GitHub Desktop.
Save bensheldon/94483f08e6e2fd48da0b7630d9b583d6 to your computer and use it in GitHub Desktop.
import {Controller} from "@hotwired/stimulus"
// Warns if form fields have unsaved changes before leaving the page.
// Changes are stored in Session Storage to restore un-warnable events
// like using the back button
//
// To use:
// <form data-controller="unsaved-changes">
// <input type="text" name="name" data-unsaved-changes-target="field">
export default class extends Controller {
static targets = ["field"]
static values = {
message: {type: String, default: "You have unsaved changes. Are you sure you want to leave?"}
}
initialize() {
this.fieldStorage = new FieldStorage();
this.isOtherFormSubmission = false;
this.inputChanged = this.inputChanged.bind(this);
this.restoreFormValues = this.restoreFormValues.bind(this);
}
connect() {
this.restoreFormValues();
document.addEventListener("turbo:load", this.restoreFormValues);
window.addEventListener("popstate", this.restoreFormValues);
this.formSubmitHandler = (event) => {
if (event.target === this.element) {
this.fieldStorage.unset(this.fieldTargets);
} else {
this.isOtherFormSubmission = true;
}
};
document.addEventListener("submit", this.formSubmitHandler);
this.turboBeforeVisitHandler = (event) => {
const isTurboPermanent = !!this.element.closest('[data-turbo-permanent]')
if (this.#hasUnsavedChanges() && !(this.isOtherFormSubmission && isTurboPermanent)) {
if (confirm(this.messageValue)) {
this.fieldStorage.unset(this.fieldTargets);
}
else {
event.preventDefault();
}
}
this.isOtherFormSubmission = false;
};
document.addEventListener("turbo:before-visit", this.turboBeforeVisitHandler);
this.beforeUnloadHandler = (event) => {
if (this.#hasUnsavedChanges() && !this.isOtherFormSubmission) {
event.preventDefault();
// This message may not be displayed in modern browsers, but a confirmation dialog will still appear.
event.returnValue = this.messageValue;
return event.returnValue;
}
this.isOtherFormSubmission = false;
};
window.addEventListener("beforeunload", this.beforeUnloadHandler);
}
disconnect() {
document.removeEventListener("turbo:load", this.restoreFormValues);
window.removeEventListener("popstate", this.restoreFormValues);
document.removeEventListener("submit", this.formSubmitHandler);
document.removeEventListener("turbo:before-visit", this.turboBeforeVisitHandler);
window.removeEventListener("beforeunload", this.beforeUnloadHandler);
}
// Restore form values from sessionStorage
restoreFormValues() {
this.fieldTargets.forEach(field => {
const storedValue = this.fieldStorage.get(field);
if (storedValue !== null) {
field.value = storedValue;
this.inputChanged({target: field});
}
});
}
fieldTargetConnected(element) {
element.addEventListener("input", this.inputChanged);
}
fieldTargetDisconnected(element) {
element.removeEventListener("input", this.inputChanged);
}
inputChanged(event) {
const field = event.target;
if (!this.fieldTargets.includes(field)) { return }
const isModified = field.value !== field.defaultValue;
field.classList.toggle('is-modified', isModified);
if (isModified) {
this.fieldStorage.set(field);
} else {
this.fieldStorage.unset(field);
}
}
#hasUnsavedChanges() {
return this.fieldTargets.some(field => {
return field.value !== field.defaultValue;
});
}
}
class FieldStorage {
get(field) {
const key = this.key(field);
if (key) {
return sessionStorage.getItem(key);
}
}
set(field) {
const key = this.key(field);
if (key) {
sessionStorage.setItem(key, field.value);
}
}
unset(fields) {
fields = Array.isArray(fields) ? fields : [fields];
fields.forEach(field => {
const key = this.key(field);
if (key) {
sessionStorage.removeItem(key);
}
});
}
key(field) {
const url = window.location.pathname;
const fieldId = field.id || field.name
if (fieldId) {
return `unsaved_changes_${btoa(url)}_${fieldId}`.replace(/[^a-z0-9]/gi, '_');
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment