@verbb/formie-browser 1.0.14 → 1.0.15

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 (82) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/chunks/{address-country-Dn71jroa.js → address-country-BcEQKw0T.js} +1 -1
  3. package/dist/chunks/{address-finder-O49uDRoR.js → address-finder-BWGINCSA.js} +1 -1
  4. package/dist/chunks/{address-state-ByYatUur.js → address-state-DI3ndhMU.js} +98 -88
  5. package/dist/chunks/{api-DYYsVIub.js → api-BK9b340y.js} +16 -16
  6. package/dist/chunks/{api-D6kJ7kXZ.js → api-Dxl_CKw-.js} +30 -25
  7. package/dist/chunks/{async-nPFRNQ06.js → async-2GdXNFCE.js} +18 -1
  8. package/dist/chunks/{bpoint-D0RKFM4F.js → bpoint-BwtuyN_O.js} +1 -1
  9. package/dist/chunks/{captcha-eu-DERJbXnH.js → captcha-eu-DWLoQFKB.js} +2 -2
  10. package/dist/chunks/{combobox-CWNdx4Fk.js → combobox-Ba08_cHL.js} +259 -258
  11. package/dist/chunks/{conditions-DqNHLTbN.js → conditions-Ceeg6ReZ.js} +113 -110
  12. package/dist/chunks/{date-picker-RUnevF7l.js → date-picker-DaPvMrop.js} +12 -11
  13. package/dist/chunks/{eway-or6dtjoT.js → eway-uw0DzU-L.js} +3 -3
  14. package/dist/chunks/{file-upload-Bm-TXyNB.js → file-upload-B8_mH012.js} +28 -16
  15. package/dist/chunks/{friendly-captcha-v1-GFqCt1Mh.js → friendly-captcha-v1-CVXaXgDO.js} +1 -1
  16. package/dist/chunks/{friendly-captcha-v2-WoW246JG.js → friendly-captcha-v2-DkeIVTKI.js} +1 -1
  17. package/dist/chunks/{go-cardless-DzJ-0FKg.js → go-cardless-c4wLJdOt.js} +1 -1
  18. package/dist/chunks/{google-address-C1P1nTDM.js → google-address-JHYvjwgN.js} +13 -8
  19. package/dist/chunks/{hcaptcha-D0T8aIHz.js → hcaptcha-Dt3ELLlP.js} +2 -2
  20. package/dist/chunks/{loqate-DD1S7PY9.js → loqate-m51iPyO1.js} +1 -1
  21. package/dist/chunks/{mollie-DG8Ju83x.js → mollie-Bn0PX173.js} +1 -1
  22. package/dist/chunks/{moneris-C8m3Fvqu.js → moneris-BRbUvI4t.js} +1 -1
  23. package/dist/chunks/{opayo-B3EvrVxw.js → opayo-BwWdInEP.js} +2 -2
  24. package/dist/chunks/{paddle-BIYR9Um3.js → paddle-CympHVvd.js} +2 -2
  25. package/dist/chunks/{paypal-B_yxIo2d.js → paypal-h75h6wae.js} +2 -2
  26. package/dist/chunks/{payway-CeOzS0sL.js → payway-C9rHTCC_.js} +2 -2
  27. package/dist/chunks/{phone-country-bAqje17P.js → phone-country-B8fv-L5_.js} +1 -1
  28. package/dist/chunks/{place-kit-BE5gi3vo.js → place-kit-BnZGQ-L1.js} +8 -8
  29. package/dist/chunks/{recaptcha-enterprise-CruXddZq.js → recaptcha-enterprise-CUs8BO6V.js} +2 -2
  30. package/dist/chunks/{recaptcha-shared-AQRMDMme.js → recaptcha-shared-DTbTw-MF.js} +2 -2
  31. package/dist/chunks/{recaptcha-v2-checkbox-CCnJYCnG.js → recaptcha-v2-checkbox-P1McW4Pv.js} +2 -2
  32. package/dist/chunks/{recaptcha-v2-invisible-BuVqRRYn.js → recaptcha-v2-invisible-FGDqozSP.js} +2 -2
  33. package/dist/chunks/{recaptcha-v3-CloqhO2A.js → recaptcha-v3-CflUHeZD.js} +2 -2
  34. package/dist/chunks/{repeater-DwE27jI1.js → repeater-CyhDbozO.js} +47 -33
  35. package/dist/chunks/{scripts-CbQ7agX3.js → scripts-DrccjrFv.js} +1 -1
  36. package/dist/chunks/skip-CEW89ExY.js +7 -0
  37. package/dist/chunks/{snaptcha-C04kNGYQ.js → snaptcha-C3QLYxFT.js} +1 -1
  38. package/dist/chunks/{square-DEzxv9D9.js → square-C1z5DPVw.js} +2 -2
  39. package/dist/chunks/{stripe-CVPQGDz3.js → stripe-Dmkdkq1y.js} +2 -2
  40. package/dist/chunks/{summary-BF_8mrXI.js → summary-Bn-g5SDx.js} +1 -1
  41. package/dist/chunks/{table-DnIBdeKF.js → table-j7MlLLCc.js} +1 -1
  42. package/dist/chunks/{turnstile-n9DNyPxi.js → turnstile-ptihQfLl.js} +2 -2
  43. package/dist/chunks/{upload-manager-BbiUosZq.js → upload-manager-BW7l-BhF.js} +248 -236
  44. package/dist/index.js +548 -535
  45. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  46. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  47. package/dist/js/core/submit-result-ui.d.ts +3 -1
  48. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  49. package/dist/js/modules/address/google-address.d.ts +10 -0
  50. package/dist/js/modules/address/google-address.d.ts.map +1 -1
  51. package/dist/js/modules/address/place-kit.d.ts.map +1 -1
  52. package/dist/js/modules/fields/address-state.d.ts.map +1 -1
  53. package/dist/js/modules/fields/combobox.d.ts.map +1 -1
  54. package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
  55. package/dist/js/modules/fields/conditions.d.ts.map +1 -1
  56. package/dist/js/modules/fields/date-picker.d.ts.map +1 -1
  57. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  58. package/dist/js/modules/fields/repeater.d.ts.map +1 -1
  59. package/dist/js/modules/fields/upload-manager.d.ts.map +1 -1
  60. package/dist/js/utils/async.d.ts +1 -0
  61. package/dist/js/utils/async.d.ts.map +1 -1
  62. package/dist/js/validation/rules/required.d.ts.map +1 -1
  63. package/dist/js/validation/validator.d.ts.map +1 -1
  64. package/package.json +2 -2
  65. package/src/js/core/create-formie-client.ts +17 -10
  66. package/src/js/core/submit-result-state.ts +17 -0
  67. package/src/js/core/submit-result-ui.ts +4 -3
  68. package/src/js/modules/address/google-address.ts +95 -37
  69. package/src/js/modules/address/place-kit.ts +13 -7
  70. package/src/js/modules/fields/address-country.ts +5 -5
  71. package/src/js/modules/fields/address-state.ts +74 -23
  72. package/src/js/modules/fields/combobox.ts +3 -0
  73. package/src/js/modules/fields/conditions/effects.ts +4 -5
  74. package/src/js/modules/fields/conditions.ts +6 -0
  75. package/src/js/modules/fields/date-picker.ts +6 -3
  76. package/src/js/modules/fields/file-upload.ts +44 -14
  77. package/src/js/modules/fields/phone-country.ts +2 -2
  78. package/src/js/modules/fields/repeater.ts +55 -4
  79. package/src/js/modules/fields/upload-manager.ts +54 -18
  80. package/src/js/utils/async.ts +34 -0
  81. package/src/js/validation/rules/required.ts +12 -0
  82. package/src/js/validation/validator.ts +14 -2
@@ -58,6 +58,9 @@ type ComboboxSelect = HTMLSelectElement & {
58
58
 
59
59
  type FieldState = {
60
60
  addressRoot: HTMLElement;
61
+ isCpEdit: boolean;
62
+ initialCountry: string;
63
+ initialValue: string;
61
64
  stateField: HTMLElement;
62
65
  stateControl: AddressStateControl;
63
66
  countryControl: HTMLInputElement | HTMLSelectElement | null;
@@ -105,7 +108,8 @@ function getStateControl(field: HTMLElement): AddressStateControl | null {
105
108
  }
106
109
 
107
110
  function getFieldLabel(field: HTMLElement): HTMLElement | null {
108
- return field.querySelector('[data-formie-field-label]');
111
+ return field.querySelector('[data-formie-field-label]')
112
+ || (field.querySelector('[data-formie-address-state-cp]') ? field.querySelector(':scope > .heading > label') : null);
109
113
  }
110
114
 
111
115
  function dispatchControlEvents(control: AddressStateControl): void {
@@ -130,13 +134,15 @@ function syncComboboxValue(control: AddressStateControl, value: string): void {
130
134
  }
131
135
 
132
136
  function setControlValue(control: AddressStateControl, value: string): void {
133
- if (control.value !== value) {
134
- control.value = value;
137
+ const previousValue = control.value;
138
+ control.value = value;
139
+ syncComboboxValue(control, control.value);
140
+
141
+ // A select rejects values outside its options. Dispatch only actual changes
142
+ // so an unmatched autofill value cannot recursively trigger reconciliation.
143
+ if (control.value !== previousValue) {
135
144
  dispatchControlEvents(control);
136
- return;
137
145
  }
138
-
139
- syncComboboxValue(control, value);
140
146
  }
141
147
 
142
148
  function syncAutofillAnchor(fieldState: FieldState, value: string): void {
@@ -181,6 +187,7 @@ function ensureAutofillAnchor(fieldState: FieldState): HTMLInputElement {
181
187
  anchor.setAttribute('tabindex', '-1');
182
188
  anchor.setAttribute('aria-hidden', 'true');
183
189
  anchor.className = 'formie-sr-only';
190
+ anchor.hidden = fieldState.isCpEdit;
184
191
  fieldState.addressRoot.appendChild(anchor);
185
192
  fieldState.autofillAnchor = anchor;
186
193
  }
@@ -204,14 +211,13 @@ function reconcileAutofillValue(fieldState: FieldState): void {
204
211
  }
205
212
 
206
213
  const subdivisions = fieldState.lastSubdivisions;
207
- const resolved = subdivisions.length > 0
208
- ? (resolveOptionMatch(pending, subdivisions) || pending)
214
+ const resolved = fieldState.stateControl instanceof HTMLSelectElement
215
+ ? (resolveOptionMatch(pending, subdivisions) || '')
209
216
  : pending;
210
217
 
211
- setControlValue(fieldState.stateControl, resolved);
212
- syncComboboxValue(fieldState.stateControl, resolved);
213
218
  syncAutofillAnchor(fieldState, resolved);
214
219
  fieldState.pendingStateValue = resolved;
220
+ setControlValue(fieldState.stateControl, resolved);
215
221
  }
216
222
 
217
223
  function buildSubdivisionsUrl(action: string, country: string, optionLabel: string, optionValue: string): string {
@@ -320,6 +326,7 @@ function copyControlAttributes(source: AddressStateControl, target: AddressState
320
326
  'data-formie-input-type',
321
327
  'data-formie-input-error-state',
322
328
  'data-formie-address-state-dynamic',
329
+ 'data-formie-address-state-cp',
323
330
  'data-formie-address-state-hide-when-unused',
324
331
  'data-formie-address-state-use-searchable',
325
332
  'data-formie-address-state-use-datalist',
@@ -340,7 +347,7 @@ function copyControlAttributes(source: AddressStateControl, target: AddressState
340
347
  }
341
348
 
342
349
  function applyTextThemeClasses(template: AddressStateControl, input: HTMLInputElement): void {
343
- input.className = template.className;
350
+ input.className = template.hasAttribute('data-formie-address-state-cp') ? 'text fullwidth' : template.className;
344
351
  }
345
352
 
346
353
  function applySelectThemeClasses(template: AddressStateControl, select: HTMLSelectElement): void {
@@ -461,11 +468,16 @@ function setFieldVisibility(fieldState: FieldState, visible: boolean): void {
461
468
  const { stateField, stateControl, required } = fieldState;
462
469
 
463
470
  toggleThemeClasses(stateField, stateField, 'conditionalHidden', !visible);
464
- stateField.toggleAttribute('data-formie-conditionally-hidden', !visible);
471
+ // Use module-owned visibility: the generic hidden attribute unmounts modules,
472
+ // which would remove the country listener needed to show this field again.
473
+ stateField.toggleAttribute('data-formie-address-state-hidden', !visible);
474
+
465
475
 
466
476
  if (!visible) {
467
477
  stateControl.required = false;
468
- stateControl.disabled = true;
478
+ // Keep the empty value in Craft's serialized form data so hiding the
479
+ // field during initialization does not count as an unsaved edit.
480
+ stateControl.disabled = !fieldState.isCpEdit;
469
481
  return;
470
482
  }
471
483
 
@@ -479,7 +491,7 @@ function ensureFetchingAnnouncement(fieldState: FieldState): HTMLElement {
479
491
  }
480
492
 
481
493
  const announcement = document.createElement('div');
482
- announcement.className = 'formie-sr-only';
494
+ announcement.className = fieldState.isCpEdit ? 'visually-hidden' : 'formie-sr-only';
483
495
  announcement.setAttribute('data-formie-address-state-fetching-announce', 'true');
484
496
  announcement.setAttribute('aria-live', 'polite');
485
497
  announcement.setAttribute('aria-atomic', 'true');
@@ -509,7 +521,7 @@ function showFetchingUI(fieldState: FieldState): void {
509
521
  fieldState.stateControl.setAttribute('tabindex', '-1');
510
522
 
511
523
  toggleThemeClasses(fieldState.stateField, fieldState.stateField, 'conditionalHidden', false);
512
- fieldState.stateField.removeAttribute('data-formie-conditionally-hidden');
524
+ fieldState.stateField.removeAttribute('data-formie-address-state-hidden');
513
525
  fieldState.stateField.setAttribute('data-formie-address-state-skeleton-active', 'true');
514
526
 
515
527
  const controlWrapper = fieldState.stateField.querySelector('[data-formie-field-control]');
@@ -546,7 +558,7 @@ function updateFieldLabel(stateField: HTMLElement, label: string): void {
546
558
  return;
547
559
  }
548
560
 
549
- const required = labelEl.querySelector('[data-formie-field-required]');
561
+ const required = labelEl.querySelector('[data-formie-field-required], .required');
550
562
  labelEl.textContent = label;
551
563
 
552
564
  if (required) {
@@ -595,6 +607,10 @@ async function applyCountryState(
595
607
  const country = fieldState.countryControl?.value?.trim() || '';
596
608
  stashPendingStateValue(fieldState);
597
609
  const currentValue = getEffectiveStateValue(fieldState);
610
+ // Opening an existing submission must not discard a historical state value
611
+ // merely because current country metadata no longer includes it.
612
+ const preserveStoredValue = fieldState.isCpEdit && !!currentValue
613
+ && country === fieldState.initialCountry && currentValue === fieldState.initialValue;
598
614
  const fetchGeneration = ++fieldState.fetchGeneration;
599
615
 
600
616
  fieldState.comboboxCleanup?.();
@@ -608,7 +624,7 @@ async function applyCountryState(
608
624
 
609
625
  hideFetchingUI(fieldState);
610
626
 
611
- if (hideWhenUnused) {
627
+ if (hideWhenUnused && !preserveStoredValue) {
612
628
  setFieldVisibility(fieldState, false);
613
629
  setControlValue(fieldState.stateControl, '');
614
630
  syncAutofillAnchor(fieldState, '');
@@ -623,7 +639,7 @@ async function applyCountryState(
623
639
  const textControl = createTextControl(fieldState.stateControl, currentValue);
624
640
  fieldState.stateControl = replaceControl(fieldState.stateControl, textControl);
625
641
  } else {
626
- fieldState.stateControl.disabled = true;
642
+ fieldState.stateControl.disabled = !fieldState.isCpEdit && !preserveStoredValue;
627
643
  fieldState.stateControl.placeholder = placeholder || fieldState.stateControl.placeholder;
628
644
  }
629
645
 
@@ -644,14 +660,17 @@ async function applyCountryState(
644
660
  return;
645
661
  }
646
662
 
647
- const subdivisions = response?.subdivisions || [];
663
+ const subdivisions = [...(response?.subdivisions || [])];
664
+ if (preserveStoredValue && subdivisions.length && !resolveOptionMatch(currentValue, subdivisions)) {
665
+ subdivisions.push({ label: currentValue, value: currentValue });
666
+ }
648
667
  const administrativeAreaUsed = response?.administrativeAreaUsed ?? true;
649
668
  const administrativeAreaLabel = response?.administrativeAreaLabel || 'State / Province';
650
669
  fieldState.lastSubdivisions = subdivisions;
651
670
 
652
671
  hideFetchingUI(fieldState);
653
672
 
654
- if (hideWhenUnused && !administrativeAreaUsed) {
673
+ if (hideWhenUnused && !administrativeAreaUsed && !preserveStoredValue) {
655
674
  setFieldVisibility(fieldState, false);
656
675
  setControlValue(fieldState.stateControl, '');
657
676
  syncAutofillAnchor(fieldState, '');
@@ -755,6 +774,9 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
755
774
 
756
775
  const fieldState: FieldState = {
757
776
  addressRoot,
777
+ isCpEdit: stateControl.hasAttribute('data-formie-address-state-cp'),
778
+ initialCountry: getCountryControl(addressRoot)?.value?.trim() || '',
779
+ initialValue: stateControl.value?.trim() || '',
758
780
  stateField: field,
759
781
  stateControl,
760
782
  countryControl: getCountryControl(addressRoot),
@@ -772,6 +794,10 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
772
794
  lastSubdivisions: [],
773
795
  };
774
796
 
797
+ const initialStateValue = stateControl.value;
798
+ const form = field.closest('form');
799
+ let disposed = false;
800
+ let resetTimer: number | null = null;
775
801
  ensureAutofillAnchor(fieldState);
776
802
 
777
803
  const refresh = (force = false) => {
@@ -830,6 +856,7 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
830
856
 
831
857
  if (isStateControl && !isAnchor) {
832
858
  syncAutofillAnchor(fieldState, target.value);
859
+ fieldState.pendingStateValue = target.value;
833
860
  }
834
861
 
835
862
  stashPendingStateValue(fieldState);
@@ -891,12 +918,31 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
891
918
  syncAutofillAnchor(fieldState, stateValue);
892
919
 
893
920
  void applyCountryState(fieldState, options).then(() => {
894
- setControlValue(fieldState.stateControl, stateValue);
895
- syncComboboxValue(fieldState.stateControl, stateValue);
921
+ // Keep the normalized option value, which may differ from a provider's
922
+ // abbreviation (for example "CA" when options store "California").
923
+ reconcileAutofillValue(fieldState);
896
924
  fieldState.lastCountry = fieldState.countryControl?.value?.trim() || '';
897
925
  });
898
926
  };
899
927
 
928
+ const onReset = (event: Event) => {
929
+ if (resetTimer !== null) window.clearTimeout(resetTimer);
930
+ // Enhanced selects finish their own reset in microtasks. Rebuild the
931
+ // dependent options after those controls have restored the country.
932
+ resetTimer = window.setTimeout(() => {
933
+ resetTimer = null;
934
+ if (event.defaultPrevented || disposed) return;
935
+ clearScheduledAutofillTimers(fieldState);
936
+ // The current select may contain another country's options. Keep the
937
+ // original value pending until the reset country's options are ready.
938
+ fieldState.stateControl.value = '';
939
+ fieldState.pendingStateValue = initialStateValue;
940
+ syncAutofillAnchor(fieldState, initialStateValue);
941
+ refresh(true);
942
+ }, 0);
943
+ };
944
+ form?.addEventListener('reset', onReset);
945
+
900
946
  fieldState.countryControl?.addEventListener('change', onCountryChange);
901
947
  fieldState.countryControl?.addEventListener('input', onCountryChange);
902
948
  addressRoot.addEventListener('input', onAddressInput);
@@ -907,10 +953,15 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
907
953
  addressRoot.addEventListener('formie:address:loqate:populate', onProviderPopulate);
908
954
  addressRoot.addEventListener('formie:address:place-kit:populate', onProviderPopulate);
909
955
 
910
- refresh();
956
+ // Apply the empty-country visibility state on first mount as well.
957
+ refresh(true);
911
958
  scheduleAutofillSweep(fieldState, options);
912
959
 
913
960
  return () => {
961
+ disposed = true;
962
+ if (resetTimer !== null) window.clearTimeout(resetTimer);
963
+ fieldState.fetchGeneration++;
964
+ form?.removeEventListener('reset', onReset);
914
965
  clearScheduledAutofillTimers(fieldState);
915
966
  hideFetchingUI(fieldState);
916
967
  fieldState.skeletonEl?.remove();
@@ -6,6 +6,7 @@ import { dispatchFieldEvent, getModuleFieldContainers } from '#modules/fields/sh
6
6
  import { ensureModuleStyles } from '#modules/styles';
7
7
  import { getThemeClasses } from '#theme/theme-classes';
8
8
  import { createDebug } from '#utils/debug';
9
+ import { VALIDATION_SKIP_ATTR } from '#validation/skip';
9
10
 
10
11
  const SELECT_SELECTOR = 'select[data-formie-combobox-input]';
11
12
  const MODULE_ID = 'combobox';
@@ -116,6 +117,8 @@ export function initFormieCombobox(select: SelectElement, options: FormieCombobo
116
117
  });
117
118
 
118
119
  const instance = new TomSelect(select, mergedOptions) as TomSelectInstance;
120
+ // The search query is temporary; the native select carries the field value.
121
+ instance.control_input.setAttribute(VALIDATION_SKIP_ATTR, '');
119
122
  removeEmptyOptionFromCombobox(instance);
120
123
  cleanLeakedThemeClasses(instance.wrapper, select);
121
124
 
@@ -47,9 +47,10 @@ function clearConditionNodeValues(node: Element): void {
47
47
  }
48
48
 
49
49
  function setVisibilityState(node: Element, hidden: boolean): boolean {
50
- const isPage = node.hasAttribute('data-formie-page');
51
- const hiddenAttr = isPage ? PAGE_HIDDEN_ATTR : CONDITIONAL_HIDDEN_ATTR;
52
- const hiddenClasses = getThemeClasses(node, isPage ? 'pageHidden' : 'conditionalHidden');
50
+ // Navigation owns page-hidden. A matching condition must not reveal an
51
+ // inactive page, or clear its values just because the visitor moved on.
52
+ const hiddenAttr = CONDITIONAL_HIDDEN_ATTR;
53
+ const hiddenClasses = getThemeClasses(node, 'conditionalHidden');
53
54
  const wasHidden = node.hasAttribute(hiddenAttr);
54
55
 
55
56
  if (hidden) {
@@ -190,9 +191,7 @@ function clearCpMutedState(node: Element): void {
190
191
 
191
192
  function clearHideState(node: Element): void {
192
193
  node.removeAttribute(CONDITIONAL_HIDDEN_ATTR);
193
- node.removeAttribute(PAGE_HIDDEN_ATTR);
194
194
  node.classList.remove(...getThemeClasses(node, 'conditionalHidden'));
195
- node.classList.remove(...getThemeClasses(node, 'pageHidden'));
196
195
  }
197
196
 
198
197
  function syncAncestorRows(node: Element): void {
@@ -10,6 +10,7 @@ import {
10
10
  import type { ConditionEntry, ConditionInput } from '#modules/fields/conditions/types';
11
11
  import { getConditionInputEventNames } from '#modules/fields/conditions/values';
12
12
  import { createDebug } from '#utils/debug';
13
+ import { getFormStateEventName } from '#utils/event-names';
13
14
 
14
15
  const debug = createDebug('conditions');
15
16
 
@@ -212,8 +213,13 @@ export const conditionsModule: BrowserModuleDefinition = {
212
213
  };
213
214
 
214
215
  ctx.form.addEventListener('reset', resetHandler);
216
+ // Canonical resets have already restored values. Settle conditions
217
+ // before the unload guard captures the form's new clean state.
218
+ const stateResetEvent = getFormStateEventName('reset');
219
+ ctx.form.addEventListener(stateResetEvent, evaluateAll);
215
220
  sourceUnbinds.push(() => {
216
221
  ctx.form?.removeEventListener('reset', resetHandler);
222
+ ctx.form?.removeEventListener(stateResetEvent, evaluateAll);
217
223
  });
218
224
 
219
225
  // CP status menu (etc.) updates `data-formie-submission` and fires this event.
@@ -90,8 +90,9 @@ function normalizeOffsetDate(input: string | null | undefined, type: 'min' | 'ma
90
90
  return null;
91
91
  }
92
92
 
93
- if (!Number.isNaN(Date.parse(input))) {
94
- return new Date(input);
93
+ const absoluteDate = parseSubmissionDate(input);
94
+ if (absoluteDate) {
95
+ return absoluteDate;
95
96
  }
96
97
 
97
98
  // Form config may express relative min/max bounds like "+2 weeks", which are
@@ -209,7 +210,9 @@ function formatSubmissionDate(date: Date, options: DatePickerOptions): string {
209
210
  }
210
211
 
211
212
  function parseSubmissionDate(value: string): Date | null {
212
- const parsed = new Date(value);
213
+ // Date-only field values represent a local calendar day. JavaScript otherwise
214
+ // parses YYYY-MM-DD as UTC and shifts the picker to yesterday west of UTC.
215
+ const parsed = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00` : value);
213
216
 
214
217
  if (Number.isNaN(parsed.getTime())) {
215
218
  return null;
@@ -30,6 +30,9 @@ type UploadedAsset = {
30
30
  assetId: number | null;
31
31
  filename: string;
32
32
  url?: string | null;
33
+ uploadUid?: string | null;
34
+ attachToken?: string | null;
35
+ viewToken?: string | null;
33
36
  };
34
37
 
35
38
  type FileUploadHydrateResponse = {
@@ -151,6 +154,9 @@ function normalizeUploadedAsset(candidate: unknown): UploadedAsset | null {
151
154
  assetId,
152
155
  filename,
153
156
  url,
157
+ uploadUid: toTrimmedString(candidate.uploadUid) || null,
158
+ attachToken: toTrimmedString(candidate.attachToken) || null,
159
+ viewToken: toTrimmedString(candidate.viewToken ?? candidate.uploadToken) || null,
154
160
  };
155
161
  }
156
162
 
@@ -190,6 +196,9 @@ function readUploadedAssetsFromHiddenInputs(field: HTMLElement, input: HTMLInput
190
196
  return {
191
197
  assetId: toPositiveInt(hiddenInput.getAttribute(HIDDEN_INPUT_VALUE_ATTR)) ?? toPositiveInt(hiddenInput.value),
192
198
  filename: '',
199
+ viewToken: hiddenInput.getAttribute('data-formie-file-upload-token'),
200
+ uploadUid: hiddenInput.name.endsWith('[uploadUid]') ? hiddenInput.value : null,
201
+ attachToken: getHiddenInputs(field).find((entry) => entry.name === hiddenInput.name.replace(/\[uploadUid\]$/, '[attachToken]'))?.value,
193
202
  };
194
203
  }).filter((asset) => {
195
204
  return asset.assetId !== null;
@@ -203,6 +212,7 @@ function mergeUploadedAssets(assetIds: UploadedAsset[], summaryItems: UploadedAs
203
212
 
204
213
  return assetIds.map((asset, index) => {
205
214
  return {
215
+ ...asset,
206
216
  assetId: asset.assetId,
207
217
  filename: summaryItems[index]?.filename || '',
208
218
  };
@@ -248,8 +258,8 @@ function registerValidators(form: HTMLFormElement | null): void {
248
258
  });
249
259
 
250
260
  validator.addValidator('fileSizeMinLimit', ({ input }) => {
251
- const sizeLimit = parseFloat(input.getAttribute('data-formie-size-min-limit') || '');
252
- const sizeBytes = sizeLimit * 1000 * 1000;
261
+ const maxFileSize = parseFloat(input.getAttribute('data-formie-size-min-limit') || '');
262
+ const sizeBytes = maxFileSize * 1000 * 1000;
253
263
 
254
264
  if (input.type !== 'file' || !sizeBytes || !('files' in input) || !input.files?.length) {
255
265
  return true;
@@ -266,8 +276,8 @@ function registerValidators(form: HTMLFormElement | null): void {
266
276
  });
267
277
 
268
278
  validator.addValidator('fileSizeMaxLimit', ({ input }) => {
269
- const sizeLimit = parseFloat(input.getAttribute('data-formie-size-max-limit') || '');
270
- const sizeBytes = sizeLimit * 1000 * 1000;
279
+ const maxFileSize = parseFloat(input.getAttribute('data-formie-size-max-limit') || '');
280
+ const sizeBytes = maxFileSize * 1000 * 1000;
271
281
 
272
282
  if (input.type !== 'file' || !sizeBytes || !('files' in input) || !input.files?.length) {
273
283
  return true;
@@ -366,12 +376,26 @@ function updateUploadedAssetInputs(field: HTMLElement, input: HTMLInputElement,
366
376
 
367
377
  const hiddenInput = document.createElement('input');
368
378
  hiddenInput.type = 'hidden';
369
- hiddenInput.name = `${baseName}[${index}][assetId]`;
370
- hiddenInput.value = String(asset.assetId);
379
+ const hasCapability = asset.uploadUid && asset.attachToken;
380
+ hiddenInput.name = `${baseName}[${index}][${hasCapability ? 'uploadUid' : 'assetId'}]`;
381
+ hiddenInput.value = hasCapability ? asset.uploadUid! : String(asset.assetId);
371
382
  hiddenInput.setAttribute(HIDDEN_INPUT_ENTRY_ATTR, 'true');
372
383
  hiddenInput.setAttribute(HIDDEN_INPUT_VALUE_ATTR, String(asset.assetId));
384
+ if (asset.viewToken) {
385
+ hiddenInput.setAttribute('data-formie-file-upload-token', asset.viewToken);
386
+ }
373
387
  insertionPoint.insertAdjacentElement('afterend', hiddenInput);
374
388
  insertionPoint = hiddenInput;
389
+
390
+ if (hasCapability) {
391
+ const tokenInput = document.createElement('input');
392
+ tokenInput.type = 'hidden';
393
+ tokenInput.name = `${baseName}[${index}][attachToken]`;
394
+ tokenInput.value = asset.attachToken!;
395
+ tokenInput.setAttribute(HIDDEN_INPUT_ENTRY_ATTR, 'true');
396
+ insertionPoint.insertAdjacentElement('afterend', tokenInput);
397
+ insertionPoint = tokenInput;
398
+ }
375
399
  });
376
400
 
377
401
  input.value = '';
@@ -391,7 +415,7 @@ function getFormHandle(form: HTMLFormElement | null): string {
391
415
  return form.getAttribute('data-formie-handle')?.trim() || '';
392
416
  }
393
417
 
394
- function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assetIds: number[]): FormData {
418
+ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assets: UploadedAsset[]): FormData {
395
419
  const form = input.form;
396
420
  const body = new FormData();
397
421
  const handle = getFormHandle(form);
@@ -411,8 +435,13 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
411
435
  body.append('submissionUid', submissionUidInput.value.trim());
412
436
  }
413
437
 
414
- assetIds.forEach((assetId) => {
415
- body.append('assetIds[]', String(assetId));
438
+ assets.forEach((asset) => {
439
+ if (asset.assetId) {
440
+ body.append('assetIds[]', String(asset.assetId));
441
+ if (asset.viewToken) {
442
+ body.append(`uploadTokens[${asset.assetId}]`, asset.viewToken);
443
+ }
444
+ }
416
445
  });
417
446
 
418
447
  return body;
@@ -433,7 +462,7 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
433
462
  const response = await requestJson<FileUploadHydrateResponse>(hydrateEndpoint, {
434
463
  profile: field.closest('form')?.dataset.formieRequestProfile as import('@verbb/formie-core').BrowserRequestOptions['profile'],
435
464
  method: 'POST',
436
- body: buildHydrateFormData(field, input, assetIds),
465
+ body: buildHydrateFormData(field, input, uploadedAssets),
437
466
  });
438
467
  const hydratedAssets = normalizeUploadedAssets(response.assets);
439
468
 
@@ -452,6 +481,7 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
452
481
 
453
482
  const hydratedAsset = hydratedMap.get(asset.assetId)!;
454
483
  return {
484
+ ...asset,
455
485
  assetId: asset.assetId,
456
486
  filename: hydratedAsset.filename || asset.filename,
457
487
  url: hydratedAsset.url ?? asset.url,
@@ -480,24 +510,24 @@ function getUploadedAssetsFromEvent(event: Event, matcher: FileUploadEventMatche
480
510
 
481
511
  if (detailInputKey !== '' && detailInputKey === matcher.inputKey) {
482
512
  matchedByInputKey = true;
483
- candidates.push(detail.assets, detail.assetIds, detail.data);
513
+ candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
484
514
  }
485
515
 
486
516
  if (detailInputName !== '' && detailInputName === matcher.inputName) {
487
517
  matchedByInputName = true;
488
- candidates.push(detail.assets, detail.assetIds, detail.data);
518
+ candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
489
519
  }
490
520
 
491
521
  if (detailFieldHandle === matcher.fieldHandle) {
492
522
  matchedByFieldHandle = true;
493
- candidates.push(detail.assets, detail.assetIds, detail.data);
523
+ candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
494
524
  }
495
525
 
496
526
  if (nestedData) {
497
527
  const nestedAssetCandidates = [
528
+ nestedData.uploadedAssets,
498
529
  nestedData.assets,
499
530
  nestedData.assetIds,
500
- nestedData.uploadedAssets,
501
531
  nestedData.data,
502
532
  ];
503
533
 
@@ -18,7 +18,7 @@ ensureModuleStyles(MODULE_ID, [intlTelInputCss]);
18
18
 
19
19
  type PhoneCountryOptions = {
20
20
  countryDefaultValue?: string;
21
- countryAllowed?: string[];
21
+ allowedCountryCodes?: string[];
22
22
  countryPreselectFromIp?: boolean;
23
23
  countryFromIpAction?: string;
24
24
  language?: string;
@@ -93,7 +93,7 @@ function buildOptions(options: PhoneCountryOptions, form: HTMLFormElement | null
93
93
  loadUtils: () => import('intl-tel-input/utils'),
94
94
  };
95
95
 
96
- const allowedCountries = (options.countryAllowed || [])
96
+ const allowedCountries = (options.allowedCountryCodes || [])
97
97
  .map((item) => {
98
98
  return item.toLowerCase();
99
99
  })
@@ -32,6 +32,18 @@ function getRowCount(field: HTMLElement): number {
32
32
  return field.querySelectorAll(ROW_SELECTOR).length;
33
33
  }
34
34
 
35
+ function focusRow(row: Element | null): boolean {
36
+ const control = Array.from(row?.querySelectorAll<HTMLElement>(
37
+ 'input:not([type="hidden"]), select, textarea, button, a[href], [role="button"], [role="combobox"], [role="textbox"], [contenteditable="true"]',
38
+ ) || []).find((element) => {
39
+ return element.tabIndex >= 0 && !element.matches(':disabled')
40
+ && !element.closest('[hidden], [inert]') && element.getClientRects().length > 0;
41
+ });
42
+
43
+ control?.focus();
44
+ return !!control;
45
+ }
46
+
35
47
  function syncAddButton(addButton: HTMLButtonElement | null, rowCount: number): void {
36
48
  if (!addButton) {
37
49
  return;
@@ -46,7 +58,7 @@ function syncAddButton(addButton: HTMLButtonElement | null, rowCount: number): v
46
58
  addButton.disabled = false;
47
59
  }
48
60
 
49
- function bindRepeaterField(field: HTMLElement): () => void {
61
+ function bindRepeaterField(field: HTMLElement, isCpEdit: boolean): () => void {
50
62
  const container = field.matches(CONTAINER_SELECTOR)
51
63
  ? field
52
64
  : field.querySelector(CONTAINER_SELECTOR);
@@ -57,6 +69,9 @@ function bindRepeaterField(field: HTMLElement): () => void {
57
69
  return () => {};
58
70
  }
59
71
 
72
+ const form = field.closest('form');
73
+ let disposed = false;
74
+ let generation = 0;
60
75
  const removeHandlers = new Map<HTMLElement, EventListener>();
61
76
  // Start after the highest existing row id so SSR rows and client-added rows
62
77
  // keep producing unique `__ROW__` replacements across one repeater instance.
@@ -84,8 +99,17 @@ function bindRepeaterField(field: HTMLElement): () => void {
84
99
  return;
85
100
  }
86
101
 
102
+ const restoreFocus = row.contains(document.activeElement)
103
+ && (!isCpEdit || (event instanceof MouseEvent && event.detail === 0));
104
+ const nextRow = row.nextElementSibling;
105
+ const previousRow = row.previousElementSibling;
87
106
  row.remove();
88
107
  syncAddButton(addButton instanceof HTMLButtonElement ? addButton : null, getRowCount(field));
108
+ // Removing the focused control otherwise sends keyboard users back
109
+ // to the document body. Keep them in the nearest surviving row.
110
+ if (restoreFocus && !focusRow(nextRow) && !focusRow(previousRow)) {
111
+ if (addButton instanceof HTMLButtonElement) addButton.focus();
112
+ }
89
113
  debug.log('Row removed.', {
90
114
  rowCount: getRowCount(field),
91
115
  });
@@ -100,7 +124,7 @@ function bindRepeaterField(field: HTMLElement): () => void {
100
124
  });
101
125
  };
102
126
 
103
- const addRow = async() => {
127
+ const addRow = async(focusNewRow = false) => {
104
128
  if (!(addButton instanceof HTMLButtonElement)) {
105
129
  return;
106
130
  }
@@ -130,11 +154,16 @@ function bindRepeaterField(field: HTMLElement): () => void {
130
154
  return;
131
155
  }
132
156
 
157
+ const addedGeneration = generation;
133
158
  container.appendChild(row);
134
159
  // Give downstream field modules a tick to see the new row before we emit
135
160
  // the public append/init-row lifecycle events used for nested enhancements.
136
161
  await sleep(50);
162
+ if (disposed || addedGeneration !== generation || !row.isConnected) return;
137
163
  bindRemoveButtons();
164
+ // Rows are inserted before Add, so leaving focus there skips the new
165
+ // inputs on Tab. Do not steal focus if the user moved on while mounting.
166
+ if (focusNewRow && document.activeElement === addButton) focusRow(row);
138
167
  syncAddButton(addButton, getRowCount(field));
139
168
  debug.log('Row appended.', {
140
169
  rowCount: getRowCount(field),
@@ -151,7 +180,10 @@ function bindRepeaterField(field: HTMLElement): () => void {
151
180
 
152
181
  const addHandler = (event: Event) => {
153
182
  event.preventDefault();
154
- void addRow();
183
+ // Pointer additions in the CP should not focus Craft's field wrappers
184
+ // and show a focus ring around the newly inserted field.
185
+ const focusNewRow = !isCpEdit || (event instanceof MouseEvent && event.detail === 0);
186
+ void addRow(focusNewRow);
155
187
  };
156
188
 
157
189
  if (addButton instanceof HTMLButtonElement) {
@@ -170,6 +202,23 @@ function bindRepeaterField(field: HTMLElement): () => void {
170
202
  }
171
203
  }
172
204
 
205
+ const initialRows = Array.from(container.children).map((row) => row.cloneNode(true));
206
+
207
+ const onReset = (event: Event) => {
208
+ queueMicrotask(() => {
209
+ if (event.defaultPrevented || disposed) return;
210
+ generation++;
211
+ removeHandlers.forEach((handler, button) => button.removeEventListener('click', handler));
212
+ removeHandlers.clear();
213
+ // Replace rows so removed initial rows return and added rows disappear.
214
+ // The module loader disposes old controls and hydrates the new rows.
215
+ container.replaceChildren(...initialRows.map((row) => row.cloneNode(true)));
216
+ bindRemoveButtons();
217
+ syncAddButton(addButton instanceof HTMLButtonElement ? addButton : null, getRowCount(field));
218
+ });
219
+ };
220
+ form?.addEventListener('reset', onReset);
221
+
173
222
  dispatchFieldEvent(field, MODULE_ID, 'init', {
174
223
  repeater: field,
175
224
  });
@@ -178,6 +227,8 @@ function bindRepeaterField(field: HTMLElement): () => void {
178
227
  });
179
228
 
180
229
  return () => {
230
+ disposed = true;
231
+ form?.removeEventListener('reset', onReset);
181
232
  if (addButton instanceof HTMLButtonElement) {
182
233
  addButton.removeEventListener('click', addHandler);
183
234
  }
@@ -211,7 +262,7 @@ export const repeaterModule: BrowserModuleDefinition = {
211
262
  });
212
263
 
213
264
  const destroyBindings = fields.map((field) => {
214
- return bindRepeaterField(field);
265
+ return bindRepeaterField(field, ctx.surface === 'cp-edit');
215
266
  });
216
267
  debug.log('Module setup.', { fieldCount: fields.length });
217
268