Last active
June 6, 2025 02:45
-
-
Save bensheldon/94483f08e6e2fd48da0b7630d9b583d6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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