@conform-to/dom 1.7.2 → 1.8.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ╚══════╝ ╚═════╝ ╚═╝ ╚══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
8
8
  ```
9
9
 
10
- Version 1.7.2 / License MIT / Copyright (c) 2024 Edmund Hung
10
+ Version 1.8.0 / License MIT / Copyright (c) 2024 Edmund Hung
11
11
 
12
12
  A type-safe form validation library utilizing web fundamentals to progressively enhance HTML Forms with full support for server frameworks like Remix and Next.js.
13
13
 
package/dist/dom.js CHANGED
@@ -121,6 +121,8 @@ function createGlobalFormsObserver() {
121
121
  observer.observe(document.body, {
122
122
  subtree: true,
123
123
  childList: true,
124
+ attributes: true,
125
+ attributeOldValue: true,
124
126
  attributeFilter: ['form', 'name', 'data-conform']
125
127
  });
126
128
  document.addEventListener('input', handleInput);
@@ -187,6 +189,15 @@ function createGlobalFormsObserver() {
187
189
  }));
188
190
  }
189
191
  }
192
+ function getAssociatedFormElement(formId, node) {
193
+ if (formId !== null) {
194
+ return document.forms.namedItem(formId);
195
+ }
196
+ if (node instanceof Element) {
197
+ return node.closest('form');
198
+ }
199
+ return null;
200
+ }
190
201
  function handleMutation(mutations) {
191
202
  var seenForms = new Set();
192
203
  var seenInputs = new Set();
@@ -196,16 +207,27 @@ function createGlobalFormsObserver() {
196
207
  }
197
208
  return node instanceof Element ? Array.from(node.querySelectorAll('input,select,textarea')) : [];
198
209
  };
210
+ var collectForms = node => {
211
+ if (node instanceof HTMLFormElement) {
212
+ return [node];
213
+ }
214
+ return node instanceof Element ? Array.from(node.querySelectorAll('form')) : [];
215
+ };
199
216
  for (var mutation of mutations) {
200
217
  switch (mutation.type) {
201
218
  case 'childList':
202
219
  {
203
220
  var nodes = [...mutation.addedNodes, ...mutation.removedNodes];
204
221
  for (var node of nodes) {
222
+ for (var form of collectForms(node)) {
223
+ seenForms.add(form);
224
+ }
205
225
  for (var input of collectInputs(node)) {
226
+ var _input$form;
206
227
  seenInputs.add(input);
207
- if (input.form) {
208
- seenForms.add(input.form);
228
+ var _form = (_input$form = input.form) !== null && _input$form !== void 0 ? _input$form : getAssociatedFormElement(input.getAttribute('form'), mutation.target);
229
+ if (_form) {
230
+ seenForms.add(_form);
209
231
  }
210
232
  }
211
233
  }
@@ -218,6 +240,12 @@ function createGlobalFormsObserver() {
218
240
  if (mutation.target.form) {
219
241
  seenForms.add(mutation.target.form);
220
242
  }
243
+ if (mutation.attributeName === 'form') {
244
+ var oldForm = getAssociatedFormElement(mutation.oldValue, mutation.target);
245
+ if (oldForm) {
246
+ seenForms.add(oldForm);
247
+ }
248
+ }
221
249
  }
222
250
  break;
223
251
  }
package/dist/dom.mjs CHANGED
@@ -117,6 +117,8 @@ function createGlobalFormsObserver() {
117
117
  observer.observe(document.body, {
118
118
  subtree: true,
119
119
  childList: true,
120
+ attributes: true,
121
+ attributeOldValue: true,
120
122
  attributeFilter: ['form', 'name', 'data-conform']
121
123
  });
122
124
  document.addEventListener('input', handleInput);
@@ -183,6 +185,15 @@ function createGlobalFormsObserver() {
183
185
  }));
184
186
  }
185
187
  }
188
+ function getAssociatedFormElement(formId, node) {
189
+ if (formId !== null) {
190
+ return document.forms.namedItem(formId);
191
+ }
192
+ if (node instanceof Element) {
193
+ return node.closest('form');
194
+ }
195
+ return null;
196
+ }
186
197
  function handleMutation(mutations) {
187
198
  var seenForms = new Set();
188
199
  var seenInputs = new Set();
@@ -192,16 +203,27 @@ function createGlobalFormsObserver() {
192
203
  }
193
204
  return node instanceof Element ? Array.from(node.querySelectorAll('input,select,textarea')) : [];
194
205
  };
206
+ var collectForms = node => {
207
+ if (node instanceof HTMLFormElement) {
208
+ return [node];
209
+ }
210
+ return node instanceof Element ? Array.from(node.querySelectorAll('form')) : [];
211
+ };
195
212
  for (var mutation of mutations) {
196
213
  switch (mutation.type) {
197
214
  case 'childList':
198
215
  {
199
216
  var nodes = [...mutation.addedNodes, ...mutation.removedNodes];
200
217
  for (var node of nodes) {
218
+ for (var form of collectForms(node)) {
219
+ seenForms.add(form);
220
+ }
201
221
  for (var input of collectInputs(node)) {
222
+ var _input$form;
202
223
  seenInputs.add(input);
203
- if (input.form) {
204
- seenForms.add(input.form);
224
+ var _form = (_input$form = input.form) !== null && _input$form !== void 0 ? _input$form : getAssociatedFormElement(input.getAttribute('form'), mutation.target);
225
+ if (_form) {
226
+ seenForms.add(_form);
205
227
  }
206
228
  }
207
229
  }
@@ -214,6 +236,12 @@ function createGlobalFormsObserver() {
214
236
  if (mutation.target.form) {
215
237
  seenForms.add(mutation.target.form);
216
238
  }
239
+ if (mutation.attributeName === 'form') {
240
+ var oldForm = getAssociatedFormElement(mutation.oldValue, mutation.target);
241
+ if (oldForm) {
242
+ seenForms.add(oldForm);
243
+ }
244
+ }
217
245
  }
218
246
  break;
219
247
  }
@@ -64,6 +64,141 @@ export declare function flatten(data: unknown, options?: {
64
64
  resolve?: (data: unknown) => unknown;
65
65
  prefix?: string;
66
66
  }): Record<string, unknown>;
67
- export declare function deepEqual<Value>(prev: Value, next: Value): boolean;
67
+ export declare function deepEqual(left: unknown, right: unknown): boolean;
68
+ export type JsonPrimitive = string | number | boolean | null;
69
+ /**
70
+ * The form value of a submission. This is usually constructed from a FormData or URLSearchParams.
71
+ * It may contains JSON primitives if the value is updated based on a form intent.
72
+ */
73
+ export type FormValue<Type extends JsonPrimitive | FormDataEntryValue = JsonPrimitive | FormDataEntryValue> = Type | FormValue<Type | null>[] | {
74
+ [key: string]: FormValue<Type>;
75
+ };
76
+ /**
77
+ * The data of a form submission.
78
+ */
79
+ export type Submission<ValueType extends FormDataEntryValue = FormDataEntryValue> = {
80
+ /**
81
+ * The form value structured following the naming convention.
82
+ */
83
+ value: Record<string, FormValue<ValueType>>;
84
+ /**
85
+ * The field names that are included in the FormData or URLSearchParams.
86
+ */
87
+ fields: string[];
88
+ /**
89
+ * The intent of the submission. This is usally included by specifying a name and value on a submit button.
90
+ */
91
+ intent: string | null;
92
+ };
93
+ /**
94
+ * Parse `FormData` or `URLSearchParams` into a submission object.
95
+ * This function structures the form values based on the naming convention.
96
+ * It also includes all the field names and the intent if the `intentName` option is provided.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const formData = new FormData();
101
+ *
102
+ * formData.append('email', 'test@example.com');
103
+ * formData.append('password', 'secret');
104
+ *
105
+ * parseSubmission(formData)
106
+ * // {
107
+ * // value: { email: 'test@example.com', password: 'secret' },
108
+ * // fields: ['email', 'password'],
109
+ * // intent: null,
110
+ * // }
111
+ *
112
+ * // If you have an intent field
113
+ * formData.append('intent', 'login');
114
+ * parseSubmission(formData, { intentName: 'intent' })
115
+ * // {
116
+ * // value: { email: 'test@example.com', password: 'secret' },
117
+ * // fields: ['email', 'password'],
118
+ * // intent: 'login',
119
+ * // }
120
+ * ```
121
+ */
122
+ export declare function parseSubmission(formData: FormData | URLSearchParams, options?: {
123
+ /**
124
+ * The name of the submit button that triggered the form submission.
125
+ * Used to extract the submission's intent.
126
+ */
127
+ intentName?: string;
128
+ /**
129
+ * A filter function that excludes specific entries from being parsed.
130
+ * Return `true` to skip the entry.
131
+ */
132
+ skipEntry?: (name: string) => boolean;
133
+ }): Submission;
134
+ export type ParseSubmissionOptions = Required<Parameters<typeof parseSubmission>>[1];
135
+ export declare function defaultSerialize(value: unknown): FormDataEntryValue | undefined;
136
+ /**
137
+ * A utility function that checks whether the current form data differs from the default values.
138
+ *
139
+ * @see https://conform.guide/api/react/future/isDirty
140
+ * @example Enable a submit button only if the form is dirty
141
+ *
142
+ * ```tsx
143
+ * const dirty = useFormData(
144
+ * formRef,
145
+ * (formData) => isDirty(formData, { defaultValue }) ?? false,
146
+ * );
147
+ *
148
+ * return (
149
+ * <button type="submit" disabled={!dirty}>
150
+ * Save changes
151
+ * </button>
152
+ * );
153
+ * ```
154
+ */
155
+ export declare function isDirty(
156
+ /**
157
+ * The current form data to compare. It can be:
158
+ *
159
+ * - A `FormData` object
160
+ * - A `URLSearchParams` object
161
+ * - A plain object that was parsed from form data (i.e. `submission.payload`)
162
+ */
163
+ formData: FormData | URLSearchParams | FormValue<FormDataEntryValue> | null, options?: {
164
+ /**
165
+ * An object representing the default values of the form to compare against.
166
+ * Defaults to an empty object if not provided.
167
+ */
168
+ defaultValue?: unknown;
169
+ /**
170
+ * The name of the submit button that triggered the submission.
171
+ * It will be excluded from the dirty comparison.
172
+ */
173
+ intentName?: string;
174
+ /**
175
+ * A function to serialize values in defaultValue before comparing them to the form data.
176
+ * If not provided, a default serializer is used that behaves as follows:
177
+ *
178
+ * - string / File:
179
+ * - Returned as-is
180
+ * - boolean:
181
+ * - true → 'on'
182
+ * - false → undefined
183
+ * - number / bigint:
184
+ * - Converted to string using `.toString()`
185
+ * - Date:
186
+ * - Converted to ISO string using `.toISOString()`
187
+ */
188
+ serialize?: (value: unknown, defaultSerialize: (value: unknown) => FormDataEntryValue | undefined) => FormDataEntryValue | undefined;
189
+ /**
190
+ * A function to exclude specific fields from the comparison.
191
+ * Useful for ignoring hidden inputs like CSRF tokens or internal fields added by frameworks
192
+ * (e.g. Next.js uses hidden inputs to support server actions).
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * isDirty(formData, {
197
+ * skipEntry: (name) => name === 'csrf-token',
198
+ * });
199
+ * ```
200
+ */
201
+ skipEntry?: (name: string) => boolean;
202
+ }): boolean | undefined;
68
203
  export {};
69
204
  //# sourceMappingURL=formdata.d.ts.map
package/dist/formdata.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
+ var submission = require('./submission.js');
6
+
5
7
  /**
6
8
  * Construct a form data with the submitter value.
7
9
  * It utilizes the submitter argument on the FormData constructor from modern browsers
@@ -104,11 +106,11 @@ function setValue(target, name, valueFn) {
104
106
  var index = -1;
105
107
  var pointer = target;
106
108
  while (pointer != null && ++index < length) {
107
- var key = paths[index];
109
+ var _key = paths[index];
108
110
  var nextKey = paths[index + 1];
109
- var newValue = index != lastIndex ? Object.prototype.hasOwnProperty.call(pointer, key) && pointer[key] !== null ? pointer[key] : typeof nextKey === 'number' ? [] : {} : valueFn(pointer[key]);
110
- pointer[key] = newValue;
111
- pointer = pointer[key];
111
+ var newValue = index != lastIndex ? Object.prototype.hasOwnProperty.call(pointer, _key) && pointer[_key] !== null ? pointer[_key] : typeof nextKey === 'number' ? [] : {} : valueFn(pointer[_key]);
112
+ pointer[_key] = newValue;
113
+ pointer = pointer[_key];
112
114
  }
113
115
  }
114
116
 
@@ -195,8 +197,8 @@ function flatten(data) {
195
197
  process(data[i], "".concat(prefix, "[").concat(i, "]"));
196
198
  }
197
199
  } else if (isPlainObject(data)) {
198
- for (var [key, _value] of Object.entries(data)) {
199
- process(_value, prefix ? "".concat(prefix, ".").concat(key) : key);
200
+ for (var [_key2, _value] of Object.entries(data)) {
201
+ process(_value, prefix ? "".concat(prefix, ".").concat(_key2) : _key2);
200
202
  }
201
203
  }
202
204
  }
@@ -206,34 +208,36 @@ function flatten(data) {
206
208
  }
207
209
  return result;
208
210
  }
209
- function deepEqual(prev, next) {
210
- if (prev === next) {
211
+ function deepEqual(left, right) {
212
+ if (Object.is(left, right)) {
211
213
  return true;
212
214
  }
213
- if (!prev || !next) {
215
+ if (left == null || right == null) {
214
216
  return false;
215
217
  }
216
- if (Array.isArray(prev) && Array.isArray(next)) {
217
- if (prev.length !== next.length) {
218
+
219
+ // Compare plain objects
220
+ if (isPlainObject(left) && isPlainObject(right)) {
221
+ var prevKeys = Object.keys(left);
222
+ var nextKeys = Object.keys(right);
223
+ if (prevKeys.length !== nextKeys.length) {
218
224
  return false;
219
225
  }
220
- for (var i = 0; i < prev.length; i++) {
221
- if (!deepEqual(prev[i], next[i])) {
226
+ for (var _key3 of prevKeys) {
227
+ if (!Object.prototype.hasOwnProperty.call(right, _key3) || !deepEqual(left[_key3], right[_key3])) {
222
228
  return false;
223
229
  }
224
230
  }
225
231
  return true;
226
232
  }
227
- if (isPlainObject(prev) && isPlainObject(next)) {
228
- var prevKeys = Object.keys(prev);
229
- var nextKeys = Object.keys(next);
230
- if (prevKeys.length !== nextKeys.length) {
233
+
234
+ // Compare arrays
235
+ if (Array.isArray(left) && Array.isArray(right)) {
236
+ if (left.length !== right.length) {
231
237
  return false;
232
238
  }
233
- for (var key of prevKeys) {
234
- if (!Object.prototype.hasOwnProperty.call(next, key) ||
235
- // @ts-expect-error FIXME
236
- !deepEqual(prev[key], next[key])) {
239
+ for (var i = 0; i < left.length; i++) {
240
+ if (!deepEqual(left[i], right[i])) {
237
241
  return false;
238
242
  }
239
243
  }
@@ -242,7 +246,170 @@ function deepEqual(prev, next) {
242
246
  return false;
243
247
  }
244
248
 
249
+ /**
250
+ * The form value of a submission. This is usually constructed from a FormData or URLSearchParams.
251
+ * It may contains JSON primitives if the value is updated based on a form intent.
252
+ */
253
+
254
+ /**
255
+ * The data of a form submission.
256
+ */
257
+
258
+ /**
259
+ * Parse `FormData` or `URLSearchParams` into a submission object.
260
+ * This function structures the form values based on the naming convention.
261
+ * It also includes all the field names and the intent if the `intentName` option is provided.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * const formData = new FormData();
266
+ *
267
+ * formData.append('email', 'test@example.com');
268
+ * formData.append('password', 'secret');
269
+ *
270
+ * parseSubmission(formData)
271
+ * // {
272
+ * // value: { email: 'test@example.com', password: 'secret' },
273
+ * // fields: ['email', 'password'],
274
+ * // intent: null,
275
+ * // }
276
+ *
277
+ * // If you have an intent field
278
+ * formData.append('intent', 'login');
279
+ * parseSubmission(formData, { intentName: 'intent' })
280
+ * // {
281
+ * // value: { email: 'test@example.com', password: 'secret' },
282
+ * // fields: ['email', 'password'],
283
+ * // intent: 'login',
284
+ * // }
285
+ * ```
286
+ */
287
+ function parseSubmission(formData, options) {
288
+ var _options$intentName;
289
+ var intentName = (_options$intentName = options === null || options === void 0 ? void 0 : options.intentName) !== null && _options$intentName !== void 0 ? _options$intentName : submission.INTENT;
290
+ var submission$1 = {
291
+ value: {},
292
+ fields: [],
293
+ intent: null
294
+ };
295
+ var _loop = function _loop() {
296
+ var _options$skipEntry;
297
+ if (_name !== intentName && !(options !== null && options !== void 0 && (_options$skipEntry = options.skipEntry) !== null && _options$skipEntry !== void 0 && _options$skipEntry.call(options, _name))) {
298
+ var _value2 = formData.getAll(_name);
299
+ setValue(submission$1.value, _name, () => _value2.length > 1 ? _value2 : _value2[0]);
300
+ submission$1.fields.push(_name);
301
+ }
302
+ };
303
+ for (var _name of new Set(formData.keys())) {
304
+ _loop();
305
+ }
306
+ if (intentName) {
307
+ // We take the first value of the intent field if it exists.
308
+ var intent = formData.get(intentName);
309
+ if (typeof intent === 'string') {
310
+ submission$1.intent = intent;
311
+ }
312
+ }
313
+ return submission$1;
314
+ }
315
+ function defaultSerialize(value) {
316
+ if (typeof value === 'string' || isGlobalInstance(value, 'File')) {
317
+ return value;
318
+ }
319
+ if (typeof value === 'boolean') {
320
+ return value ? 'on' : undefined;
321
+ }
322
+ if (value instanceof Date) {
323
+ return value.toISOString();
324
+ }
325
+ return value === null || value === void 0 ? void 0 : value.toString();
326
+ }
327
+
328
+ /**
329
+ * A utility function that checks whether the current form data differs from the default values.
330
+ *
331
+ * @see https://conform.guide/api/react/future/isDirty
332
+ * @example Enable a submit button only if the form is dirty
333
+ *
334
+ * ```tsx
335
+ * const dirty = useFormData(
336
+ * formRef,
337
+ * (formData) => isDirty(formData, { defaultValue }) ?? false,
338
+ * );
339
+ *
340
+ * return (
341
+ * <button type="submit" disabled={!dirty}>
342
+ * Save changes
343
+ * </button>
344
+ * );
345
+ * ```
346
+ */
347
+ function isDirty(
348
+ /**
349
+ * The current form data to compare. It can be:
350
+ *
351
+ * - A `FormData` object
352
+ * - A `URLSearchParams` object
353
+ * - A plain object that was parsed from form data (i.e. `submission.payload`)
354
+ */
355
+ formData, options) {
356
+ var _options$serialize;
357
+ if (!formData) {
358
+ return;
359
+ }
360
+ var formValue = formData instanceof FormData || formData instanceof URLSearchParams ? parseSubmission(formData, {
361
+ intentName: options === null || options === void 0 ? void 0 : options.intentName,
362
+ skipEntry: options === null || options === void 0 ? void 0 : options.skipEntry
363
+ }).value : formData;
364
+ var defaultValue = options === null || options === void 0 ? void 0 : options.defaultValue;
365
+ var serialize = (_options$serialize = options === null || options === void 0 ? void 0 : options.serialize) !== null && _options$serialize !== void 0 ? _options$serialize : defaultSerialize;
366
+ function normalize(value) {
367
+ if (Array.isArray(value)) {
368
+ if (value.length === 0) {
369
+ return undefined;
370
+ }
371
+ var array = value.map(normalize);
372
+ if (array.length === 1 && (typeof array[0] === 'string' || array[0] === undefined)) {
373
+ return array[0];
374
+ }
375
+ return array;
376
+ }
377
+ if (isPlainObject(value)) {
378
+ var entries = Object.entries(value).reduce((list, _ref) => {
379
+ var [key, value] = _ref;
380
+ var normalizedValue = normalize(value);
381
+ if (typeof normalizedValue !== 'undefined') {
382
+ list.push([key, normalizedValue]);
383
+ }
384
+ return list;
385
+ }, []);
386
+ if (entries.length === 0) {
387
+ return undefined;
388
+ }
389
+ return Object.fromEntries(entries);
390
+ }
391
+
392
+ // If the value is null or undefined, treat it as undefined
393
+ if (value == null) {
394
+ return undefined;
395
+ }
396
+
397
+ // Removes empty strings, so that bpth empty string and undefined are treated as the same
398
+ if (typeof value === 'string' && value === '') {
399
+ return undefined;
400
+ }
401
+
402
+ // Remove empty File as well, which happens if no File was selected
403
+ if (isGlobalInstance(value, 'File') && value.name === '' && value.size === 0) {
404
+ return undefined;
405
+ }
406
+ return serialize(value, defaultSerialize);
407
+ }
408
+ return !deepEqual(normalize(formValue), normalize(defaultValue));
409
+ }
410
+
245
411
  exports.deepEqual = deepEqual;
412
+ exports.defaultSerialize = defaultSerialize;
246
413
  exports.flatten = flatten;
247
414
  exports.formatName = formatName;
248
415
  exports.formatPaths = formatPaths;
@@ -250,8 +417,10 @@ exports.getChildPaths = getChildPaths;
250
417
  exports.getFormData = getFormData;
251
418
  exports.getPaths = getPaths;
252
419
  exports.getValue = getValue;
420
+ exports.isDirty = isDirty;
253
421
  exports.isGlobalInstance = isGlobalInstance;
254
422
  exports.isPlainObject = isPlainObject;
255
423
  exports.isPrefix = isPrefix;
256
424
  exports.normalize = normalize;
425
+ exports.parseSubmission = parseSubmission;
257
426
  exports.setValue = setValue;
package/dist/formdata.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { INTENT } from './submission.mjs';
2
+
1
3
  /**
2
4
  * Construct a form data with the submitter value.
3
5
  * It utilizes the submitter argument on the FormData constructor from modern browsers
@@ -100,11 +102,11 @@ function setValue(target, name, valueFn) {
100
102
  var index = -1;
101
103
  var pointer = target;
102
104
  while (pointer != null && ++index < length) {
103
- var key = paths[index];
105
+ var _key = paths[index];
104
106
  var nextKey = paths[index + 1];
105
- var newValue = index != lastIndex ? Object.prototype.hasOwnProperty.call(pointer, key) && pointer[key] !== null ? pointer[key] : typeof nextKey === 'number' ? [] : {} : valueFn(pointer[key]);
106
- pointer[key] = newValue;
107
- pointer = pointer[key];
107
+ var newValue = index != lastIndex ? Object.prototype.hasOwnProperty.call(pointer, _key) && pointer[_key] !== null ? pointer[_key] : typeof nextKey === 'number' ? [] : {} : valueFn(pointer[_key]);
108
+ pointer[_key] = newValue;
109
+ pointer = pointer[_key];
108
110
  }
109
111
  }
110
112
 
@@ -191,8 +193,8 @@ function flatten(data) {
191
193
  process(data[i], "".concat(prefix, "[").concat(i, "]"));
192
194
  }
193
195
  } else if (isPlainObject(data)) {
194
- for (var [key, _value] of Object.entries(data)) {
195
- process(_value, prefix ? "".concat(prefix, ".").concat(key) : key);
196
+ for (var [_key2, _value] of Object.entries(data)) {
197
+ process(_value, prefix ? "".concat(prefix, ".").concat(_key2) : _key2);
196
198
  }
197
199
  }
198
200
  }
@@ -202,34 +204,36 @@ function flatten(data) {
202
204
  }
203
205
  return result;
204
206
  }
205
- function deepEqual(prev, next) {
206
- if (prev === next) {
207
+ function deepEqual(left, right) {
208
+ if (Object.is(left, right)) {
207
209
  return true;
208
210
  }
209
- if (!prev || !next) {
211
+ if (left == null || right == null) {
210
212
  return false;
211
213
  }
212
- if (Array.isArray(prev) && Array.isArray(next)) {
213
- if (prev.length !== next.length) {
214
+
215
+ // Compare plain objects
216
+ if (isPlainObject(left) && isPlainObject(right)) {
217
+ var prevKeys = Object.keys(left);
218
+ var nextKeys = Object.keys(right);
219
+ if (prevKeys.length !== nextKeys.length) {
214
220
  return false;
215
221
  }
216
- for (var i = 0; i < prev.length; i++) {
217
- if (!deepEqual(prev[i], next[i])) {
222
+ for (var _key3 of prevKeys) {
223
+ if (!Object.prototype.hasOwnProperty.call(right, _key3) || !deepEqual(left[_key3], right[_key3])) {
218
224
  return false;
219
225
  }
220
226
  }
221
227
  return true;
222
228
  }
223
- if (isPlainObject(prev) && isPlainObject(next)) {
224
- var prevKeys = Object.keys(prev);
225
- var nextKeys = Object.keys(next);
226
- if (prevKeys.length !== nextKeys.length) {
229
+
230
+ // Compare arrays
231
+ if (Array.isArray(left) && Array.isArray(right)) {
232
+ if (left.length !== right.length) {
227
233
  return false;
228
234
  }
229
- for (var key of prevKeys) {
230
- if (!Object.prototype.hasOwnProperty.call(next, key) ||
231
- // @ts-expect-error FIXME
232
- !deepEqual(prev[key], next[key])) {
235
+ for (var i = 0; i < left.length; i++) {
236
+ if (!deepEqual(left[i], right[i])) {
233
237
  return false;
234
238
  }
235
239
  }
@@ -238,4 +242,166 @@ function deepEqual(prev, next) {
238
242
  return false;
239
243
  }
240
244
 
241
- export { deepEqual, flatten, formatName, formatPaths, getChildPaths, getFormData, getPaths, getValue, isGlobalInstance, isPlainObject, isPrefix, normalize, setValue };
245
+ /**
246
+ * The form value of a submission. This is usually constructed from a FormData or URLSearchParams.
247
+ * It may contains JSON primitives if the value is updated based on a form intent.
248
+ */
249
+
250
+ /**
251
+ * The data of a form submission.
252
+ */
253
+
254
+ /**
255
+ * Parse `FormData` or `URLSearchParams` into a submission object.
256
+ * This function structures the form values based on the naming convention.
257
+ * It also includes all the field names and the intent if the `intentName` option is provided.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * const formData = new FormData();
262
+ *
263
+ * formData.append('email', 'test@example.com');
264
+ * formData.append('password', 'secret');
265
+ *
266
+ * parseSubmission(formData)
267
+ * // {
268
+ * // value: { email: 'test@example.com', password: 'secret' },
269
+ * // fields: ['email', 'password'],
270
+ * // intent: null,
271
+ * // }
272
+ *
273
+ * // If you have an intent field
274
+ * formData.append('intent', 'login');
275
+ * parseSubmission(formData, { intentName: 'intent' })
276
+ * // {
277
+ * // value: { email: 'test@example.com', password: 'secret' },
278
+ * // fields: ['email', 'password'],
279
+ * // intent: 'login',
280
+ * // }
281
+ * ```
282
+ */
283
+ function parseSubmission(formData, options) {
284
+ var _options$intentName;
285
+ var intentName = (_options$intentName = options === null || options === void 0 ? void 0 : options.intentName) !== null && _options$intentName !== void 0 ? _options$intentName : INTENT;
286
+ var submission = {
287
+ value: {},
288
+ fields: [],
289
+ intent: null
290
+ };
291
+ var _loop = function _loop() {
292
+ var _options$skipEntry;
293
+ if (_name !== intentName && !(options !== null && options !== void 0 && (_options$skipEntry = options.skipEntry) !== null && _options$skipEntry !== void 0 && _options$skipEntry.call(options, _name))) {
294
+ var _value2 = formData.getAll(_name);
295
+ setValue(submission.value, _name, () => _value2.length > 1 ? _value2 : _value2[0]);
296
+ submission.fields.push(_name);
297
+ }
298
+ };
299
+ for (var _name of new Set(formData.keys())) {
300
+ _loop();
301
+ }
302
+ if (intentName) {
303
+ // We take the first value of the intent field if it exists.
304
+ var intent = formData.get(intentName);
305
+ if (typeof intent === 'string') {
306
+ submission.intent = intent;
307
+ }
308
+ }
309
+ return submission;
310
+ }
311
+ function defaultSerialize(value) {
312
+ if (typeof value === 'string' || isGlobalInstance(value, 'File')) {
313
+ return value;
314
+ }
315
+ if (typeof value === 'boolean') {
316
+ return value ? 'on' : undefined;
317
+ }
318
+ if (value instanceof Date) {
319
+ return value.toISOString();
320
+ }
321
+ return value === null || value === void 0 ? void 0 : value.toString();
322
+ }
323
+
324
+ /**
325
+ * A utility function that checks whether the current form data differs from the default values.
326
+ *
327
+ * @see https://conform.guide/api/react/future/isDirty
328
+ * @example Enable a submit button only if the form is dirty
329
+ *
330
+ * ```tsx
331
+ * const dirty = useFormData(
332
+ * formRef,
333
+ * (formData) => isDirty(formData, { defaultValue }) ?? false,
334
+ * );
335
+ *
336
+ * return (
337
+ * <button type="submit" disabled={!dirty}>
338
+ * Save changes
339
+ * </button>
340
+ * );
341
+ * ```
342
+ */
343
+ function isDirty(
344
+ /**
345
+ * The current form data to compare. It can be:
346
+ *
347
+ * - A `FormData` object
348
+ * - A `URLSearchParams` object
349
+ * - A plain object that was parsed from form data (i.e. `submission.payload`)
350
+ */
351
+ formData, options) {
352
+ var _options$serialize;
353
+ if (!formData) {
354
+ return;
355
+ }
356
+ var formValue = formData instanceof FormData || formData instanceof URLSearchParams ? parseSubmission(formData, {
357
+ intentName: options === null || options === void 0 ? void 0 : options.intentName,
358
+ skipEntry: options === null || options === void 0 ? void 0 : options.skipEntry
359
+ }).value : formData;
360
+ var defaultValue = options === null || options === void 0 ? void 0 : options.defaultValue;
361
+ var serialize = (_options$serialize = options === null || options === void 0 ? void 0 : options.serialize) !== null && _options$serialize !== void 0 ? _options$serialize : defaultSerialize;
362
+ function normalize(value) {
363
+ if (Array.isArray(value)) {
364
+ if (value.length === 0) {
365
+ return undefined;
366
+ }
367
+ var array = value.map(normalize);
368
+ if (array.length === 1 && (typeof array[0] === 'string' || array[0] === undefined)) {
369
+ return array[0];
370
+ }
371
+ return array;
372
+ }
373
+ if (isPlainObject(value)) {
374
+ var entries = Object.entries(value).reduce((list, _ref) => {
375
+ var [key, value] = _ref;
376
+ var normalizedValue = normalize(value);
377
+ if (typeof normalizedValue !== 'undefined') {
378
+ list.push([key, normalizedValue]);
379
+ }
380
+ return list;
381
+ }, []);
382
+ if (entries.length === 0) {
383
+ return undefined;
384
+ }
385
+ return Object.fromEntries(entries);
386
+ }
387
+
388
+ // If the value is null or undefined, treat it as undefined
389
+ if (value == null) {
390
+ return undefined;
391
+ }
392
+
393
+ // Removes empty strings, so that bpth empty string and undefined are treated as the same
394
+ if (typeof value === 'string' && value === '') {
395
+ return undefined;
396
+ }
397
+
398
+ // Remove empty File as well, which happens if no File was selected
399
+ if (isGlobalInstance(value, 'File') && value.name === '' && value.size === 0) {
400
+ return undefined;
401
+ }
402
+ return serialize(value, defaultSerialize);
403
+ }
404
+ return !deepEqual(normalize(formValue), normalize(defaultValue));
405
+ }
406
+
407
+ export { deepEqual, defaultSerialize, flatten, formatName, formatPaths, getChildPaths, getFormData, getPaths, getValue, isDirty, isGlobalInstance, isPlainObject, isPrefix, normalize, parseSubmission, setValue };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { type Combine, type Constraint, type ControlButtonProps, type FormId, type FieldName, type DefaultValue, type FormValue, type FormOptions, type FormState, type FormContext, type SubscriptionSubject, type SubscriptionScope, createFormContext as unstable_createFormContext, } from './form';
2
2
  export { type FieldElement, isFieldElement, updateField as unstable_updateField, createFileList, createGlobalFormsObserver as unstable_createGlobalFormsObserver, focus as unstable_focus, change as unstable_change, blur as unstable_blur, } from './dom';
3
3
  export { type Submission, type SubmissionResult, type Intent, INTENT, STATE, serializeIntent, parse, } from './submission';
4
- export { getPaths, formatPaths, isPrefix, isGlobalInstance, deepEqual as unstable_deepEqual, } from './formdata';
4
+ export { getFormData, getPaths, formatPaths, isPrefix, isGlobalInstance, deepEqual as unstable_deepEqual, isDirty as unstable_isDirty, } from './formdata';
5
5
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -22,7 +22,9 @@ exports.STATE = submission.STATE;
22
22
  exports.parse = submission.parse;
23
23
  exports.serializeIntent = submission.serializeIntent;
24
24
  exports.formatPaths = formdata.formatPaths;
25
+ exports.getFormData = formdata.getFormData;
25
26
  exports.getPaths = formdata.getPaths;
26
27
  exports.isGlobalInstance = formdata.isGlobalInstance;
27
28
  exports.isPrefix = formdata.isPrefix;
28
29
  exports.unstable_deepEqual = formdata.deepEqual;
30
+ exports.unstable_isDirty = formdata.isDirty;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export { createFormContext as unstable_createFormContext } from './form.mjs';
2
2
  export { createFileList, isFieldElement, blur as unstable_blur, change as unstable_change, createGlobalFormsObserver as unstable_createGlobalFormsObserver, focus as unstable_focus, updateField as unstable_updateField } from './dom.mjs';
3
3
  export { INTENT, STATE, parse, serializeIntent } from './submission.mjs';
4
- export { formatPaths, getPaths, isGlobalInstance, isPrefix, deepEqual as unstable_deepEqual } from './formdata.mjs';
4
+ export { formatPaths, getFormData, getPaths, isGlobalInstance, isPrefix, deepEqual as unstable_deepEqual, isDirty as unstable_isDirty } from './formdata.mjs';
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "A set of opinionated helpers built on top of the Constraint Validation API",
4
4
  "homepage": "https://conform.guide",
5
5
  "license": "MIT",
6
- "version": "1.7.2",
6
+ "version": "1.8.0",
7
7
  "main": "./dist/index.js",
8
8
  "module": "./dist/index.mjs",
9
9
  "types": "./dist/index.d.ts",