Last active
June 27, 2025 14:32
-
-
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.isLinkClick = false; | |
this.isFormSubmission = false; | |
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.linkClickHandler = (event) => { | |
this.isLinkClick = true; | |
} | |
document.addEventListener("turbo:click", this.linkClickHandler); | |
this.formSubmitHandler = (event) => { | |
this.isFormSubmission = true; | |
if (event.target === this.element) { | |
this.isThisFormSubmission = true; | |
this.#reset(); | |
} else { | |
this.isThisFormSubmission = false; | |
} | |
}; | |
document.addEventListener("submit", this.formSubmitHandler); | |
this.turboBeforeVisitHandler = (event) => { | |
const isTurboRefresh = !this.isLinkClick && !this.isFormSubmission; | |
const isTurboPermanent = !!this.element.closest('[data-turbo-permanent]') | |
const isOkFormSubmission = this.isThisFormSubmission || isTurboPermanent | |
if (this.#hasUnsavedChanges() && !isTurboRefresh && (!this.isFormSubmission || !isOkFormSubmission)) { | |
if (confirm(this.messageValue)) { | |
this.#reset(); | |
} | |
else { | |
event.preventDefault(); | |
} | |
} | |
this.isLinkClick = false; | |
this.isFormSubmission = false; | |
this.isThisFormSubmission = false; | |
}; | |
document.addEventListener("turbo:before-visit", this.turboBeforeVisitHandler); | |
// The beforeunload handler is only enabled when there are changes because it disables bf cache | |
// Because Turbo intercepts most events, this only is triggered for: | |
// - Closing the browser tab/window | |
// - Refreshing the page | |
this._beforeUnloadHandler = (event) => { | |
if (this.#hasUnsavedChanges() && !(this.isFormSubmission && this.isThisFormSubmission)) { | |
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; | |
} | |
}; | |
} | |
disconnect() { | |
this.#unregisterUnloadHandler(); | |
document.removeEventListener("turbo:load", this.restoreFormValues); | |
window.removeEventListener("popstate", this.restoreFormValues); | |
document.removeEventListener("turbo:click", this.linkClickHandler); | |
document.removeEventListener("submit", this.formSubmitHandler); | |
document.removeEventListener("turbo:before-visit", this.turboBeforeVisitHandler); | |
} | |
// 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); | |
element.addEventListener("change", this.inputChanged); | |
} | |
fieldTargetDisconnected(element) { | |
element.removeEventListener("input", this.inputChanged); | |
element.removeEventListener("change", 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); | |
this.#registerUnloadHandler(); | |
} else { | |
this.fieldStorage.unset(field); | |
if (!this.#hasUnsavedChanges()) { | |
this.#unregisterUnloadHandler(); | |
} | |
} | |
} | |
#reset() { | |
this.fieldStorage.unset(this.fieldTargets); | |
this.#unregisterUnloadHandler(); | |
} | |
#hasUnsavedChanges() { | |
return this.fieldTargets.some(field => { | |
return field.value !== field.defaultValue; | |
}); | |
} | |
#registerUnloadHandler() { | |
if (!this.beforeUnloadHandler) { | |
this.beforeUnloadHandler = this._beforeUnloadHandler; | |
window.addEventListener("beforeunload", this.beforeUnloadHandler); | |
} | |
} | |
#unregisterUnloadHandler() { | |
if (this.beforeUnloadHandler) { | |
window.removeEventListener("beforeunload", this.beforeUnloadHandler); | |
this.beforeUnloadHandler = null; | |
} | |
} | |
} | |
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