@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
@@ -132,6 +132,9 @@ export function createManagedPaymentModule<
132
132
  const cleanups: Cleanup[] = [];
133
133
  let apiPromise: Promise<TApi> | null = null;
134
134
  let widget: TWidget | null = null;
135
+ let mountPromise: Promise<void> | null = null;
136
+ let destructionPromise: Promise<void> | null = null;
137
+ let destroyed = false;
135
138
  let customSetupResult: PaymentSetupResult | null = null;
136
139
  let authorizeInFlight: Promise<boolean> | null = null;
137
140
 
@@ -145,32 +148,59 @@ export function createManagedPaymentModule<
145
148
  };
146
149
 
147
150
  const ensureMounted = async() => {
148
- if (!adapter.mount || widget || !isPaymentFieldActive(ctx.target)) {
151
+ const mount = adapter.mount;
152
+
153
+ if (destroyed || !mount || widget || !isPaymentFieldActive(ctx.target)) {
149
154
  return;
150
155
  }
151
156
 
152
- const api = await getApi();
153
-
154
- try {
155
- widget = await adapter.mount({
156
- api,
157
- field: ctx.target,
158
- services,
159
- options,
160
- provider: options.provider,
161
- });
162
- debug.log('Payment widget mounted.', {
163
- moduleId: adapter.moduleId,
164
- handle: options.handle,
165
- });
166
- } catch (error) {
167
- // Propagate initialization failure to the required/optional policy.
168
- debug.warn('Payment widget mount failed.', {
169
- moduleId: adapter.moduleId,
170
- handle: options.handle,
157
+ // Conditions can fire again while a provider is requesting its session.
158
+ // Share the whole mount, including API loading, with submission callers.
159
+ if (!mountPromise) {
160
+ mountPromise = (async() => {
161
+ try {
162
+ const api = await getApi();
163
+
164
+ if (destroyed || !isPaymentFieldActive(ctx.target)) {
165
+ return;
166
+ }
167
+
168
+ services.removeError();
169
+ widget = await mount({
170
+ api,
171
+ field: ctx.target,
172
+ services,
173
+ options,
174
+ provider: options.provider,
175
+ });
176
+ debug.log('Payment widget mounted.', {
177
+ moduleId: adapter.moduleId,
178
+ handle: options.handle,
179
+ });
180
+ } catch (error) {
181
+ // Setup and submission callers still receive provider failures.
182
+ debug.warn('Payment widget mount failed.', {
183
+ moduleId: adapter.moduleId,
184
+ handle: options.handle,
185
+ });
186
+ throw error;
187
+ }
188
+ })().finally(() => {
189
+ mountPromise = null;
171
190
  });
172
- throw error;
173
191
  }
192
+
193
+ return mountPromise;
194
+ };
195
+
196
+ const handleVisibility = () => {
197
+ // Event listeners have no awaited caller to handle a rejection.
198
+ // Allow the next visibility or submission attempt to retry the mount.
199
+ void ensureMounted().catch(() => {
200
+ if (!destroyed && isPaymentFieldActive(ctx.target)) {
201
+ services.addError(options.transport.errorMessage);
202
+ }
203
+ });
174
204
  };
175
205
 
176
206
  // Clear stale payment feedback at the start of every submit attempt.
@@ -194,10 +224,6 @@ export function createManagedPaymentModule<
194
224
  // Remount when page flow or field conditions reveal this payment field.
195
225
  const visibilityEvents = ['formie:page:navigate:after', 'formie:submit:result'];
196
226
  visibilityEvents.forEach((eventName) => {
197
- const handleVisibility = () => {
198
- void ensureMounted();
199
- };
200
-
201
227
  ctx.root.addEventListener(eventName, handleVisibility as EventListener);
202
228
  cleanups.push(() => {
203
229
  ctx.root.removeEventListener(eventName, handleVisibility as EventListener);
@@ -205,41 +231,48 @@ export function createManagedPaymentModule<
205
231
  });
206
232
 
207
233
  // Conditions emit on the module bus (not only as page-nav DOM events).
208
- cleanups.push(ctx.on('formie:conditions:evaluated', () => {
209
- void ensureMounted();
210
- }));
234
+ cleanups.push(ctx.on('formie:conditions:evaluated', handleVisibility));
211
235
 
212
- const destroy = async() => {
213
- debug.log('Destroying payment module.', {
214
- moduleId: adapter.moduleId,
215
- handle: options.handle,
216
- });
217
- cleanups.forEach((c) => c());
236
+ const destroy = (): Promise<void> => {
237
+ if (destructionPromise) return destructionPromise;
238
+ destroyed = true;
239
+ destructionPromise = (async() => {
240
+ debug.log('Destroying payment module.', {
241
+ moduleId: adapter.moduleId,
242
+ handle: options.handle,
243
+ });
244
+ cleanups.forEach((cleanup) => cleanup());
218
245
 
219
- if (widget && adapter.unmount) {
220
- const api = await getApi();
246
+ // A session request may finish after teardown starts. Release the
247
+ // resulting widget before another instance can use this container.
248
+ await mountPromise?.catch(() => {});
249
+ const mountedWidget = widget;
250
+ widget = null;
221
251
 
222
- await adapter.unmount({
223
- api,
224
- widget,
225
- field: ctx.target,
226
- services,
227
- options,
228
- provider: options.provider,
229
- });
230
- debug.log('Payment widget unmounted.', {
252
+ try {
253
+ if (mountedWidget && adapter.unmount) {
254
+ const api = await getApi();
255
+
256
+ await adapter.unmount({
257
+ api,
258
+ widget: mountedWidget,
259
+ field: ctx.target,
260
+ services,
261
+ options,
262
+ provider: options.provider,
263
+ });
264
+ }
265
+ } finally {
266
+ if (registry[occurrenceKey]?.destroy === destroy) {
267
+ delete registry[occurrenceKey];
268
+ }
269
+ }
270
+ debug.log('Payment module destroy complete.', {
231
271
  moduleId: adapter.moduleId,
232
272
  handle: options.handle,
233
273
  });
234
- }
235
-
236
- if (registry[occurrenceKey]?.destroy === destroy) {
237
- delete registry[occurrenceKey];
238
- }
239
- debug.log('Payment module destroy complete.', {
240
- moduleId: adapter.moduleId,
241
- handle: options.handle,
242
- });
274
+ })();
275
+ return destructionPromise;
243
276
  };
244
277
 
245
278
  registry[occurrenceKey] = { destroy };
@@ -3,6 +3,7 @@ import { browserRequest, type BrowserRequestOptions } from '@verbb/formie-core';
3
3
  import opayoCss from '#theme-css/integrations/_opayo.css?inline';
4
4
 
5
5
  import { definePaymentModule } from '#modules/payments/api';
6
+ import { findPaymentInputBySuffix } from '#modules/payments/utils';
6
7
  import { ensureModuleStyles } from '#modules/styles';
7
8
  import { toggleThemeClasses } from '#theme/theme-classes';
8
9
  import { createDebug } from '#utils/debug';
@@ -277,6 +278,12 @@ export const opayoModule = definePaymentModule<OpayoProviderOptions, null, Opayo
277
278
  return false;
278
279
  }
279
280
 
281
+ // A completed challenge resumes the original transaction. The server
282
+ // verifies this reference and its ownership; no new card token is needed.
283
+ if (findPaymentInputBySuffix(field, 'opayo3DSComplete')?.value.trim()) {
284
+ return true;
285
+ }
286
+
280
287
  if (isDropInCheckoutMode(provider)) {
281
288
  if (!widget) {
282
289
  services.addError('Opayo drop-in checkout is not ready.');
@@ -1,5 +1,7 @@
1
+ import type { FormieFormInstance } from '#contracts/client';
1
2
  import type { FormAction, SubmitStage } from '#contracts/common';
2
3
  import type { FormSubmitResult } from '#contracts/schema';
4
+ import { withFormEventInstance } from '#core/dom-events';
3
5
  import { EventBus } from '#events/event-bus';
4
6
  import { submitForm } from '#transport/forms-api';
5
7
  import { createDebug } from '#utils/debug';
@@ -7,6 +9,7 @@ import { getFormPages, getValidationScope } from '#validation/scope';
7
9
  import type { FormieValidator } from '#validation/validator';
8
10
 
9
11
  export type SubmitPipelineContext = {
12
+ instance?: FormieFormInstance;
10
13
  form: HTMLFormElement;
11
14
  action: FormAction;
12
15
  formData: FormData;
@@ -207,7 +210,7 @@ export async function runSubmitPipeline(
207
210
  let dispatchResult: FormSubmitResult | null = null;
208
211
  const finalSubmitAttempt = isFinalSubmitAttempt(form, action);
209
212
 
210
- const context: SubmitPipelineContext = {
213
+ const context: SubmitPipelineContext = withFormEventInstance(form, {
211
214
  form,
212
215
  action,
213
216
  formData: buildSubmitFormData(form, action),
@@ -218,6 +221,10 @@ export async function runSubmitPipeline(
218
221
  },
219
222
  isAborted: () => aborted,
220
223
  abortReason: () => abortReason,
224
+ });
225
+
226
+ const emitSubmitResult = (eventName: string, result: FormSubmitResult) => {
227
+ return bus.emitSafe(eventName, withFormEventInstance(form, result));
221
228
  };
222
229
 
223
230
  const runners: Record<SubmitStage, StageRunner> = {
@@ -368,7 +375,7 @@ export async function runSubmitPipeline(
368
375
  if (aborted) {
369
376
  const abortedResult = getAbortedResult(stage, abortReason);
370
377
  {
371
- const emitReport = await bus.emitSafe('formie:submit:after', abortedResult);
378
+ const emitReport = await emitSubmitResult('formie:submit:after', abortedResult);
372
379
  if (emitReport.failed.length > 0) {
373
380
  debug.warn('Submit after listeners failed (abort before stage).', {
374
381
  stage,
@@ -377,7 +384,7 @@ export async function runSubmitPipeline(
377
384
  }
378
385
  }
379
386
  if (finalSubmitAttempt) {
380
- const emitReport = await bus.emitSafe('formie:submit:final:after', abortedResult);
387
+ const emitReport = await emitSubmitResult('formie:submit:final:after', abortedResult);
381
388
  if (emitReport.failed.length > 0) {
382
389
  debug.warn('Final submit after listeners failed (abort before stage).', {
383
390
  stage,
@@ -414,7 +421,7 @@ export async function runSubmitPipeline(
414
421
  if (aborted) {
415
422
  const abortedResult = getAbortedResult(stage, abortReason);
416
423
  {
417
- const emitReport = await bus.emitSafe('formie:submit:after', abortedResult);
424
+ const emitReport = await emitSubmitResult('formie:submit:after', abortedResult);
418
425
  if (emitReport.failed.length > 0) {
419
426
  debug.warn('Submit after listeners failed (abort after stage).', {
420
427
  stage,
@@ -423,7 +430,7 @@ export async function runSubmitPipeline(
423
430
  }
424
431
  }
425
432
  if (finalSubmitAttempt) {
426
- const emitReport = await bus.emitSafe('formie:submit:final:after', abortedResult);
433
+ const emitReport = await emitSubmitResult('formie:submit:final:after', abortedResult);
427
434
  if (emitReport.failed.length > 0) {
428
435
  debug.warn('Final submit after listeners failed (abort after stage).', {
429
436
  stage,
@@ -439,7 +446,7 @@ export async function runSubmitPipeline(
439
446
  // matching stage after-hook so modules can clean up their own state.
440
447
  if (stageResult && !stageResult.ok) {
441
448
  {
442
- const emitReport = await bus.emitSafe('formie:submit:after', stageResult);
449
+ const emitReport = await emitSubmitResult('formie:submit:after', stageResult);
443
450
  if (emitReport.failed.length > 0) {
444
451
  debug.warn('Submit after listeners failed (failed stage).', {
445
452
  stage,
@@ -448,7 +455,7 @@ export async function runSubmitPipeline(
448
455
  }
449
456
  }
450
457
  if (finalSubmitAttempt) {
451
- const emitReport = await bus.emitSafe('formie:submit:final:after', stageResult);
458
+ const emitReport = await emitSubmitResult('formie:submit:final:after', stageResult);
452
459
  if (emitReport.failed.length > 0) {
453
460
  debug.warn('Final submit after listeners failed (failed stage).', {
454
461
  stage,
@@ -473,7 +480,7 @@ export async function runSubmitPipeline(
473
480
  };
474
481
 
475
482
  {
476
- const emitReport = await bus.emitSafe('formie:submit:after', successResult);
483
+ const emitReport = await emitSubmitResult('formie:submit:after', successResult);
477
484
  if (emitReport.failed.length > 0) {
478
485
  debug.warn('Submit after listeners failed (success).', {
479
486
  failed: emitReport.failed.length,
@@ -481,7 +488,7 @@ export async function runSubmitPipeline(
481
488
  }
482
489
  }
483
490
  if (finalSubmitAttempt) {
484
- const emitReport = await bus.emitSafe('formie:submit:final:after', successResult);
491
+ const emitReport = await emitSubmitResult('formie:submit:final:after', successResult);
485
492
  if (emitReport.failed.length > 0) {
486
493
  debug.warn('Final submit after listeners failed (success).', {
487
494
  failed: emitReport.failed.length,
@@ -79,3 +79,37 @@ export function waitForElement(
79
79
  });
80
80
  });
81
81
  }
82
+
83
+ export function waitForElementRemoval(
84
+ selector: string,
85
+ root: ParentNode = document,
86
+ timeoutMs = 10000,
87
+ ): Promise<void> {
88
+ return new Promise((resolve, reject) => {
89
+ if (!root.querySelector(selector)) {
90
+ resolve();
91
+ return;
92
+ }
93
+
94
+ let timeoutId: number | null = null;
95
+ const observer = new MutationObserver(() => {
96
+ if (root.querySelector(selector)) {
97
+ return;
98
+ }
99
+
100
+ observer.disconnect();
101
+ if (timeoutId !== null) window.clearTimeout(timeoutId);
102
+ resolve();
103
+ });
104
+
105
+ observer.observe(root, {
106
+ childList: true,
107
+ subtree: true,
108
+ });
109
+
110
+ timeoutId = window.setTimeout(() => {
111
+ observer.disconnect();
112
+ reject(new Error(`Timed out waiting for "${selector}" to be removed.`));
113
+ }, Math.max(timeoutMs, 0));
114
+ });
115
+ }
@@ -11,15 +11,30 @@ export function ensureFormStartedAt(form: HTMLFormElement): void {
11
11
  const renderId = renderIdInput?.value?.trim() ?? '';
12
12
  const storageKey = renderId ? `${FORM_STARTED_AT_STORAGE_PREFIX}${renderId}` : null;
13
13
 
14
- let startedAt = storageKey ? sessionStorage.getItem(storageKey) : null;
14
+ const continuing = ['submissionUid', 'progressId'].some((name) =>
15
+ form.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.value.trim()
16
+ );
17
+
18
+ // Reuse elapsed time for an existing draft or live mount, not a fresh visit.
19
+ let startedAt = startedAtInput.value || (continuing && storageKey ? sessionStorage.getItem(storageKey) : null);
15
20
 
16
21
  if (!startedAt) {
17
22
  startedAt = String(Date.now());
23
+ }
18
24
 
19
- if (storageKey) {
20
- sessionStorage.setItem(storageKey, startedAt);
21
- }
25
+ if (storageKey) {
26
+ sessionStorage.setItem(storageKey, startedAt);
22
27
  }
23
28
 
24
29
  startedAtInput.value = startedAt;
25
30
  }
31
+
32
+ export function resetFormStartedAt(form: HTMLFormElement): void {
33
+ const input = form.querySelector<HTMLInputElement>('input[name="formStartedAt"]');
34
+
35
+ if (input) {
36
+ // Hidden input values survive form.reset(), so start the next submission explicitly.
37
+ input.value = String(Date.now());
38
+ ensureFormStartedAt(form);
39
+ }
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { isFormCsrfFieldName } from '#utils/csrf';
2
+ import { dispatchFormieDomEvent } from '#core/dom-events';
2
3
 
3
4
  const DIRTY_TRACKING_IGNORED_FIELD_NAMES = new Set([
4
5
  'action',
@@ -14,6 +15,37 @@ const DIRTY_TRACKING_IGNORED_FIELD_NAMES = new Set([
14
15
  'progressId',
15
16
  ]);
16
17
 
18
+ type FormSnapshot = Array<[string, FormDataEntryValue]>;
19
+
20
+ export type FormUnloadWarningExclusionsEventDetail = {
21
+ readonly form: HTMLFormElement;
22
+ readonly exclude: (...fieldNames: string[]) => void;
23
+ };
24
+
25
+ function collectExcludedFieldNames(form: HTMLFormElement): Set<string> {
26
+ const excludedFieldNames = new Set<string>();
27
+ let collecting = true;
28
+
29
+ dispatchFormieDomEvent(form, 'formie:unload-warning:collect-exclusions', {
30
+ form,
31
+ exclude: (...fieldNames: string[]) => {
32
+ // Exclusions belong to this synchronous comparison, not a later check.
33
+ if (!collecting) {
34
+ return;
35
+ }
36
+
37
+ fieldNames.forEach((name) => {
38
+ if (typeof name === 'string' && name !== '') {
39
+ excludedFieldNames.add(name);
40
+ }
41
+ });
42
+ },
43
+ } satisfies FormUnloadWarningExclusionsEventDetail);
44
+ collecting = false;
45
+
46
+ return excludedFieldNames;
47
+ }
48
+
17
49
  function serializeStableValue(value: unknown, seen: WeakSet<object>): string {
18
50
  if (value == null) {
19
51
  return String(value);
@@ -82,9 +114,16 @@ function shouldTrackFieldName(name: string, form: HTMLFormElement): boolean {
82
114
  return !DIRTY_TRACKING_IGNORED_FIELD_NAMES.has(normalizedName);
83
115
  }
84
116
 
85
- function buildTrackedSnapshot(form: HTMLFormElement): string {
86
- const entries = Array.from(new FormData(form).entries()).filter(([name]) => {
87
- return shouldTrackFieldName(String(name || ''), form);
117
+ function buildTrackedSnapshot(
118
+ snapshot: FormSnapshot,
119
+ form: HTMLFormElement,
120
+ excludedFieldNames: Set<string>,
121
+ ): string {
122
+ const entries = snapshot.filter(([name]) => {
123
+ return (
124
+ !excludedFieldNames.has(name) &&
125
+ shouldTrackFieldName(String(name || ''), form)
126
+ );
88
127
  });
89
128
 
90
129
  return stableSerialize(entries);
@@ -103,8 +142,9 @@ export function createFormUnloadWarningGuard(
103
142
  shouldWarn?: () => boolean;
104
143
  } = {},
105
144
  ): FormUnloadWarningGuard {
106
- let baselineSnapshot: string | null = null;
145
+ let baselineSnapshot: FormSnapshot = [];
107
146
  let isReady = false;
147
+ let destroyed = false;
108
148
  let isDirty = false;
109
149
  let animationFrameId: number | null = null;
110
150
  let dirtyTimerId: number | null = null;
@@ -132,18 +172,31 @@ export function createFormUnloadWarningGuard(
132
172
  return false;
133
173
  }
134
174
 
135
- isDirty = buildTrackedSnapshot(form) !== baselineSnapshot;
175
+ // Apply the same current exclusions to both sides. A late registration
176
+ // must neither create a false edit nor reset genuine visitor changes.
177
+ const excludedFieldNames = collectExcludedFieldNames(form);
178
+ const currentSnapshot = Array.from(new FormData(form).entries());
179
+ isDirty =
180
+ buildTrackedSnapshot(currentSnapshot, form, excludedFieldNames) !==
181
+ buildTrackedSnapshot(baselineSnapshot, form, excludedFieldNames);
136
182
 
137
183
  return isDirty;
138
184
  };
139
185
 
140
186
  const captureBaseline = (): void => {
141
- baselineSnapshot = buildTrackedSnapshot(form);
187
+ if (destroyed) {
188
+ return;
189
+ }
190
+ clearScheduledWork();
191
+ baselineSnapshot = Array.from(new FormData(form).entries());
142
192
  isReady = true;
143
193
  isDirty = false;
144
194
  };
145
195
 
146
196
  const scheduleBaselineCapture = (): void => {
197
+ if (destroyed) {
198
+ return;
199
+ }
147
200
  clearScheduledWork();
148
201
  isReady = false;
149
202
 
@@ -183,13 +236,16 @@ export function createFormUnloadWarningGuard(
183
236
  form.addEventListener('input', scheduleDirtyRefresh);
184
237
  form.addEventListener('change', scheduleDirtyRefresh);
185
238
  window.addEventListener('beforeunload', handleBeforeUnload);
186
- scheduleBaselineCapture();
239
+ captureBaseline();
187
240
 
188
241
  return {
189
242
  captureBaseline,
190
243
  scheduleBaselineCapture,
191
244
  refreshDirtyState,
192
245
  destroy: () => {
246
+ destroyed = true;
247
+ isReady = false;
248
+ baselineSnapshot = [];
193
249
  clearScheduledWork();
194
250
  form.removeEventListener('input', scheduleDirtyRefresh);
195
251
  form.removeEventListener('change', scheduleDirtyRefresh);
@@ -7,6 +7,24 @@ const required: ValidationRuleDefinition = {
7
7
  return true;
8
8
  }
9
9
 
10
+ // Required tables need rows, not a value in every cell. Cell values are
11
+ // optional, including unchecked checkboxes and blank email addresses.
12
+ if (input.closest('[data-formie-field-handle]')?.getAttribute('data-formie-field-type') === 'table') {
13
+ return true;
14
+ }
15
+
16
+ if (input instanceof HTMLInputElement && input.type === 'file') {
17
+ if (input.files?.length) {
18
+ return true;
19
+ }
20
+
21
+ const field = input.closest('[data-formie-field-handle]');
22
+ const name = input.name.replace(/\[\]$/, '');
23
+
24
+ return Array.from(field?.querySelectorAll<HTMLInputElement>('input[type="hidden"][data-formie-file-upload-asset-id]') || [])
25
+ .some((asset) => !asset.disabled && asset.name.startsWith(`${name}[`) && Number(asset.getAttribute('data-formie-file-upload-asset-id')) > 0);
26
+ }
27
+
10
28
  if (input.type === 'checkbox' || input.type === 'radio') {
11
29
  const checkboxInputs = input.form?.querySelectorAll(`[name="${input.name}"]:not([type="hidden"]):not([disabled])`) || [];
12
30
 
@@ -33,8 +33,10 @@ export function syncSubmitReadiness(form: HTMLFormElement, validator: FormieVali
33
33
  }
34
34
 
35
35
  const { scope, final } = getValidationScope(form);
36
+ // Readiness observes DOM changes; redrawing errors here would retrigger it indefinitely.
36
37
  const isReady = validator.isValid(scope, {
37
38
  includeHiddenPages: final,
39
+ renderErrors: false,
38
40
  });
39
41
 
40
42
  form.setAttribute(SUBMIT_READY_ATTR, isReady ? 'true' : 'false');
@@ -25,6 +25,7 @@ type ValidatorDefinition = {
25
25
 
26
26
  type ValidateOptions = {
27
27
  includeHiddenPages?: boolean;
28
+ renderErrors?: boolean;
28
29
  };
29
30
 
30
31
  const DEFAULT_PATTERNS: Record<string, RegExp> = {
@@ -40,11 +41,13 @@ const DEFAULT_PATTERNS: Record<string, RegExp> = {
40
41
  const debug = createDebug('general', 'validator');
41
42
 
42
43
  function isValidationInput(node: Element | null): node is ValidationInput {
44
+ // Enhancement libraries inject unnamed search controls that do not submit
45
+ // field values and must not inherit the field's validation rules.
43
46
  return !!node && (
44
47
  node instanceof HTMLInputElement ||
45
48
  node instanceof HTMLSelectElement ||
46
49
  node instanceof HTMLTextAreaElement
47
- );
50
+ ) && !!node.name;
48
51
  }
49
52
 
50
53
  function isPainted(element: HTMLElement): boolean {
@@ -161,6 +164,7 @@ export class FormieValidator {
161
164
 
162
165
  this.inputs(inputOrSelector).forEach((input) => {
163
166
  let errorShown = false;
167
+ const renderErrors = options.renderErrors !== false && this.shouldShowError(input);
164
168
 
165
169
  if (!this.isVisible(input, options)) {
166
170
  return;
@@ -179,7 +183,7 @@ export class FormieValidator {
179
183
  seenGroups.add(groupKey);
180
184
  }
181
185
 
182
- if (this.shouldShowError(input)) {
186
+ if (renderErrors) {
183
187
  this.removeError(input);
184
188
  }
185
189
 
@@ -191,7 +195,7 @@ export class FormieValidator {
191
195
  if (!isValid) {
192
196
  const errorMessage = this.getErrorMessage(input, validatorName, validatorConfig, opts);
193
197
 
194
- if (this.shouldShowError(input) && !errorShown) {
198
+ if (renderErrors && !errorShown) {
195
199
  this.showError(input, validatorName, errorMessage);
196
200
  }
197
201
 
@@ -208,7 +212,7 @@ export class FormieValidator {
208
212
  }
209
213
  });
210
214
 
211
- if (!errorShown && this.shouldShowError(input)) {
215
+ if (!errorShown && renderErrors) {
212
216
  this.removeError(input);
213
217
  }
214
218
  });
@@ -298,8 +302,10 @@ export class FormieValidator {
298
302
  errorMessages.innerHTML = '';
299
303
 
300
304
  const handle = fieldContainer.getAttribute('data-formie-field-handle') || 'field';
301
- const errorId = `${handle}-error`;
302
- errorMessages.id = errorMessages.id || `${handle}-errors`;
305
+ errorMessages.id = errorMessages.id || `${input.id || `${this.form.id}-${handle}`}-errors`;
306
+ // Use the rendered field's scoped region so matching handles in other
307
+ // forms cannot capture this control's accessible error reference.
308
+ const errorId = `${errorMessages.id}-1`;
303
309
  applyErrorAriaLive(
304
310
  errorMessages,
305
311
  resolveValidationErrorAriaLive(this.config.errorAriaLive, this.submitted),
@@ -485,6 +491,23 @@ export class FormieValidator {
485
491
  return isPainted(richTextHost);
486
492
  }
487
493
 
494
+ // Searchable selects keep their value in a hidden native control. Its
495
+ // adjacent combobox determines whether that value is active for validation.
496
+ if (element instanceof HTMLSelectElement && element.hasAttribute('data-formie-combobox-input')) {
497
+ const combobox = element.nextElementSibling;
498
+
499
+ if (combobox instanceof HTMLElement && combobox.classList.contains('formie-combobox')) {
500
+ return isPainted(combobox);
501
+ }
502
+ }
503
+
504
+ // Third-party select enhancements hide the native value control without
505
+ // Formie's combobox marker. The containing field still determines whether
506
+ // its selected values need validation, including an empty required value.
507
+ if (element instanceof HTMLSelectElement && field instanceof HTMLElement) {
508
+ return isPainted(element) || isPainted(field);
509
+ }
510
+
488
511
  return isPainted(element);
489
512
  }
490
513
 
package/src/vendor.d.ts CHANGED
@@ -58,7 +58,9 @@ declare module 'flatpickr/dist/l10n/index.js' {
58
58
 
59
59
  declare module 'intl-tel-input' {
60
60
  type IntlTelInputInstance = {
61
+ readonly promise: Promise<void>;
61
62
  setCountry(country: string): void;
63
+ setNumber(number: string): void;
62
64
  isValidNumber(): boolean;
63
65
  getSelectedCountryData(): { iso2?: string } | null;
64
66
  getValidationError(): number;