@verbb/formie-browser 1.0.14 → 1.0.16

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 (156) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/chunks/{address-country-Dn71jroa.js → address-country-DLJF-OkC.js} +5 -9
  3. package/dist/chunks/{address-finder-O49uDRoR.js → address-finder-QK1m1eyv.js} +2 -2
  4. package/dist/chunks/{address-state-ByYatUur.js → address-state-CHKit8AW.js} +113 -112
  5. package/dist/chunks/{api-DYYsVIub.js → api-BPtH5RBA.js} +162 -130
  6. package/dist/chunks/{api-D6kJ7kXZ.js → api-BR2Gqy37.js} +313 -303
  7. package/dist/chunks/{api-ghNr2uxX.js → api-CmuOZ0i3.js} +1 -1
  8. package/dist/chunks/{async-nPFRNQ06.js → async-2GdXNFCE.js} +18 -1
  9. package/dist/chunks/{bpoint-D0RKFM4F.js → bpoint-GvYfyPjD.js} +1 -1
  10. package/dist/chunks/{calculations-YEoVCG2q.js → calculations-DeEmdUDq.js} +5 -7
  11. package/dist/chunks/{captcha-eu-DERJbXnH.js → captcha-eu-eU_M6snp.js} +2 -2
  12. package/dist/chunks/{checkbox-radio-gsm8QPJg.js → checkbox-radio-CSXo6svm.js} +4 -12
  13. package/dist/chunks/{combobox-CWNdx4Fk.js → combobox-CJh2DoSO.js} +323 -363
  14. package/dist/chunks/{conditions-DqNHLTbN.js → conditions-Dz3068K0.js} +140 -153
  15. package/dist/chunks/{country-from-ip-DDj_mFki.js → country-from-ip-BqQ-BqLH.js} +1 -1
  16. package/dist/chunks/{custom-google-maps-C4l2S3GO.js → custom-google-maps-rNm5VFbb.js} +2 -6
  17. package/dist/chunks/{custom-link-CLazgKYH.js → custom-link-CLqFCssQ.js} +1 -1
  18. package/dist/chunks/{custom-maps-DqcAlD0v.js → custom-maps-DgjYQDLj.js} +4 -8
  19. package/dist/chunks/{date-picker-RUnevF7l.js → date-picker-BVJxJVYT.js} +42 -58
  20. package/dist/chunks/{debug-BV0DvdHx.js → debug-rhzqP1j2.js} +2 -14
  21. package/dist/chunks/{dist-ZLeW0zZ3.js → dist-CJReDlDK.js} +13 -8
  22. package/dist/chunks/{eway-or6dtjoT.js → eway-BZuTBAUW.js} +3 -3
  23. package/dist/chunks/{field-references.resolver-Ca8MguFv.js → field-references.resolver-uSHf5-NP.js} +1 -1
  24. package/dist/chunks/{file-upload-Bm-TXyNB.js → file-upload-vEUr_EuP.js} +131 -113
  25. package/dist/chunks/{friendly-captcha-v1-GFqCt1Mh.js → friendly-captcha-v1-C_cF1WD2.js} +2 -2
  26. package/dist/chunks/{friendly-captcha-v2-WoW246JG.js → friendly-captcha-v2-BenESH-n.js} +2 -2
  27. package/dist/chunks/{friendly-challenge-DPG9MJb1.js → friendly-challenge-BgPBEMXx.js} +21 -35
  28. package/dist/chunks/{go-cardless-DzJ-0FKg.js → go-cardless-DWZeB0Bq.js} +2 -6
  29. package/dist/chunks/{google-address-C1P1nTDM.js → google-address-DQR6rsbh.js} +15 -10
  30. package/dist/chunks/{hcaptcha-D0T8aIHz.js → hcaptcha-COilLmRT.js} +2 -2
  31. package/dist/chunks/{hidden-BZhXnNPe.js → hidden-Bc2TpcgF.js} +1 -1
  32. package/dist/chunks/{http-C2QdHuWO.js → http-Dj_skqaa.js} +1 -1
  33. package/dist/chunks/{leaflet-src-DE6rv6M-.js → leaflet-src-Cg-LUOD8.js} +74 -122
  34. package/dist/chunks/{loqate-DD1S7PY9.js → loqate-ZZClcacr.js} +2 -2
  35. package/dist/chunks/{mollie-DG8Ju83x.js → mollie-8OMXIYa8.js} +2 -6
  36. package/dist/chunks/{moneris-C8m3Fvqu.js → moneris-qYHJ9EFy.js} +1 -1
  37. package/dist/chunks/opayo-DlWPEnla.js +159 -0
  38. package/dist/chunks/{paddle-BIYR9Um3.js → paddle-CArfWih0.js} +2 -2
  39. package/dist/chunks/{password-validation-C8_uO_c_.js → password-validation-HGOU9kf3.js} +2 -2
  40. package/dist/chunks/{paypal-B_yxIo2d.js → paypal-BcaMCMGY.js} +2 -2
  41. package/dist/chunks/{payway-CeOzS0sL.js → payway-vxC7_bfR.js} +4 -12
  42. package/dist/chunks/{phone-country-bAqje17P.js → phone-country-Bx9EG50h.js} +124 -141
  43. package/dist/chunks/{place-kit-BE5gi3vo.js → place-kit-BKk8xDXf.js} +10 -10
  44. package/dist/chunks/{placekit-autocomplete.esm-CZZERYur.js → placekit-autocomplete.esm-D2A_0iVU.js} +2 -5
  45. package/dist/chunks/{recaptcha-enterprise-CruXddZq.js → recaptcha-enterprise-Cx6HUpMa.js} +6 -12
  46. package/dist/chunks/recaptcha-shared-CcMI-r8t.js +33 -0
  47. package/dist/chunks/{recaptcha-v2-checkbox-CCnJYCnG.js → recaptcha-v2-checkbox-DQ9BJzcv.js} +2 -2
  48. package/dist/chunks/{recaptcha-v2-invisible-BuVqRRYn.js → recaptcha-v2-invisible-BP0T8Pan.js} +2 -2
  49. package/dist/chunks/recaptcha-v3-DKMGQ27K.js +38 -0
  50. package/dist/chunks/{repeater-DwE27jI1.js → repeater-BR0RUvTs.js} +50 -40
  51. package/dist/chunks/{rich-text-uV4o_pGk.js → rich-text-H2A6cm1t.js} +33 -11
  52. package/dist/chunks/{rolldown-runtime-DtPi1Y-2.js → rolldown-runtime-VHNaYcLd.js} +1 -1
  53. package/dist/chunks/{scripts-CbQ7agX3.js → scripts-DrccjrFv.js} +1 -1
  54. package/dist/chunks/{sdk-lkwSvlpM.js → sdk-_cmrRYgp.js} +4 -16
  55. package/dist/chunks/{shared-OKhSay59.js → shared-CJ1LIEh3.js} +1 -3
  56. package/dist/chunks/{signature-7cldFbqj.js → signature-CfKA_poI.js} +9 -41
  57. package/dist/chunks/skip-CEW89ExY.js +7 -0
  58. package/dist/chunks/{snaptcha-C04kNGYQ.js → snaptcha-P3EoSQud.js} +1 -1
  59. package/dist/chunks/{square-DEzxv9D9.js → square-Dyz0p_GU.js} +2 -2
  60. package/dist/chunks/{stripe-CVPQGDz3.js → stripe-Bfd_jp_k.js} +4 -7
  61. package/dist/chunks/{summary-BF_8mrXI.js → summary-B3CAebLV.js} +45 -37
  62. package/dist/chunks/{survey-likert-fk1hUPff.js → survey-likert-CEtUceag.js} +1 -1
  63. package/dist/chunks/{survey-rank-BsLGl2LX.js → survey-rank-Dtt97y6s.js} +3 -7
  64. package/dist/chunks/{survey-rating-DVS6e6fM.js → survey-rating-mlDMI0uq.js} +9 -23
  65. package/dist/chunks/{table-DnIBdeKF.js → table-0k6Md0IO.js} +2 -2
  66. package/dist/chunks/{text-limit-LGMPhFuC.js → text-limit-CTVML6s7.js} +10 -10
  67. package/dist/chunks/{turnstile-n9DNyPxi.js → turnstile-Sp4ubowK.js} +2 -2
  68. package/dist/chunks/{upload-manager-BbiUosZq.js → upload-manager-Ccg3d_4I.js} +258 -260
  69. package/dist/index.d.ts +1 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +743 -734
  72. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  73. package/dist/js/core/dom-events.d.ts +5 -0
  74. package/dist/js/core/dom-events.d.ts.map +1 -1
  75. package/dist/js/core/page-client-event.d.ts.map +1 -1
  76. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  77. package/dist/js/core/submit-result-ui.d.ts +3 -1
  78. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  79. package/dist/js/core/validation-focus.d.ts.map +1 -1
  80. package/dist/js/modules/address/google-address.d.ts +10 -0
  81. package/dist/js/modules/address/google-address.d.ts.map +1 -1
  82. package/dist/js/modules/address/place-kit.d.ts.map +1 -1
  83. package/dist/js/modules/captchas/factories.d.ts.map +1 -1
  84. package/dist/js/modules/captchas/host.d.ts +4 -1
  85. package/dist/js/modules/captchas/host.d.ts.map +1 -1
  86. package/dist/js/modules/captchas/recaptcha-shared.d.ts +1 -1
  87. package/dist/js/modules/captchas/recaptcha-shared.d.ts.map +1 -1
  88. package/dist/js/modules/captchas/recaptcha-v3.d.ts.map +1 -1
  89. package/dist/js/modules/fields/address-state.d.ts.map +1 -1
  90. package/dist/js/modules/fields/combobox.d.ts +2 -0
  91. package/dist/js/modules/fields/combobox.d.ts.map +1 -1
  92. package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
  93. package/dist/js/modules/fields/conditions.d.ts.map +1 -1
  94. package/dist/js/modules/fields/date-picker.d.ts.map +1 -1
  95. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  96. package/dist/js/modules/fields/phone-country.d.ts.map +1 -1
  97. package/dist/js/modules/fields/repeater.d.ts.map +1 -1
  98. package/dist/js/modules/fields/rich-text.d.ts.map +1 -1
  99. package/dist/js/modules/fields/summary.d.ts.map +1 -1
  100. package/dist/js/modules/fields/text-limit.d.ts.map +1 -1
  101. package/dist/js/modules/fields/upload-manager.d.ts.map +1 -1
  102. package/dist/js/modules/payments/factories.d.ts.map +1 -1
  103. package/dist/js/modules/payments/opayo.d.ts.map +1 -1
  104. package/dist/js/submit/pipeline.d.ts +2 -0
  105. package/dist/js/submit/pipeline.d.ts.map +1 -1
  106. package/dist/js/utils/async.d.ts +1 -0
  107. package/dist/js/utils/async.d.ts.map +1 -1
  108. package/dist/js/utils/form-started-at.d.ts +1 -0
  109. package/dist/js/utils/form-started-at.d.ts.map +1 -1
  110. package/dist/js/utils/unload-warning.d.ts +4 -0
  111. package/dist/js/utils/unload-warning.d.ts.map +1 -1
  112. package/dist/js/validation/rules/required.d.ts.map +1 -1
  113. package/dist/js/validation/submit-readiness.d.ts.map +1 -1
  114. package/dist/js/validation/validator.d.ts +1 -0
  115. package/dist/js/validation/validator.d.ts.map +1 -1
  116. package/package.json +3 -3
  117. package/src/index.ts +1 -0
  118. package/src/js/core/create-formie-client.ts +37 -28
  119. package/src/js/core/dom-events.ts +30 -1
  120. package/src/js/core/page-client-event.ts +5 -7
  121. package/src/js/core/submit-result-state.ts +30 -2
  122. package/src/js/core/submit-result-ui.ts +25 -3
  123. package/src/js/core/validation-focus.ts +17 -3
  124. package/src/js/modules/address/google-address.ts +95 -37
  125. package/src/js/modules/address/place-kit.ts +13 -7
  126. package/src/js/modules/captchas/factories.ts +241 -101
  127. package/src/js/modules/captchas/host.ts +16 -0
  128. package/src/js/modules/captchas/recaptcha-enterprise.ts +1 -1
  129. package/src/js/modules/captchas/recaptcha-shared.ts +23 -5
  130. package/src/js/modules/captchas/recaptcha-v3.ts +23 -14
  131. package/src/js/modules/fields/address-country.ts +5 -5
  132. package/src/js/modules/fields/address-state.ts +80 -27
  133. package/src/js/modules/fields/combobox.ts +22 -1
  134. package/src/js/modules/fields/conditions/effects.ts +4 -5
  135. package/src/js/modules/fields/conditions.ts +9 -0
  136. package/src/js/modules/fields/date-picker.ts +6 -3
  137. package/src/js/modules/fields/file-upload.ts +55 -18
  138. package/src/js/modules/fields/phone-country.ts +25 -2
  139. package/src/js/modules/fields/repeater.ts +55 -4
  140. package/src/js/modules/fields/rich-text.ts +33 -0
  141. package/src/js/modules/fields/summary.ts +63 -10
  142. package/src/js/modules/fields/text-limit.ts +11 -0
  143. package/src/js/modules/fields/upload-manager.ts +54 -18
  144. package/src/js/modules/payments/factories.ts +87 -54
  145. package/src/js/modules/payments/opayo.ts +7 -0
  146. package/src/js/submit/pipeline.ts +16 -9
  147. package/src/js/utils/async.ts +34 -0
  148. package/src/js/utils/form-started-at.ts +19 -4
  149. package/src/js/utils/unload-warning.ts +63 -7
  150. package/src/js/validation/rules/required.ts +18 -0
  151. package/src/js/validation/submit-readiness.ts +2 -0
  152. package/src/js/validation/validator.ts +29 -6
  153. package/src/vendor.d.ts +2 -0
  154. package/dist/chunks/opayo-B3EvrVxw.js +0 -188
  155. package/dist/chunks/recaptcha-shared-AQRMDMme.js +0 -27
  156. package/dist/chunks/recaptcha-v3-CloqhO2A.js +0 -28
@@ -16,7 +16,7 @@ type ComboboxSelect = HTMLSelectElement & {
16
16
 
17
17
  type AddressCountryOptions = {
18
18
  countryPreselectFromIp?: boolean;
19
- countryAllowed?: string[];
19
+ allowedCountryCodes?: string[];
20
20
  countryOptionValue?: 'short' | 'full';
21
21
  countryFromIpAction?: string;
22
22
  };
@@ -33,14 +33,14 @@ function resolveCountrySelectValue(
33
33
  return countryCode.toUpperCase();
34
34
  }
35
35
 
36
- function isCountryAllowed(countryCode: string, countryAllowed: string[] = []): boolean {
37
- if (!countryAllowed.length) {
36
+ function isCountryAllowed(countryCode: string, allowedCountryCodes: string[] = []): boolean {
37
+ if (!allowedCountryCodes.length) {
38
38
  return true;
39
39
  }
40
40
 
41
41
  const upperCode = countryCode.toUpperCase();
42
42
 
43
- return countryAllowed.some((allowed) => allowed.toUpperCase() === upperCode);
43
+ return allowedCountryCodes.some((allowed) => allowed.toUpperCase() === upperCode);
44
44
  }
45
45
 
46
46
  function optionExists(select: HTMLSelectElement, value: string): boolean {
@@ -91,7 +91,7 @@ async function preselectCountry(
91
91
  return;
92
92
  }
93
93
 
94
- if (!isCountryAllowed(response.countryCode, options.countryAllowed)) {
94
+ if (!isCountryAllowed(response.countryCode, options.allowedCountryCodes)) {
95
95
  debug.log('Detected country is not in the allowed list; skipping preselect.', {
96
96
  countryCode: response.countryCode,
97
97
  });
@@ -39,6 +39,7 @@ type AddressStateOptions = {
39
39
  inputMode?: string;
40
40
  hideWhenUnused?: boolean;
41
41
  useSearchable?: boolean;
42
+ searchableOptionLimit?: number | null;
42
43
  useDatalist?: boolean;
43
44
  optionLabel?: 'name' | 'short';
44
45
  optionValue?: 'name' | 'short';
@@ -58,6 +59,9 @@ type ComboboxSelect = HTMLSelectElement & {
58
59
 
59
60
  type FieldState = {
60
61
  addressRoot: HTMLElement;
62
+ isCpEdit: boolean;
63
+ initialCountry: string;
64
+ initialValue: string;
61
65
  stateField: HTMLElement;
62
66
  stateControl: AddressStateControl;
63
67
  countryControl: HTMLInputElement | HTMLSelectElement | null;
@@ -105,7 +109,8 @@ function getStateControl(field: HTMLElement): AddressStateControl | null {
105
109
  }
106
110
 
107
111
  function getFieldLabel(field: HTMLElement): HTMLElement | null {
108
- return field.querySelector('[data-formie-field-label]');
112
+ return field.querySelector('[data-formie-field-label]')
113
+ || (field.querySelector('[data-formie-address-state-cp]') ? field.querySelector(':scope > .heading > label') : null);
109
114
  }
110
115
 
111
116
  function dispatchControlEvents(control: AddressStateControl): void {
@@ -130,13 +135,15 @@ function syncComboboxValue(control: AddressStateControl, value: string): void {
130
135
  }
131
136
 
132
137
  function setControlValue(control: AddressStateControl, value: string): void {
133
- if (control.value !== value) {
134
- control.value = value;
138
+ const previousValue = control.value;
139
+ control.value = value;
140
+ syncComboboxValue(control, control.value);
141
+
142
+ // A select rejects values outside its options. Dispatch only actual changes
143
+ // so an unmatched autofill value cannot recursively trigger reconciliation.
144
+ if (control.value !== previousValue) {
135
145
  dispatchControlEvents(control);
136
- return;
137
146
  }
138
-
139
- syncComboboxValue(control, value);
140
147
  }
141
148
 
142
149
  function syncAutofillAnchor(fieldState: FieldState, value: string): void {
@@ -181,6 +188,7 @@ function ensureAutofillAnchor(fieldState: FieldState): HTMLInputElement {
181
188
  anchor.setAttribute('tabindex', '-1');
182
189
  anchor.setAttribute('aria-hidden', 'true');
183
190
  anchor.className = 'formie-sr-only';
191
+ anchor.hidden = fieldState.isCpEdit;
184
192
  fieldState.addressRoot.appendChild(anchor);
185
193
  fieldState.autofillAnchor = anchor;
186
194
  }
@@ -204,14 +212,13 @@ function reconcileAutofillValue(fieldState: FieldState): void {
204
212
  }
205
213
 
206
214
  const subdivisions = fieldState.lastSubdivisions;
207
- const resolved = subdivisions.length > 0
208
- ? (resolveOptionMatch(pending, subdivisions) || pending)
215
+ const resolved = fieldState.stateControl instanceof HTMLSelectElement
216
+ ? (resolveOptionMatch(pending, subdivisions) || '')
209
217
  : pending;
210
218
 
211
- setControlValue(fieldState.stateControl, resolved);
212
- syncComboboxValue(fieldState.stateControl, resolved);
213
219
  syncAutofillAnchor(fieldState, resolved);
214
220
  fieldState.pendingStateValue = resolved;
221
+ setControlValue(fieldState.stateControl, resolved);
215
222
  }
216
223
 
217
224
  function buildSubdivisionsUrl(action: string, country: string, optionLabel: string, optionValue: string): string {
@@ -320,6 +327,7 @@ function copyControlAttributes(source: AddressStateControl, target: AddressState
320
327
  'data-formie-input-type',
321
328
  'data-formie-input-error-state',
322
329
  'data-formie-address-state-dynamic',
330
+ 'data-formie-address-state-cp',
323
331
  'data-formie-address-state-hide-when-unused',
324
332
  'data-formie-address-state-use-searchable',
325
333
  'data-formie-address-state-use-datalist',
@@ -340,7 +348,7 @@ function copyControlAttributes(source: AddressStateControl, target: AddressState
340
348
  }
341
349
 
342
350
  function applyTextThemeClasses(template: AddressStateControl, input: HTMLInputElement): void {
343
- input.className = template.className;
351
+ input.className = template.hasAttribute('data-formie-address-state-cp') ? 'text fullwidth' : template.className;
344
352
  }
345
353
 
346
354
  function applySelectThemeClasses(template: AddressStateControl, select: HTMLSelectElement): void {
@@ -461,11 +469,16 @@ function setFieldVisibility(fieldState: FieldState, visible: boolean): void {
461
469
  const { stateField, stateControl, required } = fieldState;
462
470
 
463
471
  toggleThemeClasses(stateField, stateField, 'conditionalHidden', !visible);
464
- stateField.toggleAttribute('data-formie-conditionally-hidden', !visible);
472
+ // Use module-owned visibility: the generic hidden attribute unmounts modules,
473
+ // which would remove the country listener needed to show this field again.
474
+ stateField.toggleAttribute('data-formie-address-state-hidden', !visible);
475
+
465
476
 
466
477
  if (!visible) {
467
478
  stateControl.required = false;
468
- stateControl.disabled = true;
479
+ // Keep the empty value in Craft's serialized form data so hiding the
480
+ // field during initialization does not count as an unsaved edit.
481
+ stateControl.disabled = !fieldState.isCpEdit;
469
482
  return;
470
483
  }
471
484
 
@@ -479,7 +492,7 @@ function ensureFetchingAnnouncement(fieldState: FieldState): HTMLElement {
479
492
  }
480
493
 
481
494
  const announcement = document.createElement('div');
482
- announcement.className = 'formie-sr-only';
495
+ announcement.className = fieldState.isCpEdit ? 'visually-hidden' : 'formie-sr-only';
483
496
  announcement.setAttribute('data-formie-address-state-fetching-announce', 'true');
484
497
  announcement.setAttribute('aria-live', 'polite');
485
498
  announcement.setAttribute('aria-atomic', 'true');
@@ -509,7 +522,7 @@ function showFetchingUI(fieldState: FieldState): void {
509
522
  fieldState.stateControl.setAttribute('tabindex', '-1');
510
523
 
511
524
  toggleThemeClasses(fieldState.stateField, fieldState.stateField, 'conditionalHidden', false);
512
- fieldState.stateField.removeAttribute('data-formie-conditionally-hidden');
525
+ fieldState.stateField.removeAttribute('data-formie-address-state-hidden');
513
526
  fieldState.stateField.setAttribute('data-formie-address-state-skeleton-active', 'true');
514
527
 
515
528
  const controlWrapper = fieldState.stateField.querySelector('[data-formie-field-control]');
@@ -546,7 +559,7 @@ function updateFieldLabel(stateField: HTMLElement, label: string): void {
546
559
  return;
547
560
  }
548
561
 
549
- const required = labelEl.querySelector('[data-formie-field-required]');
562
+ const required = labelEl.querySelector('[data-formie-field-required], .required');
550
563
  labelEl.textContent = label;
551
564
 
552
565
  if (required) {
@@ -557,22 +570,23 @@ function updateFieldLabel(stateField: HTMLElement, label: string): void {
557
570
  function initSearchableSelect(
558
571
  select: HTMLSelectElement,
559
572
  placeholder: string | null,
573
+ maxOptions: number | null | undefined,
560
574
  ): () => void {
561
575
  select.setAttribute('data-formie-combobox-input', 'true');
562
576
 
563
577
  dispatchFieldEvent(select, 'combobox', 'before-init', {
564
578
  select,
565
- options: { placeholder },
579
+ options: { placeholder, maxOptions },
566
580
  });
567
581
 
568
582
  const cleanup = initFormieCombobox(
569
583
  select as Parameters<typeof initFormieCombobox>[0],
570
- { placeholder },
584
+ { placeholder, maxOptions },
571
585
  );
572
586
 
573
587
  dispatchFieldEvent(select, 'combobox', 'after-init', {
574
588
  combobox: (select as Parameters<typeof initFormieCombobox>[0])._formieTomSelect,
575
- options: { placeholder },
589
+ options: { placeholder, maxOptions },
576
590
  });
577
591
 
578
592
  return cleanup;
@@ -595,6 +609,10 @@ async function applyCountryState(
595
609
  const country = fieldState.countryControl?.value?.trim() || '';
596
610
  stashPendingStateValue(fieldState);
597
611
  const currentValue = getEffectiveStateValue(fieldState);
612
+ // Opening an existing submission must not discard a historical state value
613
+ // merely because current country metadata no longer includes it.
614
+ const preserveStoredValue = fieldState.isCpEdit && !!currentValue
615
+ && country === fieldState.initialCountry && currentValue === fieldState.initialValue;
598
616
  const fetchGeneration = ++fieldState.fetchGeneration;
599
617
 
600
618
  fieldState.comboboxCleanup?.();
@@ -608,7 +626,7 @@ async function applyCountryState(
608
626
 
609
627
  hideFetchingUI(fieldState);
610
628
 
611
- if (hideWhenUnused) {
629
+ if (hideWhenUnused && !preserveStoredValue) {
612
630
  setFieldVisibility(fieldState, false);
613
631
  setControlValue(fieldState.stateControl, '');
614
632
  syncAutofillAnchor(fieldState, '');
@@ -623,7 +641,7 @@ async function applyCountryState(
623
641
  const textControl = createTextControl(fieldState.stateControl, currentValue);
624
642
  fieldState.stateControl = replaceControl(fieldState.stateControl, textControl);
625
643
  } else {
626
- fieldState.stateControl.disabled = true;
644
+ fieldState.stateControl.disabled = !fieldState.isCpEdit && !preserveStoredValue;
627
645
  fieldState.stateControl.placeholder = placeholder || fieldState.stateControl.placeholder;
628
646
  }
629
647
 
@@ -644,14 +662,17 @@ async function applyCountryState(
644
662
  return;
645
663
  }
646
664
 
647
- const subdivisions = response?.subdivisions || [];
665
+ const subdivisions = [...(response?.subdivisions || [])];
666
+ if (preserveStoredValue && subdivisions.length && !resolveOptionMatch(currentValue, subdivisions)) {
667
+ subdivisions.push({ label: currentValue, value: currentValue });
668
+ }
648
669
  const administrativeAreaUsed = response?.administrativeAreaUsed ?? true;
649
670
  const administrativeAreaLabel = response?.administrativeAreaLabel || 'State / Province';
650
671
  fieldState.lastSubdivisions = subdivisions;
651
672
 
652
673
  hideFetchingUI(fieldState);
653
674
 
654
- if (hideWhenUnused && !administrativeAreaUsed) {
675
+ if (hideWhenUnused && !administrativeAreaUsed && !preserveStoredValue) {
655
676
  setFieldVisibility(fieldState, false);
656
677
  setControlValue(fieldState.stateControl, '');
657
678
  syncAutofillAnchor(fieldState, '');
@@ -675,7 +696,7 @@ async function applyCountryState(
675
696
  }
676
697
 
677
698
  if (useSearchable) {
678
- fieldState.comboboxCleanup = initSearchableSelect(select, placeholder);
699
+ fieldState.comboboxCleanup = initSearchableSelect(select, placeholder, options.searchableOptionLimit);
679
700
  } else {
680
701
  select.removeAttribute('data-formie-combobox-input');
681
702
  }
@@ -755,6 +776,9 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
755
776
 
756
777
  const fieldState: FieldState = {
757
778
  addressRoot,
779
+ isCpEdit: stateControl.hasAttribute('data-formie-address-state-cp'),
780
+ initialCountry: getCountryControl(addressRoot)?.value?.trim() || '',
781
+ initialValue: stateControl.value?.trim() || '',
758
782
  stateField: field,
759
783
  stateControl,
760
784
  countryControl: getCountryControl(addressRoot),
@@ -772,6 +796,10 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
772
796
  lastSubdivisions: [],
773
797
  };
774
798
 
799
+ const initialStateValue = stateControl.value;
800
+ const form = field.closest('form');
801
+ let disposed = false;
802
+ let resetTimer: number | null = null;
775
803
  ensureAutofillAnchor(fieldState);
776
804
 
777
805
  const refresh = (force = false) => {
@@ -830,6 +858,7 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
830
858
 
831
859
  if (isStateControl && !isAnchor) {
832
860
  syncAutofillAnchor(fieldState, target.value);
861
+ fieldState.pendingStateValue = target.value;
833
862
  }
834
863
 
835
864
  stashPendingStateValue(fieldState);
@@ -891,12 +920,31 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
891
920
  syncAutofillAnchor(fieldState, stateValue);
892
921
 
893
922
  void applyCountryState(fieldState, options).then(() => {
894
- setControlValue(fieldState.stateControl, stateValue);
895
- syncComboboxValue(fieldState.stateControl, stateValue);
923
+ // Keep the normalized option value, which may differ from a provider's
924
+ // abbreviation (for example "CA" when options store "California").
925
+ reconcileAutofillValue(fieldState);
896
926
  fieldState.lastCountry = fieldState.countryControl?.value?.trim() || '';
897
927
  });
898
928
  };
899
929
 
930
+ const onReset = (event: Event) => {
931
+ if (resetTimer !== null) window.clearTimeout(resetTimer);
932
+ // Enhanced selects finish their own reset in microtasks. Rebuild the
933
+ // dependent options after those controls have restored the country.
934
+ resetTimer = window.setTimeout(() => {
935
+ resetTimer = null;
936
+ if (event.defaultPrevented || disposed) return;
937
+ clearScheduledAutofillTimers(fieldState);
938
+ // The current select may contain another country's options. Keep the
939
+ // original value pending until the reset country's options are ready.
940
+ fieldState.stateControl.value = '';
941
+ fieldState.pendingStateValue = initialStateValue;
942
+ syncAutofillAnchor(fieldState, initialStateValue);
943
+ refresh(true);
944
+ }, 0);
945
+ };
946
+ form?.addEventListener('reset', onReset);
947
+
900
948
  fieldState.countryControl?.addEventListener('change', onCountryChange);
901
949
  fieldState.countryControl?.addEventListener('input', onCountryChange);
902
950
  addressRoot.addEventListener('input', onAddressInput);
@@ -907,10 +955,15 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
907
955
  addressRoot.addEventListener('formie:address:loqate:populate', onProviderPopulate);
908
956
  addressRoot.addEventListener('formie:address:place-kit:populate', onProviderPopulate);
909
957
 
910
- refresh();
958
+ // Apply the empty-country visibility state on first mount as well.
959
+ refresh(true);
911
960
  scheduleAutofillSweep(fieldState, options);
912
961
 
913
962
  return () => {
963
+ disposed = true;
964
+ if (resetTimer !== null) window.clearTimeout(resetTimer);
965
+ fieldState.fetchGeneration++;
966
+ form?.removeEventListener('reset', onReset);
914
967
  clearScheduledAutofillTimers(fieldState);
915
968
  hideFetchingUI(fieldState);
916
969
  fieldState.skeletonEl?.remove();
@@ -6,6 +6,8 @@ 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 { t } from '#utils/i18n';
10
+ import { VALIDATION_SKIP_ATTR } from '#validation/skip';
9
11
 
10
12
  const SELECT_SELECTOR = 'select[data-formie-combobox-input]';
11
13
  const MODULE_ID = 'combobox';
@@ -20,6 +22,8 @@ const NATIVE_THEME_CLASSES = [
20
22
 
21
23
  export type FormieComboboxOptions = {
22
24
  multiple?: boolean;
25
+ maxOptions?: number | null;
26
+ allowClear?: boolean;
23
27
  placeholder?: string | null;
24
28
  };
25
29
 
@@ -86,8 +90,23 @@ export function initFormieCombobox(select: SelectElement, options: FormieCombobo
86
90
 
87
91
  const mergedOptions: Record<string, unknown> = {
88
92
  create: false,
93
+ // Limit rendered results while retaining the full option list for searching.
94
+ maxOptions: options.maxOptions === undefined ? 100 : options.maxOptions,
89
95
  maxItems: multiple ? null : 1,
90
- plugins: multiple ? ['remove_button'] : [],
96
+ plugins: multiple ? ['remove_button'] : options.allowClear ? {
97
+ remove_button: {
98
+ // Use a native button so clearing a single answer works with keyboard input too.
99
+ html: () => {
100
+ const button = document.createElement('button');
101
+ button.type = 'button';
102
+ button.className = 'remove';
103
+ button.title = t('Clear selection');
104
+ button.setAttribute('aria-label', button.title);
105
+ button.textContent = '×';
106
+ return button;
107
+ },
108
+ },
109
+ } : [],
91
110
  hideSelected: multiple ? true : null,
92
111
  clearAfterSelect: multiple,
93
112
  closeAfterSelect: !multiple,
@@ -116,6 +135,8 @@ export function initFormieCombobox(select: SelectElement, options: FormieCombobo
116
135
  });
117
136
 
118
137
  const instance = new TomSelect(select, mergedOptions) as TomSelectInstance;
138
+ // The search query is temporary; the native select carries the field value.
139
+ instance.control_input.setAttribute(VALIDATION_SKIP_ATTR, '');
119
140
  removeEmptyOptionFromCombobox(instance);
120
141
  cleanLeakedThemeClasses(instance.wrapper, select);
121
142
 
@@ -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.
@@ -294,6 +300,9 @@ export const conditionsModule: BrowserModuleDefinition = {
294
300
  });
295
301
 
296
302
  rebuild();
303
+ // Initial visibility and disabled state belong to module setup. Finish
304
+ // them before the form captures its clean baseline or announces readiness.
305
+ evaluateAll();
297
306
 
298
307
  await ctx.emit('formie:module:conditions:init', {
299
308
  count: entries.length,
@@ -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;
@@ -3,6 +3,7 @@ import fileUploadCss from '#theme-css/fields/_file.css?inline';
3
3
  import type { BrowserModuleDefinition } from '#contracts/modules';
4
4
  import { dispatchFieldEvent, releaseFormValidators, retainFormValidators } from '#modules/fields/shared';
5
5
  import { ensureModuleStyles } from '#modules/styles';
6
+ import { appendFormCsrfToFormData } from '#utils/csrf';
6
7
  import { getFieldModuleEventName, getFileUploadEventName, getFormStateEventName } from '#utils/event-names';
7
8
  import { requestJson } from '#utils/http';
8
9
 
@@ -30,6 +31,9 @@ type UploadedAsset = {
30
31
  assetId: number | null;
31
32
  filename: string;
32
33
  url?: string | null;
34
+ uploadUid?: string | null;
35
+ attachToken?: string | null;
36
+ viewToken?: string | null;
33
37
  };
34
38
 
35
39
  type FileUploadHydrateResponse = {
@@ -151,6 +155,9 @@ function normalizeUploadedAsset(candidate: unknown): UploadedAsset | null {
151
155
  assetId,
152
156
  filename,
153
157
  url,
158
+ uploadUid: toTrimmedString(candidate.uploadUid) || null,
159
+ attachToken: toTrimmedString(candidate.attachToken) || null,
160
+ viewToken: toTrimmedString(candidate.viewToken ?? candidate.uploadToken) || null,
154
161
  };
155
162
  }
156
163
 
@@ -190,6 +197,9 @@ function readUploadedAssetsFromHiddenInputs(field: HTMLElement, input: HTMLInput
190
197
  return {
191
198
  assetId: toPositiveInt(hiddenInput.getAttribute(HIDDEN_INPUT_VALUE_ATTR)) ?? toPositiveInt(hiddenInput.value),
192
199
  filename: '',
200
+ viewToken: hiddenInput.getAttribute('data-formie-file-upload-token'),
201
+ uploadUid: hiddenInput.name.endsWith('[uploadUid]') ? hiddenInput.value : null,
202
+ attachToken: getHiddenInputs(field).find((entry) => entry.name === hiddenInput.name.replace(/\[uploadUid\]$/, '[attachToken]'))?.value,
193
203
  };
194
204
  }).filter((asset) => {
195
205
  return asset.assetId !== null;
@@ -203,6 +213,7 @@ function mergeUploadedAssets(assetIds: UploadedAsset[], summaryItems: UploadedAs
203
213
 
204
214
  return assetIds.map((asset, index) => {
205
215
  return {
216
+ ...asset,
206
217
  assetId: asset.assetId,
207
218
  filename: summaryItems[index]?.filename || '',
208
219
  };
@@ -248,8 +259,8 @@ function registerValidators(form: HTMLFormElement | null): void {
248
259
  });
249
260
 
250
261
  validator.addValidator('fileSizeMinLimit', ({ input }) => {
251
- const sizeLimit = parseFloat(input.getAttribute('data-formie-size-min-limit') || '');
252
- const sizeBytes = sizeLimit * 1000 * 1000;
262
+ const maxFileSize = parseFloat(input.getAttribute('data-formie-size-min-limit') || '');
263
+ const sizeBytes = maxFileSize * 1000 * 1000;
253
264
 
254
265
  if (input.type !== 'file' || !sizeBytes || !('files' in input) || !input.files?.length) {
255
266
  return true;
@@ -266,8 +277,8 @@ function registerValidators(form: HTMLFormElement | null): void {
266
277
  });
267
278
 
268
279
  validator.addValidator('fileSizeMaxLimit', ({ input }) => {
269
- const sizeLimit = parseFloat(input.getAttribute('data-formie-size-max-limit') || '');
270
- const sizeBytes = sizeLimit * 1000 * 1000;
280
+ const maxFileSize = parseFloat(input.getAttribute('data-formie-size-max-limit') || '');
281
+ const sizeBytes = maxFileSize * 1000 * 1000;
271
282
 
272
283
  if (input.type !== 'file' || !sizeBytes || !('files' in input) || !input.files?.length) {
273
284
  return true;
@@ -366,12 +377,26 @@ function updateUploadedAssetInputs(field: HTMLElement, input: HTMLInputElement,
366
377
 
367
378
  const hiddenInput = document.createElement('input');
368
379
  hiddenInput.type = 'hidden';
369
- hiddenInput.name = `${baseName}[${index}][assetId]`;
370
- hiddenInput.value = String(asset.assetId);
380
+ const hasCapability = asset.uploadUid && asset.attachToken;
381
+ hiddenInput.name = `${baseName}[${index}][${hasCapability ? 'uploadUid' : 'assetId'}]`;
382
+ hiddenInput.value = hasCapability ? asset.uploadUid! : String(asset.assetId);
371
383
  hiddenInput.setAttribute(HIDDEN_INPUT_ENTRY_ATTR, 'true');
372
384
  hiddenInput.setAttribute(HIDDEN_INPUT_VALUE_ATTR, String(asset.assetId));
385
+ if (asset.viewToken) {
386
+ hiddenInput.setAttribute('data-formie-file-upload-token', asset.viewToken);
387
+ }
373
388
  insertionPoint.insertAdjacentElement('afterend', hiddenInput);
374
389
  insertionPoint = hiddenInput;
390
+
391
+ if (hasCapability) {
392
+ const tokenInput = document.createElement('input');
393
+ tokenInput.type = 'hidden';
394
+ tokenInput.name = `${baseName}[${index}][attachToken]`;
395
+ tokenInput.value = asset.attachToken!;
396
+ tokenInput.setAttribute(HIDDEN_INPUT_ENTRY_ATTR, 'true');
397
+ insertionPoint.insertAdjacentElement('afterend', tokenInput);
398
+ insertionPoint = tokenInput;
399
+ }
375
400
  });
376
401
 
377
402
  input.value = '';
@@ -391,7 +416,7 @@ function getFormHandle(form: HTMLFormElement | null): string {
391
416
  return form.getAttribute('data-formie-handle')?.trim() || '';
392
417
  }
393
418
 
394
- function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assetIds: number[]): FormData {
419
+ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assets: UploadedAsset[]): FormData {
395
420
  const form = input.form;
396
421
  const body = new FormData();
397
422
  const handle = getFormHandle(form);
@@ -405,16 +430,27 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
405
430
  body.append('fieldHandle', fieldHandle);
406
431
  }
407
432
 
408
- const submissionUidInput = form?.querySelector('input[name="submissionUid"]');
433
+ const passthroughNames = ['submissionUid', 'resumeToken', 'submissionEditToken', 'siteId', 'renderId', 'draftContext', 'draftContextToken'] as const;
409
434
 
410
- if (submissionUidInput instanceof HTMLInputElement && submissionUidInput.value.trim()) {
411
- body.append('submissionUid', submissionUidInput.value.trim());
412
- }
435
+ passthroughNames.forEach((name) => {
436
+ const contextInput = form?.querySelector(`input[name="${name}"]`);
437
+
438
+ if (contextInput instanceof HTMLInputElement && contextInput.value.trim()) {
439
+ body.append(name, contextInput.value.trim());
440
+ }
441
+ });
413
442
 
414
- assetIds.forEach((assetId) => {
415
- body.append('assetIds[]', String(assetId));
443
+ assets.forEach((asset) => {
444
+ if (asset.assetId) {
445
+ body.append('assetIds[]', String(asset.assetId));
446
+ if (asset.viewToken) {
447
+ body.append(`uploadTokens[${asset.assetId}]`, asset.viewToken);
448
+ }
449
+ }
416
450
  });
417
451
 
452
+ appendFormCsrfToFormData(body, form);
453
+
418
454
  return body;
419
455
  }
420
456
 
@@ -433,7 +469,7 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
433
469
  const response = await requestJson<FileUploadHydrateResponse>(hydrateEndpoint, {
434
470
  profile: field.closest('form')?.dataset.formieRequestProfile as import('@verbb/formie-core').BrowserRequestOptions['profile'],
435
471
  method: 'POST',
436
- body: buildHydrateFormData(field, input, assetIds),
472
+ body: buildHydrateFormData(field, input, uploadedAssets),
437
473
  });
438
474
  const hydratedAssets = normalizeUploadedAssets(response.assets);
439
475
 
@@ -452,6 +488,7 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
452
488
 
453
489
  const hydratedAsset = hydratedMap.get(asset.assetId)!;
454
490
  return {
491
+ ...asset,
455
492
  assetId: asset.assetId,
456
493
  filename: hydratedAsset.filename || asset.filename,
457
494
  url: hydratedAsset.url ?? asset.url,
@@ -480,24 +517,24 @@ function getUploadedAssetsFromEvent(event: Event, matcher: FileUploadEventMatche
480
517
 
481
518
  if (detailInputKey !== '' && detailInputKey === matcher.inputKey) {
482
519
  matchedByInputKey = true;
483
- candidates.push(detail.assets, detail.assetIds, detail.data);
520
+ candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
484
521
  }
485
522
 
486
523
  if (detailInputName !== '' && detailInputName === matcher.inputName) {
487
524
  matchedByInputName = true;
488
- candidates.push(detail.assets, detail.assetIds, detail.data);
525
+ candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
489
526
  }
490
527
 
491
528
  if (detailFieldHandle === matcher.fieldHandle) {
492
529
  matchedByFieldHandle = true;
493
- candidates.push(detail.assets, detail.assetIds, detail.data);
530
+ candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
494
531
  }
495
532
 
496
533
  if (nestedData) {
497
534
  const nestedAssetCandidates = [
535
+ nestedData.uploadedAssets,
498
536
  nestedData.assets,
499
537
  nestedData.assetIds,
500
- nestedData.uploadedAssets,
501
538
  nestedData.data,
502
539
  ];
503
540