@samline/forms 2.2.3 → 2.3.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.2.3/dist/browser/global.global.js"></script>
45
+ <script src="https://unpkg.com/@samline/forms@2.3.0/dist/browser/global.global.js"></script>
46
46
  ```
47
47
 
48
- > Pin the version in production. Replace `2.2.3` with the version you ship.
48
+ > Pin the version in production. Replace `2.3.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.2.3/dist/browser/global.global.js"></script>
58
+ <script src="https://unpkg.com/@samline/forms@2.3.0/dist/browser/global.global.js"></script>
59
59
  <script>
60
60
  const contactForm = window.Forms.newForm({ id: 'contact-form' })
61
61
 
@@ -2,14 +2,25 @@ type FormatType = 'general' | 'phone' | 'numeral' | 'date' | 'time' | 'creditCar
2
2
  interface FieldFormatConfig {
3
3
  /** One of the supported `@samline/formatter` `FormatType` values. */
4
4
  type: FormatType;
5
- /** Name of the visible input(s) inside the bound form. */
5
+ /**
6
+ * Canonical name of the formatted field. The value the backend
7
+ * ultimately receives (the `raw` output of the formatter) is
8
+ * exposed under this name; the formatter is responsible for
9
+ * keeping it in sync with the visible input.
10
+ */
6
11
  field: string | string[];
7
12
  /**
8
- * Optional name for the hidden raw mirror. Defaults to
9
- * `${fieldName}Raw`. The mirror is created on first `format()`
10
- * call and reused on subsequent calls.
13
+ * Name of the visible input that shows the formatted value to
14
+ * the user. Defaults to `${fieldName}_displayed`.
15
+ *
16
+ * The visible is **renamed** from `field` to `displayField` on
17
+ * the first `format()` call (idempotent on re-binding). A hidden
18
+ * `<input type="hidden" name="<field>">` is created next to it
19
+ * to carry the raw value. Both names are first-class in the
20
+ * controller's API (`getValue`, `getField`, `setValue`, `watch`,
21
+ * `getData`) — see `docs/api/format.md` for the full contract.
11
22
  */
12
- rawField?: string;
23
+ displayField?: string;
13
24
  /**
14
25
  * Format-specific options forwarded to `@samline/formatter`.
15
26
  * Any key documented for the chosen `type` is accepted (e.g.
@@ -4665,33 +4665,45 @@ var Forms = (() => {
4665
4665
  }
4666
4666
  return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
4667
4667
  };
4668
- var findRawMirror = (form2, fieldName) => {
4668
+ var findVisibleField = (form2, displayName) => {
4669
+ const node = form2.querySelector(
4670
+ `[name="${cssEscape(displayName)}"]:not([type="hidden"])`
4671
+ );
4672
+ if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
4673
+ return node;
4674
+ }
4675
+ return null;
4676
+ };
4677
+ var findHiddenMirror = (form2, fieldName) => {
4669
4678
  const byAttr = form2.querySelector(
4670
4679
  `[${FORMATTER_RAW_ATTRIBUTE}="${cssEscape(fieldName)}"]`
4671
4680
  );
4672
4681
  if (byAttr) return byAttr;
4673
4682
  const byName = form2.querySelector(
4674
- `[name="${cssEscape(`${fieldName}_raw`)}"]`
4683
+ `input[type="hidden"][name="${cssEscape(fieldName)}"]`
4675
4684
  );
4676
4685
  if (byName) return byName;
4677
- const byNameOld = form2.querySelector(
4678
- `[name="${cssEscape(`${fieldName}Raw`)}"]`
4679
- );
4680
- if (byNameOld) return byNameOld;
4681
4686
  return null;
4682
4687
  };
4683
- var ensureRawMirror = (form2, fieldName) => {
4684
- const existing = findRawMirror(form2, fieldName);
4688
+ var ensureHiddenMirror = (form2, fieldName) => {
4689
+ const existing = findHiddenMirror(form2, fieldName);
4685
4690
  if (existing) return existing;
4686
4691
  const input = document.createElement("input");
4687
4692
  input.type = "hidden";
4688
- input.name = `${fieldName}_raw`;
4693
+ input.name = fieldName;
4689
4694
  input.setAttribute("aria-hidden", "true");
4690
4695
  input.tabIndex = -1;
4691
4696
  input.setAttribute(FORMATTER_RAW_ATTRIBUTE, fieldName);
4692
4697
  form2.appendChild(input);
4693
4698
  return input;
4694
4699
  };
4700
+ var renameVisibleField = (visible, displayName) => {
4701
+ if (visible.name === displayName) return;
4702
+ visible.name = displayName;
4703
+ };
4704
+ var restoreVisibleName = (visible, originalName) => {
4705
+ visible.name = originalName;
4706
+ };
4695
4707
  var applyFormattedValue = (visible, mirror, formatted, raw, inputType) => {
4696
4708
  const rawInput = visible.value;
4697
4709
  const caret = clamp(
@@ -4809,6 +4821,7 @@ var Forms = (() => {
4809
4821
  };
4810
4822
 
4811
4823
  // src/api/format.ts
4824
+ var UNBOUND = null;
4812
4825
  var registry = /* @__PURE__ */ new WeakMap();
4813
4826
  var getRegistry = (state2) => {
4814
4827
  let bucket = registry.get(state2);
@@ -4819,99 +4832,128 @@ var Forms = (() => {
4819
4832
  return bucket;
4820
4833
  };
4821
4834
  var resolveFieldNames = (config) => Array.isArray(config.field) ? config.field : [config.field];
4822
- var addListener = (state2, target, type, handler) => {
4823
- target.addEventListener(type, handler);
4824
- state2.listeners.push({ element: target, type, handler });
4835
+ var addListener = (state2, target, type, handler, useCapture) => {
4836
+ if (useCapture) {
4837
+ target.addEventListener(type, handler, { capture: true });
4838
+ } else {
4839
+ target.addEventListener(type, handler);
4840
+ }
4841
+ const entry = { element: target, type, handler };
4842
+ if (useCapture !== void 0) entry.capture = useCapture;
4843
+ state2.listeners.push(entry);
4825
4844
  };
4826
- var buildHandler = (field, mirror, formatFn, formatType, formatOptions, mirrorFieldName) => {
4845
+ var readInputType = (event) => {
4846
+ const candidate = event;
4847
+ return typeof candidate.inputType === "string" ? candidate.inputType : void 0;
4848
+ };
4849
+ var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
4827
4850
  const handler = (event) => {
4828
- if (!field.isConnected) return;
4829
- if (event.target !== field) return;
4830
- const rawInput = event.target.value;
4851
+ if (!visible.isConnected || !mirror.isConnected) return;
4852
+ const isVisible = event.target === visible;
4853
+ const isMirror = event.target === mirror;
4854
+ if (!isVisible && !isMirror) return;
4855
+ const rawInput = isMirror ? mirror.value : visible.value;
4831
4856
  const inputType = readInputType(event);
4832
4857
  const { formatted, raw } = formatFn(rawInput, formatType, formatOptions);
4833
4858
  if (!formatted && !raw) {
4834
- field.value = "";
4835
- if (mirror && mirror.value !== "") mirror.value = "";
4859
+ visible.value = "";
4860
+ if (mirror.value !== "") mirror.value = "";
4836
4861
  return;
4837
4862
  }
4838
- applyFormattedValue(field, mirror, formatted, raw, inputType);
4839
- void mirrorFieldName;
4863
+ applyFormattedValue(visible, mirror, formatted, raw, inputType);
4840
4864
  };
4841
4865
  return handler;
4842
4866
  };
4843
- var readInputType = (event) => {
4844
- const candidate = event;
4845
- return typeof candidate.inputType === "string" ? candidate.inputType : void 0;
4846
- };
4847
4867
  var applyFormat = async (state2, helpers, config) => {
4848
4868
  if (!state2.element || !state2.api) return;
4849
- const formatter = await loadFormatter();
4850
- if (!formatter) return;
4851
4869
  const formatType = config.type;
4852
4870
  const formatOptions = config.options;
4853
4871
  const fieldNames = resolveFieldNames(config);
4854
4872
  const bucket = getRegistry(state2);
4855
4873
  for (const fieldName of fieldNames) {
4856
- const fields = helpers.getFieldsByName(fieldName);
4857
- if (fields.length === 0) continue;
4858
- const writable = fields.filter(
4859
- (f2) => f2 instanceof HTMLInputElement && f2.type !== "checkbox" && f2.type !== "radio" && f2.type !== "file" && f2.type !== "submit" && f2.type !== "button" || f2 instanceof HTMLTextAreaElement
4874
+ const displayName = config.displayField ?? `${fieldName}_displayed`;
4875
+ const canonicalFields = helpers.getFieldsByName(fieldName);
4876
+ const candidates = canonicalFields.filter(
4877
+ (f2) => f2 instanceof HTMLInputElement && f2.type !== "hidden" || f2 instanceof HTMLTextAreaElement
4860
4878
  );
4861
- if (writable.length === 0) continue;
4862
- for (const field of writable) {
4863
- const existing = bucket.get(fieldName);
4864
- const mirrorName = config.rawField ?? `${fieldName}_raw`;
4865
- let mirror = findRawMirror(state2.element, fieldName) ?? null;
4866
- const mirrorIsOwned = !mirror;
4867
- if (!mirror) mirror = ensureRawMirror(state2.element, fieldName);
4868
- if (mirror && mirror.name !== mirrorName) mirror.name = mirrorName;
4869
- const trackerMirrorName = mirror?.name ?? mirrorName;
4870
- if (existing) {
4871
- existing.mirrorName = trackerMirrorName;
4872
- existing.mirrorIsOwned = mirrorIsOwned;
4873
- state2.formattedFields.set(fieldName, {
4874
- config,
4875
- mirrorName: trackerMirrorName,
4876
- mirrorIsOwned
4877
- });
4878
- const current = field.value;
4879
- if (current !== "") {
4880
- const { formatted, raw } = formatter.format(current, formatType, formatOptions);
4881
- applyFormattedValue(field, mirror, formatted, raw);
4882
- }
4883
- continue;
4884
- }
4885
- const handler = buildHandler(
4886
- field,
4887
- mirror,
4888
- formatter.format,
4889
- formatType,
4890
- formatOptions,
4891
- trackerMirrorName
4892
- );
4893
- addListener(state2, state2.element, "input", handler);
4894
- bucket.set(fieldName, {
4895
- fields: /* @__PURE__ */ new Set([field]),
4896
- mirrorName: trackerMirrorName,
4897
- handler,
4879
+ let visible = candidates[0] ?? null;
4880
+ if (!visible) {
4881
+ visible = findVisibleField(state2.element, displayName);
4882
+ }
4883
+ if (!visible) continue;
4884
+ const existing = bucket.get(fieldName);
4885
+ let mirror = findHiddenMirror(state2.element, fieldName);
4886
+ const mirrorIsOwned = !mirror;
4887
+ if (!mirror) mirror = ensureHiddenMirror(state2.element, fieldName);
4888
+ renameVisibleField(visible, displayName);
4889
+ if (existing) {
4890
+ existing.displayName = displayName;
4891
+ existing.mirrorIsOwned = mirrorIsOwned;
4892
+ state2.formattedFields.set(fieldName, {
4893
+ config,
4894
+ displayName,
4898
4895
  mirrorIsOwned
4899
4896
  });
4897
+ } else {
4898
+ bucket.set(fieldName, {
4899
+ canonicalName: fieldName,
4900
+ displayName,
4901
+ visible,
4902
+ mirror,
4903
+ mirrorIsOwned,
4904
+ handler: UNBOUND
4905
+ });
4900
4906
  state2.formattedFields.set(fieldName, {
4901
4907
  config,
4902
- mirrorName: trackerMirrorName,
4908
+ displayName,
4903
4909
  mirrorIsOwned
4904
4910
  });
4905
- const initial = field.value;
4906
- if (initial !== "") {
4907
- const { formatted, raw } = formatter.format(initial, formatType, formatOptions);
4908
- applyFormattedValue(field, mirror, formatted, raw);
4909
- } else if (mirrorIsOwned && mirror && mirror.value !== "") {
4910
- mirror.value = "";
4911
+ }
4912
+ }
4913
+ const formatter = await loadFormatter();
4914
+ if (!formatter) {
4915
+ rollbackPhase1(bucket, state2.formattedFields);
4916
+ return;
4917
+ }
4918
+ for (const [, entry] of bucket) {
4919
+ if (entry.handler !== UNBOUND) {
4920
+ const current = entry.visible.value;
4921
+ if (current !== "") {
4922
+ const { formatted, raw } = formatter.format(current, formatType, formatOptions);
4923
+ applyFormattedValue(entry.visible, entry.mirror, formatted, raw);
4911
4924
  }
4925
+ continue;
4926
+ }
4927
+ const handler = buildHandler(
4928
+ entry.visible,
4929
+ entry.mirror,
4930
+ formatter.format,
4931
+ formatType,
4932
+ formatOptions
4933
+ );
4934
+ addListener(state2, state2.element, "input", handler, true);
4935
+ entry.handler = handler;
4936
+ const initial = entry.visible.value;
4937
+ if (initial !== "") {
4938
+ const { formatted, raw } = formatter.format(initial, formatType, formatOptions);
4939
+ applyFormattedValue(entry.visible, entry.mirror, formatted, raw);
4940
+ } else if (entry.mirrorIsOwned && entry.mirror.value !== "") {
4941
+ entry.mirror.value = "";
4912
4942
  }
4913
4943
  }
4914
4944
  };
4945
+ var rollbackPhase1 = (bucket, registry2) => {
4946
+ for (const [fieldName, entry] of bucket) {
4947
+ if (entry.visible.isConnected) {
4948
+ restoreVisibleName(entry.visible, fieldName);
4949
+ }
4950
+ if (entry.mirrorIsOwned && entry.mirror.isConnected) {
4951
+ entry.mirror.remove();
4952
+ }
4953
+ bucket.delete(fieldName);
4954
+ registry2.delete(fieldName);
4955
+ }
4956
+ };
4915
4957
  var createFormat = (state2, helpers) => (config) => {
4916
4958
  if (state2.isDestroyed) return state2.api;
4917
4959
  if (!state2.element) return state2.api;
@@ -4919,6 +4961,14 @@ var Forms = (() => {
4919
4961
  return state2.api;
4920
4962
  };
4921
4963
  var createFormatAll = createFormat;
4964
+ var resolveCanonicalForName = (state2, name) => {
4965
+ const bucket = registry.get(state2);
4966
+ if (!bucket) return null;
4967
+ for (const [canonical, entry] of bucket) {
4968
+ if (canonical === name || entry.displayName === name) return canonical;
4969
+ }
4970
+ return null;
4971
+ };
4922
4972
  var cleanupFormatRegistry = (state2) => {
4923
4973
  if (!state2.element) {
4924
4974
  registry.delete(state2);
@@ -4930,10 +4980,14 @@ var Forms = (() => {
4930
4980
  return;
4931
4981
  }
4932
4982
  for (const [fieldName, entry] of bucket) {
4933
- state2.element.removeEventListener("input", entry.handler);
4934
- if (entry.mirrorIsOwned) {
4935
- const mirror = findRawMirror(state2.element, entry.mirrorName);
4936
- mirror?.remove();
4983
+ if (entry.handler !== UNBOUND) {
4984
+ state2.element.removeEventListener("input", entry.handler, true);
4985
+ }
4986
+ if (entry.visible.isConnected) {
4987
+ restoreVisibleName(entry.visible, fieldName);
4988
+ }
4989
+ if (entry.mirrorIsOwned && entry.mirror.isConnected) {
4990
+ entry.mirror.remove();
4937
4991
  }
4938
4992
  bucket.delete(fieldName);
4939
4993
  state2.formattedFields.delete(fieldName);
@@ -4946,7 +5000,12 @@ var Forms = (() => {
4946
5000
  if (state2.isDestroyed) return;
4947
5001
  state2.isDestroyed = true;
4948
5002
  for (const listener of state2.listeners) {
4949
- listener.element.removeEventListener(listener.type, listener.handler);
5003
+ const options = listener.capture ? { capture: true } : void 0;
5004
+ if (options) {
5005
+ listener.element.removeEventListener(listener.type, listener.handler, options);
5006
+ } else {
5007
+ listener.element.removeEventListener(listener.type, listener.handler);
5008
+ }
4950
5009
  }
4951
5010
  state2.listeners.length = 0;
4952
5011
  if (state2.autoSubmitTimer) {
@@ -5383,9 +5442,15 @@ var Forms = (() => {
5383
5442
  const snapshot = state2.api.getState();
5384
5443
  state2.subscribers.forEach((listener) => listener(snapshot));
5385
5444
  };
5386
- const addListener2 = (node, type, handler) => {
5387
- node.addEventListener(type, handler);
5388
- state2.listeners.push({ element: node, type, handler });
5445
+ const addListener2 = (node, type, handler, capture) => {
5446
+ if (capture) {
5447
+ node.addEventListener(type, handler, { capture: true });
5448
+ } else {
5449
+ node.addEventListener(type, handler);
5450
+ }
5451
+ const entry = { element: node, type, handler };
5452
+ if (capture !== void 0) entry.capture = capture;
5453
+ state2.listeners.push(entry);
5389
5454
  };
5390
5455
  const clearFieldCache = () => state2.fieldCache.clear();
5391
5456
  const getFieldsByName = (name) => {
@@ -5401,6 +5466,12 @@ var Forms = (() => {
5401
5466
  for (const field of getNamedFields(state2.element)) {
5402
5467
  names.add(field.name);
5403
5468
  }
5469
+ for (const [canonical, entry] of state2.formattedFields) {
5470
+ names.add(canonical);
5471
+ if (entry.displayName && entry.displayName !== canonical) {
5472
+ names.add(entry.displayName);
5473
+ }
5474
+ }
5404
5475
  return Array.from(names);
5405
5476
  };
5406
5477
  const getValues = () => {
@@ -5486,14 +5557,16 @@ var Forms = (() => {
5486
5557
  const name = target2.name;
5487
5558
  if (!name) return;
5488
5559
  clearFieldCache();
5560
+ const canonical = resolveCanonicalForName(state2, name) ?? name;
5489
5561
  if (normalizedOptions.clearManualErrorsOnChange) {
5490
- delete state2.manualErrors[name];
5562
+ delete state2.manualErrors[canonical];
5491
5563
  }
5492
- syncVisualState([name]);
5493
- if (state2.isValidated && state2.validators[name]) {
5494
- validateNames([name]);
5564
+ syncVisualState([canonical]);
5565
+ if (state2.isValidated && state2.validators[canonical]) {
5566
+ validateNames([canonical]);
5495
5567
  }
5496
- emitFieldWatchers(name);
5568
+ emitFieldWatchers(canonical);
5569
+ if (canonical !== name) emitFieldWatchers(name);
5497
5570
  notifySubscribers();
5498
5571
  scheduleAutoSubmit();
5499
5572
  };