@samline/forms 2.6.0 → 2.7.1

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 (39) hide show
  1. package/README.md +6 -4
  2. package/dist/browser/global.d.ts +19 -3
  3. package/dist/browser/global.global.js +176 -30
  4. package/dist/browser/global.global.js.map +1 -1
  5. package/dist/browser/index.cjs +176 -30
  6. package/dist/browser/index.cjs.map +1 -1
  7. package/dist/browser/index.d.cts +1 -1
  8. package/dist/browser/index.d.ts +1 -1
  9. package/dist/browser/index.js +1 -1
  10. package/dist/{chunk-L4GRKSQ5.js → chunk-MMHX34WB.js} +181 -31
  11. package/dist/chunk-MMHX34WB.js.map +1 -0
  12. package/dist/index.cjs +180 -30
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +2 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/{registry-BpqJetAt.d.cts → registry-GzopYPCR.d.cts} +20 -4
  18. package/dist/{registry-BpqJetAt.d.ts → registry-GzopYPCR.d.ts} +20 -4
  19. package/docs/api/add-cleanup.md +52 -0
  20. package/docs/api/destroy.md +16 -1
  21. package/docs/api/form.md +4 -2
  22. package/docs/api/format.md +3 -3
  23. package/docs/api/get-state.md +17 -1
  24. package/docs/api/index.md +1 -0
  25. package/docs/api/on-submit.md +7 -3
  26. package/docs/api/reset.md +3 -2
  27. package/docs/api/revalidate.md +3 -1
  28. package/docs/api/subscribe.md +16 -0
  29. package/docs/api/validate-field-value.md +39 -3
  30. package/docs/api/validate-values.md +4 -1
  31. package/docs/api/validate.md +5 -3
  32. package/docs/browser.md +5 -5
  33. package/docs/css-styling.md +5 -1
  34. package/docs/getting-started.md +13 -8
  35. package/docs/options.md +69 -2
  36. package/docs/recipes.md +65 -11
  37. package/docs/typescript.md +47 -8
  38. package/package.json +1 -1
  39. package/dist/chunk-L4GRKSQ5.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -59,6 +59,22 @@ var createAppend = (state2, helpers) => ({ tag, content, class: className, atSta
59
59
  return node;
60
60
  };
61
61
 
62
+ // src/api/add-cleanup.ts
63
+ var createAddCleanup = (state2, _helpers) => (cleanup) => {
64
+ if (state2.isDestroyed) {
65
+ cleanup();
66
+ return () => void 0;
67
+ }
68
+ const registration = () => cleanup();
69
+ state2.cleanups.add(registration);
70
+ let active = true;
71
+ return () => {
72
+ if (!active) return;
73
+ active = false;
74
+ state2.cleanups.delete(registration);
75
+ };
76
+ };
77
+
62
78
  // src/api/auto-submit.ts
63
79
  var createAutoSubmit = (state2, helpers) => (next = true) => {
64
80
  state2.autoSubmitEnabled = next !== false;
@@ -280,6 +296,10 @@ var buildAutoInterpretedOptions = (formatOptions) => ({
280
296
  ...formatOptions ?? {},
281
297
  interpretInputAs: "auto"
282
298
  });
299
+ var buildDisplayInterpretedOptions = (formatOptions) => ({
300
+ ...formatOptions ?? {},
301
+ interpretInputAs: "display"
302
+ });
283
303
  var shouldForceAutoInterpretation = (formatOptions) => formatOptions?.interpretInputAs === void 0;
284
304
  var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
285
305
  const handler = (event) => {
@@ -289,7 +309,7 @@ var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
289
309
  if (!isVisible && !isMirror) return;
290
310
  const rawInput = isMirror ? mirror.value : visible.value;
291
311
  const inputType = readInputType(event);
292
- const sourceOptions = isMirror && shouldForceAutoInterpretation(formatOptions) ? buildAutoInterpretedOptions(formatOptions) : formatOptions;
312
+ const sourceOptions = shouldForceAutoInterpretation(formatOptions) ? isMirror ? buildAutoInterpretedOptions(formatOptions) : buildDisplayInterpretedOptions(formatOptions) : formatOptions;
293
313
  const { formatted, raw } = formatFn(rawInput, formatType, sourceOptions);
294
314
  if (!formatted && !raw) {
295
315
  visible.value = "";
@@ -371,7 +391,8 @@ var applyFormat = async (state2, helpers, config) => {
371
391
  if (entry.handler !== UNBOUND) {
372
392
  const current = entry.visible.value;
373
393
  if (current !== "") {
374
- const { formatted, raw } = formatter.format(current, formatType, formatOptions);
394
+ const rebindOptions = shouldForceAutoInterpretation(formatOptions) ? buildDisplayInterpretedOptions(formatOptions) : formatOptions;
395
+ const { formatted, raw } = formatter.format(current, formatType, rebindOptions);
375
396
  applyFormattedValue(entry.visible, entry.mirror, formatted, raw);
376
397
  }
377
398
  continue;
@@ -486,12 +507,24 @@ var createDestroy = (state2, _helpers) => () => {
486
507
  }
487
508
  state2.mutationObserver?.disconnect();
488
509
  state2.mutationObserver = null;
510
+ const cleanups = Array.from(state2.cleanups).reverse();
511
+ state2.cleanups.clear();
512
+ for (const cleanup of cleanups) {
513
+ try {
514
+ cleanup();
515
+ } catch (error) {
516
+ console.error("[forms] cleanup failed", error);
517
+ }
518
+ }
489
519
  state2.watchedFields.clear();
490
520
  state2.subscribers.clear();
491
521
  state2.submitHandlers.clear();
492
522
  state2.fieldCache.clear();
493
523
  state2.manualErrors = {};
494
524
  state2.validationErrors = {};
525
+ state2.validationGroupErrors = {};
526
+ state2.validationElementErrors.clear();
527
+ state2.pendingSubmitCount = 0;
495
528
  cleanupFormatRegistry(state2);
496
529
  };
497
530
 
@@ -558,6 +591,7 @@ var createEmptyFormState = () => ({
558
591
  isValid: true,
559
592
  isValidated: false,
560
593
  autoSubmit: false,
594
+ isSubmitting: false,
561
595
  submitCount: 0
562
596
  });
563
597
  var cloneErrors = (errors) => Object.fromEntries(
@@ -589,6 +623,7 @@ var createGetState = (state2, helpers) => () => {
589
623
  isValid: Object.keys(errors).length === 0,
590
624
  isValidated: state2.isValidated,
591
625
  autoSubmit: state2.autoSubmitEnabled,
626
+ isSubmitting: state2.pendingSubmitCount > 0,
592
627
  submitCount: state2.submitCount
593
628
  };
594
629
  };
@@ -786,12 +821,14 @@ var createReset = (state2, helpers) => () => {
786
821
  resetFormattedFields(state2);
787
822
  state2.manualErrors = {};
788
823
  state2.validationErrors = {};
824
+ state2.validationGroupErrors = {};
825
+ state2.validationElementErrors.clear();
789
826
  clearAttributes(state2.element, [
790
827
  state2.attributes.error,
791
828
  state2.attributes.filled,
792
829
  "aria-invalid"
793
830
  ]);
794
- if (state2.isValidated) helpers.syncVisualState();
831
+ helpers.syncVisualState();
795
832
  helpers.notifySubscribers();
796
833
  return state2.api;
797
834
  };
@@ -902,14 +939,29 @@ var valuesAreEqual = (left, right) => {
902
939
  }
903
940
  return left === right;
904
941
  };
905
- var validateFieldValue = (field, value, rules, values) => {
942
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
943
+ var parseStrictNumber = (value) => {
944
+ if (typeof value !== "string") return void 0;
945
+ const candidate = value.trim();
946
+ if (!DECIMAL_PATTERN.test(candidate)) return void 0;
947
+ const number = Number(candidate);
948
+ return Number.isFinite(number) ? number : void 0;
949
+ };
950
+ var validateValue = (field, value, rules, values, contextOverrides = {}) => {
906
951
  const errors = [];
907
- const context = { field, value, values };
952
+ const context = {
953
+ field,
954
+ value,
955
+ values,
956
+ ...contextOverrides
957
+ };
908
958
  const required = resolveRule(rules.required);
909
959
  const minLength = resolveRule(rules.minLength);
910
960
  const maxLength = resolveRule(rules.maxLength);
911
961
  const pattern = resolveRule(rules.pattern);
912
- const sameAs = resolveRule(rules.sameAs);
962
+ const numeric = resolveRule(rules.numeric);
963
+ const min = resolveRule(rules.min);
964
+ const max = resolveRule(rules.max);
913
965
  if (required.value && !hasValue(value)) {
914
966
  errors.push(required.message ?? "This field is required.");
915
967
  }
@@ -927,10 +979,17 @@ var validateFieldValue = (field, value, rules, values) => {
927
979
  errors.push(pattern.message ?? "Value does not match the required pattern.");
928
980
  }
929
981
  }
930
- if (sameAs.value !== void 0) {
931
- const otherValue = values[sameAs.value];
932
- if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
933
- errors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
982
+ if (hasValue(value) && (numeric.value || min.value !== void 0 || max.value !== void 0)) {
983
+ const number = parseStrictNumber(value);
984
+ if (number === void 0) {
985
+ errors.push(numeric.message ?? "Value must be a number.");
986
+ } else {
987
+ if (min.value !== void 0 && number < min.value) {
988
+ errors.push(min.message ?? `Minimum value is ${min.value}.`);
989
+ }
990
+ if (max.value !== void 0 && number > max.value) {
991
+ errors.push(max.message ?? `Maximum value is ${max.value}.`);
992
+ }
934
993
  }
935
994
  }
936
995
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
@@ -941,6 +1000,41 @@ var validateFieldValue = (field, value, rules, values) => {
941
1000
  }
942
1001
  return errors;
943
1002
  };
1003
+ var validateFieldValueDetailed = (field, value, rules, values, items) => {
1004
+ const groupErrors = validateValue(field, value, rules, values);
1005
+ const sameAs = resolveRule(rules.sameAs);
1006
+ if (sameAs.value !== void 0) {
1007
+ const otherValue = values[sameAs.value];
1008
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
1009
+ groupErrors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
1010
+ }
1011
+ }
1012
+ const itemErrors = [];
1013
+ if (rules.each) {
1014
+ const sourceItems = items ?? (Array.isArray(value) ? value.map((entry) => ({
1015
+ value: typeof entry === "string" ? entry : [entry]
1016
+ })) : [{ value }]);
1017
+ sourceItems.forEach((item, index) => {
1018
+ const overrides = {
1019
+ index
1020
+ };
1021
+ if (item.element) overrides.element = item.element;
1022
+ const messages = validateValue(field, item.value, rules.each, values, overrides);
1023
+ if (messages.length === 0) return;
1024
+ const error = {
1025
+ index,
1026
+ messages
1027
+ };
1028
+ if (item.element) error.element = item.element;
1029
+ itemErrors.push(error);
1030
+ });
1031
+ }
1032
+ return { groupErrors, itemErrors };
1033
+ };
1034
+ var validateFieldValue = (field, value, rules, values) => {
1035
+ const result = validateFieldValueDetailed(field, value, rules, values);
1036
+ return [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1037
+ };
944
1038
  var validateValues = (values, schema) => {
945
1039
  const errors = {};
946
1040
  for (const [field, rules] of Object.entries(schema)) {
@@ -982,25 +1076,35 @@ var createFormController = (target, options = {}) => {
982
1076
  fieldCache: /* @__PURE__ */ new Map(),
983
1077
  manualErrors: {},
984
1078
  validationErrors: {},
1079
+ validationGroupErrors: {},
1080
+ validationElementErrors: /* @__PURE__ */ new Map(),
985
1081
  isValidated: Boolean(normalizedOptions.autoValidate),
986
1082
  autoSubmitEnabled: false,
987
1083
  autoSubmitDebounce: 0,
988
1084
  submitCount: 0,
1085
+ pendingSubmitCount: 0,
989
1086
  autoSubmitTimer: null,
990
1087
  isDestroyed: false,
991
1088
  listeners: [],
992
1089
  mutationObserver: null,
1090
+ cleanups: /* @__PURE__ */ new Set(),
993
1091
  formattedFields: /* @__PURE__ */ new Map(),
994
1092
  api: null
995
1093
  };
996
1094
  const validationDependents = /* @__PURE__ */ new Map();
997
1095
  for (const [field, rules] of Object.entries(state2.validators)) {
1096
+ const sources = /* @__PURE__ */ new Set();
998
1097
  const sameAs = rules.sameAs;
999
- if (sameAs === void 0) continue;
1000
- const source = typeof sameAs === "string" ? sameAs : sameAs.value;
1001
- const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1002
- dependents.add(field);
1003
- validationDependents.set(source, dependents);
1098
+ if (sameAs !== void 0) {
1099
+ sources.add(typeof sameAs === "string" ? sameAs : sameAs.value);
1100
+ }
1101
+ if (typeof rules.dependsOn === "string") sources.add(rules.dependsOn);
1102
+ else rules.dependsOn?.forEach((source) => sources.add(source));
1103
+ for (const source of sources) {
1104
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1105
+ dependents.add(field);
1106
+ validationDependents.set(source, dependents);
1107
+ }
1004
1108
  }
1005
1109
  const notifySubscribers = () => {
1006
1110
  if (!state2.api) return;
@@ -1049,18 +1153,21 @@ var createFormController = (target, options = {}) => {
1049
1153
  const syncVisualState = (names) => {
1050
1154
  if (!state2.element) return;
1051
1155
  const sourceNames = names ?? getTrackedFieldNames();
1052
- const errors = getMergedErrors();
1053
1156
  for (const name of sourceNames) {
1054
1157
  const displayName = resolveDisplayNameForName(state2, name);
1055
1158
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1056
1159
  const fields = getFieldsByName(displayName);
1057
- const hasError = Boolean(errors[canonical]?.length);
1058
- for (const field of fields) {
1160
+ const hasGroupError = Boolean(
1161
+ state2.validationGroupErrors[canonical]?.length || state2.manualErrors[canonical]?.length
1162
+ );
1163
+ const elementErrors = state2.validationElementErrors.get(canonical);
1164
+ fields.forEach((field) => {
1165
+ const hasError = hasGroupError || Boolean(elementErrors?.get(field)?.length);
1059
1166
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
1060
1167
  applyBooleanAttribute(field, attributes.error, hasError);
1061
1168
  if (hasError) field.setAttribute("aria-invalid", "true");
1062
1169
  else field.removeAttribute("aria-invalid");
1063
- }
1170
+ });
1064
1171
  }
1065
1172
  };
1066
1173
  void readFieldValue;
@@ -1069,17 +1176,38 @@ var createFormController = (target, options = {}) => {
1069
1176
  const targetNames = names ?? Object.keys(state2.validators);
1070
1177
  const values = getValues();
1071
1178
  const nextValidationErrors = names ? cloneErrors(state2.validationErrors) : {};
1179
+ const nextGroupErrors = names ? cloneErrors(state2.validationGroupErrors) : {};
1180
+ const nextElementErrors = names ? new Map(state2.validationElementErrors) : /* @__PURE__ */ new Map();
1072
1181
  for (const name of targetNames) {
1073
1182
  const rules = state2.validators[name];
1074
1183
  if (!rules) {
1075
1184
  delete nextValidationErrors[name];
1185
+ delete nextGroupErrors[name];
1186
+ nextElementErrors.delete(name);
1076
1187
  continue;
1077
1188
  }
1078
- const messages = validateFieldValue(name, values[name], rules, values);
1189
+ const fields = getFieldsByName(name);
1190
+ const displayFields = getFieldsByName(resolveDisplayNameForName(state2, name));
1191
+ const items = fields.map((field, index) => ({
1192
+ value: readFieldValue([field]),
1193
+ element: displayFields[index] ?? field
1194
+ }));
1195
+ const result = validateFieldValueDetailed(name, values[name], rules, values, items);
1196
+ const messages = [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1079
1197
  if (messages.length > 0) nextValidationErrors[name] = messages;
1080
1198
  else delete nextValidationErrors[name];
1199
+ if (result.groupErrors.length > 0) nextGroupErrors[name] = result.groupErrors;
1200
+ else delete nextGroupErrors[name];
1201
+ const perElement = /* @__PURE__ */ new Map();
1202
+ result.itemErrors.forEach((item) => {
1203
+ if (item.element) perElement.set(item.element, item.messages);
1204
+ });
1205
+ if (perElement.size > 0) nextElementErrors.set(name, perElement);
1206
+ else nextElementErrors.delete(name);
1081
1207
  }
1082
1208
  state2.validationErrors = nextValidationErrors;
1209
+ state2.validationGroupErrors = nextGroupErrors;
1210
+ state2.validationElementErrors = nextElementErrors;
1083
1211
  syncVisualState(names);
1084
1212
  return {
1085
1213
  isValid: Object.keys(getMergedErrors()).length === 0,
@@ -1173,7 +1301,13 @@ var createFormController = (target, options = {}) => {
1173
1301
  if (!validation.isValid) {
1174
1302
  for (const name of Object.keys(validation.errors)) {
1175
1303
  const displayName = resolveDisplayNameForName(state2, name);
1176
- const field = getFieldsByName(displayName).find((candidate2) => {
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) => {
1177
1311
  if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1178
1312
  return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1179
1313
  });
@@ -1188,18 +1322,35 @@ var createFormController = (target, options = {}) => {
1188
1322
  const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
1189
1323
  const { data, formData } = parseFormData(state2.element, submitter);
1190
1324
  const snapshot = state2.api.getState();
1191
- handlers.forEach(
1192
- (handler) => handler.callback(state2.element, data, formData, snapshot)
1193
- );
1325
+ const pending = [];
1326
+ let synchronousError;
1327
+ let hasSynchronousError = false;
1328
+ try {
1329
+ for (const handler of handlers) {
1330
+ const result = handler.callback(state2.element, data, formData, snapshot);
1331
+ if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1332
+ }
1333
+ } catch (error) {
1334
+ synchronousError = error;
1335
+ hasSynchronousError = true;
1336
+ }
1337
+ if (pending.length > 0) {
1338
+ state2.pendingSubmitCount += 1;
1339
+ notifySubscribers();
1340
+ void Promise.allSettled(pending).then(() => {
1341
+ state2.pendingSubmitCount = Math.max(0, state2.pendingSubmitCount - 1);
1342
+ if (!state2.isDestroyed) notifySubscribers();
1343
+ });
1344
+ }
1345
+ if (hasSynchronousError) throw synchronousError;
1194
1346
  };
1195
1347
  const startMutationObserver = () => {
1196
1348
  if (!state2.element || typeof MutationObserver === "undefined") return;
1197
1349
  state2.mutationObserver = new MutationObserver(() => {
1198
1350
  clearFieldCache();
1199
1351
  if (state2.isValidated) {
1200
- syncVisualState();
1201
1352
  validateNames();
1202
- }
1353
+ } else syncVisualState();
1203
1354
  notifySubscribers();
1204
1355
  });
1205
1356
  state2.mutationObserver.observe(state2.element, {
@@ -1220,6 +1371,7 @@ var createFormController = (target, options = {}) => {
1220
1371
  return state2.options;
1221
1372
  },
1222
1373
  onSubmit: createOnSubmit(state2, helpers),
1374
+ addCleanup: createAddCleanup(state2, helpers),
1223
1375
  watch: createWatch(state2, helpers),
1224
1376
  observe: createObserve(state2, helpers),
1225
1377
  unwatch: createUnwatch(state2, helpers),
@@ -1257,10 +1409,8 @@ var createFormController = (target, options = {}) => {
1257
1409
  api.format(config);
1258
1410
  }
1259
1411
  }
1260
- if (state2.isValidated) {
1261
- syncVisualState();
1262
- validateNames();
1263
- }
1412
+ if (state2.isValidated) validateNames();
1413
+ else syncVisualState();
1264
1414
  return api;
1265
1415
  };
1266
1416