@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
package/dist/index.cjs CHANGED
@@ -41,7 +41,7 @@ module.exports = __toCommonJS(src_exports);
41
41
 
42
42
  // src/api/append.ts
43
43
  var createAppend = (state2, helpers) => ({ tag, content, class: className, atStart = false }) => {
44
- if (!state2.element) return null;
44
+ if (!state2.element || state2.isDestroyed) return null;
45
45
  helpers.clearFieldCache();
46
46
  if (className) {
47
47
  const classSelector = className.trim().split(/\s+/)[0];
@@ -77,6 +77,7 @@ var createAddCleanup = (state2, _helpers) => (cleanup) => {
77
77
 
78
78
  // src/api/auto-submit.ts
79
79
  var createAutoSubmit = (state2, helpers) => (next = true) => {
80
+ if (state2.isDestroyed) return state2.api;
80
81
  state2.autoSubmitEnabled = next !== false;
81
82
  if (!state2.autoSubmitEnabled && state2.autoSubmitTimer) {
82
83
  clearTimeout(state2.autoSubmitTimer);
@@ -89,6 +90,7 @@ var createAutoSubmit = (state2, helpers) => (next = true) => {
89
90
 
90
91
  // src/api/clear-errors.ts
91
92
  var createClearErrors = (state2, helpers) => (fields) => {
93
+ if (state2.isDestroyed) return state2.api;
92
94
  if (!fields) {
93
95
  state2.manualErrors = {};
94
96
  helpers.syncVisualState();
@@ -215,6 +217,7 @@ var isFormatChar = (char) => {
215
217
  return true;
216
218
  };
217
219
  var restoreCursor = (field, position) => {
220
+ if (field.ownerDocument.activeElement !== field) return;
218
221
  const max = field.value.length;
219
222
  const safe = clamp(position, 0, max);
220
223
  const apply = () => {
@@ -609,7 +612,7 @@ var mergeErrors = (left, right) => {
609
612
  var createGetState = (state2, helpers) => () => {
610
613
  const values = helpers.getValues();
611
614
  const errors = cloneErrors(helpers.getMergedErrors());
612
- const filledFields = helpers.getTrackedFieldNames().filter((name) => {
615
+ const filledFields = Object.keys(values).filter((name) => {
613
616
  const value = values[name];
614
617
  if (Array.isArray(value)) return value.length > 0;
615
618
  if (typeof value === "string") return value.trim().length > 0;
@@ -782,6 +785,7 @@ var createGetValue = (state2, helpers) => (name) => readFieldValue(helpers.getFi
782
785
 
783
786
  // src/api/observe.ts
784
787
  var createObserve = (state2, helpers) => (field, callback) => {
788
+ if (state2.isDestroyed) return () => void 0;
785
789
  const callbacks = state2.watchedFields.get(field) ?? /* @__PURE__ */ new Set();
786
790
  callbacks.add(callback);
787
791
  state2.watchedFields.set(field, callbacks);
@@ -799,44 +803,49 @@ var createObserve = (state2, helpers) => (field, callback) => {
799
803
 
800
804
  // src/api/on-submit.ts
801
805
  var createOnSubmit = (state2, _helpers) => (callback, preventDefault = true) => {
806
+ if (state2.isDestroyed) return state2.api;
802
807
  state2.submitHandlers.add({ callback, preventDefault });
803
808
  return state2.api;
804
809
  };
805
810
 
806
811
  // src/api/prefill.ts
807
- var createPrefill = (state2, _helpers) => (fieldName) => {
808
- if (!state2.element) return state2.api;
812
+ var createPrefill = (state2, helpers) => (fieldName) => {
813
+ if (!state2.element || state2.isDestroyed) return state2.api;
809
814
  const params = new URLSearchParams(window.location.search);
810
- params.forEach((value, key) => {
811
- if (fieldName && fieldName !== key) return;
812
- state2.api.setValue(key, value);
813
- });
815
+ const keys = Array.from(new Set(params.keys())).filter(
816
+ (key) => !fieldName || fieldName === key
817
+ );
818
+ if (keys.length === 0) return state2.api;
819
+ state2.autoSubmitSuspended = true;
820
+ try {
821
+ for (const key of keys) {
822
+ const values = params.getAll(key);
823
+ state2.api.setValue(key, values.length > 1 ? values : values[0]);
824
+ }
825
+ } finally {
826
+ state2.autoSubmitSuspended = false;
827
+ }
828
+ helpers.scheduleAutoSubmit();
814
829
  return state2.api;
815
830
  };
816
831
 
817
832
  // src/api/reset.ts
818
833
  var createReset = (state2, helpers) => () => {
819
- if (!state2.element) return state2.api;
820
- state2.element.reset();
821
- resetFormattedFields(state2);
822
- state2.manualErrors = {};
823
- state2.validationErrors = {};
824
- state2.validationGroupErrors = {};
825
- state2.validationElementErrors.clear();
826
- clearAttributes(state2.element, [
827
- state2.attributes.error,
828
- state2.attributes.filled,
829
- "aria-invalid"
830
- ]);
831
- helpers.syncVisualState();
832
- helpers.notifySubscribers();
834
+ if (!state2.element || state2.isDestroyed) return state2.api;
835
+ state2.isResetting = true;
836
+ try {
837
+ state2.element.reset();
838
+ } finally {
839
+ state2.isResetting = false;
840
+ }
841
+ helpers.applyReset();
833
842
  return state2.api;
834
843
  };
835
844
 
836
845
  // src/api/validate.ts
837
846
  var createValidate = (state2, helpers) => (fields) => {
847
+ if (state2.isDestroyed) return { isValid: true, errors: {} };
838
848
  state2.isValidated = true;
839
- helpers.syncVisualState(fields);
840
849
  return helpers.validateNames(fields);
841
850
  };
842
851
 
@@ -845,6 +854,7 @@ var createRevalidate = createValidate;
845
854
 
846
855
  // src/api/set-errors.ts
847
856
  var createSetErrors = (state2, helpers) => (fields) => {
857
+ if (state2.isDestroyed) return state2.api;
848
858
  if (Array.isArray(fields)) {
849
859
  state2.manualErrors = {
850
860
  ...state2.manualErrors,
@@ -863,6 +873,7 @@ var createSetErrors = (state2, helpers) => (fields) => {
863
873
 
864
874
  // src/api/set-value.ts
865
875
  var createSetValue = (state2, helpers) => (name, value) => {
876
+ if (state2.isDestroyed) return state2.api;
866
877
  const fields = helpers.getFieldsByName(name);
867
878
  if (fields.length === 0) return state2.api;
868
879
  writeFieldValue(fields, value);
@@ -874,6 +885,7 @@ var createSetValue = (state2, helpers) => (name, value) => {
874
885
 
875
886
  // src/api/subscribe.ts
876
887
  var createSubscribe = (state2, _helpers) => (listener) => {
888
+ if (state2.isDestroyed) return () => void 0;
877
889
  state2.subscribers.add(listener);
878
890
  listener(state2.api.getState());
879
891
  return () => {
@@ -1084,6 +1096,9 @@ var createFormController = (target, options = {}) => {
1084
1096
  submitCount: 0,
1085
1097
  pendingSubmitCount: 0,
1086
1098
  autoSubmitTimer: null,
1099
+ isAutoSubmitting: false,
1100
+ autoSubmitSuspended: false,
1101
+ isResetting: false,
1087
1102
  isDestroyed: false,
1088
1103
  listeners: [],
1089
1104
  mutationObserver: null,
@@ -1128,12 +1143,20 @@ var createFormController = (target, options = {}) => {
1128
1143
  state2.fieldCache.set(name, fields);
1129
1144
  return fields;
1130
1145
  };
1131
- const getTrackedFieldNames = () => {
1132
- if (!state2.element) return Object.keys(state2.validators);
1133
- const names = new Set(Object.keys(state2.validators));
1146
+ const indexFieldsByName = () => {
1147
+ const index = /* @__PURE__ */ new Map();
1148
+ if (!state2.element) return index;
1134
1149
  for (const field of getNamedFields(state2.element)) {
1135
- names.add(field.name);
1150
+ const bucket = index.get(field.name);
1151
+ if (bucket) bucket.push(field);
1152
+ else index.set(field.name, [field]);
1136
1153
  }
1154
+ return index;
1155
+ };
1156
+ const getTrackedFieldNames = (index = indexFieldsByName()) => {
1157
+ if (!state2.element) return Object.keys(state2.validators);
1158
+ const names = new Set(Object.keys(state2.validators));
1159
+ for (const name of index.keys()) names.add(name);
1137
1160
  for (const [canonical, entry] of state2.formattedFields) {
1138
1161
  names.add(canonical);
1139
1162
  if (entry.displayName && entry.displayName !== canonical) {
@@ -1144,8 +1167,9 @@ var createFormController = (target, options = {}) => {
1144
1167
  };
1145
1168
  const getValues = () => {
1146
1169
  const values = {};
1147
- for (const name of getTrackedFieldNames()) {
1148
- values[name] = readFieldValue(getFieldsByName(name));
1170
+ const index = indexFieldsByName();
1171
+ for (const name of getTrackedFieldNames(index)) {
1172
+ values[name] = readFieldValue(index.get(name) ?? []);
1149
1173
  }
1150
1174
  return values;
1151
1175
  };
@@ -1238,29 +1262,54 @@ var createFormController = (target, options = {}) => {
1238
1262
  const snapshot = state2.api.getState();
1239
1263
  callbacks.forEach((callback) => callback(value, fieldElement, state2.element, snapshot));
1240
1264
  };
1265
+ const runAutoSubmit = () => {
1266
+ state2.autoSubmitTimer = null;
1267
+ if (!state2.element || state2.isDestroyed || !state2.autoSubmitEnabled) return;
1268
+ state2.isAutoSubmitting = true;
1269
+ try {
1270
+ submitForm(state2.element);
1271
+ } finally {
1272
+ state2.isAutoSubmitting = false;
1273
+ }
1274
+ };
1241
1275
  const scheduleAutoSubmit = () => {
1242
- if (!state2.element || !state2.autoSubmitEnabled) return;
1276
+ if (!state2.element || !state2.autoSubmitEnabled || state2.autoSubmitSuspended) return;
1243
1277
  if (state2.autoSubmitTimer) clearTimeout(state2.autoSubmitTimer);
1278
+ state2.autoSubmitTimer = null;
1244
1279
  if (state2.autoSubmitDebounce > 0) {
1245
- state2.autoSubmitTimer = setTimeout(
1246
- () => submitForm(state2.element),
1247
- state2.autoSubmitDebounce
1248
- );
1280
+ state2.autoSubmitTimer = setTimeout(runAutoSubmit, state2.autoSubmitDebounce);
1249
1281
  return;
1250
1282
  }
1251
- submitForm(state2.element);
1283
+ runAutoSubmit();
1284
+ };
1285
+ const emitAllFieldWatchers = () => {
1286
+ for (const name of Array.from(state2.watchedFields.keys())) emitFieldWatchers(name);
1287
+ };
1288
+ const applyReset = () => {
1289
+ if (!state2.element || state2.isDestroyed) return;
1290
+ clearFieldCache();
1291
+ resetFormattedFields(state2);
1292
+ state2.manualErrors = {};
1293
+ state2.validationErrors = {};
1294
+ state2.validationGroupErrors = {};
1295
+ state2.validationElementErrors.clear();
1296
+ clearAttributes(state2.element, [attributes.error, attributes.filled, "aria-invalid"]);
1297
+ syncVisualState();
1298
+ emitAllFieldWatchers();
1299
+ notifySubscribers();
1252
1300
  };
1253
1301
  const helpers = {
1254
1302
  notifySubscribers,
1255
1303
  clearFieldCache,
1256
1304
  getFieldsByName,
1257
- getTrackedFieldNames,
1305
+ getTrackedFieldNames: () => getTrackedFieldNames(),
1258
1306
  getValues,
1259
1307
  getMergedErrors,
1260
1308
  syncVisualState,
1261
1309
  validateNames,
1262
1310
  emitFieldWatchers,
1263
- scheduleAutoSubmit
1311
+ scheduleAutoSubmit,
1312
+ applyReset
1264
1313
  };
1265
1314
  const handleDelegatedEvent = (event) => {
1266
1315
  const target2 = event.target;
@@ -1289,6 +1338,23 @@ var createFormController = (target, options = {}) => {
1289
1338
  if (target2.form !== state2.element || state2.element?.contains(target2)) return;
1290
1339
  handleDelegatedEvent(event);
1291
1340
  };
1341
+ const isFocusable = (candidate) => {
1342
+ if (!candidate.isConnected || candidate.disabled || candidate.hidden) return false;
1343
+ return !(candidate instanceof HTMLInputElement && candidate.type === "hidden");
1344
+ };
1345
+ const focusFirstInvalidField = (errors) => {
1346
+ const candidates = [];
1347
+ for (const name of Object.keys(errors)) {
1348
+ const fields = getFieldsByName(resolveDisplayNameForName(state2, name)).filter(isFocusable);
1349
+ const elementErrors = state2.validationElementErrors.get(name);
1350
+ const field = fields.find((candidate) => elementErrors?.has(candidate)) ?? fields[0];
1351
+ if (field) candidates.push(field);
1352
+ }
1353
+ candidates.sort(
1354
+ (left, right) => left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
1355
+ );
1356
+ candidates[0]?.focus();
1357
+ };
1292
1358
  const handleSubmitEvent = (event) => {
1293
1359
  if (!state2.element || state2.isDestroyed || !state2.api) return;
1294
1360
  if (normalizedOptions.clearErrorsOnSubmit) state2.manualErrors = {};
@@ -1299,23 +1365,7 @@ var createFormController = (target, options = {}) => {
1299
1365
  state2.submitCount += 1;
1300
1366
  notifySubscribers();
1301
1367
  if (!validation.isValid) {
1302
- for (const name of Object.keys(validation.errors)) {
1303
- const displayName = resolveDisplayNameForName(state2, name);
1304
- const fields = getFieldsByName(displayName);
1305
- const elementErrors = state2.validationElementErrors.get(name);
1306
- const field = fields.find((candidate2) => {
1307
- if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1308
- if (candidate2 instanceof HTMLInputElement && candidate2.type === "hidden") return false;
1309
- return elementErrors?.has(candidate2);
1310
- }) ?? fields.find((candidate2) => {
1311
- if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1312
- return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1313
- });
1314
- if (field) {
1315
- field.focus();
1316
- break;
1317
- }
1318
- }
1368
+ if (!state2.isAutoSubmitting) focusFirstInvalidField(validation.errors);
1319
1369
  return;
1320
1370
  }
1321
1371
  const candidate = event;
@@ -1328,7 +1378,13 @@ var createFormController = (target, options = {}) => {
1328
1378
  try {
1329
1379
  for (const handler of handlers) {
1330
1380
  const result = handler.callback(state2.element, data, formData, snapshot);
1331
- if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1381
+ if (result && typeof result.then === "function") {
1382
+ pending.push(
1383
+ Promise.resolve(result).catch((error) => {
1384
+ console.error("[forms] submit handler failed", error);
1385
+ })
1386
+ );
1387
+ }
1332
1388
  }
1333
1389
  } catch (error) {
1334
1390
  synchronousError = error;
@@ -1344,9 +1400,21 @@ var createFormController = (target, options = {}) => {
1344
1400
  }
1345
1401
  if (hasSynchronousError) throw synchronousError;
1346
1402
  };
1403
+ const handleResetEvent = (event) => {
1404
+ if (state2.isResetting) return;
1405
+ const timer = setTimeout(() => {
1406
+ state2.cleanups.delete(cancel);
1407
+ if (!event.defaultPrevented) applyReset();
1408
+ }, 0);
1409
+ const cancel = () => clearTimeout(timer);
1410
+ state2.cleanups.add(cancel);
1411
+ };
1347
1412
  const startMutationObserver = () => {
1348
1413
  if (!state2.element || typeof MutationObserver === "undefined") return;
1349
- state2.mutationObserver = new MutationObserver(() => {
1414
+ const touchesFields = (node) => node instanceof Element && (isFieldElement(node) || node.querySelector("input, select, textarea") !== null);
1415
+ const isRelevant = (record) => record.type === "attributes" || Array.from(record.addedNodes).some(touchesFields) || Array.from(record.removedNodes).some(touchesFields);
1416
+ state2.mutationObserver = new MutationObserver((records) => {
1417
+ if (state2.isDestroyed || !records.some(isRelevant)) return;
1350
1418
  clearFieldCache();
1351
1419
  if (state2.isValidated) {
1352
1420
  validateNames();
@@ -1399,6 +1467,7 @@ var createFormController = (target, options = {}) => {
1399
1467
  addListener2(state2.element, "input", handleDelegatedEvent);
1400
1468
  addListener2(document, "input", handleExternalDelegatedEvent);
1401
1469
  addListener2(state2.element, "submit", handleSubmitEvent);
1470
+ addListener2(state2.element, "reset", handleResetEvent);
1402
1471
  startMutationObserver();
1403
1472
  }
1404
1473
  if (normalizedOptions.autoSubmit) {