@samline/forms 2.6.0 → 2.7.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.
@@ -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;
@@ -485,12 +501,24 @@ var createDestroy = (state2, _helpers) => () => {
485
501
  }
486
502
  state2.mutationObserver?.disconnect();
487
503
  state2.mutationObserver = null;
504
+ const cleanups = Array.from(state2.cleanups).reverse();
505
+ state2.cleanups.clear();
506
+ for (const cleanup of cleanups) {
507
+ try {
508
+ cleanup();
509
+ } catch (error) {
510
+ console.error("[forms] cleanup failed", error);
511
+ }
512
+ }
488
513
  state2.watchedFields.clear();
489
514
  state2.subscribers.clear();
490
515
  state2.submitHandlers.clear();
491
516
  state2.fieldCache.clear();
492
517
  state2.manualErrors = {};
493
518
  state2.validationErrors = {};
519
+ state2.validationGroupErrors = {};
520
+ state2.validationElementErrors.clear();
521
+ state2.pendingSubmitCount = 0;
494
522
  cleanupFormatRegistry(state2);
495
523
  };
496
524
 
@@ -557,6 +585,7 @@ var createEmptyFormState = () => ({
557
585
  isValid: true,
558
586
  isValidated: false,
559
587
  autoSubmit: false,
588
+ isSubmitting: false,
560
589
  submitCount: 0
561
590
  });
562
591
  var cloneErrors = (errors) => Object.fromEntries(
@@ -588,6 +617,7 @@ var createGetState = (state2, helpers) => () => {
588
617
  isValid: Object.keys(errors).length === 0,
589
618
  isValidated: state2.isValidated,
590
619
  autoSubmit: state2.autoSubmitEnabled,
620
+ isSubmitting: state2.pendingSubmitCount > 0,
591
621
  submitCount: state2.submitCount
592
622
  };
593
623
  };
@@ -785,12 +815,14 @@ var createReset = (state2, helpers) => () => {
785
815
  resetFormattedFields(state2);
786
816
  state2.manualErrors = {};
787
817
  state2.validationErrors = {};
818
+ state2.validationGroupErrors = {};
819
+ state2.validationElementErrors.clear();
788
820
  clearAttributes(state2.element, [
789
821
  state2.attributes.error,
790
822
  state2.attributes.filled,
791
823
  "aria-invalid"
792
824
  ]);
793
- if (state2.isValidated) helpers.syncVisualState();
825
+ helpers.syncVisualState();
794
826
  helpers.notifySubscribers();
795
827
  return state2.api;
796
828
  };
@@ -901,14 +933,29 @@ var valuesAreEqual = (left, right) => {
901
933
  }
902
934
  return left === right;
903
935
  };
904
- var validateFieldValue = (field, value, rules, values) => {
936
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
937
+ var parseStrictNumber = (value) => {
938
+ if (typeof value !== "string") return void 0;
939
+ const candidate = value.trim();
940
+ if (!DECIMAL_PATTERN.test(candidate)) return void 0;
941
+ const number = Number(candidate);
942
+ return Number.isFinite(number) ? number : void 0;
943
+ };
944
+ var validateValue = (field, value, rules, values, contextOverrides = {}) => {
905
945
  const errors = [];
906
- const context = { field, value, values };
946
+ const context = {
947
+ field,
948
+ value,
949
+ values,
950
+ ...contextOverrides
951
+ };
907
952
  const required = resolveRule(rules.required);
908
953
  const minLength = resolveRule(rules.minLength);
909
954
  const maxLength = resolveRule(rules.maxLength);
910
955
  const pattern = resolveRule(rules.pattern);
911
- const sameAs = resolveRule(rules.sameAs);
956
+ const numeric = resolveRule(rules.numeric);
957
+ const min = resolveRule(rules.min);
958
+ const max = resolveRule(rules.max);
912
959
  if (required.value && !hasValue(value)) {
913
960
  errors.push(required.message ?? "This field is required.");
914
961
  }
@@ -926,10 +973,17 @@ var validateFieldValue = (field, value, rules, values) => {
926
973
  errors.push(pattern.message ?? "Value does not match the required pattern.");
927
974
  }
928
975
  }
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}.`);
976
+ if (hasValue(value) && (numeric.value || min.value !== void 0 || max.value !== void 0)) {
977
+ const number = parseStrictNumber(value);
978
+ if (number === void 0) {
979
+ errors.push(numeric.message ?? "Value must be a number.");
980
+ } else {
981
+ if (min.value !== void 0 && number < min.value) {
982
+ errors.push(min.message ?? `Minimum value is ${min.value}.`);
983
+ }
984
+ if (max.value !== void 0 && number > max.value) {
985
+ errors.push(max.message ?? `Maximum value is ${max.value}.`);
986
+ }
933
987
  }
934
988
  }
935
989
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
@@ -940,6 +994,37 @@ var validateFieldValue = (field, value, rules, values) => {
940
994
  }
941
995
  return errors;
942
996
  };
997
+ var validateFieldValueDetailed = (field, value, rules, values, items) => {
998
+ const groupErrors = validateValue(field, value, rules, values);
999
+ const sameAs = resolveRule(rules.sameAs);
1000
+ if (sameAs.value !== void 0) {
1001
+ const otherValue = values[sameAs.value];
1002
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
1003
+ groupErrors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
1004
+ }
1005
+ }
1006
+ const itemErrors = [];
1007
+ if (rules.each) {
1008
+ const sourceItems = items ?? (Array.isArray(value) ? value.map((entry) => ({
1009
+ value: typeof entry === "string" ? entry : [entry]
1010
+ })) : [{ value }]);
1011
+ sourceItems.forEach((item, index) => {
1012
+ const overrides = {
1013
+ index
1014
+ };
1015
+ if (item.element) overrides.element = item.element;
1016
+ const messages = validateValue(field, item.value, rules.each, values, overrides);
1017
+ if (messages.length === 0) return;
1018
+ const error = {
1019
+ index,
1020
+ messages
1021
+ };
1022
+ if (item.element) error.element = item.element;
1023
+ itemErrors.push(error);
1024
+ });
1025
+ }
1026
+ return { groupErrors, itemErrors };
1027
+ };
943
1028
 
944
1029
  // src/core/controller.ts
945
1030
  var DEFAULT_ATTRIBUTES = {
@@ -970,25 +1055,35 @@ var createFormController = (target, options = {}) => {
970
1055
  fieldCache: /* @__PURE__ */ new Map(),
971
1056
  manualErrors: {},
972
1057
  validationErrors: {},
1058
+ validationGroupErrors: {},
1059
+ validationElementErrors: /* @__PURE__ */ new Map(),
973
1060
  isValidated: Boolean(normalizedOptions.autoValidate),
974
1061
  autoSubmitEnabled: false,
975
1062
  autoSubmitDebounce: 0,
976
1063
  submitCount: 0,
1064
+ pendingSubmitCount: 0,
977
1065
  autoSubmitTimer: null,
978
1066
  isDestroyed: false,
979
1067
  listeners: [],
980
1068
  mutationObserver: null,
1069
+ cleanups: /* @__PURE__ */ new Set(),
981
1070
  formattedFields: /* @__PURE__ */ new Map(),
982
1071
  api: null
983
1072
  };
984
1073
  const validationDependents = /* @__PURE__ */ new Map();
985
1074
  for (const [field, rules] of Object.entries(state2.validators)) {
1075
+ const sources = /* @__PURE__ */ new Set();
986
1076
  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);
1077
+ if (sameAs !== void 0) {
1078
+ sources.add(typeof sameAs === "string" ? sameAs : sameAs.value);
1079
+ }
1080
+ if (typeof rules.dependsOn === "string") sources.add(rules.dependsOn);
1081
+ else rules.dependsOn?.forEach((source) => sources.add(source));
1082
+ for (const source of sources) {
1083
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1084
+ dependents.add(field);
1085
+ validationDependents.set(source, dependents);
1086
+ }
992
1087
  }
993
1088
  const notifySubscribers = () => {
994
1089
  if (!state2.api) return;
@@ -1037,18 +1132,21 @@ var createFormController = (target, options = {}) => {
1037
1132
  const syncVisualState = (names) => {
1038
1133
  if (!state2.element) return;
1039
1134
  const sourceNames = names ?? getTrackedFieldNames();
1040
- const errors = getMergedErrors();
1041
1135
  for (const name of sourceNames) {
1042
1136
  const displayName = resolveDisplayNameForName(state2, name);
1043
1137
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1044
1138
  const fields = getFieldsByName(displayName);
1045
- const hasError = Boolean(errors[canonical]?.length);
1046
- for (const field of fields) {
1139
+ const hasGroupError = Boolean(
1140
+ state2.validationGroupErrors[canonical]?.length || state2.manualErrors[canonical]?.length
1141
+ );
1142
+ const elementErrors = state2.validationElementErrors.get(canonical);
1143
+ fields.forEach((field) => {
1144
+ const hasError = hasGroupError || Boolean(elementErrors?.get(field)?.length);
1047
1145
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
1048
1146
  applyBooleanAttribute(field, attributes.error, hasError);
1049
1147
  if (hasError) field.setAttribute("aria-invalid", "true");
1050
1148
  else field.removeAttribute("aria-invalid");
1051
- }
1149
+ });
1052
1150
  }
1053
1151
  };
1054
1152
  void readFieldValue;
@@ -1057,17 +1155,38 @@ var createFormController = (target, options = {}) => {
1057
1155
  const targetNames = names ?? Object.keys(state2.validators);
1058
1156
  const values = getValues();
1059
1157
  const nextValidationErrors = names ? cloneErrors(state2.validationErrors) : {};
1158
+ const nextGroupErrors = names ? cloneErrors(state2.validationGroupErrors) : {};
1159
+ const nextElementErrors = names ? new Map(state2.validationElementErrors) : /* @__PURE__ */ new Map();
1060
1160
  for (const name of targetNames) {
1061
1161
  const rules = state2.validators[name];
1062
1162
  if (!rules) {
1063
1163
  delete nextValidationErrors[name];
1164
+ delete nextGroupErrors[name];
1165
+ nextElementErrors.delete(name);
1064
1166
  continue;
1065
1167
  }
1066
- const messages = validateFieldValue(name, values[name], rules, values);
1168
+ const fields = getFieldsByName(name);
1169
+ const displayFields = getFieldsByName(resolveDisplayNameForName(state2, name));
1170
+ const items = fields.map((field, index) => ({
1171
+ value: readFieldValue([field]),
1172
+ element: displayFields[index] ?? field
1173
+ }));
1174
+ const result = validateFieldValueDetailed(name, values[name], rules, values, items);
1175
+ const messages = [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1067
1176
  if (messages.length > 0) nextValidationErrors[name] = messages;
1068
1177
  else delete nextValidationErrors[name];
1178
+ if (result.groupErrors.length > 0) nextGroupErrors[name] = result.groupErrors;
1179
+ else delete nextGroupErrors[name];
1180
+ const perElement = /* @__PURE__ */ new Map();
1181
+ result.itemErrors.forEach((item) => {
1182
+ if (item.element) perElement.set(item.element, item.messages);
1183
+ });
1184
+ if (perElement.size > 0) nextElementErrors.set(name, perElement);
1185
+ else nextElementErrors.delete(name);
1069
1186
  }
1070
1187
  state2.validationErrors = nextValidationErrors;
1188
+ state2.validationGroupErrors = nextGroupErrors;
1189
+ state2.validationElementErrors = nextElementErrors;
1071
1190
  syncVisualState(names);
1072
1191
  return {
1073
1192
  isValid: Object.keys(getMergedErrors()).length === 0,
@@ -1161,7 +1280,13 @@ var createFormController = (target, options = {}) => {
1161
1280
  if (!validation.isValid) {
1162
1281
  for (const name of Object.keys(validation.errors)) {
1163
1282
  const displayName = resolveDisplayNameForName(state2, name);
1164
- const field = getFieldsByName(displayName).find((candidate2) => {
1283
+ const fields = getFieldsByName(displayName);
1284
+ const elementErrors = state2.validationElementErrors.get(name);
1285
+ const field = fields.find((candidate2) => {
1286
+ if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1287
+ if (candidate2 instanceof HTMLInputElement && candidate2.type === "hidden") return false;
1288
+ return elementErrors?.has(candidate2);
1289
+ }) ?? fields.find((candidate2) => {
1165
1290
  if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1166
1291
  return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1167
1292
  });
@@ -1176,18 +1301,35 @@ var createFormController = (target, options = {}) => {
1176
1301
  const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
1177
1302
  const { data, formData } = parseFormData(state2.element, submitter);
1178
1303
  const snapshot = state2.api.getState();
1179
- handlers.forEach(
1180
- (handler) => handler.callback(state2.element, data, formData, snapshot)
1181
- );
1304
+ const pending = [];
1305
+ let synchronousError;
1306
+ let hasSynchronousError = false;
1307
+ try {
1308
+ for (const handler of handlers) {
1309
+ const result = handler.callback(state2.element, data, formData, snapshot);
1310
+ if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1311
+ }
1312
+ } catch (error) {
1313
+ synchronousError = error;
1314
+ hasSynchronousError = true;
1315
+ }
1316
+ if (pending.length > 0) {
1317
+ state2.pendingSubmitCount += 1;
1318
+ notifySubscribers();
1319
+ void Promise.allSettled(pending).then(() => {
1320
+ state2.pendingSubmitCount = Math.max(0, state2.pendingSubmitCount - 1);
1321
+ if (!state2.isDestroyed) notifySubscribers();
1322
+ });
1323
+ }
1324
+ if (hasSynchronousError) throw synchronousError;
1182
1325
  };
1183
1326
  const startMutationObserver = () => {
1184
1327
  if (!state2.element || typeof MutationObserver === "undefined") return;
1185
1328
  state2.mutationObserver = new MutationObserver(() => {
1186
1329
  clearFieldCache();
1187
1330
  if (state2.isValidated) {
1188
- syncVisualState();
1189
1331
  validateNames();
1190
- }
1332
+ } else syncVisualState();
1191
1333
  notifySubscribers();
1192
1334
  });
1193
1335
  state2.mutationObserver.observe(state2.element, {
@@ -1208,6 +1350,7 @@ var createFormController = (target, options = {}) => {
1208
1350
  return state2.options;
1209
1351
  },
1210
1352
  onSubmit: createOnSubmit(state2, helpers),
1353
+ addCleanup: createAddCleanup(state2, helpers),
1211
1354
  watch: createWatch(state2, helpers),
1212
1355
  observe: createObserve(state2, helpers),
1213
1356
  unwatch: createUnwatch(state2, helpers),
@@ -1245,10 +1388,8 @@ var createFormController = (target, options = {}) => {
1245
1388
  api.format(config);
1246
1389
  }
1247
1390
  }
1248
- if (state2.isValidated) {
1249
- syncVisualState();
1250
- validateNames();
1251
- }
1391
+ if (state2.isValidated) validateNames();
1392
+ else syncVisualState();
1252
1393
  return api;
1253
1394
  };
1254
1395