@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.
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;
@@ -486,12 +502,24 @@ var createDestroy = (state2, _helpers) => () => {
486
502
  }
487
503
  state2.mutationObserver?.disconnect();
488
504
  state2.mutationObserver = null;
505
+ const cleanups = Array.from(state2.cleanups).reverse();
506
+ state2.cleanups.clear();
507
+ for (const cleanup of cleanups) {
508
+ try {
509
+ cleanup();
510
+ } catch (error) {
511
+ console.error("[forms] cleanup failed", error);
512
+ }
513
+ }
489
514
  state2.watchedFields.clear();
490
515
  state2.subscribers.clear();
491
516
  state2.submitHandlers.clear();
492
517
  state2.fieldCache.clear();
493
518
  state2.manualErrors = {};
494
519
  state2.validationErrors = {};
520
+ state2.validationGroupErrors = {};
521
+ state2.validationElementErrors.clear();
522
+ state2.pendingSubmitCount = 0;
495
523
  cleanupFormatRegistry(state2);
496
524
  };
497
525
 
@@ -558,6 +586,7 @@ var createEmptyFormState = () => ({
558
586
  isValid: true,
559
587
  isValidated: false,
560
588
  autoSubmit: false,
589
+ isSubmitting: false,
561
590
  submitCount: 0
562
591
  });
563
592
  var cloneErrors = (errors) => Object.fromEntries(
@@ -589,6 +618,7 @@ var createGetState = (state2, helpers) => () => {
589
618
  isValid: Object.keys(errors).length === 0,
590
619
  isValidated: state2.isValidated,
591
620
  autoSubmit: state2.autoSubmitEnabled,
621
+ isSubmitting: state2.pendingSubmitCount > 0,
592
622
  submitCount: state2.submitCount
593
623
  };
594
624
  };
@@ -786,12 +816,14 @@ var createReset = (state2, helpers) => () => {
786
816
  resetFormattedFields(state2);
787
817
  state2.manualErrors = {};
788
818
  state2.validationErrors = {};
819
+ state2.validationGroupErrors = {};
820
+ state2.validationElementErrors.clear();
789
821
  clearAttributes(state2.element, [
790
822
  state2.attributes.error,
791
823
  state2.attributes.filled,
792
824
  "aria-invalid"
793
825
  ]);
794
- if (state2.isValidated) helpers.syncVisualState();
826
+ helpers.syncVisualState();
795
827
  helpers.notifySubscribers();
796
828
  return state2.api;
797
829
  };
@@ -902,14 +934,29 @@ var valuesAreEqual = (left, right) => {
902
934
  }
903
935
  return left === right;
904
936
  };
905
- var validateFieldValue = (field, value, rules, values) => {
937
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
938
+ var parseStrictNumber = (value) => {
939
+ if (typeof value !== "string") return void 0;
940
+ const candidate = value.trim();
941
+ if (!DECIMAL_PATTERN.test(candidate)) return void 0;
942
+ const number = Number(candidate);
943
+ return Number.isFinite(number) ? number : void 0;
944
+ };
945
+ var validateValue = (field, value, rules, values, contextOverrides = {}) => {
906
946
  const errors = [];
907
- const context = { field, value, values };
947
+ const context = {
948
+ field,
949
+ value,
950
+ values,
951
+ ...contextOverrides
952
+ };
908
953
  const required = resolveRule(rules.required);
909
954
  const minLength = resolveRule(rules.minLength);
910
955
  const maxLength = resolveRule(rules.maxLength);
911
956
  const pattern = resolveRule(rules.pattern);
912
- const sameAs = resolveRule(rules.sameAs);
957
+ const numeric = resolveRule(rules.numeric);
958
+ const min = resolveRule(rules.min);
959
+ const max = resolveRule(rules.max);
913
960
  if (required.value && !hasValue(value)) {
914
961
  errors.push(required.message ?? "This field is required.");
915
962
  }
@@ -927,10 +974,17 @@ var validateFieldValue = (field, value, rules, values) => {
927
974
  errors.push(pattern.message ?? "Value does not match the required pattern.");
928
975
  }
929
976
  }
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}.`);
977
+ if (hasValue(value) && (numeric.value || min.value !== void 0 || max.value !== void 0)) {
978
+ const number = parseStrictNumber(value);
979
+ if (number === void 0) {
980
+ errors.push(numeric.message ?? "Value must be a number.");
981
+ } else {
982
+ if (min.value !== void 0 && number < min.value) {
983
+ errors.push(min.message ?? `Minimum value is ${min.value}.`);
984
+ }
985
+ if (max.value !== void 0 && number > max.value) {
986
+ errors.push(max.message ?? `Maximum value is ${max.value}.`);
987
+ }
934
988
  }
935
989
  }
936
990
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
@@ -941,6 +995,41 @@ var validateFieldValue = (field, value, rules, values) => {
941
995
  }
942
996
  return errors;
943
997
  };
998
+ var validateFieldValueDetailed = (field, value, rules, values, items) => {
999
+ const groupErrors = validateValue(field, value, rules, values);
1000
+ const sameAs = resolveRule(rules.sameAs);
1001
+ if (sameAs.value !== void 0) {
1002
+ const otherValue = values[sameAs.value];
1003
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
1004
+ groupErrors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
1005
+ }
1006
+ }
1007
+ const itemErrors = [];
1008
+ if (rules.each) {
1009
+ const sourceItems = items ?? (Array.isArray(value) ? value.map((entry) => ({
1010
+ value: typeof entry === "string" ? entry : [entry]
1011
+ })) : [{ value }]);
1012
+ sourceItems.forEach((item, index) => {
1013
+ const overrides = {
1014
+ index
1015
+ };
1016
+ if (item.element) overrides.element = item.element;
1017
+ const messages = validateValue(field, item.value, rules.each, values, overrides);
1018
+ if (messages.length === 0) return;
1019
+ const error = {
1020
+ index,
1021
+ messages
1022
+ };
1023
+ if (item.element) error.element = item.element;
1024
+ itemErrors.push(error);
1025
+ });
1026
+ }
1027
+ return { groupErrors, itemErrors };
1028
+ };
1029
+ var validateFieldValue = (field, value, rules, values) => {
1030
+ const result = validateFieldValueDetailed(field, value, rules, values);
1031
+ return [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1032
+ };
944
1033
  var validateValues = (values, schema) => {
945
1034
  const errors = {};
946
1035
  for (const [field, rules] of Object.entries(schema)) {
@@ -982,25 +1071,35 @@ var createFormController = (target, options = {}) => {
982
1071
  fieldCache: /* @__PURE__ */ new Map(),
983
1072
  manualErrors: {},
984
1073
  validationErrors: {},
1074
+ validationGroupErrors: {},
1075
+ validationElementErrors: /* @__PURE__ */ new Map(),
985
1076
  isValidated: Boolean(normalizedOptions.autoValidate),
986
1077
  autoSubmitEnabled: false,
987
1078
  autoSubmitDebounce: 0,
988
1079
  submitCount: 0,
1080
+ pendingSubmitCount: 0,
989
1081
  autoSubmitTimer: null,
990
1082
  isDestroyed: false,
991
1083
  listeners: [],
992
1084
  mutationObserver: null,
1085
+ cleanups: /* @__PURE__ */ new Set(),
993
1086
  formattedFields: /* @__PURE__ */ new Map(),
994
1087
  api: null
995
1088
  };
996
1089
  const validationDependents = /* @__PURE__ */ new Map();
997
1090
  for (const [field, rules] of Object.entries(state2.validators)) {
1091
+ const sources = /* @__PURE__ */ new Set();
998
1092
  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);
1093
+ if (sameAs !== void 0) {
1094
+ sources.add(typeof sameAs === "string" ? sameAs : sameAs.value);
1095
+ }
1096
+ if (typeof rules.dependsOn === "string") sources.add(rules.dependsOn);
1097
+ else rules.dependsOn?.forEach((source) => sources.add(source));
1098
+ for (const source of sources) {
1099
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1100
+ dependents.add(field);
1101
+ validationDependents.set(source, dependents);
1102
+ }
1004
1103
  }
1005
1104
  const notifySubscribers = () => {
1006
1105
  if (!state2.api) return;
@@ -1049,18 +1148,21 @@ var createFormController = (target, options = {}) => {
1049
1148
  const syncVisualState = (names) => {
1050
1149
  if (!state2.element) return;
1051
1150
  const sourceNames = names ?? getTrackedFieldNames();
1052
- const errors = getMergedErrors();
1053
1151
  for (const name of sourceNames) {
1054
1152
  const displayName = resolveDisplayNameForName(state2, name);
1055
1153
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1056
1154
  const fields = getFieldsByName(displayName);
1057
- const hasError = Boolean(errors[canonical]?.length);
1058
- for (const field of fields) {
1155
+ const hasGroupError = Boolean(
1156
+ state2.validationGroupErrors[canonical]?.length || state2.manualErrors[canonical]?.length
1157
+ );
1158
+ const elementErrors = state2.validationElementErrors.get(canonical);
1159
+ fields.forEach((field) => {
1160
+ const hasError = hasGroupError || Boolean(elementErrors?.get(field)?.length);
1059
1161
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
1060
1162
  applyBooleanAttribute(field, attributes.error, hasError);
1061
1163
  if (hasError) field.setAttribute("aria-invalid", "true");
1062
1164
  else field.removeAttribute("aria-invalid");
1063
- }
1165
+ });
1064
1166
  }
1065
1167
  };
1066
1168
  void readFieldValue;
@@ -1069,17 +1171,38 @@ var createFormController = (target, options = {}) => {
1069
1171
  const targetNames = names ?? Object.keys(state2.validators);
1070
1172
  const values = getValues();
1071
1173
  const nextValidationErrors = names ? cloneErrors(state2.validationErrors) : {};
1174
+ const nextGroupErrors = names ? cloneErrors(state2.validationGroupErrors) : {};
1175
+ const nextElementErrors = names ? new Map(state2.validationElementErrors) : /* @__PURE__ */ new Map();
1072
1176
  for (const name of targetNames) {
1073
1177
  const rules = state2.validators[name];
1074
1178
  if (!rules) {
1075
1179
  delete nextValidationErrors[name];
1180
+ delete nextGroupErrors[name];
1181
+ nextElementErrors.delete(name);
1076
1182
  continue;
1077
1183
  }
1078
- const messages = validateFieldValue(name, values[name], rules, values);
1184
+ const fields = getFieldsByName(name);
1185
+ const displayFields = getFieldsByName(resolveDisplayNameForName(state2, name));
1186
+ const items = fields.map((field, index) => ({
1187
+ value: readFieldValue([field]),
1188
+ element: displayFields[index] ?? field
1189
+ }));
1190
+ const result = validateFieldValueDetailed(name, values[name], rules, values, items);
1191
+ const messages = [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1079
1192
  if (messages.length > 0) nextValidationErrors[name] = messages;
1080
1193
  else delete nextValidationErrors[name];
1194
+ if (result.groupErrors.length > 0) nextGroupErrors[name] = result.groupErrors;
1195
+ else delete nextGroupErrors[name];
1196
+ const perElement = /* @__PURE__ */ new Map();
1197
+ result.itemErrors.forEach((item) => {
1198
+ if (item.element) perElement.set(item.element, item.messages);
1199
+ });
1200
+ if (perElement.size > 0) nextElementErrors.set(name, perElement);
1201
+ else nextElementErrors.delete(name);
1081
1202
  }
1082
1203
  state2.validationErrors = nextValidationErrors;
1204
+ state2.validationGroupErrors = nextGroupErrors;
1205
+ state2.validationElementErrors = nextElementErrors;
1083
1206
  syncVisualState(names);
1084
1207
  return {
1085
1208
  isValid: Object.keys(getMergedErrors()).length === 0,
@@ -1173,7 +1296,13 @@ var createFormController = (target, options = {}) => {
1173
1296
  if (!validation.isValid) {
1174
1297
  for (const name of Object.keys(validation.errors)) {
1175
1298
  const displayName = resolveDisplayNameForName(state2, name);
1176
- const field = getFieldsByName(displayName).find((candidate2) => {
1299
+ const fields = getFieldsByName(displayName);
1300
+ const elementErrors = state2.validationElementErrors.get(name);
1301
+ const field = fields.find((candidate2) => {
1302
+ if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1303
+ if (candidate2 instanceof HTMLInputElement && candidate2.type === "hidden") return false;
1304
+ return elementErrors?.has(candidate2);
1305
+ }) ?? fields.find((candidate2) => {
1177
1306
  if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1178
1307
  return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1179
1308
  });
@@ -1188,18 +1317,35 @@ var createFormController = (target, options = {}) => {
1188
1317
  const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
1189
1318
  const { data, formData } = parseFormData(state2.element, submitter);
1190
1319
  const snapshot = state2.api.getState();
1191
- handlers.forEach(
1192
- (handler) => handler.callback(state2.element, data, formData, snapshot)
1193
- );
1320
+ const pending = [];
1321
+ let synchronousError;
1322
+ let hasSynchronousError = false;
1323
+ try {
1324
+ for (const handler of handlers) {
1325
+ const result = handler.callback(state2.element, data, formData, snapshot);
1326
+ if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1327
+ }
1328
+ } catch (error) {
1329
+ synchronousError = error;
1330
+ hasSynchronousError = true;
1331
+ }
1332
+ if (pending.length > 0) {
1333
+ state2.pendingSubmitCount += 1;
1334
+ notifySubscribers();
1335
+ void Promise.allSettled(pending).then(() => {
1336
+ state2.pendingSubmitCount = Math.max(0, state2.pendingSubmitCount - 1);
1337
+ if (!state2.isDestroyed) notifySubscribers();
1338
+ });
1339
+ }
1340
+ if (hasSynchronousError) throw synchronousError;
1194
1341
  };
1195
1342
  const startMutationObserver = () => {
1196
1343
  if (!state2.element || typeof MutationObserver === "undefined") return;
1197
1344
  state2.mutationObserver = new MutationObserver(() => {
1198
1345
  clearFieldCache();
1199
1346
  if (state2.isValidated) {
1200
- syncVisualState();
1201
1347
  validateNames();
1202
- }
1348
+ } else syncVisualState();
1203
1349
  notifySubscribers();
1204
1350
  });
1205
1351
  state2.mutationObserver.observe(state2.element, {
@@ -1220,6 +1366,7 @@ var createFormController = (target, options = {}) => {
1220
1366
  return state2.options;
1221
1367
  },
1222
1368
  onSubmit: createOnSubmit(state2, helpers),
1369
+ addCleanup: createAddCleanup(state2, helpers),
1223
1370
  watch: createWatch(state2, helpers),
1224
1371
  observe: createObserve(state2, helpers),
1225
1372
  unwatch: createUnwatch(state2, helpers),
@@ -1257,10 +1404,8 @@ var createFormController = (target, options = {}) => {
1257
1404
  api.format(config);
1258
1405
  }
1259
1406
  }
1260
- if (state2.isValidated) {
1261
- syncVisualState();
1262
- validateNames();
1263
- }
1407
+ if (state2.isValidated) validateNames();
1408
+ else syncVisualState();
1264
1409
  return api;
1265
1410
  };
1266
1411