@verbb/formie-browser 1.0.15 → 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 (135) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/chunks/{address-country-BcEQKw0T.js → address-country-DLJF-OkC.js} +4 -8
  3. package/dist/chunks/{address-finder-BWGINCSA.js → address-finder-QK1m1eyv.js} +1 -1
  4. package/dist/chunks/{address-state-DI3ndhMU.js → address-state-CHKit8AW.js} +26 -35
  5. package/dist/chunks/{api-BK9b340y.js → api-BPtH5RBA.js} +152 -120
  6. package/dist/chunks/{api-Dxl_CKw-.js → api-BR2Gqy37.js} +306 -301
  7. package/dist/chunks/{api-ghNr2uxX.js → api-CmuOZ0i3.js} +1 -1
  8. package/dist/chunks/{bpoint-BwtuyN_O.js → bpoint-GvYfyPjD.js} +1 -1
  9. package/dist/chunks/{calculations-YEoVCG2q.js → calculations-DeEmdUDq.js} +5 -7
  10. package/dist/chunks/{captcha-eu-DWLoQFKB.js → captcha-eu-eU_M6snp.js} +1 -1
  11. package/dist/chunks/{checkbox-radio-gsm8QPJg.js → checkbox-radio-CSXo6svm.js} +4 -12
  12. package/dist/chunks/{combobox-Ba08_cHL.js → combobox-CJh2DoSO.js} +292 -333
  13. package/dist/chunks/{conditions-Ceeg6ReZ.js → conditions-Dz3068K0.js} +56 -72
  14. package/dist/chunks/{country-from-ip-DDj_mFki.js → country-from-ip-BqQ-BqLH.js} +1 -1
  15. package/dist/chunks/{custom-google-maps-C4l2S3GO.js → custom-google-maps-rNm5VFbb.js} +2 -6
  16. package/dist/chunks/{custom-link-CLazgKYH.js → custom-link-CLqFCssQ.js} +1 -1
  17. package/dist/chunks/{custom-maps-DqcAlD0v.js → custom-maps-DgjYQDLj.js} +4 -8
  18. package/dist/chunks/{date-picker-DaPvMrop.js → date-picker-BVJxJVYT.js} +30 -47
  19. package/dist/chunks/{debug-BV0DvdHx.js → debug-rhzqP1j2.js} +2 -14
  20. package/dist/chunks/{dist-ZLeW0zZ3.js → dist-CJReDlDK.js} +13 -8
  21. package/dist/chunks/{eway-uw0DzU-L.js → eway-BZuTBAUW.js} +1 -1
  22. package/dist/chunks/{field-references.resolver-Ca8MguFv.js → field-references.resolver-uSHf5-NP.js} +1 -1
  23. package/dist/chunks/{file-upload-B8_mH012.js → file-upload-vEUr_EuP.js} +114 -108
  24. package/dist/chunks/{friendly-captcha-v1-CVXaXgDO.js → friendly-captcha-v1-C_cF1WD2.js} +2 -2
  25. package/dist/chunks/{friendly-captcha-v2-DkeIVTKI.js → friendly-captcha-v2-BenESH-n.js} +2 -2
  26. package/dist/chunks/{friendly-challenge-DPG9MJb1.js → friendly-challenge-BgPBEMXx.js} +21 -35
  27. package/dist/chunks/{go-cardless-c4wLJdOt.js → go-cardless-DWZeB0Bq.js} +2 -6
  28. package/dist/chunks/{google-address-JHYvjwgN.js → google-address-DQR6rsbh.js} +2 -2
  29. package/dist/chunks/{hcaptcha-Dt3ELLlP.js → hcaptcha-COilLmRT.js} +1 -1
  30. package/dist/chunks/{hidden-BZhXnNPe.js → hidden-Bc2TpcgF.js} +1 -1
  31. package/dist/chunks/{http-C2QdHuWO.js → http-Dj_skqaa.js} +1 -1
  32. package/dist/chunks/{leaflet-src-DE6rv6M-.js → leaflet-src-Cg-LUOD8.js} +74 -122
  33. package/dist/chunks/{loqate-m51iPyO1.js → loqate-ZZClcacr.js} +1 -1
  34. package/dist/chunks/{mollie-Bn0PX173.js → mollie-8OMXIYa8.js} +2 -6
  35. package/dist/chunks/{moneris-BRbUvI4t.js → moneris-qYHJ9EFy.js} +1 -1
  36. package/dist/chunks/opayo-DlWPEnla.js +159 -0
  37. package/dist/chunks/{paddle-CympHVvd.js → paddle-CArfWih0.js} +1 -1
  38. package/dist/chunks/{password-validation-C8_uO_c_.js → password-validation-HGOU9kf3.js} +2 -2
  39. package/dist/chunks/{paypal-h75h6wae.js → paypal-BcaMCMGY.js} +1 -1
  40. package/dist/chunks/{payway-C9rHTCC_.js → payway-vxC7_bfR.js} +3 -11
  41. package/dist/chunks/{phone-country-B8fv-L5_.js → phone-country-Bx9EG50h.js} +123 -140
  42. package/dist/chunks/{place-kit-BnZGQ-L1.js → place-kit-BKk8xDXf.js} +2 -2
  43. package/dist/chunks/{placekit-autocomplete.esm-CZZERYur.js → placekit-autocomplete.esm-D2A_0iVU.js} +2 -5
  44. package/dist/chunks/{recaptcha-enterprise-CUs8BO6V.js → recaptcha-enterprise-Cx6HUpMa.js} +6 -12
  45. package/dist/chunks/recaptcha-shared-CcMI-r8t.js +33 -0
  46. package/dist/chunks/{recaptcha-v2-checkbox-P1McW4Pv.js → recaptcha-v2-checkbox-DQ9BJzcv.js} +2 -2
  47. package/dist/chunks/{recaptcha-v2-invisible-FGDqozSP.js → recaptcha-v2-invisible-BP0T8Pan.js} +2 -2
  48. package/dist/chunks/recaptcha-v3-DKMGQ27K.js +38 -0
  49. package/dist/chunks/{repeater-CyhDbozO.js → repeater-BR0RUvTs.js} +3 -7
  50. package/dist/chunks/{rich-text-uV4o_pGk.js → rich-text-H2A6cm1t.js} +33 -11
  51. package/dist/chunks/{rolldown-runtime-DtPi1Y-2.js → rolldown-runtime-VHNaYcLd.js} +1 -1
  52. package/dist/chunks/{sdk-lkwSvlpM.js → sdk-_cmrRYgp.js} +4 -16
  53. package/dist/chunks/{shared-OKhSay59.js → shared-CJ1LIEh3.js} +1 -3
  54. package/dist/chunks/{signature-7cldFbqj.js → signature-CfKA_poI.js} +9 -41
  55. package/dist/chunks/{snaptcha-C3QLYxFT.js → snaptcha-P3EoSQud.js} +1 -1
  56. package/dist/chunks/{square-C1z5DPVw.js → square-Dyz0p_GU.js} +1 -1
  57. package/dist/chunks/{stripe-Dmkdkq1y.js → stripe-Bfd_jp_k.js} +3 -6
  58. package/dist/chunks/{summary-Bn-g5SDx.js → summary-B3CAebLV.js} +44 -36
  59. package/dist/chunks/{survey-likert-fk1hUPff.js → survey-likert-CEtUceag.js} +1 -1
  60. package/dist/chunks/{survey-rank-BsLGl2LX.js → survey-rank-Dtt97y6s.js} +3 -7
  61. package/dist/chunks/{survey-rating-DVS6e6fM.js → survey-rating-mlDMI0uq.js} +9 -23
  62. package/dist/chunks/{table-j7MlLLCc.js → table-0k6Md0IO.js} +1 -1
  63. package/dist/chunks/{text-limit-LGMPhFuC.js → text-limit-CTVML6s7.js} +10 -10
  64. package/dist/chunks/{turnstile-ptihQfLl.js → turnstile-Sp4ubowK.js} +1 -1
  65. package/dist/chunks/{upload-manager-BW7l-BhF.js → upload-manager-Ccg3d_4I.js} +11 -25
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +752 -756
  69. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  70. package/dist/js/core/dom-events.d.ts +5 -0
  71. package/dist/js/core/dom-events.d.ts.map +1 -1
  72. package/dist/js/core/page-client-event.d.ts.map +1 -1
  73. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  74. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  75. package/dist/js/core/validation-focus.d.ts.map +1 -1
  76. package/dist/js/modules/captchas/factories.d.ts.map +1 -1
  77. package/dist/js/modules/captchas/host.d.ts +4 -1
  78. package/dist/js/modules/captchas/host.d.ts.map +1 -1
  79. package/dist/js/modules/captchas/recaptcha-shared.d.ts +1 -1
  80. package/dist/js/modules/captchas/recaptcha-shared.d.ts.map +1 -1
  81. package/dist/js/modules/captchas/recaptcha-v3.d.ts.map +1 -1
  82. package/dist/js/modules/fields/address-state.d.ts.map +1 -1
  83. package/dist/js/modules/fields/combobox.d.ts +2 -0
  84. package/dist/js/modules/fields/combobox.d.ts.map +1 -1
  85. package/dist/js/modules/fields/conditions.d.ts.map +1 -1
  86. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  87. package/dist/js/modules/fields/phone-country.d.ts.map +1 -1
  88. package/dist/js/modules/fields/rich-text.d.ts.map +1 -1
  89. package/dist/js/modules/fields/summary.d.ts.map +1 -1
  90. package/dist/js/modules/fields/text-limit.d.ts.map +1 -1
  91. package/dist/js/modules/payments/factories.d.ts.map +1 -1
  92. package/dist/js/modules/payments/opayo.d.ts.map +1 -1
  93. package/dist/js/submit/pipeline.d.ts +2 -0
  94. package/dist/js/submit/pipeline.d.ts.map +1 -1
  95. package/dist/js/utils/form-started-at.d.ts +1 -0
  96. package/dist/js/utils/form-started-at.d.ts.map +1 -1
  97. package/dist/js/utils/unload-warning.d.ts +4 -0
  98. package/dist/js/utils/unload-warning.d.ts.map +1 -1
  99. package/dist/js/validation/rules/required.d.ts.map +1 -1
  100. package/dist/js/validation/submit-readiness.d.ts.map +1 -1
  101. package/dist/js/validation/validator.d.ts +1 -0
  102. package/dist/js/validation/validator.d.ts.map +1 -1
  103. package/package.json +3 -3
  104. package/src/index.ts +1 -0
  105. package/src/js/core/create-formie-client.ts +20 -18
  106. package/src/js/core/dom-events.ts +30 -1
  107. package/src/js/core/page-client-event.ts +5 -7
  108. package/src/js/core/submit-result-state.ts +13 -2
  109. package/src/js/core/submit-result-ui.ts +21 -0
  110. package/src/js/core/validation-focus.ts +17 -3
  111. package/src/js/modules/captchas/factories.ts +241 -101
  112. package/src/js/modules/captchas/host.ts +16 -0
  113. package/src/js/modules/captchas/recaptcha-enterprise.ts +1 -1
  114. package/src/js/modules/captchas/recaptcha-shared.ts +23 -5
  115. package/src/js/modules/captchas/recaptcha-v3.ts +23 -14
  116. package/src/js/modules/fields/address-state.ts +6 -4
  117. package/src/js/modules/fields/combobox.ts +19 -1
  118. package/src/js/modules/fields/conditions.ts +3 -0
  119. package/src/js/modules/fields/file-upload.ts +11 -4
  120. package/src/js/modules/fields/phone-country.ts +23 -0
  121. package/src/js/modules/fields/rich-text.ts +33 -0
  122. package/src/js/modules/fields/summary.ts +63 -10
  123. package/src/js/modules/fields/text-limit.ts +11 -0
  124. package/src/js/modules/payments/factories.ts +87 -54
  125. package/src/js/modules/payments/opayo.ts +7 -0
  126. package/src/js/submit/pipeline.ts +16 -9
  127. package/src/js/utils/form-started-at.ts +19 -4
  128. package/src/js/utils/unload-warning.ts +63 -7
  129. package/src/js/validation/rules/required.ts +6 -0
  130. package/src/js/validation/submit-readiness.ts +2 -0
  131. package/src/js/validation/validator.ts +15 -4
  132. package/src/vendor.d.ts +2 -0
  133. package/dist/chunks/opayo-BwWdInEP.js +0 -188
  134. package/dist/chunks/recaptcha-shared-DTbTw-MF.js +0 -27
  135. package/dist/chunks/recaptcha-v3-CflUHeZD.js +0 -28
@@ -17,21 +17,25 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
17
17
  defaultPlaceholderSelector: '[data-recaptcha-placeholder]',
18
18
  defaultTokenFieldNames: ['g-recaptcha-response'],
19
19
  load: ({ options }) => {
20
- // Score-based flows use a site-key `render=` mode instead of an
21
- // explicit widget render, so the loader needs the site key up front.
22
- return loadRecaptchaGlobal(options.provider, false, options.provider.siteKey || undefined);
20
+ return loadRecaptchaGlobal(options.provider);
23
21
  },
24
- mount: ({ api, provider }) => {
25
- return new Promise((resolve) => {
26
- api.ready(() => {
27
- // There is no real widget id for v3. We store the site key as
28
- // the "widget" value purely so the managed factory can treat
29
- // this provider like every other captcha module instance.
30
- resolve(provider.siteKey || 'recaptcha-v3');
31
- });
22
+ mount: ({ api, container, provider, services }) => {
23
+ return new Promise((resolve, reject) => {
24
+ // An existing api.js may have loaded without render=<our site key>.
25
+ // Register this form explicitly, then execute its returned client ID.
26
+ void whenRecaptchaReady(api, async() => {
27
+ resolve(api.render(container, {
28
+ sitekey: provider.siteKey || '',
29
+ size: 'invisible',
30
+ badge: provider.badge || 'bottomright',
31
+ theme: provider.theme || 'light',
32
+ 'expired-callback': () => services.tokens.clear(),
33
+ 'error-callback': () => services.tokens.clear(),
34
+ }));
35
+ }).catch(reject);
32
36
  });
33
37
  },
34
- challenge: async({ api, provider, placeholder, services, stageCtx }) => {
38
+ challenge: async({ api, widget, provider, placeholder, services, stageCtx }) => {
35
39
  // If a token already exists we can skip execution, which matters for
36
40
  // re-renders or multi-step flows where the same challenge was already
37
41
  // completed very recently.
@@ -42,7 +46,7 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
42
46
  // v3 returns its token directly from `execute()`, unlike widget-based
43
47
  // captchas that signal completion later via callbacks.
44
48
  await whenRecaptchaReady(api, async() => {
45
- const token = await api.execute(provider.siteKey || '', { action: provider.action || 'submit' });
49
+ const token = await api.execute(widget, { action: provider.action || 'submit' });
46
50
 
47
51
  if (typeof token === 'string' && token.trim() !== '') {
48
52
  services.tokens.write(token.trim());
@@ -59,7 +63,12 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
59
63
  stageCtx.abort(message);
60
64
  }
61
65
  },
62
- unmount: ({ services }) => {
66
+ reset: ({ api, widget, services }) => {
67
+ api.reset(widget);
68
+ services.tokens.clear();
69
+ },
70
+ unmount: ({ api, widget, services }) => {
71
+ api.reset(widget);
63
72
  services.tokens.clear();
64
73
  },
65
74
  });
@@ -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';
@@ -569,22 +570,23 @@ function updateFieldLabel(stateField: HTMLElement, label: string): void {
569
570
  function initSearchableSelect(
570
571
  select: HTMLSelectElement,
571
572
  placeholder: string | null,
573
+ maxOptions: number | null | undefined,
572
574
  ): () => void {
573
575
  select.setAttribute('data-formie-combobox-input', 'true');
574
576
 
575
577
  dispatchFieldEvent(select, 'combobox', 'before-init', {
576
578
  select,
577
- options: { placeholder },
579
+ options: { placeholder, maxOptions },
578
580
  });
579
581
 
580
582
  const cleanup = initFormieCombobox(
581
583
  select as Parameters<typeof initFormieCombobox>[0],
582
- { placeholder },
584
+ { placeholder, maxOptions },
583
585
  );
584
586
 
585
587
  dispatchFieldEvent(select, 'combobox', 'after-init', {
586
588
  combobox: (select as Parameters<typeof initFormieCombobox>[0])._formieTomSelect,
587
- options: { placeholder },
589
+ options: { placeholder, maxOptions },
588
590
  });
589
591
 
590
592
  return cleanup;
@@ -694,7 +696,7 @@ async function applyCountryState(
694
696
  }
695
697
 
696
698
  if (useSearchable) {
697
- fieldState.comboboxCleanup = initSearchableSelect(select, placeholder);
699
+ fieldState.comboboxCleanup = initSearchableSelect(select, placeholder, options.searchableOptionLimit);
698
700
  } else {
699
701
  select.removeAttribute('data-formie-combobox-input');
700
702
  }
@@ -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 { t } from '#utils/i18n';
9
10
  import { VALIDATION_SKIP_ATTR } from '#validation/skip';
10
11
 
11
12
  const SELECT_SELECTOR = 'select[data-formie-combobox-input]';
@@ -21,6 +22,8 @@ const NATIVE_THEME_CLASSES = [
21
22
 
22
23
  export type FormieComboboxOptions = {
23
24
  multiple?: boolean;
25
+ maxOptions?: number | null;
26
+ allowClear?: boolean;
24
27
  placeholder?: string | null;
25
28
  };
26
29
 
@@ -87,8 +90,23 @@ export function initFormieCombobox(select: SelectElement, options: FormieCombobo
87
90
 
88
91
  const mergedOptions: Record<string, unknown> = {
89
92
  create: false,
93
+ // Limit rendered results while retaining the full option list for searching.
94
+ maxOptions: options.maxOptions === undefined ? 100 : options.maxOptions,
90
95
  maxItems: multiple ? null : 1,
91
- 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
+ } : [],
92
110
  hideSelected: multiple ? true : null,
93
111
  clearAfterSelect: multiple,
94
112
  closeAfterSelect: !multiple,
@@ -300,6 +300,9 @@ export const conditionsModule: BrowserModuleDefinition = {
300
300
  });
301
301
 
302
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();
303
306
 
304
307
  await ctx.emit('formie:module:conditions:init', {
305
308
  count: entries.length,
@@ -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
 
@@ -429,11 +430,15 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
429
430
  body.append('fieldHandle', fieldHandle);
430
431
  }
431
432
 
432
- const submissionUidInput = form?.querySelector('input[name="submissionUid"]');
433
+ const passthroughNames = ['submissionUid', 'resumeToken', 'submissionEditToken', 'siteId', 'renderId', 'draftContext', 'draftContextToken'] as const;
433
434
 
434
- if (submissionUidInput instanceof HTMLInputElement && submissionUidInput.value.trim()) {
435
- body.append('submissionUid', submissionUidInput.value.trim());
436
- }
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
+ });
437
442
 
438
443
  assets.forEach((asset) => {
439
444
  if (asset.assetId) {
@@ -444,6 +449,8 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
444
449
  }
445
450
  });
446
451
 
452
+ appendFormCsrfToFormData(body, form);
453
+
447
454
  return body;
448
455
  }
449
456
 
@@ -164,6 +164,27 @@ function initPhoneField(phoneInput: PhoneInput, countryInput: HTMLInputElement,
164
164
  phoneInput.addEventListener('blur', syncCountry);
165
165
  syncCountry();
166
166
 
167
+ // Hidden input values also change their native reset default, so retain the initial country.
168
+ let defaultCountry = countryInput.value;
169
+ let destroyed = false;
170
+ void validator.promise.then(() => {
171
+ if (!destroyed) {
172
+ defaultCountry ||= validator.getSelectedCountryData()?.iso2?.toUpperCase() || '';
173
+ }
174
+ }, () => {});
175
+
176
+ const form = phoneInput.form;
177
+ const onReset = (event: Event) => {
178
+ queueMicrotask(() => {
179
+ if (!destroyed && !event.defaultPrevented && defaultCountry) {
180
+ validator.setCountry(defaultCountry.toLowerCase() as IntlCountryCode);
181
+ validator.setNumber(phoneInput.defaultValue);
182
+ syncCountry();
183
+ }
184
+ });
185
+ };
186
+ form?.addEventListener('reset', onReset);
187
+
167
188
  // Keep the country dropdown button in sync when the tel input is enabled/disabled after
168
189
  // init (multi-page page-hidden, or conditions). intl-tel-input disables the button to match
169
190
  // a disabled input at init, but won't re-enable it when `disabled` is later removed.
@@ -199,6 +220,8 @@ function initPhoneField(phoneInput: PhoneInput, countryInput: HTMLInputElement,
199
220
  });
200
221
 
201
222
  return () => {
223
+ destroyed = true;
224
+ form?.removeEventListener('reset', onReset);
202
225
  phoneInput.removeEventListener('countrychange', syncCountry);
203
226
  phoneInput.removeEventListener('blur', syncCountry);
204
227
  countryButtonObserver.disconnect();
@@ -168,6 +168,37 @@ function initRichTextField(container: HTMLElement, input: RichTextInput, options
168
168
  input.richText = editor;
169
169
  editor.content.innerHTML = input.value || '';
170
170
 
171
+ editor.content.setAttribute('role', 'textbox');
172
+ editor.content.setAttribute('aria-multiline', 'true');
173
+
174
+ const labels = Array.from(input.labels || []);
175
+ const ariaAttributes = ['aria-label', 'aria-labelledby', 'aria-invalid', 'aria-describedby', 'aria-errormessage'];
176
+ const syncAccessibility = () => {
177
+ ariaAttributes.forEach((attribute) => {
178
+ const value = input.getAttribute(attribute);
179
+
180
+ if (value !== null) {
181
+ editor.content.setAttribute(attribute, value);
182
+ } else {
183
+ editor.content.removeAttribute(attribute);
184
+ }
185
+ });
186
+
187
+ if (!input.hasAttribute('aria-label') && !input.hasAttribute('aria-labelledby') && labels.length) {
188
+ editor.content.setAttribute('aria-label', labels.map((label) => label.textContent?.trim() || '').join(' '));
189
+ }
190
+
191
+ editor.content.setAttribute('aria-required', input.getAttribute('aria-required') || String(input.required));
192
+ };
193
+ syncAccessibility();
194
+
195
+ // Validation updates the submitted textarea; expose the same state on the editor.
196
+ const accessibilityObserver = new MutationObserver(syncAccessibility);
197
+ accessibilityObserver.observe(input, { attributes: true, attributeFilter: [...ariaAttributes, 'aria-required', 'required'] });
198
+
199
+ const onLabelClick = () => editor.content.focus();
200
+ labels.forEach((label) => label.addEventListener('click', onLabelClick));
201
+
171
202
  const form = input.form;
172
203
  const onReset = (event: Event) => {
173
204
  // Wait for the textarea to regain its default value before updating Pell.
@@ -188,6 +219,8 @@ function initRichTextField(container: HTMLElement, input: RichTextInput, options
188
219
  });
189
220
 
190
221
  return () => {
222
+ accessibilityObserver.disconnect();
223
+ labels.forEach((label) => label.removeEventListener('click', onLabelClick));
191
224
  form?.removeEventListener('reset', onReset);
192
225
  container.innerHTML = '';
193
226
  delete input.richText;
@@ -1,6 +1,7 @@
1
1
  import summaryCss from '#theme-css/fields/_summary.css?inline';
2
2
 
3
3
  import type { BrowserModuleDefinition } from '#contracts/modules';
4
+ import type { FormSubmitResult } from '#contracts/schema';
4
5
  import { dispatchFieldEvent, getModuleFieldContainers } from '#modules/fields/shared';
5
6
  import { ensureModuleStyles } from '#modules/styles';
6
7
  import { toggleThemeClasses } from '#theme/theme-classes';
@@ -28,8 +29,10 @@ function getSummaryRequestUrl(): string {
28
29
  }
29
30
 
30
31
  function getSummaryRequestState(field: HTMLElement): SummaryRequestState {
31
- const summaryTokenInput = field.querySelector('[data-formie-summary-token]') as HTMLInputElement | null;
32
- const accessToken = summaryTokenInput?.value?.trim() || null;
32
+ const tokenElement = field.querySelector('[data-formie-summary-token]');
33
+ const accessToken = (tokenElement instanceof HTMLInputElement
34
+ ? tokenElement.value
35
+ : tokenElement?.getAttribute('data-formie-summary-token'))?.trim() || null;
33
36
 
34
37
  return {
35
38
  accessToken,
@@ -71,6 +74,9 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
71
74
  let dirtyVersion = 0;
72
75
  let requestVersion = 0;
73
76
  let activeRequest: AbortController | null = null;
77
+ let destroyed = false;
78
+ const initialState = getSummaryRequestState(field);
79
+ let accessToken = initialState.accessToken;
74
80
 
75
81
  const getBlocks = (): HTMLElement | null => {
76
82
  const blocks = field.querySelector(BLOCKS_SELECTOR);
@@ -101,11 +107,10 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
101
107
  toggleThemeClasses(blocks, form, 'loading', false);
102
108
  };
103
109
 
104
- const initialState = getSummaryRequestState(field);
105
110
  setLoadingState(!!initialState.accessToken);
106
111
 
107
112
  const queueFetch = (): void => {
108
- if (!isVisible || (hasFetched && !isDirty)) {
113
+ if (destroyed || !isVisible || (hasFetched && !isDirty)) {
109
114
  return;
110
115
  }
111
116
 
@@ -114,7 +119,11 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
114
119
  };
115
120
 
116
121
  const fetchSummary = debounce(async() => {
117
- const state = getSummaryRequestState(field);
122
+ if (destroyed) {
123
+ return;
124
+ }
125
+
126
+ const state = { accessToken };
118
127
 
119
128
  if (!getBlocks() || !state.accessToken) {
120
129
  debug.warn('Missing state for fetch.', state);
@@ -170,7 +179,9 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
170
179
  setLoadingState(false);
171
180
  activeRequest = null;
172
181
 
173
- if (isDirty) {
182
+ // Retry only when newer input arrived during this request. A
183
+ // failed request alone must not create an endless retry loop.
184
+ if (isDirty && dirtyVersion !== requestDirtyVersion) {
174
185
  queueFetch();
175
186
  }
176
187
  }
@@ -194,18 +205,56 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
194
205
  queueFetch();
195
206
  };
196
207
 
197
- const handleSubmitResult = (): void => {
198
- isDirty = true;
199
- debug.log('Submit result received; refreshing.');
208
+ const handleSubmitResult = (event: Event): void => {
209
+ const result = (event as CustomEvent<FormSubmitResult>).detail;
210
+ const tokens = result?.meta?.summaryTokens;
211
+ const uid = field.dataset.formieFieldUid;
212
+
213
+ // AJAX navigation reuses the initial markup, which had no submission
214
+ // token. The accepted save now grants access to this summary field.
215
+ if (uid && tokens && typeof tokens === 'object') {
216
+ const token = (tokens as Record<string, unknown>)[uid];
217
+
218
+ if (typeof token === 'string' && token) {
219
+ accessToken = token;
220
+ // Page navigation can remount modules with updated configuration.
221
+ getContainer()?.setAttribute('data-formie-summary-token', token);
222
+ }
223
+ }
224
+
225
+ markDirty();
226
+ debug.log('Submit result received; refreshing.', { uid, hasToken: !!accessToken });
200
227
  queueFetch();
201
228
  };
202
229
 
203
230
  const handlePageNavigate = (): void => {
204
- isDirty = true;
231
+ markDirty();
205
232
  debug.log('Page navigation received; refreshing.');
206
233
  queueFetch();
207
234
  };
208
235
 
236
+ const handleReset = (event: Event): void => {
237
+ queueMicrotask(() => {
238
+ if (event.defaultPrevented || destroyed) {
239
+ return;
240
+ }
241
+
242
+ // A new submission must not display or fetch the previous visitor's
243
+ // completed values while its initial markup is reused.
244
+ requestVersion += 1;
245
+ activeRequest?.abort();
246
+ activeRequest = null;
247
+ accessToken = null;
248
+ getContainer()?.removeAttribute('data-formie-summary-token');
249
+ field.querySelectorAll('input[data-formie-summary-token]').forEach((input) => input.remove());
250
+ hasFetched = false;
251
+ getBlocks()?.replaceChildren();
252
+ setLoadingState(false);
253
+ markDirty();
254
+ queueFetch();
255
+ });
256
+ };
257
+
209
258
  // Defer the summary fetch until the field is near view. This avoids extra
210
259
  // requests for hidden pages or summaries the user never reaches.
211
260
  const observer = new IntersectionObserver((entries) => {
@@ -228,14 +277,18 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
228
277
  observer.observe(field);
229
278
  form.addEventListener('input', handleFieldMutation);
230
279
  form.addEventListener('change', handleFieldMutation);
280
+ form.addEventListener('reset', handleReset);
231
281
  root.addEventListener('formie:page:navigate:after', handlePageNavigate as EventListener);
232
282
  root.addEventListener('formie:submit:result', handleSubmitResult as EventListener);
233
283
 
234
284
  return () => {
285
+ destroyed = true;
286
+ requestVersion += 1;
235
287
  activeRequest?.abort();
236
288
  observer.disconnect();
237
289
  form.removeEventListener('input', handleFieldMutation);
238
290
  form.removeEventListener('change', handleFieldMutation);
291
+ form.removeEventListener('reset', handleReset);
239
292
  root.removeEventListener('formie:page:navigate:after', handlePageNavigate as EventListener);
240
293
  root.removeEventListener('formie:submit:result', handleSubmitResult as EventListener);
241
294
  debug.log('Field destroyed.');
@@ -323,12 +323,23 @@ export const textLimitModule: BrowserModuleDefinition = {
323
323
  };
324
324
  });
325
325
 
326
+ const onReset = (event: Event) => {
327
+ // Native reset restores input values after the reset event.
328
+ queueMicrotask(() => {
329
+ if (!event.defaultPrevented) {
330
+ counterInputs.forEach(updateCounter);
331
+ }
332
+ });
333
+ };
334
+ ctx.form?.addEventListener('reset', onReset);
335
+
326
336
  await ctx.emit('formie:module:text-limit:init', {
327
337
  count: inputs.length,
328
338
  });
329
339
 
330
340
  return {
331
341
  destroy: () => {
342
+ ctx.form?.removeEventListener('reset', onReset);
332
343
  unbinds.forEach((unbind) => {
333
344
  unbind();
334
345
  });
@@ -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.');