@samline/forms 2.7.1 → 2.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +302 -0
  2. package/README.md +110 -122
  3. package/dist/browser/global.d.ts +1 -13
  4. package/dist/browser/global.global.js +12 -6179
  5. package/dist/browser/global.global.js.map +1 -1
  6. package/dist/browser/index.cjs +125 -56
  7. package/dist/browser/index.cjs.map +1 -1
  8. package/dist/browser/index.js +1 -1
  9. package/dist/{chunk-MMHX34WB.js → chunk-23VZAYA3.js} +126 -57
  10. package/dist/chunk-23VZAYA3.js.map +1 -0
  11. package/dist/index.cjs +125 -56
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.js +1 -1
  14. package/package.json +35 -7
  15. package/dist/chunk-MMHX34WB.js.map +0 -1
  16. package/docs/api/add-cleanup.md +0 -52
  17. package/docs/api/append.md +0 -96
  18. package/docs/api/auto-submit.md +0 -91
  19. package/docs/api/clear-errors.md +0 -69
  20. package/docs/api/destroy.md +0 -80
  21. package/docs/api/disable-auto-submit.md +0 -65
  22. package/docs/api/element.md +0 -78
  23. package/docs/api/form.md +0 -156
  24. package/docs/api/format.md +0 -270
  25. package/docs/api/get-data.md +0 -90
  26. package/docs/api/get-field.md +0 -87
  27. package/docs/api/get-state.md +0 -111
  28. package/docs/api/get-value.md +0 -114
  29. package/docs/api/index.md +0 -55
  30. package/docs/api/observe.md +0 -126
  31. package/docs/api/on-submit.md +0 -94
  32. package/docs/api/parse-form-data.md +0 -94
  33. package/docs/api/prefill.md +0 -89
  34. package/docs/api/regex.md +0 -155
  35. package/docs/api/reset.md +0 -61
  36. package/docs/api/revalidate.md +0 -52
  37. package/docs/api/set-errors.md +0 -97
  38. package/docs/api/set-value.md +0 -120
  39. package/docs/api/subscribe.md +0 -107
  40. package/docs/api/unwatch.md +0 -74
  41. package/docs/api/validate-field-value.md +0 -144
  42. package/docs/api/validate-values.md +0 -103
  43. package/docs/api/validate.md +0 -102
  44. package/docs/api/watch.md +0 -117
  45. package/docs/browser.md +0 -156
  46. package/docs/css-styling.md +0 -191
  47. package/docs/getting-started.md +0 -222
  48. package/docs/options.md +0 -326
  49. package/docs/recipes.md +0 -531
  50. package/docs/typescript.md +0 -615
  51. package/docs/vanilla.md +0 -12
  52. /package/{LICENSE → LICENSE.md} +0 -0
@@ -40,7 +40,7 @@ module.exports = __toCommonJS(browser_exports);
40
40
 
41
41
  // src/api/append.ts
42
42
  var createAppend = (state2, helpers) => ({ tag, content, class: className, atStart = false }) => {
43
- if (!state2.element) return null;
43
+ if (!state2.element || state2.isDestroyed) return null;
44
44
  helpers.clearFieldCache();
45
45
  if (className) {
46
46
  const classSelector = className.trim().split(/\s+/)[0];
@@ -76,6 +76,7 @@ var createAddCleanup = (state2, _helpers) => (cleanup) => {
76
76
 
77
77
  // src/api/auto-submit.ts
78
78
  var createAutoSubmit = (state2, helpers) => (next = true) => {
79
+ if (state2.isDestroyed) return state2.api;
79
80
  state2.autoSubmitEnabled = next !== false;
80
81
  if (!state2.autoSubmitEnabled && state2.autoSubmitTimer) {
81
82
  clearTimeout(state2.autoSubmitTimer);
@@ -88,6 +89,7 @@ var createAutoSubmit = (state2, helpers) => (next = true) => {
88
89
 
89
90
  // src/api/clear-errors.ts
90
91
  var createClearErrors = (state2, helpers) => (fields) => {
92
+ if (state2.isDestroyed) return state2.api;
91
93
  if (!fields) {
92
94
  state2.manualErrors = {};
93
95
  helpers.syncVisualState();
@@ -214,6 +216,7 @@ var isFormatChar = (char) => {
214
216
  return true;
215
217
  };
216
218
  var restoreCursor = (field, position) => {
219
+ if (field.ownerDocument.activeElement !== field) return;
217
220
  const max = field.value.length;
218
221
  const safe = clamp(position, 0, max);
219
222
  const apply = () => {
@@ -608,7 +611,7 @@ var mergeErrors = (left, right) => {
608
611
  var createGetState = (state2, helpers) => () => {
609
612
  const values = helpers.getValues();
610
613
  const errors = cloneErrors(helpers.getMergedErrors());
611
- const filledFields = helpers.getTrackedFieldNames().filter((name) => {
614
+ const filledFields = Object.keys(values).filter((name) => {
612
615
  const value = values[name];
613
616
  if (Array.isArray(value)) return value.length > 0;
614
617
  if (typeof value === "string") return value.trim().length > 0;
@@ -781,6 +784,7 @@ var createGetValue = (state2, helpers) => (name) => readFieldValue(helpers.getFi
781
784
 
782
785
  // src/api/observe.ts
783
786
  var createObserve = (state2, helpers) => (field, callback) => {
787
+ if (state2.isDestroyed) return () => void 0;
784
788
  const callbacks = state2.watchedFields.get(field) ?? /* @__PURE__ */ new Set();
785
789
  callbacks.add(callback);
786
790
  state2.watchedFields.set(field, callbacks);
@@ -798,44 +802,49 @@ var createObserve = (state2, helpers) => (field, callback) => {
798
802
 
799
803
  // src/api/on-submit.ts
800
804
  var createOnSubmit = (state2, _helpers) => (callback, preventDefault = true) => {
805
+ if (state2.isDestroyed) return state2.api;
801
806
  state2.submitHandlers.add({ callback, preventDefault });
802
807
  return state2.api;
803
808
  };
804
809
 
805
810
  // src/api/prefill.ts
806
- var createPrefill = (state2, _helpers) => (fieldName) => {
807
- if (!state2.element) return state2.api;
811
+ var createPrefill = (state2, helpers) => (fieldName) => {
812
+ if (!state2.element || state2.isDestroyed) return state2.api;
808
813
  const params = new URLSearchParams(window.location.search);
809
- params.forEach((value, key) => {
810
- if (fieldName && fieldName !== key) return;
811
- state2.api.setValue(key, value);
812
- });
814
+ const keys = Array.from(new Set(params.keys())).filter(
815
+ (key) => !fieldName || fieldName === key
816
+ );
817
+ if (keys.length === 0) return state2.api;
818
+ state2.autoSubmitSuspended = true;
819
+ try {
820
+ for (const key of keys) {
821
+ const values = params.getAll(key);
822
+ state2.api.setValue(key, values.length > 1 ? values : values[0]);
823
+ }
824
+ } finally {
825
+ state2.autoSubmitSuspended = false;
826
+ }
827
+ helpers.scheduleAutoSubmit();
813
828
  return state2.api;
814
829
  };
815
830
 
816
831
  // src/api/reset.ts
817
832
  var createReset = (state2, helpers) => () => {
818
- if (!state2.element) return state2.api;
819
- state2.element.reset();
820
- resetFormattedFields(state2);
821
- state2.manualErrors = {};
822
- state2.validationErrors = {};
823
- state2.validationGroupErrors = {};
824
- state2.validationElementErrors.clear();
825
- clearAttributes(state2.element, [
826
- state2.attributes.error,
827
- state2.attributes.filled,
828
- "aria-invalid"
829
- ]);
830
- helpers.syncVisualState();
831
- helpers.notifySubscribers();
833
+ if (!state2.element || state2.isDestroyed) return state2.api;
834
+ state2.isResetting = true;
835
+ try {
836
+ state2.element.reset();
837
+ } finally {
838
+ state2.isResetting = false;
839
+ }
840
+ helpers.applyReset();
832
841
  return state2.api;
833
842
  };
834
843
 
835
844
  // src/api/validate.ts
836
845
  var createValidate = (state2, helpers) => (fields) => {
846
+ if (state2.isDestroyed) return { isValid: true, errors: {} };
837
847
  state2.isValidated = true;
838
- helpers.syncVisualState(fields);
839
848
  return helpers.validateNames(fields);
840
849
  };
841
850
 
@@ -844,6 +853,7 @@ var createRevalidate = createValidate;
844
853
 
845
854
  // src/api/set-errors.ts
846
855
  var createSetErrors = (state2, helpers) => (fields) => {
856
+ if (state2.isDestroyed) return state2.api;
847
857
  if (Array.isArray(fields)) {
848
858
  state2.manualErrors = {
849
859
  ...state2.manualErrors,
@@ -862,6 +872,7 @@ var createSetErrors = (state2, helpers) => (fields) => {
862
872
 
863
873
  // src/api/set-value.ts
864
874
  var createSetValue = (state2, helpers) => (name, value) => {
875
+ if (state2.isDestroyed) return state2.api;
865
876
  const fields = helpers.getFieldsByName(name);
866
877
  if (fields.length === 0) return state2.api;
867
878
  writeFieldValue(fields, value);
@@ -873,6 +884,7 @@ var createSetValue = (state2, helpers) => (name, value) => {
873
884
 
874
885
  // src/api/subscribe.ts
875
886
  var createSubscribe = (state2, _helpers) => (listener) => {
887
+ if (state2.isDestroyed) return () => void 0;
876
888
  state2.subscribers.add(listener);
877
889
  listener(state2.api.getState());
878
890
  return () => {
@@ -1068,6 +1080,9 @@ var createFormController = (target, options = {}) => {
1068
1080
  submitCount: 0,
1069
1081
  pendingSubmitCount: 0,
1070
1082
  autoSubmitTimer: null,
1083
+ isAutoSubmitting: false,
1084
+ autoSubmitSuspended: false,
1085
+ isResetting: false,
1071
1086
  isDestroyed: false,
1072
1087
  listeners: [],
1073
1088
  mutationObserver: null,
@@ -1112,12 +1127,20 @@ var createFormController = (target, options = {}) => {
1112
1127
  state2.fieldCache.set(name, fields);
1113
1128
  return fields;
1114
1129
  };
1115
- const getTrackedFieldNames = () => {
1116
- if (!state2.element) return Object.keys(state2.validators);
1117
- const names = new Set(Object.keys(state2.validators));
1130
+ const indexFieldsByName = () => {
1131
+ const index = /* @__PURE__ */ new Map();
1132
+ if (!state2.element) return index;
1118
1133
  for (const field of getNamedFields(state2.element)) {
1119
- names.add(field.name);
1134
+ const bucket = index.get(field.name);
1135
+ if (bucket) bucket.push(field);
1136
+ else index.set(field.name, [field]);
1120
1137
  }
1138
+ return index;
1139
+ };
1140
+ const getTrackedFieldNames = (index = indexFieldsByName()) => {
1141
+ if (!state2.element) return Object.keys(state2.validators);
1142
+ const names = new Set(Object.keys(state2.validators));
1143
+ for (const name of index.keys()) names.add(name);
1121
1144
  for (const [canonical, entry] of state2.formattedFields) {
1122
1145
  names.add(canonical);
1123
1146
  if (entry.displayName && entry.displayName !== canonical) {
@@ -1128,8 +1151,9 @@ var createFormController = (target, options = {}) => {
1128
1151
  };
1129
1152
  const getValues = () => {
1130
1153
  const values = {};
1131
- for (const name of getTrackedFieldNames()) {
1132
- values[name] = readFieldValue(getFieldsByName(name));
1154
+ const index = indexFieldsByName();
1155
+ for (const name of getTrackedFieldNames(index)) {
1156
+ values[name] = readFieldValue(index.get(name) ?? []);
1133
1157
  }
1134
1158
  return values;
1135
1159
  };
@@ -1222,29 +1246,54 @@ var createFormController = (target, options = {}) => {
1222
1246
  const snapshot = state2.api.getState();
1223
1247
  callbacks.forEach((callback) => callback(value, fieldElement, state2.element, snapshot));
1224
1248
  };
1249
+ const runAutoSubmit = () => {
1250
+ state2.autoSubmitTimer = null;
1251
+ if (!state2.element || state2.isDestroyed || !state2.autoSubmitEnabled) return;
1252
+ state2.isAutoSubmitting = true;
1253
+ try {
1254
+ submitForm(state2.element);
1255
+ } finally {
1256
+ state2.isAutoSubmitting = false;
1257
+ }
1258
+ };
1225
1259
  const scheduleAutoSubmit = () => {
1226
- if (!state2.element || !state2.autoSubmitEnabled) return;
1260
+ if (!state2.element || !state2.autoSubmitEnabled || state2.autoSubmitSuspended) return;
1227
1261
  if (state2.autoSubmitTimer) clearTimeout(state2.autoSubmitTimer);
1262
+ state2.autoSubmitTimer = null;
1228
1263
  if (state2.autoSubmitDebounce > 0) {
1229
- state2.autoSubmitTimer = setTimeout(
1230
- () => submitForm(state2.element),
1231
- state2.autoSubmitDebounce
1232
- );
1264
+ state2.autoSubmitTimer = setTimeout(runAutoSubmit, state2.autoSubmitDebounce);
1233
1265
  return;
1234
1266
  }
1235
- submitForm(state2.element);
1267
+ runAutoSubmit();
1268
+ };
1269
+ const emitAllFieldWatchers = () => {
1270
+ for (const name of Array.from(state2.watchedFields.keys())) emitFieldWatchers(name);
1271
+ };
1272
+ const applyReset = () => {
1273
+ if (!state2.element || state2.isDestroyed) return;
1274
+ clearFieldCache();
1275
+ resetFormattedFields(state2);
1276
+ state2.manualErrors = {};
1277
+ state2.validationErrors = {};
1278
+ state2.validationGroupErrors = {};
1279
+ state2.validationElementErrors.clear();
1280
+ clearAttributes(state2.element, [attributes.error, attributes.filled, "aria-invalid"]);
1281
+ syncVisualState();
1282
+ emitAllFieldWatchers();
1283
+ notifySubscribers();
1236
1284
  };
1237
1285
  const helpers = {
1238
1286
  notifySubscribers,
1239
1287
  clearFieldCache,
1240
1288
  getFieldsByName,
1241
- getTrackedFieldNames,
1289
+ getTrackedFieldNames: () => getTrackedFieldNames(),
1242
1290
  getValues,
1243
1291
  getMergedErrors,
1244
1292
  syncVisualState,
1245
1293
  validateNames,
1246
1294
  emitFieldWatchers,
1247
- scheduleAutoSubmit
1295
+ scheduleAutoSubmit,
1296
+ applyReset
1248
1297
  };
1249
1298
  const handleDelegatedEvent = (event) => {
1250
1299
  const target2 = event.target;
@@ -1273,6 +1322,23 @@ var createFormController = (target, options = {}) => {
1273
1322
  if (target2.form !== state2.element || state2.element?.contains(target2)) return;
1274
1323
  handleDelegatedEvent(event);
1275
1324
  };
1325
+ const isFocusable = (candidate) => {
1326
+ if (!candidate.isConnected || candidate.disabled || candidate.hidden) return false;
1327
+ return !(candidate instanceof HTMLInputElement && candidate.type === "hidden");
1328
+ };
1329
+ const focusFirstInvalidField = (errors) => {
1330
+ const candidates = [];
1331
+ for (const name of Object.keys(errors)) {
1332
+ const fields = getFieldsByName(resolveDisplayNameForName(state2, name)).filter(isFocusable);
1333
+ const elementErrors = state2.validationElementErrors.get(name);
1334
+ const field = fields.find((candidate) => elementErrors?.has(candidate)) ?? fields[0];
1335
+ if (field) candidates.push(field);
1336
+ }
1337
+ candidates.sort(
1338
+ (left, right) => left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
1339
+ );
1340
+ candidates[0]?.focus();
1341
+ };
1276
1342
  const handleSubmitEvent = (event) => {
1277
1343
  if (!state2.element || state2.isDestroyed || !state2.api) return;
1278
1344
  if (normalizedOptions.clearErrorsOnSubmit) state2.manualErrors = {};
@@ -1283,23 +1349,7 @@ var createFormController = (target, options = {}) => {
1283
1349
  state2.submitCount += 1;
1284
1350
  notifySubscribers();
1285
1351
  if (!validation.isValid) {
1286
- for (const name of Object.keys(validation.errors)) {
1287
- const displayName = resolveDisplayNameForName(state2, name);
1288
- const fields = getFieldsByName(displayName);
1289
- const elementErrors = state2.validationElementErrors.get(name);
1290
- const field = fields.find((candidate2) => {
1291
- if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1292
- if (candidate2 instanceof HTMLInputElement && candidate2.type === "hidden") return false;
1293
- return elementErrors?.has(candidate2);
1294
- }) ?? fields.find((candidate2) => {
1295
- if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1296
- return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1297
- });
1298
- if (field) {
1299
- field.focus();
1300
- break;
1301
- }
1302
- }
1352
+ if (!state2.isAutoSubmitting) focusFirstInvalidField(validation.errors);
1303
1353
  return;
1304
1354
  }
1305
1355
  const candidate = event;
@@ -1312,7 +1362,13 @@ var createFormController = (target, options = {}) => {
1312
1362
  try {
1313
1363
  for (const handler of handlers) {
1314
1364
  const result = handler.callback(state2.element, data, formData, snapshot);
1315
- if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1365
+ if (result && typeof result.then === "function") {
1366
+ pending.push(
1367
+ Promise.resolve(result).catch((error) => {
1368
+ console.error("[forms] submit handler failed", error);
1369
+ })
1370
+ );
1371
+ }
1316
1372
  }
1317
1373
  } catch (error) {
1318
1374
  synchronousError = error;
@@ -1328,9 +1384,21 @@ var createFormController = (target, options = {}) => {
1328
1384
  }
1329
1385
  if (hasSynchronousError) throw synchronousError;
1330
1386
  };
1387
+ const handleResetEvent = (event) => {
1388
+ if (state2.isResetting) return;
1389
+ const timer = setTimeout(() => {
1390
+ state2.cleanups.delete(cancel);
1391
+ if (!event.defaultPrevented) applyReset();
1392
+ }, 0);
1393
+ const cancel = () => clearTimeout(timer);
1394
+ state2.cleanups.add(cancel);
1395
+ };
1331
1396
  const startMutationObserver = () => {
1332
1397
  if (!state2.element || typeof MutationObserver === "undefined") return;
1333
- state2.mutationObserver = new MutationObserver(() => {
1398
+ const touchesFields = (node) => node instanceof Element && (isFieldElement(node) || node.querySelector("input, select, textarea") !== null);
1399
+ const isRelevant = (record) => record.type === "attributes" || Array.from(record.addedNodes).some(touchesFields) || Array.from(record.removedNodes).some(touchesFields);
1400
+ state2.mutationObserver = new MutationObserver((records) => {
1401
+ if (state2.isDestroyed || !records.some(isRelevant)) return;
1334
1402
  clearFieldCache();
1335
1403
  if (state2.isValidated) {
1336
1404
  validateNames();
@@ -1383,6 +1451,7 @@ var createFormController = (target, options = {}) => {
1383
1451
  addListener2(state2.element, "input", handleDelegatedEvent);
1384
1452
  addListener2(document, "input", handleExternalDelegatedEvent);
1385
1453
  addListener2(state2.element, "submit", handleSubmitEvent);
1454
+ addListener2(state2.element, "reset", handleResetEvent);
1386
1455
  startMutationObserver();
1387
1456
  }
1388
1457
  if (normalizedOptions.autoSubmit) {