@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
@@ -1,4 +1,4 @@
1
- import { t as FormsApi, u as FormsAvailable, F as FormTarget, a as FormControllerOptions, b as FormController, N as NewFormInput } from '../registry-BpqJetAt.cjs';
1
+ import { u as FormsApi, v as FormsAvailable, F as FormTarget, a as FormControllerOptions, b as FormController, N as NewFormInput } from '../registry-GzopYPCR.cjs';
2
2
 
3
3
  declare const Forms: FormsApi;
4
4
  declare const form: (target: FormTarget, options?: FormControllerOptions) => FormController;
@@ -1,4 +1,4 @@
1
- import { t as FormsApi, u as FormsAvailable, F as FormTarget, a as FormControllerOptions, b as FormController, N as NewFormInput } from '../registry-BpqJetAt.js';
1
+ import { u as FormsApi, v as FormsAvailable, F as FormTarget, a as FormControllerOptions, b as FormController, N as NewFormInput } from '../registry-GzopYPCR.js';
2
2
 
3
3
  declare const Forms: FormsApi;
4
4
  declare const form: (target: FormTarget, options?: FormControllerOptions) => FormController;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  browser
3
- } from "../chunk-L4GRKSQ5.js";
3
+ } from "../chunk-MMHX34WB.js";
4
4
 
5
5
  // src/browser/global.ts
6
6
  var Forms = browser;
@@ -62,14 +62,29 @@ var valuesAreEqual = (left, right) => {
62
62
  }
63
63
  return left === right;
64
64
  };
65
- var validateFieldValue = (field, value, rules, values) => {
65
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
66
+ var parseStrictNumber = (value) => {
67
+ if (typeof value !== "string") return void 0;
68
+ const candidate = value.trim();
69
+ if (!DECIMAL_PATTERN.test(candidate)) return void 0;
70
+ const number = Number(candidate);
71
+ return Number.isFinite(number) ? number : void 0;
72
+ };
73
+ var validateValue = (field, value, rules, values, contextOverrides = {}) => {
66
74
  const errors = [];
67
- const context = { field, value, values };
75
+ const context = {
76
+ field,
77
+ value,
78
+ values,
79
+ ...contextOverrides
80
+ };
68
81
  const required = resolveRule(rules.required);
69
82
  const minLength = resolveRule(rules.minLength);
70
83
  const maxLength = resolveRule(rules.maxLength);
71
84
  const pattern = resolveRule(rules.pattern);
72
- const sameAs = resolveRule(rules.sameAs);
85
+ const numeric = resolveRule(rules.numeric);
86
+ const min = resolveRule(rules.min);
87
+ const max = resolveRule(rules.max);
73
88
  if (required.value && !hasValue(value)) {
74
89
  errors.push(required.message ?? "This field is required.");
75
90
  }
@@ -87,10 +102,17 @@ var validateFieldValue = (field, value, rules, values) => {
87
102
  errors.push(pattern.message ?? "Value does not match the required pattern.");
88
103
  }
89
104
  }
90
- if (sameAs.value !== void 0) {
91
- const otherValue = values[sameAs.value];
92
- if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
93
- errors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
105
+ if (hasValue(value) && (numeric.value || min.value !== void 0 || max.value !== void 0)) {
106
+ const number = parseStrictNumber(value);
107
+ if (number === void 0) {
108
+ errors.push(numeric.message ?? "Value must be a number.");
109
+ } else {
110
+ if (min.value !== void 0 && number < min.value) {
111
+ errors.push(min.message ?? `Minimum value is ${min.value}.`);
112
+ }
113
+ if (max.value !== void 0 && number > max.value) {
114
+ errors.push(max.message ?? `Maximum value is ${max.value}.`);
115
+ }
94
116
  }
95
117
  }
96
118
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
@@ -101,6 +123,41 @@ var validateFieldValue = (field, value, rules, values) => {
101
123
  }
102
124
  return errors;
103
125
  };
126
+ var validateFieldValueDetailed = (field, value, rules, values, items) => {
127
+ const groupErrors = validateValue(field, value, rules, values);
128
+ const sameAs = resolveRule(rules.sameAs);
129
+ if (sameAs.value !== void 0) {
130
+ const otherValue = values[sameAs.value];
131
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
132
+ groupErrors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
133
+ }
134
+ }
135
+ const itemErrors = [];
136
+ if (rules.each) {
137
+ const sourceItems = items ?? (Array.isArray(value) ? value.map((entry) => ({
138
+ value: typeof entry === "string" ? entry : [entry]
139
+ })) : [{ value }]);
140
+ sourceItems.forEach((item, index) => {
141
+ const overrides = {
142
+ index
143
+ };
144
+ if (item.element) overrides.element = item.element;
145
+ const messages = validateValue(field, item.value, rules.each, values, overrides);
146
+ if (messages.length === 0) return;
147
+ const error = {
148
+ index,
149
+ messages
150
+ };
151
+ if (item.element) error.element = item.element;
152
+ itemErrors.push(error);
153
+ });
154
+ }
155
+ return { groupErrors, itemErrors };
156
+ };
157
+ var validateFieldValue = (field, value, rules, values) => {
158
+ const result = validateFieldValueDetailed(field, value, rules, values);
159
+ return [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
160
+ };
104
161
  var validateValues = (values, schema) => {
105
162
  const errors = {};
106
163
  for (const [field, rules] of Object.entries(schema)) {
@@ -133,6 +190,22 @@ var createAppend = (state2, helpers) => ({ tag, content, class: className, atSta
133
190
  return node;
134
191
  };
135
192
 
193
+ // src/api/add-cleanup.ts
194
+ var createAddCleanup = (state2, _helpers) => (cleanup) => {
195
+ if (state2.isDestroyed) {
196
+ cleanup();
197
+ return () => void 0;
198
+ }
199
+ const registration = () => cleanup();
200
+ state2.cleanups.add(registration);
201
+ let active = true;
202
+ return () => {
203
+ if (!active) return;
204
+ active = false;
205
+ state2.cleanups.delete(registration);
206
+ };
207
+ };
208
+
136
209
  // src/api/auto-submit.ts
137
210
  var createAutoSubmit = (state2, helpers) => (next = true) => {
138
211
  state2.autoSubmitEnabled = next !== false;
@@ -354,6 +427,10 @@ var buildAutoInterpretedOptions = (formatOptions) => ({
354
427
  ...formatOptions ?? {},
355
428
  interpretInputAs: "auto"
356
429
  });
430
+ var buildDisplayInterpretedOptions = (formatOptions) => ({
431
+ ...formatOptions ?? {},
432
+ interpretInputAs: "display"
433
+ });
357
434
  var shouldForceAutoInterpretation = (formatOptions) => formatOptions?.interpretInputAs === void 0;
358
435
  var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
359
436
  const handler = (event) => {
@@ -363,7 +440,7 @@ var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
363
440
  if (!isVisible && !isMirror) return;
364
441
  const rawInput = isMirror ? mirror.value : visible.value;
365
442
  const inputType = readInputType(event);
366
- const sourceOptions = isMirror && shouldForceAutoInterpretation(formatOptions) ? buildAutoInterpretedOptions(formatOptions) : formatOptions;
443
+ const sourceOptions = shouldForceAutoInterpretation(formatOptions) ? isMirror ? buildAutoInterpretedOptions(formatOptions) : buildDisplayInterpretedOptions(formatOptions) : formatOptions;
367
444
  const { formatted, raw } = formatFn(rawInput, formatType, sourceOptions);
368
445
  if (!formatted && !raw) {
369
446
  visible.value = "";
@@ -445,7 +522,8 @@ var applyFormat = async (state2, helpers, config) => {
445
522
  if (entry.handler !== UNBOUND) {
446
523
  const current = entry.visible.value;
447
524
  if (current !== "") {
448
- const { formatted, raw } = formatter.format(current, formatType, formatOptions);
525
+ const rebindOptions = shouldForceAutoInterpretation(formatOptions) ? buildDisplayInterpretedOptions(formatOptions) : formatOptions;
526
+ const { formatted, raw } = formatter.format(current, formatType, rebindOptions);
449
527
  applyFormattedValue(entry.visible, entry.mirror, formatted, raw);
450
528
  }
451
529
  continue;
@@ -560,12 +638,24 @@ var createDestroy = (state2, _helpers) => () => {
560
638
  }
561
639
  state2.mutationObserver?.disconnect();
562
640
  state2.mutationObserver = null;
641
+ const cleanups = Array.from(state2.cleanups).reverse();
642
+ state2.cleanups.clear();
643
+ for (const cleanup of cleanups) {
644
+ try {
645
+ cleanup();
646
+ } catch (error) {
647
+ console.error("[forms] cleanup failed", error);
648
+ }
649
+ }
563
650
  state2.watchedFields.clear();
564
651
  state2.subscribers.clear();
565
652
  state2.submitHandlers.clear();
566
653
  state2.fieldCache.clear();
567
654
  state2.manualErrors = {};
568
655
  state2.validationErrors = {};
656
+ state2.validationGroupErrors = {};
657
+ state2.validationElementErrors.clear();
658
+ state2.pendingSubmitCount = 0;
569
659
  cleanupFormatRegistry(state2);
570
660
  };
571
661
 
@@ -601,6 +691,7 @@ var createEmptyFormState = () => ({
601
691
  isValid: true,
602
692
  isValidated: false,
603
693
  autoSubmit: false,
694
+ isSubmitting: false,
604
695
  submitCount: 0
605
696
  });
606
697
  var cloneErrors = (errors) => Object.fromEntries(
@@ -632,6 +723,7 @@ var createGetState = (state2, helpers) => () => {
632
723
  isValid: Object.keys(errors).length === 0,
633
724
  isValidated: state2.isValidated,
634
725
  autoSubmit: state2.autoSubmitEnabled,
726
+ isSubmitting: state2.pendingSubmitCount > 0,
635
727
  submitCount: state2.submitCount
636
728
  };
637
729
  };
@@ -829,12 +921,14 @@ var createReset = (state2, helpers) => () => {
829
921
  resetFormattedFields(state2);
830
922
  state2.manualErrors = {};
831
923
  state2.validationErrors = {};
924
+ state2.validationGroupErrors = {};
925
+ state2.validationElementErrors.clear();
832
926
  clearAttributes(state2.element, [
833
927
  state2.attributes.error,
834
928
  state2.attributes.filled,
835
929
  "aria-invalid"
836
930
  ]);
837
- if (state2.isValidated) helpers.syncVisualState();
931
+ helpers.syncVisualState();
838
932
  helpers.notifySubscribers();
839
933
  return state2.api;
840
934
  };
@@ -941,25 +1035,35 @@ var createFormController = (target, options = {}) => {
941
1035
  fieldCache: /* @__PURE__ */ new Map(),
942
1036
  manualErrors: {},
943
1037
  validationErrors: {},
1038
+ validationGroupErrors: {},
1039
+ validationElementErrors: /* @__PURE__ */ new Map(),
944
1040
  isValidated: Boolean(normalizedOptions.autoValidate),
945
1041
  autoSubmitEnabled: false,
946
1042
  autoSubmitDebounce: 0,
947
1043
  submitCount: 0,
1044
+ pendingSubmitCount: 0,
948
1045
  autoSubmitTimer: null,
949
1046
  isDestroyed: false,
950
1047
  listeners: [],
951
1048
  mutationObserver: null,
1049
+ cleanups: /* @__PURE__ */ new Set(),
952
1050
  formattedFields: /* @__PURE__ */ new Map(),
953
1051
  api: null
954
1052
  };
955
1053
  const validationDependents = /* @__PURE__ */ new Map();
956
1054
  for (const [field, rules] of Object.entries(state2.validators)) {
1055
+ const sources = /* @__PURE__ */ new Set();
957
1056
  const sameAs = rules.sameAs;
958
- if (sameAs === void 0) continue;
959
- const source = typeof sameAs === "string" ? sameAs : sameAs.value;
960
- const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
961
- dependents.add(field);
962
- validationDependents.set(source, dependents);
1057
+ if (sameAs !== void 0) {
1058
+ sources.add(typeof sameAs === "string" ? sameAs : sameAs.value);
1059
+ }
1060
+ if (typeof rules.dependsOn === "string") sources.add(rules.dependsOn);
1061
+ else rules.dependsOn?.forEach((source) => sources.add(source));
1062
+ for (const source of sources) {
1063
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1064
+ dependents.add(field);
1065
+ validationDependents.set(source, dependents);
1066
+ }
963
1067
  }
964
1068
  const notifySubscribers = () => {
965
1069
  if (!state2.api) return;
@@ -1008,18 +1112,21 @@ var createFormController = (target, options = {}) => {
1008
1112
  const syncVisualState = (names) => {
1009
1113
  if (!state2.element) return;
1010
1114
  const sourceNames = names ?? getTrackedFieldNames();
1011
- const errors = getMergedErrors();
1012
1115
  for (const name of sourceNames) {
1013
1116
  const displayName = resolveDisplayNameForName(state2, name);
1014
1117
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1015
1118
  const fields = getFieldsByName(displayName);
1016
- const hasError = Boolean(errors[canonical]?.length);
1017
- for (const field of fields) {
1119
+ const hasGroupError = Boolean(
1120
+ state2.validationGroupErrors[canonical]?.length || state2.manualErrors[canonical]?.length
1121
+ );
1122
+ const elementErrors = state2.validationElementErrors.get(canonical);
1123
+ fields.forEach((field) => {
1124
+ const hasError = hasGroupError || Boolean(elementErrors?.get(field)?.length);
1018
1125
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
1019
1126
  applyBooleanAttribute(field, attributes.error, hasError);
1020
1127
  if (hasError) field.setAttribute("aria-invalid", "true");
1021
1128
  else field.removeAttribute("aria-invalid");
1022
- }
1129
+ });
1023
1130
  }
1024
1131
  };
1025
1132
  void readFieldValue;
@@ -1028,17 +1135,38 @@ var createFormController = (target, options = {}) => {
1028
1135
  const targetNames = names ?? Object.keys(state2.validators);
1029
1136
  const values = getValues();
1030
1137
  const nextValidationErrors = names ? cloneErrors(state2.validationErrors) : {};
1138
+ const nextGroupErrors = names ? cloneErrors(state2.validationGroupErrors) : {};
1139
+ const nextElementErrors = names ? new Map(state2.validationElementErrors) : /* @__PURE__ */ new Map();
1031
1140
  for (const name of targetNames) {
1032
1141
  const rules = state2.validators[name];
1033
1142
  if (!rules) {
1034
1143
  delete nextValidationErrors[name];
1144
+ delete nextGroupErrors[name];
1145
+ nextElementErrors.delete(name);
1035
1146
  continue;
1036
1147
  }
1037
- const messages = validateFieldValue(name, values[name], rules, values);
1148
+ const fields = getFieldsByName(name);
1149
+ const displayFields = getFieldsByName(resolveDisplayNameForName(state2, name));
1150
+ const items = fields.map((field, index) => ({
1151
+ value: readFieldValue([field]),
1152
+ element: displayFields[index] ?? field
1153
+ }));
1154
+ const result = validateFieldValueDetailed(name, values[name], rules, values, items);
1155
+ const messages = [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
1038
1156
  if (messages.length > 0) nextValidationErrors[name] = messages;
1039
1157
  else delete nextValidationErrors[name];
1158
+ if (result.groupErrors.length > 0) nextGroupErrors[name] = result.groupErrors;
1159
+ else delete nextGroupErrors[name];
1160
+ const perElement = /* @__PURE__ */ new Map();
1161
+ result.itemErrors.forEach((item) => {
1162
+ if (item.element) perElement.set(item.element, item.messages);
1163
+ });
1164
+ if (perElement.size > 0) nextElementErrors.set(name, perElement);
1165
+ else nextElementErrors.delete(name);
1040
1166
  }
1041
1167
  state2.validationErrors = nextValidationErrors;
1168
+ state2.validationGroupErrors = nextGroupErrors;
1169
+ state2.validationElementErrors = nextElementErrors;
1042
1170
  syncVisualState(names);
1043
1171
  return {
1044
1172
  isValid: Object.keys(getMergedErrors()).length === 0,
@@ -1132,7 +1260,13 @@ var createFormController = (target, options = {}) => {
1132
1260
  if (!validation.isValid) {
1133
1261
  for (const name of Object.keys(validation.errors)) {
1134
1262
  const displayName = resolveDisplayNameForName(state2, name);
1135
- const field = getFieldsByName(displayName).find((candidate2) => {
1263
+ const fields = getFieldsByName(displayName);
1264
+ const elementErrors = state2.validationElementErrors.get(name);
1265
+ const field = fields.find((candidate2) => {
1266
+ if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1267
+ if (candidate2 instanceof HTMLInputElement && candidate2.type === "hidden") return false;
1268
+ return elementErrors?.has(candidate2);
1269
+ }) ?? fields.find((candidate2) => {
1136
1270
  if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1137
1271
  return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1138
1272
  });
@@ -1147,18 +1281,35 @@ var createFormController = (target, options = {}) => {
1147
1281
  const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
1148
1282
  const { data, formData } = parseFormData(state2.element, submitter);
1149
1283
  const snapshot = state2.api.getState();
1150
- handlers.forEach(
1151
- (handler) => handler.callback(state2.element, data, formData, snapshot)
1152
- );
1284
+ const pending = [];
1285
+ let synchronousError;
1286
+ let hasSynchronousError = false;
1287
+ try {
1288
+ for (const handler of handlers) {
1289
+ const result = handler.callback(state2.element, data, formData, snapshot);
1290
+ if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
1291
+ }
1292
+ } catch (error) {
1293
+ synchronousError = error;
1294
+ hasSynchronousError = true;
1295
+ }
1296
+ if (pending.length > 0) {
1297
+ state2.pendingSubmitCount += 1;
1298
+ notifySubscribers();
1299
+ void Promise.allSettled(pending).then(() => {
1300
+ state2.pendingSubmitCount = Math.max(0, state2.pendingSubmitCount - 1);
1301
+ if (!state2.isDestroyed) notifySubscribers();
1302
+ });
1303
+ }
1304
+ if (hasSynchronousError) throw synchronousError;
1153
1305
  };
1154
1306
  const startMutationObserver = () => {
1155
1307
  if (!state2.element || typeof MutationObserver === "undefined") return;
1156
1308
  state2.mutationObserver = new MutationObserver(() => {
1157
1309
  clearFieldCache();
1158
1310
  if (state2.isValidated) {
1159
- syncVisualState();
1160
1311
  validateNames();
1161
- }
1312
+ } else syncVisualState();
1162
1313
  notifySubscribers();
1163
1314
  });
1164
1315
  state2.mutationObserver.observe(state2.element, {
@@ -1179,6 +1330,7 @@ var createFormController = (target, options = {}) => {
1179
1330
  return state2.options;
1180
1331
  },
1181
1332
  onSubmit: createOnSubmit(state2, helpers),
1333
+ addCleanup: createAddCleanup(state2, helpers),
1182
1334
  watch: createWatch(state2, helpers),
1183
1335
  observe: createObserve(state2, helpers),
1184
1336
  unwatch: createUnwatch(state2, helpers),
@@ -1216,10 +1368,8 @@ var createFormController = (target, options = {}) => {
1216
1368
  api.format(config);
1217
1369
  }
1218
1370
  }
1219
- if (state2.isValidated) {
1220
- syncVisualState();
1221
- validateNames();
1222
- }
1371
+ if (state2.isValidated) validateNames();
1372
+ else syncVisualState();
1223
1373
  return api;
1224
1374
  };
1225
1375
 
@@ -1267,4 +1417,4 @@ export {
1267
1417
  form,
1268
1418
  browser
1269
1419
  };
1270
- //# sourceMappingURL=chunk-L4GRKSQ5.js.map
1420
+ //# sourceMappingURL=chunk-MMHX34WB.js.map