@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
@@ -58,6 +58,22 @@ var createAppend = (state2, helpers) => ({ tag, content, class: className, atSta
58
58
  return node;
59
59
  };
60
60
 
61
+ // src/api/add-cleanup.ts
62
+ var createAddCleanup = (state2, _helpers) => (cleanup) => {
63
+ if (state2.isDestroyed) {
64
+ cleanup();
65
+ return () => void 0;
66
+ }
67
+ const registration = () => cleanup();
68
+ state2.cleanups.add(registration);
69
+ let active = true;
70
+ return () => {
71
+ if (!active) return;
72
+ active = false;
73
+ state2.cleanups.delete(registration);
74
+ };
75
+ };
76
+
61
77
  // src/api/auto-submit.ts
62
78
  var createAutoSubmit = (state2, helpers) => (next = true) => {
63
79
  state2.autoSubmitEnabled = next !== false;
@@ -279,6 +295,10 @@ var buildAutoInterpretedOptions = (formatOptions) => ({
279
295
  ...formatOptions ?? {},
280
296
  interpretInputAs: "auto"
281
297
  });
298
+ var buildDisplayInterpretedOptions = (formatOptions) => ({
299
+ ...formatOptions ?? {},
300
+ interpretInputAs: "display"
301
+ });
282
302
  var shouldForceAutoInterpretation = (formatOptions) => formatOptions?.interpretInputAs === void 0;
283
303
  var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
284
304
  const handler = (event) => {
@@ -288,7 +308,7 @@ var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
288
308
  if (!isVisible && !isMirror) return;
289
309
  const rawInput = isMirror ? mirror.value : visible.value;
290
310
  const inputType = readInputType(event);
291
- const sourceOptions = isMirror && shouldForceAutoInterpretation(formatOptions) ? buildAutoInterpretedOptions(formatOptions) : formatOptions;
311
+ const sourceOptions = shouldForceAutoInterpretation(formatOptions) ? isMirror ? buildAutoInterpretedOptions(formatOptions) : buildDisplayInterpretedOptions(formatOptions) : formatOptions;
292
312
  const { formatted, raw } = formatFn(rawInput, formatType, sourceOptions);
293
313
  if (!formatted && !raw) {
294
314
  visible.value = "";
@@ -370,7 +390,8 @@ var applyFormat = async (state2, helpers, config) => {
370
390
  if (entry.handler !== UNBOUND) {
371
391
  const current = entry.visible.value;
372
392
  if (current !== "") {
373
- const { formatted, raw } = formatter.format(current, formatType, formatOptions);
393
+ const rebindOptions = shouldForceAutoInterpretation(formatOptions) ? buildDisplayInterpretedOptions(formatOptions) : formatOptions;
394
+ const { formatted, raw } = formatter.format(current, formatType, rebindOptions);
374
395
  applyFormattedValue(entry.visible, entry.mirror, formatted, raw);
375
396
  }
376
397
  continue;
@@ -485,12 +506,24 @@ var createDestroy = (state2, _helpers) => () => {
485
506
  }
486
507
  state2.mutationObserver?.disconnect();
487
508
  state2.mutationObserver = null;
509
+ const cleanups = Array.from(state2.cleanups).reverse();
510
+ state2.cleanups.clear();
511
+ for (const cleanup of cleanups) {
512
+ try {
513
+ cleanup();
514
+ } catch (error) {
515
+ console.error("[forms] cleanup failed", error);
516
+ }
517
+ }
488
518
  state2.watchedFields.clear();
489
519
  state2.subscribers.clear();
490
520
  state2.submitHandlers.clear();
491
521
  state2.fieldCache.clear();
492
522
  state2.manualErrors = {};
493
523
  state2.validationErrors = {};
524
+ state2.validationGroupErrors = {};
525
+ state2.validationElementErrors.clear();
526
+ state2.pendingSubmitCount = 0;
494
527
  cleanupFormatRegistry(state2);
495
528
  };
496
529
 
@@ -557,6 +590,7 @@ var createEmptyFormState = () => ({
557
590
  isValid: true,
558
591
  isValidated: false,
559
592
  autoSubmit: false,
593
+ isSubmitting: false,
560
594
  submitCount: 0
561
595
  });
562
596
  var cloneErrors = (errors) => Object.fromEntries(
@@ -588,6 +622,7 @@ var createGetState = (state2, helpers) => () => {
588
622
  isValid: Object.keys(errors).length === 0,
589
623
  isValidated: state2.isValidated,
590
624
  autoSubmit: state2.autoSubmitEnabled,
625
+ isSubmitting: state2.pendingSubmitCount > 0,
591
626
  submitCount: state2.submitCount
592
627
  };
593
628
  };
@@ -785,12 +820,14 @@ var createReset = (state2, helpers) => () => {
785
820
  resetFormattedFields(state2);
786
821
  state2.manualErrors = {};
787
822
  state2.validationErrors = {};
823
+ state2.validationGroupErrors = {};
824
+ state2.validationElementErrors.clear();
788
825
  clearAttributes(state2.element, [
789
826
  state2.attributes.error,
790
827
  state2.attributes.filled,
791
828
  "aria-invalid"
792
829
  ]);
793
- if (state2.isValidated) helpers.syncVisualState();
830
+ helpers.syncVisualState();
794
831
  helpers.notifySubscribers();
795
832
  return state2.api;
796
833
  };
@@ -901,14 +938,29 @@ var valuesAreEqual = (left, right) => {
901
938
  }
902
939
  return left === right;
903
940
  };
904
- var validateFieldValue = (field, value, rules, values) => {
941
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
942
+ var parseStrictNumber = (value) => {
943
+ if (typeof value !== "string") return void 0;
944
+ const candidate = value.trim();
945
+ if (!DECIMAL_PATTERN.test(candidate)) return void 0;
946
+ const number = Number(candidate);
947
+ return Number.isFinite(number) ? number : void 0;
948
+ };
949
+ var validateValue = (field, value, rules, values, contextOverrides = {}) => {
905
950
  const errors = [];
906
- const context = { field, value, values };
951
+ const context = {
952
+ field,
953
+ value,
954
+ values,
955
+ ...contextOverrides
956
+ };
907
957
  const required = resolveRule(rules.required);
908
958
  const minLength = resolveRule(rules.minLength);
909
959
  const maxLength = resolveRule(rules.maxLength);
910
960
  const pattern = resolveRule(rules.pattern);
911
- const sameAs = resolveRule(rules.sameAs);
961
+ const numeric = resolveRule(rules.numeric);
962
+ const min = resolveRule(rules.min);
963
+ const max = resolveRule(rules.max);
912
964
  if (required.value && !hasValue(value)) {
913
965
  errors.push(required.message ?? "This field is required.");
914
966
  }
@@ -926,10 +978,17 @@ var validateFieldValue = (field, value, rules, values) => {
926
978
  errors.push(pattern.message ?? "Value does not match the required pattern.");
927
979
  }
928
980
  }
929
- if (sameAs.value !== void 0) {
930
- const otherValue = values[sameAs.value];
931
- if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
932
- errors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
981
+ if (hasValue(value) && (numeric.value || min.value !== void 0 || max.value !== void 0)) {
982
+ const number = parseStrictNumber(value);
983
+ if (number === void 0) {
984
+ errors.push(numeric.message ?? "Value must be a number.");
985
+ } else {
986
+ if (min.value !== void 0 && number < min.value) {
987
+ errors.push(min.message ?? `Minimum value is ${min.value}.`);
988
+ }
989
+ if (max.value !== void 0 && number > max.value) {
990
+ errors.push(max.message ?? `Maximum value is ${max.value}.`);
991
+ }
933
992
  }
934
993
  }
935
994
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
@@ -940,6 +999,37 @@ var validateFieldValue = (field, value, rules, values) => {
940
999
  }
941
1000
  return errors;
942
1001
  };
1002
+ var validateFieldValueDetailed = (field, value, rules, values, items) => {
1003
+ const groupErrors = validateValue(field, value, rules, values);
1004
+ const sameAs = resolveRule(rules.sameAs);
1005
+ if (sameAs.value !== void 0) {
1006
+ const otherValue = values[sameAs.value];
1007
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
1008
+ groupErrors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
1009
+ }
1010
+ }
1011
+ const itemErrors = [];
1012
+ if (rules.each) {
1013
+ const sourceItems = items ?? (Array.isArray(value) ? value.map((entry) => ({
1014
+ value: typeof entry === "string" ? entry : [entry]
1015
+ })) : [{ value }]);
1016
+ sourceItems.forEach((item, index) => {
1017
+ const overrides = {
1018
+ index
1019
+ };
1020
+ if (item.element) overrides.element = item.element;
1021
+ const messages = validateValue(field, item.value, rules.each, values, overrides);
1022
+ if (messages.length === 0) return;
1023
+ const error = {
1024
+ index,
1025
+ messages
1026
+ };
1027
+ if (item.element) error.element = item.element;
1028
+ itemErrors.push(error);
1029
+ });
1030
+ }
1031
+ return { groupErrors, itemErrors };
1032
+ };
943
1033
 
944
1034
  // src/core/controller.ts
945
1035
  var DEFAULT_ATTRIBUTES = {
@@ -970,25 +1060,35 @@ var createFormController = (target, options = {}) => {
970
1060
  fieldCache: /* @__PURE__ */ new Map(),
971
1061
  manualErrors: {},
972
1062
  validationErrors: {},
1063
+ validationGroupErrors: {},
1064
+ validationElementErrors: /* @__PURE__ */ new Map(),
973
1065
  isValidated: Boolean(normalizedOptions.autoValidate),
974
1066
  autoSubmitEnabled: false,
975
1067
  autoSubmitDebounce: 0,
976
1068
  submitCount: 0,
1069
+ pendingSubmitCount: 0,
977
1070
  autoSubmitTimer: null,
978
1071
  isDestroyed: false,
979
1072
  listeners: [],
980
1073
  mutationObserver: null,
1074
+ cleanups: /* @__PURE__ */ new Set(),
981
1075
  formattedFields: /* @__PURE__ */ new Map(),
982
1076
  api: null
983
1077
  };
984
1078
  const validationDependents = /* @__PURE__ */ new Map();
985
1079
  for (const [field, rules] of Object.entries(state2.validators)) {
1080
+ const sources = /* @__PURE__ */ new Set();
986
1081
  const sameAs = rules.sameAs;
987
- if (sameAs === void 0) continue;
988
- const source = typeof sameAs === "string" ? sameAs : sameAs.value;
989
- const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
990
- dependents.add(field);
991
- validationDependents.set(source, dependents);
1082
+ if (sameAs !== void 0) {
1083
+ sources.add(typeof sameAs === "string" ? sameAs : sameAs.value);
1084
+ }
1085
+ if (typeof rules.dependsOn === "string") sources.add(rules.dependsOn);
1086
+ else rules.dependsOn?.forEach((source) => sources.add(source));
1087
+ for (const source of sources) {
1088
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1089
+ dependents.add(field);
1090
+ validationDependents.set(source, dependents);
1091
+ }
992
1092
  }
993
1093
  const notifySubscribers = () => {
994
1094
  if (!state2.api) return;
@@ -1037,18 +1137,21 @@ var createFormController = (target, options = {}) => {
1037
1137
  const syncVisualState = (names) => {
1038
1138
  if (!state2.element) return;
1039
1139
  const sourceNames = names ?? getTrackedFieldNames();
1040
- const errors = getMergedErrors();
1041
1140
  for (const name of sourceNames) {
1042
1141
  const displayName = resolveDisplayNameForName(state2, name);
1043
1142
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1044
1143
  const fields = getFieldsByName(displayName);
1045
- const hasError = Boolean(errors[canonical]?.length);
1046
- for (const field of fields) {
1144
+ const hasGroupError = Boolean(
1145
+ state2.validationGroupErrors[canonical]?.length || state2.manualErrors[canonical]?.length
1146
+ );
1147
+ const elementErrors = state2.validationElementErrors.get(canonical);
1148
+ fields.forEach((field) => {
1149
+ const hasError = hasGroupError || Boolean(elementErrors?.get(field)?.length);
1047
1150
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
1048
1151
  applyBooleanAttribute(field, attributes.error, hasError);
1049
1152
  if (hasError) field.setAttribute("aria-invalid", "true");
1050
1153
  else field.removeAttribute("aria-invalid");
1051
- }
1154
+ });
1052
1155
  }
1053
1156
  };
1054
1157
  void readFieldValue;
@@ -1057,17 +1160,38 @@ var createFormController = (target, options = {}) => {
1057
1160
  const targetNames = names ?? Object.keys(state2.validators);
1058
1161
  const values = getValues();
1059
1162
  const nextValidationErrors = names ? cloneErrors(state2.validationErrors) : {};
1163
+ const nextGroupErrors = names ? cloneErrors(state2.validationGroupErrors) : {};
1164
+ const nextElementErrors = names ? new Map(state2.validationElementErrors) : /* @__PURE__ */ new Map();
1060
1165
  for (const name of targetNames) {
1061
1166
  const rules = state2.validators[name];
1062
1167
  if (!rules) {
1063
1168
  delete nextValidationErrors[name];
1169
+ delete nextGroupErrors[name];
1170
+ nextElementErrors.delete(name);
1064
1171
  continue;
1065
1172
  }
1066
- const messages = validateFieldValue(name, values[name], rules, values);
1173
+ const fields = getFieldsByName(name);
1174
+ const displayFields = getFieldsByName(resolveDisplayNameForName(state2, name));
1175
+ const items = fields.map((field, index) => ({
1176
+ value: readFieldValue([field]),
1177
+ element: displayFields[index] ?? field
1178
+ }));
1179
+ const result = validateFieldValueDetailed(name, values[name], rules, values, items);
1180
+ const messages = [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1067
1181
  if (messages.length > 0) nextValidationErrors[name] = messages;
1068
1182
  else delete nextValidationErrors[name];
1183
+ if (result.groupErrors.length > 0) nextGroupErrors[name] = result.groupErrors;
1184
+ else delete nextGroupErrors[name];
1185
+ const perElement = /* @__PURE__ */ new Map();
1186
+ result.itemErrors.forEach((item) => {
1187
+ if (item.element) perElement.set(item.element, item.messages);
1188
+ });
1189
+ if (perElement.size > 0) nextElementErrors.set(name, perElement);
1190
+ else nextElementErrors.delete(name);
1069
1191
  }
1070
1192
  state2.validationErrors = nextValidationErrors;
1193
+ state2.validationGroupErrors = nextGroupErrors;
1194
+ state2.validationElementErrors = nextElementErrors;
1071
1195
  syncVisualState(names);
1072
1196
  return {
1073
1197
  isValid: Object.keys(getMergedErrors()).length === 0,
@@ -1161,7 +1285,13 @@ var createFormController = (target, options = {}) => {
1161
1285
  if (!validation.isValid) {
1162
1286
  for (const name of Object.keys(validation.errors)) {
1163
1287
  const displayName = resolveDisplayNameForName(state2, name);
1164
- const field = getFieldsByName(displayName).find((candidate2) => {
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) => {
1165
1295
  if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1166
1296
  return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1167
1297
  });
@@ -1176,18 +1306,35 @@ var createFormController = (target, options = {}) => {
1176
1306
  const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
1177
1307
  const { data, formData } = parseFormData(state2.element, submitter);
1178
1308
  const snapshot = state2.api.getState();
1179
- handlers.forEach(
1180
- (handler) => handler.callback(state2.element, data, formData, snapshot)
1181
- );
1309
+ const pending = [];
1310
+ let synchronousError;
1311
+ let hasSynchronousError = false;
1312
+ try {
1313
+ for (const handler of handlers) {
1314
+ const result = handler.callback(state2.element, data, formData, snapshot);
1315
+ if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1316
+ }
1317
+ } catch (error) {
1318
+ synchronousError = error;
1319
+ hasSynchronousError = true;
1320
+ }
1321
+ if (pending.length > 0) {
1322
+ state2.pendingSubmitCount += 1;
1323
+ notifySubscribers();
1324
+ void Promise.allSettled(pending).then(() => {
1325
+ state2.pendingSubmitCount = Math.max(0, state2.pendingSubmitCount - 1);
1326
+ if (!state2.isDestroyed) notifySubscribers();
1327
+ });
1328
+ }
1329
+ if (hasSynchronousError) throw synchronousError;
1182
1330
  };
1183
1331
  const startMutationObserver = () => {
1184
1332
  if (!state2.element || typeof MutationObserver === "undefined") return;
1185
1333
  state2.mutationObserver = new MutationObserver(() => {
1186
1334
  clearFieldCache();
1187
1335
  if (state2.isValidated) {
1188
- syncVisualState();
1189
1336
  validateNames();
1190
- }
1337
+ } else syncVisualState();
1191
1338
  notifySubscribers();
1192
1339
  });
1193
1340
  state2.mutationObserver.observe(state2.element, {
@@ -1208,6 +1355,7 @@ var createFormController = (target, options = {}) => {
1208
1355
  return state2.options;
1209
1356
  },
1210
1357
  onSubmit: createOnSubmit(state2, helpers),
1358
+ addCleanup: createAddCleanup(state2, helpers),
1211
1359
  watch: createWatch(state2, helpers),
1212
1360
  observe: createObserve(state2, helpers),
1213
1361
  unwatch: createUnwatch(state2, helpers),
@@ -1245,10 +1393,8 @@ var createFormController = (target, options = {}) => {
1245
1393
  api.format(config);
1246
1394
  }
1247
1395
  }
1248
- if (state2.isValidated) {
1249
- syncVisualState();
1250
- validateNames();
1251
- }
1396
+ if (state2.isValidated) validateNames();
1397
+ else syncVisualState();
1252
1398
  return api;
1253
1399
  };
1254
1400