1import { Controller } from "@hotwired/stimulus" 2 3const BUTTON_TYPES = ["submit", "button", "reset"] 4 5// Tracks form field changes and shows a sticky save bar when fields are dirty. 6export default class extends Controller { 7 static targets = ["bar", "cleanNotice", "form", "submit"] 8 static values = { showSubmitWhenClean: Boolean } 9 10 connect() { 11 this.rememberCurrentValues() 12 this.watchForChanges() 13 this.warnBeforeLeaving() 14 this.setDirtyState(false) 15 } 16 17 disconnect() { 18 window.removeEventListener("beforeunload", this.beforeUnloadHandler) 19 document.removeEventListener("input", this.checkHandler) 20 document.removeEventListener("change", this.checkHandler) 21 this.form.removeEventListener("submit", this.submitHandler) 22 } 23 24 check() { 25 const dirty = this.snapshot() !== this.initialSnapshot 26 this.setDirtyState(dirty) 27 28 if (dirty) { 29 window.addEventListener("beforeunload", this.beforeUnloadHandler) 30 } else { 31 window.removeEventListener("beforeunload", this.beforeUnloadHandler) 32 } 33 } 34 35 discard() { 36 this.initialValues.forEach((value, field) => { 37 this.restore(field, value) 38 field.dispatchEvent(new Event("change", { bubbles: true })) 39 }) 40 this.setDirtyState(false) 41 window.removeEventListener("beforeunload", this.beforeUnloadHandler) 42 } 43 44 setDirtyState(dirty) { 45 this.form.dataset.dirty = String(dirty) 46 this.barTargets.forEach((bar) => bar.classList.toggle("hidden", !dirty)) 47 this.cleanNoticeTargets.forEach((notice) => notice.classList.toggle("hidden", dirty)) 48 this.submitTargets.forEach((submit) => { 49 submit.disabled = !dirty 50 submit.classList.toggle("hidden", !dirty && !this.showSubmitWhenCleanValue) 51 }) 52 } 53 54 get form() { 55 return this.hasFormTarget ? this.formTarget : this.element 56 } 57 58 rememberCurrentValues() { 59 this.initialValues = new Map(this.submittableFields().map((field) => [field, this.fieldValue(field)])) 60 this.initialSnapshot = this.snapshot() 61 } 62 63 watchForChanges() { 64 // Fields attached with form="..." sit outside the form, so their events never bubble to it. 65 this.checkHandler = (event) => { if (event.target.form === this.form) this.check() } 66 document.addEventListener("input", this.checkHandler) 67 document.addEventListener("change", this.checkHandler) 68 } 69 70 warnBeforeLeaving() { 71 this.beforeUnloadHandler = (event) => event.preventDefault() 72 this.submitHandler = () => { 73 this.form.dataset.dirty = "false" 74 window.removeEventListener("beforeunload", this.beforeUnloadHandler) 75 } 76 this.form.addEventListener("submit", this.submitHandler) 77 } 78 79 snapshot() { 80 return this.submittableFields().map((field) => `${field.name}=${this.fieldValue(field)}`).join("&") 81 } 82 83 submittableFields() { 84 return Array.from(this.form.elements) 85 .filter((field) => field.name && !field.disabled && !BUTTON_TYPES.includes(field.type)) 86 } 87 88 fieldValue(field) { 89 return this.usesCheckedState(field) ? field.checked.toString() : field.value 90 } 91 92 restore(field, value) { 93 if (this.usesCheckedState(field)) { 94 field.checked = value === "true" 95 } else { 96 field.value = value 97 } 98 } 99 100 usesCheckedState(field) { 101 return field.type === "checkbox" || field.type === "radio" 102 } 103}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.