@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/README.md CHANGED
@@ -42,10 +42,10 @@ Requires Node 20+ when bundling. Runtime target is ES2020.
42
42
  Use the browser build when you do not have a bundler and need to run the package directly in HTML, Shopify, WordPress, or any traditional template.
43
43
 
44
44
  ```html
45
- <script src="https://unpkg.com/@samline/forms@2.6.0/dist/browser/global.global.js"></script>
45
+ <script src="https://unpkg.com/@samline/forms@2.7.0/dist/browser/global.global.js"></script>
46
46
  ```
47
47
 
48
- > Pin the version in production. Replace `2.6.0` with the version you ship.
48
+ > Pin the version in production. Replace `2.7.0` with the version you ship.
49
49
 
50
50
  The browser bundle exposes a single global: `window.Forms`.
51
51
 
@@ -55,7 +55,7 @@ The browser bundle exposes a single global: `window.Forms`.
55
55
  <button type="submit">Send</button>
56
56
  </form>
57
57
 
58
- <script src="https://unpkg.com/@samline/forms@2.6.0/dist/browser/global.global.js"></script>
58
+ <script src="https://unpkg.com/@samline/forms@2.7.0/dist/browser/global.global.js"></script>
59
59
  <script>
60
60
  const contactForm = window.Forms.newForm({ id: 'contact-form' })
61
61
 
@@ -134,7 +134,7 @@ The controller is built around one factory and a small set of focused methods. M
134
134
 
135
135
  | Group | Methods |
136
136
  | --- | --- |
137
- | Lifecycle | [`form`](docs/api/form.md) · [`destroy`](docs/api/destroy.md) · [`reset`](docs/api/reset.md) |
137
+ | Lifecycle | [`form`](docs/api/form.md) · [`addCleanup`](docs/api/add-cleanup.md) · [`destroy`](docs/api/destroy.md) · [`reset`](docs/api/reset.md) |
138
138
  | Registry (vanilla) | [`browser`](docs/getting-started.md#browser-registry-helpers) — bundler-friendly `{ form, newForm, destroyForm, available }` singleton. |
139
139
  | Properties | [`element`](docs/api/element.md) · [`options`](docs/options.md) |
140
140
  | Submission | [`onSubmit`](docs/api/on-submit.md) · [`autoSubmit`](docs/api/auto-submit.md) · [`disableAutoSubmit`](docs/api/disable-auto-submit.md) |
@@ -144,6 +144,8 @@ The controller is built around one factory and a small set of focused methods. M
144
144
  | State and data | [`getData`](docs/api/get-data.md) · [`getState`](docs/api/get-state.md) · [`append`](docs/api/append.md) |
145
145
  | Pure helpers | [`parseFormData`](docs/api/parse-form-data.md) · [`validateValues`](docs/api/validate-values.md) · [`validateFieldValue`](docs/api/validate-field-value.md) |
146
146
 
147
+ Validation supports explicit cross-field dependencies with `dependsOn`, per-member collection rules with `each`, and strict decimal `numeric` / inclusive `min` / `max` rules. Async `onSubmit` handlers are reflected by `getState().isSubmitting`, while `addCleanup()` lets integrations register controller-owned teardown work.
148
+
147
149
  See the full per-method reference in [`docs/api/`](docs/api/index.md).
148
150
 
149
151
  ---
@@ -30,16 +30,29 @@ interface FieldValidationContext {
30
30
  field: string;
31
31
  value: FormFieldValue;
32
32
  values: FormValues;
33
+ /** Concrete control while validating one member through `each`. */
34
+ element?: FormFieldElement;
35
+ /** Zero-based member position while validating through `each`. */
36
+ index?: number;
33
37
  }
34
38
  type FieldValidator = (context: FieldValidationContext) => string | undefined | null | false | true;
35
- interface FieldValidationRules {
39
+ interface ValueValidationRules {
36
40
  required?: RuleConfig<boolean>;
37
41
  minLength?: RuleConfig<number>;
38
42
  maxLength?: RuleConfig<number>;
39
43
  pattern?: RuleConfig<RegExp>;
44
+ numeric?: RuleConfig<boolean>;
45
+ min?: RuleConfig<number>;
46
+ max?: RuleConfig<number>;
47
+ validate?: FieldValidator | FieldValidator[];
48
+ }
49
+ interface FieldValidationRules extends ValueValidationRules {
40
50
  /** Field name whose non-empty value must equal this field's value. */
41
51
  sameAs?: RuleConfig<string>;
42
- validate?: FieldValidator | FieldValidator[];
52
+ /** Fields whose changes should revalidate this field. */
53
+ dependsOn?: string | string[];
54
+ /** Rules applied independently to each collection member. */
55
+ each?: ValueValidationRules;
43
56
  }
44
57
  type ValidationSchema = Record<string, FieldValidationRules>;
45
58
  interface VisualAttributes {
@@ -79,6 +92,7 @@ interface FormStateSnapshot {
79
92
  isValid: boolean;
80
93
  isValidated: boolean;
81
94
  autoSubmit: boolean;
95
+ isSubmitting: boolean;
82
96
  submitCount: number;
83
97
  }
84
98
  type FormStateListener = (state: FormStateSnapshot) => void;
@@ -93,12 +107,14 @@ interface AppendContentOptions {
93
107
  atStart?: boolean;
94
108
  }
95
109
  type FormFieldWatcher = (value: FormFieldValue, field: FormFieldElement | FormFieldElement[] | null, form: HTMLFormElement, state: FormStateSnapshot) => void;
96
- type FormSubmitHandler = (form: HTMLFormElement, data: Record<string, SerializedFormValue>, formData: FormData, state: FormStateSnapshot) => void;
110
+ type FormSubmitHandler = (form: HTMLFormElement, data: Record<string, SerializedFormValue>, formData: FormData, state: FormStateSnapshot) => void | Promise<void>;
111
+ type FormCleanup = () => void;
97
112
  interface FormController {
98
113
  readonly element: HTMLFormElement | null;
99
114
  readonly f: HTMLFormElement | null;
100
115
  readonly options: FormControllerOptions;
101
116
  onSubmit: (callback: FormSubmitHandler, preventDefault?: boolean) => FormController;
117
+ addCleanup: (cleanup: FormCleanup) => () => void;
102
118
  watch: (field: string, callback: FormFieldWatcher) => FormController;
103
119
  observe: (field: string, callback: FormFieldWatcher) => () => void;
104
120
  unwatch: (field?: string, callback?: FormFieldWatcher) => FormController;
@@ -4788,6 +4788,22 @@ var Forms = (() => {
4788
4788
  return node;
4789
4789
  };
4790
4790
 
4791
+ // src/api/add-cleanup.ts
4792
+ var createAddCleanup = (state2, _helpers) => (cleanup) => {
4793
+ if (state2.isDestroyed) {
4794
+ cleanup();
4795
+ return () => void 0;
4796
+ }
4797
+ const registration = () => cleanup();
4798
+ state2.cleanups.add(registration);
4799
+ let active = true;
4800
+ return () => {
4801
+ if (!active) return;
4802
+ active = false;
4803
+ state2.cleanups.delete(registration);
4804
+ };
4805
+ };
4806
+
4791
4807
  // src/api/auto-submit.ts
4792
4808
  var createAutoSubmit = (state2, helpers) => (next = true) => {
4793
4809
  state2.autoSubmitEnabled = next !== false;
@@ -5215,12 +5231,24 @@ var Forms = (() => {
5215
5231
  }
5216
5232
  state2.mutationObserver?.disconnect();
5217
5233
  state2.mutationObserver = null;
5234
+ const cleanups = Array.from(state2.cleanups).reverse();
5235
+ state2.cleanups.clear();
5236
+ for (const cleanup of cleanups) {
5237
+ try {
5238
+ cleanup();
5239
+ } catch (error) {
5240
+ console.error("[forms] cleanup failed", error);
5241
+ }
5242
+ }
5218
5243
  state2.watchedFields.clear();
5219
5244
  state2.subscribers.clear();
5220
5245
  state2.submitHandlers.clear();
5221
5246
  state2.fieldCache.clear();
5222
5247
  state2.manualErrors = {};
5223
5248
  state2.validationErrors = {};
5249
+ state2.validationGroupErrors = {};
5250
+ state2.validationElementErrors.clear();
5251
+ state2.pendingSubmitCount = 0;
5224
5252
  cleanupFormatRegistry(state2);
5225
5253
  };
5226
5254
 
@@ -5287,6 +5315,7 @@ var Forms = (() => {
5287
5315
  isValid: true,
5288
5316
  isValidated: false,
5289
5317
  autoSubmit: false,
5318
+ isSubmitting: false,
5290
5319
  submitCount: 0
5291
5320
  });
5292
5321
  var cloneErrors = (errors) => Object.fromEntries(
@@ -5318,6 +5347,7 @@ var Forms = (() => {
5318
5347
  isValid: Object.keys(errors).length === 0,
5319
5348
  isValidated: state2.isValidated,
5320
5349
  autoSubmit: state2.autoSubmitEnabled,
5350
+ isSubmitting: state2.pendingSubmitCount > 0,
5321
5351
  submitCount: state2.submitCount
5322
5352
  };
5323
5353
  };
@@ -5515,12 +5545,14 @@ var Forms = (() => {
5515
5545
  resetFormattedFields(state2);
5516
5546
  state2.manualErrors = {};
5517
5547
  state2.validationErrors = {};
5548
+ state2.validationGroupErrors = {};
5549
+ state2.validationElementErrors.clear();
5518
5550
  clearAttributes(state2.element, [
5519
5551
  state2.attributes.error,
5520
5552
  state2.attributes.filled,
5521
5553
  "aria-invalid"
5522
5554
  ]);
5523
- if (state2.isValidated) helpers.syncVisualState();
5555
+ helpers.syncVisualState();
5524
5556
  helpers.notifySubscribers();
5525
5557
  return state2.api;
5526
5558
  };
@@ -5631,14 +5663,29 @@ var Forms = (() => {
5631
5663
  }
5632
5664
  return left === right;
5633
5665
  };
5634
- var validateFieldValue = (field, value, rules, values) => {
5666
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
5667
+ var parseStrictNumber = (value) => {
5668
+ if (typeof value !== "string") return void 0;
5669
+ const candidate = value.trim();
5670
+ if (!DECIMAL_PATTERN.test(candidate)) return void 0;
5671
+ const number = Number(candidate);
5672
+ return Number.isFinite(number) ? number : void 0;
5673
+ };
5674
+ var validateValue = (field, value, rules, values, contextOverrides = {}) => {
5635
5675
  const errors = [];
5636
- const context = { field, value, values };
5676
+ const context = {
5677
+ field,
5678
+ value,
5679
+ values,
5680
+ ...contextOverrides
5681
+ };
5637
5682
  const required = resolveRule(rules.required);
5638
5683
  const minLength = resolveRule(rules.minLength);
5639
5684
  const maxLength = resolveRule(rules.maxLength);
5640
5685
  const pattern = resolveRule(rules.pattern);
5641
- const sameAs = resolveRule(rules.sameAs);
5686
+ const numeric = resolveRule(rules.numeric);
5687
+ const min = resolveRule(rules.min);
5688
+ const max = resolveRule(rules.max);
5642
5689
  if (required.value && !hasValue(value)) {
5643
5690
  errors.push(required.message ?? "This field is required.");
5644
5691
  }
@@ -5656,10 +5703,17 @@ var Forms = (() => {
5656
5703
  errors.push(pattern.message ?? "Value does not match the required pattern.");
5657
5704
  }
5658
5705
  }
5659
- if (sameAs.value !== void 0) {
5660
- const otherValue = values[sameAs.value];
5661
- if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
5662
- errors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
5706
+ if (hasValue(value) && (numeric.value || min.value !== void 0 || max.value !== void 0)) {
5707
+ const number = parseStrictNumber(value);
5708
+ if (number === void 0) {
5709
+ errors.push(numeric.message ?? "Value must be a number.");
5710
+ } else {
5711
+ if (min.value !== void 0 && number < min.value) {
5712
+ errors.push(min.message ?? `Minimum value is ${min.value}.`);
5713
+ }
5714
+ if (max.value !== void 0 && number > max.value) {
5715
+ errors.push(max.message ?? `Maximum value is ${max.value}.`);
5716
+ }
5663
5717
  }
5664
5718
  }
5665
5719
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
@@ -5670,6 +5724,37 @@ var Forms = (() => {
5670
5724
  }
5671
5725
  return errors;
5672
5726
  };
5727
+ var validateFieldValueDetailed = (field, value, rules, values, items) => {
5728
+ const groupErrors = validateValue(field, value, rules, values);
5729
+ const sameAs = resolveRule(rules.sameAs);
5730
+ if (sameAs.value !== void 0) {
5731
+ const otherValue = values[sameAs.value];
5732
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
5733
+ groupErrors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
5734
+ }
5735
+ }
5736
+ const itemErrors = [];
5737
+ if (rules.each) {
5738
+ const sourceItems = items ?? (Array.isArray(value) ? value.map((entry) => ({
5739
+ value: typeof entry === "string" ? entry : [entry]
5740
+ })) : [{ value }]);
5741
+ sourceItems.forEach((item, index) => {
5742
+ const overrides = {
5743
+ index
5744
+ };
5745
+ if (item.element) overrides.element = item.element;
5746
+ const messages = validateValue(field, item.value, rules.each, values, overrides);
5747
+ if (messages.length === 0) return;
5748
+ const error = {
5749
+ index,
5750
+ messages
5751
+ };
5752
+ if (item.element) error.element = item.element;
5753
+ itemErrors.push(error);
5754
+ });
5755
+ }
5756
+ return { groupErrors, itemErrors };
5757
+ };
5673
5758
 
5674
5759
  // src/core/controller.ts
5675
5760
  var DEFAULT_ATTRIBUTES = {
@@ -5700,25 +5785,35 @@ var Forms = (() => {
5700
5785
  fieldCache: /* @__PURE__ */ new Map(),
5701
5786
  manualErrors: {},
5702
5787
  validationErrors: {},
5788
+ validationGroupErrors: {},
5789
+ validationElementErrors: /* @__PURE__ */ new Map(),
5703
5790
  isValidated: Boolean(normalizedOptions.autoValidate),
5704
5791
  autoSubmitEnabled: false,
5705
5792
  autoSubmitDebounce: 0,
5706
5793
  submitCount: 0,
5794
+ pendingSubmitCount: 0,
5707
5795
  autoSubmitTimer: null,
5708
5796
  isDestroyed: false,
5709
5797
  listeners: [],
5710
5798
  mutationObserver: null,
5799
+ cleanups: /* @__PURE__ */ new Set(),
5711
5800
  formattedFields: /* @__PURE__ */ new Map(),
5712
5801
  api: null
5713
5802
  };
5714
5803
  const validationDependents = /* @__PURE__ */ new Map();
5715
5804
  for (const [field, rules] of Object.entries(state2.validators)) {
5805
+ const sources = /* @__PURE__ */ new Set();
5716
5806
  const sameAs = rules.sameAs;
5717
- if (sameAs === void 0) continue;
5718
- const source = typeof sameAs === "string" ? sameAs : sameAs.value;
5719
- const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
5720
- dependents.add(field);
5721
- validationDependents.set(source, dependents);
5807
+ if (sameAs !== void 0) {
5808
+ sources.add(typeof sameAs === "string" ? sameAs : sameAs.value);
5809
+ }
5810
+ if (typeof rules.dependsOn === "string") sources.add(rules.dependsOn);
5811
+ else rules.dependsOn?.forEach((source) => sources.add(source));
5812
+ for (const source of sources) {
5813
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
5814
+ dependents.add(field);
5815
+ validationDependents.set(source, dependents);
5816
+ }
5722
5817
  }
5723
5818
  const notifySubscribers = () => {
5724
5819
  if (!state2.api) return;
@@ -5767,18 +5862,21 @@ var Forms = (() => {
5767
5862
  const syncVisualState = (names) => {
5768
5863
  if (!state2.element) return;
5769
5864
  const sourceNames = names ?? getTrackedFieldNames();
5770
- const errors = getMergedErrors();
5771
5865
  for (const name of sourceNames) {
5772
5866
  const displayName = resolveDisplayNameForName(state2, name);
5773
5867
  const canonical = resolveCanonicalForName(state2, name) ?? name;
5774
5868
  const fields = getFieldsByName(displayName);
5775
- const hasError = Boolean(errors[canonical]?.length);
5776
- for (const field of fields) {
5869
+ const hasGroupError = Boolean(
5870
+ state2.validationGroupErrors[canonical]?.length || state2.manualErrors[canonical]?.length
5871
+ );
5872
+ const elementErrors = state2.validationElementErrors.get(canonical);
5873
+ fields.forEach((field) => {
5874
+ const hasError = hasGroupError || Boolean(elementErrors?.get(field)?.length);
5777
5875
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
5778
5876
  applyBooleanAttribute(field, attributes.error, hasError);
5779
5877
  if (hasError) field.setAttribute("aria-invalid", "true");
5780
5878
  else field.removeAttribute("aria-invalid");
5781
- }
5879
+ });
5782
5880
  }
5783
5881
  };
5784
5882
  void readFieldValue;
@@ -5787,17 +5885,38 @@ var Forms = (() => {
5787
5885
  const targetNames = names ?? Object.keys(state2.validators);
5788
5886
  const values = getValues();
5789
5887
  const nextValidationErrors = names ? cloneErrors(state2.validationErrors) : {};
5888
+ const nextGroupErrors = names ? cloneErrors(state2.validationGroupErrors) : {};
5889
+ const nextElementErrors = names ? new Map(state2.validationElementErrors) : /* @__PURE__ */ new Map();
5790
5890
  for (const name of targetNames) {
5791
5891
  const rules = state2.validators[name];
5792
5892
  if (!rules) {
5793
5893
  delete nextValidationErrors[name];
5894
+ delete nextGroupErrors[name];
5895
+ nextElementErrors.delete(name);
5794
5896
  continue;
5795
5897
  }
5796
- const messages = validateFieldValue(name, values[name], rules, values);
5898
+ const fields = getFieldsByName(name);
5899
+ const displayFields = getFieldsByName(resolveDisplayNameForName(state2, name));
5900
+ const items = fields.map((field, index) => ({
5901
+ value: readFieldValue([field]),
5902
+ element: displayFields[index] ?? field
5903
+ }));
5904
+ const result = validateFieldValueDetailed(name, values[name], rules, values, items);
5905
+ const messages = [...result.groupErrors, ...result.itemErrors.flatMap((item) => item.messages)];
5797
5906
  if (messages.length > 0) nextValidationErrors[name] = messages;
5798
5907
  else delete nextValidationErrors[name];
5908
+ if (result.groupErrors.length > 0) nextGroupErrors[name] = result.groupErrors;
5909
+ else delete nextGroupErrors[name];
5910
+ const perElement = /* @__PURE__ */ new Map();
5911
+ result.itemErrors.forEach((item) => {
5912
+ if (item.element) perElement.set(item.element, item.messages);
5913
+ });
5914
+ if (perElement.size > 0) nextElementErrors.set(name, perElement);
5915
+ else nextElementErrors.delete(name);
5799
5916
  }
5800
5917
  state2.validationErrors = nextValidationErrors;
5918
+ state2.validationGroupErrors = nextGroupErrors;
5919
+ state2.validationElementErrors = nextElementErrors;
5801
5920
  syncVisualState(names);
5802
5921
  return {
5803
5922
  isValid: Object.keys(getMergedErrors()).length === 0,
@@ -5891,7 +6010,13 @@ var Forms = (() => {
5891
6010
  if (!validation.isValid) {
5892
6011
  for (const name of Object.keys(validation.errors)) {
5893
6012
  const displayName = resolveDisplayNameForName(state2, name);
5894
- const field = getFieldsByName(displayName).find((candidate2) => {
6013
+ const fields = getFieldsByName(displayName);
6014
+ const elementErrors = state2.validationElementErrors.get(name);
6015
+ const field = fields.find((candidate2) => {
6016
+ if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
6017
+ if (candidate2 instanceof HTMLInputElement && candidate2.type === "hidden") return false;
6018
+ return elementErrors?.has(candidate2);
6019
+ }) ?? fields.find((candidate2) => {
5895
6020
  if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
5896
6021
  return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
5897
6022
  });
@@ -5906,18 +6031,35 @@ var Forms = (() => {
5906
6031
  const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
5907
6032
  const { data, formData } = parseFormData(state2.element, submitter);
5908
6033
  const snapshot = state2.api.getState();
5909
- handlers.forEach(
5910
- (handler) => handler.callback(state2.element, data, formData, snapshot)
5911
- );
6034
+ const pending = [];
6035
+ let synchronousError;
6036
+ let hasSynchronousError = false;
6037
+ try {
6038
+ for (const handler of handlers) {
6039
+ const result = handler.callback(state2.element, data, formData, snapshot);
6040
+ if (result && typeof result.then === "function") pending.push(Promise.resolve(result));
6041
+ }
6042
+ } catch (error) {
6043
+ synchronousError = error;
6044
+ hasSynchronousError = true;
6045
+ }
6046
+ if (pending.length > 0) {
6047
+ state2.pendingSubmitCount += 1;
6048
+ notifySubscribers();
6049
+ void Promise.allSettled(pending).then(() => {
6050
+ state2.pendingSubmitCount = Math.max(0, state2.pendingSubmitCount - 1);
6051
+ if (!state2.isDestroyed) notifySubscribers();
6052
+ });
6053
+ }
6054
+ if (hasSynchronousError) throw synchronousError;
5912
6055
  };
5913
6056
  const startMutationObserver = () => {
5914
6057
  if (!state2.element || typeof MutationObserver === "undefined") return;
5915
6058
  state2.mutationObserver = new MutationObserver(() => {
5916
6059
  clearFieldCache();
5917
6060
  if (state2.isValidated) {
5918
- syncVisualState();
5919
6061
  validateNames();
5920
- }
6062
+ } else syncVisualState();
5921
6063
  notifySubscribers();
5922
6064
  });
5923
6065
  state2.mutationObserver.observe(state2.element, {
@@ -5938,6 +6080,7 @@ var Forms = (() => {
5938
6080
  return state2.options;
5939
6081
  },
5940
6082
  onSubmit: createOnSubmit(state2, helpers),
6083
+ addCleanup: createAddCleanup(state2, helpers),
5941
6084
  watch: createWatch(state2, helpers),
5942
6085
  observe: createObserve(state2, helpers),
5943
6086
  unwatch: createUnwatch(state2, helpers),
@@ -5975,10 +6118,8 @@ var Forms = (() => {
5975
6118
  api.format(config);
5976
6119
  }
5977
6120
  }
5978
- if (state2.isValidated) {
5979
- syncVisualState();
5980
- validateNames();
5981
- }
6121
+ if (state2.isValidated) validateNames();
6122
+ else syncVisualState();
5982
6123
  return api;
5983
6124
  };
5984
6125