form-dirty 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Everything Frontend
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # 📝 form-dirty
2
+
3
+ > Unsaved changes detection. Snapshot form state, expose `isDirty` + `changedFields`, handle `beforeunload` — in 3 lines. Zero dependencies.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/form-dirty)](https://www.npmjs.com/package/form-dirty)
6
+ [![npm downloads](https://img.shields.io/npm/dm/form-dirty)](https://www.npmjs.com/package/form-dirty)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/form-dirty)](https://bundlephobia.com/package/form-dirty)
8
+ [![license](https://img.shields.io/github/license/everything-frontend/form-dirty)](https://github.com/everything-frontend/form-dirty/blob/main/LICENSE)
9
+ [![TypeScript](https://img.shields.io/badge/types-included-blue)](https://www.npmjs.com/package/form-dirty)
10
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-green)](https://www.npmjs.com/package/form-dirty)
11
+
12
+ **[Live Demo →](https://www.everythingfrontend.com/form-dirty)**
13
+
14
+ ---
15
+
16
+ ## Why
17
+
18
+ "You have unsaved changes — are you sure you want to leave?" gets asked on every StackOverflow forum. Detecting form dirtiness across native and controlled forms, handling `beforeunload`, figuring out *which* fields changed — it's consistently painful.
19
+
20
+ Full form libraries like react-hook-form handle it internally, but you have to buy into the entire system. There's no tiny, standalone "is this form dirty?" utility.
21
+
22
+ **form-dirty is that utility.** ~0.9kB gzipped. Zero dependencies. Works with DOM forms and controlled state (React, Vue, Svelte, anything).
23
+
24
+ ---
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install form-dirty
30
+ # or
31
+ yarn add form-dirty
32
+ # or
33
+ pnpm add form-dirty
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ ### DOM form
41
+
42
+ ```ts
43
+ import FormDirty from 'form-dirty';
44
+
45
+ const fd = new FormDirty({
46
+ form: '#my-form',
47
+ beforeUnload: true,
48
+ });
49
+
50
+ // Check anytime
51
+ console.log(fd.isDirty); // true / false
52
+ console.log(fd.changedFields); // [{ name: 'email', original: '', current: 'hi@me.com' }]
53
+
54
+ // After saving, re-baseline
55
+ fd.snapshot();
56
+ ```
57
+
58
+ ### Controlled form (React / Vue / Svelte)
59
+
60
+ ```ts
61
+ import FormDirty from 'form-dirty';
62
+
63
+ const fd = new FormDirty({
64
+ fields: { name: '', email: '', bio: '' },
65
+ beforeUnload: true,
66
+ onDirtyChange: (dirty) => setHasUnsavedChanges(dirty),
67
+ });
68
+
69
+ // Whenever state changes
70
+ fd.update({ name: 'Ada', email: '', bio: '' });
71
+
72
+ console.log(fd.isDirty); // true
73
+ console.log(fd.changedFields); // [{ name: 'name', original: '', current: 'Ada' }]
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Features
79
+
80
+ - **Zero dependencies** — pure TypeScript, no external packages
81
+ - **DOM mode** — pass a form element or selector, tracks `<input>`, `<select>`, `<textarea>` automatically
82
+ - **Controlled mode** — pass initial fields object, call `update()` when state changes
83
+ - **`isDirty`** — single boolean, always up to date
84
+ - **`changedFields`** — array of `{ name, original, current }` for every changed field
85
+ - **`beforeunload` guard** — one option to prevent accidental navigation
86
+ - **`snapshot()`** — re-baseline after save
87
+ - **`onDirtyChange` callback** — fires only when dirty state transitions
88
+ - **Handles edge cases** — checkboxes, radio buttons, multi-selects, nested objects
89
+ - **SSR safe** — guards all DOM access behind `typeof window`
90
+ - **~0.9kB minified + gzipped**
91
+
92
+ ---
93
+
94
+ ## API
95
+
96
+ ### `new FormDirty(options?)`
97
+
98
+ | Option | Type | Default | Description |
99
+ |--------|------|---------|-------------|
100
+ | `form` | `HTMLFormElement \| string` | — | DOM form element or CSS selector |
101
+ | `fields` | `Record<string, unknown>` | — | Initial field values for controlled mode |
102
+ | `beforeUnload` | `boolean` | `false` | Auto-attach `beforeunload` guard |
103
+ | `onDirtyChange` | `(dirty: boolean) => void` | — | Called when dirty state changes |
104
+
105
+ ### Instance properties
106
+
107
+ | Property | Type | Description |
108
+ |----------|------|-------------|
109
+ | `.isDirty` | `boolean` | Whether any field differs from the baseline |
110
+ | `.changedFields` | `ChangedField[]` | Array of changed fields with original and current values |
111
+
112
+ ### Instance methods
113
+
114
+ | Method | Returns | Description |
115
+ |--------|---------|-------------|
116
+ | `.snapshot()` | `void` | Capture current state as the new clean baseline |
117
+ | `.update(fields)` | `void` | Push new field values (controlled mode) |
118
+ | `.guard(enable?)` | `void` | Toggle the `beforeunload` guard on/off |
119
+ | `.destroy()` | `void` | Remove all listeners and clean up |
120
+
121
+ ### `ChangedField`
122
+
123
+ ```ts
124
+ interface ChangedField {
125
+ name: string;
126
+ original: unknown;
127
+ current: unknown;
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Examples
134
+
135
+ ### React hook
136
+
137
+ ```tsx
138
+ import { useEffect, useRef } from 'react';
139
+ import FormDirty from 'form-dirty';
140
+
141
+ function useFormDirty(fields: Record<string, unknown>) {
142
+ const fdRef = useRef<FormDirty | null>(null);
143
+
144
+ useEffect(() => {
145
+ fdRef.current = new FormDirty({
146
+ fields,
147
+ beforeUnload: true,
148
+ });
149
+ return () => fdRef.current?.destroy();
150
+ }, []);
151
+
152
+ useEffect(() => {
153
+ fdRef.current?.update(fields);
154
+ }, [fields]);
155
+
156
+ return {
157
+ get isDirty() { return fdRef.current?.isDirty ?? false; },
158
+ get changedFields() { return fdRef.current?.changedFields ?? []; },
159
+ snapshot: () => fdRef.current?.snapshot(),
160
+ };
161
+ }
162
+ ```
163
+
164
+ ### Vue composable
165
+
166
+ ```ts
167
+ import { onMounted, onUnmounted, reactive } from 'vue';
168
+ import FormDirty from 'form-dirty';
169
+
170
+ export function useFormDirty(formSelector: string) {
171
+ let fd: FormDirty;
172
+ const state = reactive({ isDirty: false, changedFields: [] as any[] });
173
+
174
+ onMounted(() => {
175
+ fd = new FormDirty({
176
+ form: formSelector,
177
+ beforeUnload: true,
178
+ onDirtyChange: (dirty) => {
179
+ state.isDirty = dirty;
180
+ state.changedFields = fd.changedFields;
181
+ },
182
+ });
183
+ });
184
+
185
+ onUnmounted(() => fd?.destroy());
186
+
187
+ return { state, snapshot: () => fd?.snapshot() };
188
+ }
189
+ ```
190
+
191
+ ### Snapshot after save
192
+
193
+ ```ts
194
+ const fd = new FormDirty({ form: '#settings-form', beforeUnload: true });
195
+
196
+ async function handleSave() {
197
+ await fetch('/api/settings', { method: 'POST', body: getFormData() });
198
+ fd.snapshot(); // current state is now the new baseline
199
+ }
200
+ ```
201
+
202
+ ### CDN (no build step)
203
+
204
+ ```html
205
+ <script type="module">
206
+ import FormDirty from 'https://esm.sh/form-dirty';
207
+
208
+ const fd = new FormDirty({
209
+ form: '#contact-form',
210
+ beforeUnload: true,
211
+ onDirtyChange: (dirty) => {
212
+ document.getElementById('save-btn').disabled = !dirty;
213
+ },
214
+ });
215
+ </script>
216
+ ```
217
+
218
+ ---
219
+
220
+ ## License
221
+
222
+ [MIT](https://github.com/everything-frontend/form-dirty/blob/main/LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";var a=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var p=Object.prototype.hasOwnProperty;var m=(t,e)=>{for(var i in e)a(t,i,{get:e[i],enumerable:!0})},v=(t,e,i,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of c(e))!p.call(t,n)&&n!==i&&a(t,n,{get:()=>e[n],enumerable:!(r=h(e,n))||r.enumerable});return t};var y=t=>v(a({},"__esModule",{value:!0}),t);var g={};m(g,{FormDirty:()=>u,default:()=>E});module.exports=y(g);function b(t){return typeof t=="string"?document.querySelector(t):t}function o(t){let e={},i=t.elements;for(let r=0;r<i.length;r++){let n=i[r],s=n.getAttribute("name");if(s)if(n instanceof HTMLInputElement)if(n.type==="checkbox")e[s]=n.checked;else if(n.type==="radio")n.checked?e[s]=n.value:s in e||(e[s]="");else{if(n.type==="file")continue;e[s]=n.value}else n instanceof HTMLSelectElement?n.multiple?e[s]=Array.from(n.selectedOptions).map(d=>d.value):e[s]=n.value:n instanceof HTMLTextAreaElement&&(e[s]=n.value)}return e}function l(t){let e={};for(let i in t){let r=t[i];e[i]=typeof r=="object"&&r!==null?JSON.parse(JSON.stringify(r)):r}return e}function f(t,e){return t===e?!0:typeof t!=typeof e?!1:typeof t=="object"&&t!==null&&e!==null?JSON.stringify(t)===JSON.stringify(e):!1}var u=class{constructor(e={}){this.formEl=null;this.baseline={};this.current={};this.wasDirty=!1;this.guardActive=!1;this.boundOnInput=null;this.boundBeforeUnload=null;this.opts=e,e.form?(this.formEl=typeof window<"u"?b(e.form):null,this.formEl&&(this.baseline=o(this.formEl),this.current=o(this.formEl),this.boundOnInput=()=>this.handleInput(),this.formEl.addEventListener("input",this.boundOnInput),this.formEl.addEventListener("change",this.boundOnInput))):e.fields&&(this.baseline=l(e.fields),this.current=l(e.fields)),e.beforeUnload&&this.guard(!0)}get isDirty(){let e=new Set([...Object.keys(this.baseline),...Object.keys(this.current)]);for(let i of e)if(!f(this.baseline[i],this.current[i]))return!0;return!1}get changedFields(){let e=[],i=new Set([...Object.keys(this.baseline),...Object.keys(this.current)]);for(let r of i)f(this.baseline[r],this.current[r])||e.push({name:r,original:this.baseline[r],current:this.current[r]});return e}snapshot(){this.formEl?(this.baseline=o(this.formEl),this.current=o(this.formEl)):this.baseline=l(this.current),this.notify()}update(e){this.current=l(e),this.notify()}guard(e=!0){typeof window>"u"||(e&&!this.guardActive?(this.boundBeforeUnload=i=>{this.isDirty&&i.preventDefault()},window.addEventListener("beforeunload",this.boundBeforeUnload),this.guardActive=!0):!e&&this.guardActive&&(this.boundBeforeUnload&&(window.removeEventListener("beforeunload",this.boundBeforeUnload),this.boundBeforeUnload=null),this.guardActive=!1))}destroy(){this.formEl&&this.boundOnInput&&(this.formEl.removeEventListener("input",this.boundOnInput),this.formEl.removeEventListener("change",this.boundOnInput)),this.guard(!1),this.formEl=null,this.baseline={},this.current={}}handleInput(){this.formEl&&(this.current=o(this.formEl)),this.notify()}notify(){let e=this.isDirty;e!==this.wasDirty&&(this.wasDirty=e,this.opts.onDirtyChange?.(e))}},E=u;0&&(module.exports={FormDirty});
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export interface FormDirtyOptions {\n form?: HTMLFormElement | string;\n fields?: Record<string, unknown>;\n beforeUnload?: boolean;\n onDirtyChange?: (dirty: boolean) => void;\n}\n\nexport interface ChangedField {\n name: string;\n original: unknown;\n current: unknown;\n}\n\ntype FieldMap = Record<string, unknown>;\n\nfunction resolveForm(form: HTMLFormElement | string): HTMLFormElement | null {\n if (typeof form === \"string\") {\n return document.querySelector<HTMLFormElement>(form);\n }\n return form;\n}\n\nfunction readFormValues(form: HTMLFormElement): FieldMap {\n const values: FieldMap = {};\n const elements = form.elements;\n\n for (let i = 0; i < elements.length; i++) {\n const el = elements[i] as\n | HTMLInputElement\n | HTMLSelectElement\n | HTMLTextAreaElement;\n const name = el.getAttribute(\"name\");\n if (!name) continue;\n\n if (el instanceof HTMLInputElement) {\n if (el.type === \"checkbox\") {\n values[name] = el.checked;\n } else if (el.type === \"radio\") {\n if (el.checked) values[name] = el.value;\n else if (!(name in values)) values[name] = \"\";\n } else if (el.type === \"file\") {\n continue;\n } else {\n values[name] = el.value;\n }\n } else if (el instanceof HTMLSelectElement) {\n if (el.multiple) {\n values[name] = Array.from(el.selectedOptions).map((o) => o.value);\n } else {\n values[name] = el.value;\n }\n } else if (el instanceof HTMLTextAreaElement) {\n values[name] = el.value;\n }\n }\n\n return values;\n}\n\nfunction cloneFields(fields: FieldMap): FieldMap {\n const out: FieldMap = {};\n for (const key in fields) {\n const v = fields[key];\n out[key] =\n typeof v === \"object\" && v !== null ? JSON.parse(JSON.stringify(v)) : v;\n }\n return out;\n}\n\nfunction eq(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (typeof a !== typeof b) return false;\n if (typeof a === \"object\" && a !== null && b !== null) {\n return JSON.stringify(a) === JSON.stringify(b);\n }\n return false;\n}\n\nexport class FormDirty {\n private formEl: HTMLFormElement | null = null;\n private baseline: FieldMap = {};\n private current: FieldMap = {};\n private wasDirty = false;\n private guardActive = false;\n private opts: FormDirtyOptions;\n private boundOnInput: (() => void) | null = null;\n private boundBeforeUnload: ((e: BeforeUnloadEvent) => void) | null = null;\n\n constructor(options: FormDirtyOptions = {}) {\n this.opts = options;\n\n if (options.form) {\n this.formEl =\n typeof window !== \"undefined\" ? resolveForm(options.form) : null;\n if (this.formEl) {\n this.baseline = readFormValues(this.formEl);\n this.current = readFormValues(this.formEl);\n this.boundOnInput = () => this.handleInput();\n this.formEl.addEventListener(\"input\", this.boundOnInput);\n this.formEl.addEventListener(\"change\", this.boundOnInput);\n }\n } else if (options.fields) {\n this.baseline = cloneFields(options.fields);\n this.current = cloneFields(options.fields);\n }\n\n if (options.beforeUnload) {\n this.guard(true);\n }\n }\n\n get isDirty(): boolean {\n const allKeys = new Set([\n ...Object.keys(this.baseline),\n ...Object.keys(this.current),\n ]);\n for (const key of allKeys) {\n if (!eq(this.baseline[key], this.current[key])) return true;\n }\n return false;\n }\n\n get changedFields(): ChangedField[] {\n const changed: ChangedField[] = [];\n const allKeys = new Set([\n ...Object.keys(this.baseline),\n ...Object.keys(this.current),\n ]);\n for (const key of allKeys) {\n if (!eq(this.baseline[key], this.current[key])) {\n changed.push({\n name: key,\n original: this.baseline[key],\n current: this.current[key],\n });\n }\n }\n return changed;\n }\n\n snapshot(): void {\n if (this.formEl) {\n this.baseline = readFormValues(this.formEl);\n this.current = readFormValues(this.formEl);\n } else {\n this.baseline = cloneFields(this.current);\n }\n this.notify();\n }\n\n update(fields: FieldMap): void {\n this.current = cloneFields(fields);\n this.notify();\n }\n\n guard(enable = true): void {\n if (typeof window === \"undefined\") return;\n\n if (enable && !this.guardActive) {\n this.boundBeforeUnload = (e: BeforeUnloadEvent) => {\n if (this.isDirty) {\n e.preventDefault();\n }\n };\n window.addEventListener(\"beforeunload\", this.boundBeforeUnload);\n this.guardActive = true;\n } else if (!enable && this.guardActive) {\n if (this.boundBeforeUnload) {\n window.removeEventListener(\"beforeunload\", this.boundBeforeUnload);\n this.boundBeforeUnload = null;\n }\n this.guardActive = false;\n }\n }\n\n destroy(): void {\n if (this.formEl && this.boundOnInput) {\n this.formEl.removeEventListener(\"input\", this.boundOnInput);\n this.formEl.removeEventListener(\"change\", this.boundOnInput);\n }\n this.guard(false);\n this.formEl = null;\n this.baseline = {};\n this.current = {};\n }\n\n private handleInput(): void {\n if (this.formEl) {\n this.current = readFormValues(this.formEl);\n }\n this.notify();\n }\n\n private notify(): void {\n const dirty = this.isDirty;\n if (dirty !== this.wasDirty) {\n this.wasDirty = dirty;\n this.opts.onDirtyChange?.(dirty);\n }\n }\n}\n\nexport default FormDirty;\n"],"mappings":"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","names":["index_exports","__export","FormDirty","index_default","__toCommonJS","resolveForm","form","readFormValues","values","elements","i","el","name","o","cloneFields","fields","out","key","v","eq","a","b","options","allKeys","changed","enable","e","dirty"]}
@@ -0,0 +1,33 @@
1
+ interface FormDirtyOptions {
2
+ form?: HTMLFormElement | string;
3
+ fields?: Record<string, unknown>;
4
+ beforeUnload?: boolean;
5
+ onDirtyChange?: (dirty: boolean) => void;
6
+ }
7
+ interface ChangedField {
8
+ name: string;
9
+ original: unknown;
10
+ current: unknown;
11
+ }
12
+ type FieldMap = Record<string, unknown>;
13
+ declare class FormDirty {
14
+ private formEl;
15
+ private baseline;
16
+ private current;
17
+ private wasDirty;
18
+ private guardActive;
19
+ private opts;
20
+ private boundOnInput;
21
+ private boundBeforeUnload;
22
+ constructor(options?: FormDirtyOptions);
23
+ get isDirty(): boolean;
24
+ get changedFields(): ChangedField[];
25
+ snapshot(): void;
26
+ update(fields: FieldMap): void;
27
+ guard(enable?: boolean): void;
28
+ destroy(): void;
29
+ private handleInput;
30
+ private notify;
31
+ }
32
+
33
+ export { type ChangedField, FormDirty, type FormDirtyOptions, FormDirty as default };
@@ -0,0 +1,33 @@
1
+ interface FormDirtyOptions {
2
+ form?: HTMLFormElement | string;
3
+ fields?: Record<string, unknown>;
4
+ beforeUnload?: boolean;
5
+ onDirtyChange?: (dirty: boolean) => void;
6
+ }
7
+ interface ChangedField {
8
+ name: string;
9
+ original: unknown;
10
+ current: unknown;
11
+ }
12
+ type FieldMap = Record<string, unknown>;
13
+ declare class FormDirty {
14
+ private formEl;
15
+ private baseline;
16
+ private current;
17
+ private wasDirty;
18
+ private guardActive;
19
+ private opts;
20
+ private boundOnInput;
21
+ private boundBeforeUnload;
22
+ constructor(options?: FormDirtyOptions);
23
+ get isDirty(): boolean;
24
+ get changedFields(): ChangedField[];
25
+ snapshot(): void;
26
+ update(fields: FieldMap): void;
27
+ guard(enable?: boolean): void;
28
+ destroy(): void;
29
+ private handleInput;
30
+ private notify;
31
+ }
32
+
33
+ export { type ChangedField, FormDirty, type FormDirtyOptions, FormDirty as default };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ function d(t){return typeof t=="string"?document.querySelector(t):t}function o(t){let e={},r=t.elements;for(let i=0;i<r.length;i++){let n=r[i],s=n.getAttribute("name");if(s)if(n instanceof HTMLInputElement)if(n.type==="checkbox")e[s]=n.checked;else if(n.type==="radio")n.checked?e[s]=n.value:s in e||(e[s]="");else{if(n.type==="file")continue;e[s]=n.value}else n instanceof HTMLSelectElement?n.multiple?e[s]=Array.from(n.selectedOptions).map(f=>f.value):e[s]=n.value:n instanceof HTMLTextAreaElement&&(e[s]=n.value)}return e}function l(t){let e={};for(let r in t){let i=t[r];e[r]=typeof i=="object"&&i!==null?JSON.parse(JSON.stringify(i)):i}return e}function a(t,e){return t===e?!0:typeof t!=typeof e?!1:typeof t=="object"&&t!==null&&e!==null?JSON.stringify(t)===JSON.stringify(e):!1}var u=class{constructor(e={}){this.formEl=null;this.baseline={};this.current={};this.wasDirty=!1;this.guardActive=!1;this.boundOnInput=null;this.boundBeforeUnload=null;this.opts=e,e.form?(this.formEl=typeof window<"u"?d(e.form):null,this.formEl&&(this.baseline=o(this.formEl),this.current=o(this.formEl),this.boundOnInput=()=>this.handleInput(),this.formEl.addEventListener("input",this.boundOnInput),this.formEl.addEventListener("change",this.boundOnInput))):e.fields&&(this.baseline=l(e.fields),this.current=l(e.fields)),e.beforeUnload&&this.guard(!0)}get isDirty(){let e=new Set([...Object.keys(this.baseline),...Object.keys(this.current)]);for(let r of e)if(!a(this.baseline[r],this.current[r]))return!0;return!1}get changedFields(){let e=[],r=new Set([...Object.keys(this.baseline),...Object.keys(this.current)]);for(let i of r)a(this.baseline[i],this.current[i])||e.push({name:i,original:this.baseline[i],current:this.current[i]});return e}snapshot(){this.formEl?(this.baseline=o(this.formEl),this.current=o(this.formEl)):this.baseline=l(this.current),this.notify()}update(e){this.current=l(e),this.notify()}guard(e=!0){typeof window>"u"||(e&&!this.guardActive?(this.boundBeforeUnload=r=>{this.isDirty&&r.preventDefault()},window.addEventListener("beforeunload",this.boundBeforeUnload),this.guardActive=!0):!e&&this.guardActive&&(this.boundBeforeUnload&&(window.removeEventListener("beforeunload",this.boundBeforeUnload),this.boundBeforeUnload=null),this.guardActive=!1))}destroy(){this.formEl&&this.boundOnInput&&(this.formEl.removeEventListener("input",this.boundOnInput),this.formEl.removeEventListener("change",this.boundOnInput)),this.guard(!1),this.formEl=null,this.baseline={},this.current={}}handleInput(){this.formEl&&(this.current=o(this.formEl)),this.notify()}notify(){let e=this.isDirty;e!==this.wasDirty&&(this.wasDirty=e,this.opts.onDirtyChange?.(e))}},h=u;export{u as FormDirty,h as default};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export interface FormDirtyOptions {\n form?: HTMLFormElement | string;\n fields?: Record<string, unknown>;\n beforeUnload?: boolean;\n onDirtyChange?: (dirty: boolean) => void;\n}\n\nexport interface ChangedField {\n name: string;\n original: unknown;\n current: unknown;\n}\n\ntype FieldMap = Record<string, unknown>;\n\nfunction resolveForm(form: HTMLFormElement | string): HTMLFormElement | null {\n if (typeof form === \"string\") {\n return document.querySelector<HTMLFormElement>(form);\n }\n return form;\n}\n\nfunction readFormValues(form: HTMLFormElement): FieldMap {\n const values: FieldMap = {};\n const elements = form.elements;\n\n for (let i = 0; i < elements.length; i++) {\n const el = elements[i] as\n | HTMLInputElement\n | HTMLSelectElement\n | HTMLTextAreaElement;\n const name = el.getAttribute(\"name\");\n if (!name) continue;\n\n if (el instanceof HTMLInputElement) {\n if (el.type === \"checkbox\") {\n values[name] = el.checked;\n } else if (el.type === \"radio\") {\n if (el.checked) values[name] = el.value;\n else if (!(name in values)) values[name] = \"\";\n } else if (el.type === \"file\") {\n continue;\n } else {\n values[name] = el.value;\n }\n } else if (el instanceof HTMLSelectElement) {\n if (el.multiple) {\n values[name] = Array.from(el.selectedOptions).map((o) => o.value);\n } else {\n values[name] = el.value;\n }\n } else if (el instanceof HTMLTextAreaElement) {\n values[name] = el.value;\n }\n }\n\n return values;\n}\n\nfunction cloneFields(fields: FieldMap): FieldMap {\n const out: FieldMap = {};\n for (const key in fields) {\n const v = fields[key];\n out[key] =\n typeof v === \"object\" && v !== null ? JSON.parse(JSON.stringify(v)) : v;\n }\n return out;\n}\n\nfunction eq(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (typeof a !== typeof b) return false;\n if (typeof a === \"object\" && a !== null && b !== null) {\n return JSON.stringify(a) === JSON.stringify(b);\n }\n return false;\n}\n\nexport class FormDirty {\n private formEl: HTMLFormElement | null = null;\n private baseline: FieldMap = {};\n private current: FieldMap = {};\n private wasDirty = false;\n private guardActive = false;\n private opts: FormDirtyOptions;\n private boundOnInput: (() => void) | null = null;\n private boundBeforeUnload: ((e: BeforeUnloadEvent) => void) | null = null;\n\n constructor(options: FormDirtyOptions = {}) {\n this.opts = options;\n\n if (options.form) {\n this.formEl =\n typeof window !== \"undefined\" ? resolveForm(options.form) : null;\n if (this.formEl) {\n this.baseline = readFormValues(this.formEl);\n this.current = readFormValues(this.formEl);\n this.boundOnInput = () => this.handleInput();\n this.formEl.addEventListener(\"input\", this.boundOnInput);\n this.formEl.addEventListener(\"change\", this.boundOnInput);\n }\n } else if (options.fields) {\n this.baseline = cloneFields(options.fields);\n this.current = cloneFields(options.fields);\n }\n\n if (options.beforeUnload) {\n this.guard(true);\n }\n }\n\n get isDirty(): boolean {\n const allKeys = new Set([\n ...Object.keys(this.baseline),\n ...Object.keys(this.current),\n ]);\n for (const key of allKeys) {\n if (!eq(this.baseline[key], this.current[key])) return true;\n }\n return false;\n }\n\n get changedFields(): ChangedField[] {\n const changed: ChangedField[] = [];\n const allKeys = new Set([\n ...Object.keys(this.baseline),\n ...Object.keys(this.current),\n ]);\n for (const key of allKeys) {\n if (!eq(this.baseline[key], this.current[key])) {\n changed.push({\n name: key,\n original: this.baseline[key],\n current: this.current[key],\n });\n }\n }\n return changed;\n }\n\n snapshot(): void {\n if (this.formEl) {\n this.baseline = readFormValues(this.formEl);\n this.current = readFormValues(this.formEl);\n } else {\n this.baseline = cloneFields(this.current);\n }\n this.notify();\n }\n\n update(fields: FieldMap): void {\n this.current = cloneFields(fields);\n this.notify();\n }\n\n guard(enable = true): void {\n if (typeof window === \"undefined\") return;\n\n if (enable && !this.guardActive) {\n this.boundBeforeUnload = (e: BeforeUnloadEvent) => {\n if (this.isDirty) {\n e.preventDefault();\n }\n };\n window.addEventListener(\"beforeunload\", this.boundBeforeUnload);\n this.guardActive = true;\n } else if (!enable && this.guardActive) {\n if (this.boundBeforeUnload) {\n window.removeEventListener(\"beforeunload\", this.boundBeforeUnload);\n this.boundBeforeUnload = null;\n }\n this.guardActive = false;\n }\n }\n\n destroy(): void {\n if (this.formEl && this.boundOnInput) {\n this.formEl.removeEventListener(\"input\", this.boundOnInput);\n this.formEl.removeEventListener(\"change\", this.boundOnInput);\n }\n this.guard(false);\n this.formEl = null;\n this.baseline = {};\n this.current = {};\n }\n\n private handleInput(): void {\n if (this.formEl) {\n this.current = readFormValues(this.formEl);\n }\n this.notify();\n }\n\n private notify(): void {\n const dirty = this.isDirty;\n if (dirty !== this.wasDirty) {\n this.wasDirty = dirty;\n this.opts.onDirtyChange?.(dirty);\n }\n }\n}\n\nexport default FormDirty;\n"],"mappings":"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","names":["resolveForm","form","readFormValues","values","elements","el","name","o","cloneFields","fields","out","key","v","eq","a","b","FormDirty","options","allKeys","changed","enable","e","dirty","index_default"]}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "form-dirty",
3
+ "version": "1.0.0",
4
+ "description": "Unsaved changes detection. Snapshot form state, expose isDirty + changedFields, handle beforeunload. Framework agnostic. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "keywords": [
30
+ "form",
31
+ "dirty",
32
+ "unsaved-changes",
33
+ "beforeunload",
34
+ "changed-fields",
35
+ "zero-dependency"
36
+ ],
37
+ "sideEffects": false,
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/everything-frontend/form-dirty"
42
+ },
43
+ "devDependencies": {
44
+ "tsup": "^8.5.1",
45
+ "typescript": "^5.9.3"
46
+ }
47
+ }