@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
@@ -18,7 +18,7 @@ ensureModuleStyles(MODULE_ID, [intlTelInputCss]);
18
18
 
19
19
  type PhoneCountryOptions = {
20
20
  countryDefaultValue?: string;
21
- countryAllowed?: string[];
21
+ allowedCountryCodes?: string[];
22
22
  countryPreselectFromIp?: boolean;
23
23
  countryFromIpAction?: string;
24
24
  language?: string;
@@ -93,7 +93,7 @@ function buildOptions(options: PhoneCountryOptions, form: HTMLFormElement | null
93
93
  loadUtils: () => import('intl-tel-input/utils'),
94
94
  };
95
95
 
96
- const allowedCountries = (options.countryAllowed || [])
96
+ const allowedCountries = (options.allowedCountryCodes || [])
97
97
  .map((item) => {
98
98
  return item.toLowerCase();
99
99
  })
@@ -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();
@@ -32,6 +32,18 @@ function getRowCount(field: HTMLElement): number {
32
32
  return field.querySelectorAll(ROW_SELECTOR).length;
33
33
  }
34
34
 
35
+ function focusRow(row: Element | null): boolean {
36
+ const control = Array.from(row?.querySelectorAll<HTMLElement>(
37
+ 'input:not([type="hidden"]), select, textarea, button, a[href], [role="button"], [role="combobox"], [role="textbox"], [contenteditable="true"]',
38
+ ) || []).find((element) => {
39
+ return element.tabIndex >= 0 && !element.matches(':disabled')
40
+ && !element.closest('[hidden], [inert]') && element.getClientRects().length > 0;
41
+ });
42
+
43
+ control?.focus();
44
+ return !!control;
45
+ }
46
+
35
47
  function syncAddButton(addButton: HTMLButtonElement | null, rowCount: number): void {
36
48
  if (!addButton) {
37
49
  return;
@@ -46,7 +58,7 @@ function syncAddButton(addButton: HTMLButtonElement | null, rowCount: number): v
46
58
  addButton.disabled = false;
47
59
  }
48
60
 
49
- function bindRepeaterField(field: HTMLElement): () => void {
61
+ function bindRepeaterField(field: HTMLElement, isCpEdit: boolean): () => void {
50
62
  const container = field.matches(CONTAINER_SELECTOR)
51
63
  ? field
52
64
  : field.querySelector(CONTAINER_SELECTOR);
@@ -57,6 +69,9 @@ function bindRepeaterField(field: HTMLElement): () => void {
57
69
  return () => {};
58
70
  }
59
71
 
72
+ const form = field.closest('form');
73
+ let disposed = false;
74
+ let generation = 0;
60
75
  const removeHandlers = new Map<HTMLElement, EventListener>();
61
76
  // Start after the highest existing row id so SSR rows and client-added rows
62
77
  // keep producing unique `__ROW__` replacements across one repeater instance.
@@ -84,8 +99,17 @@ function bindRepeaterField(field: HTMLElement): () => void {
84
99
  return;
85
100
  }
86
101
 
102
+ const restoreFocus = row.contains(document.activeElement)
103
+ && (!isCpEdit || (event instanceof MouseEvent && event.detail === 0));
104
+ const nextRow = row.nextElementSibling;
105
+ const previousRow = row.previousElementSibling;
87
106
  row.remove();
88
107
  syncAddButton(addButton instanceof HTMLButtonElement ? addButton : null, getRowCount(field));
108
+ // Removing the focused control otherwise sends keyboard users back
109
+ // to the document body. Keep them in the nearest surviving row.
110
+ if (restoreFocus && !focusRow(nextRow) && !focusRow(previousRow)) {
111
+ if (addButton instanceof HTMLButtonElement) addButton.focus();
112
+ }
89
113
  debug.log('Row removed.', {
90
114
  rowCount: getRowCount(field),
91
115
  });
@@ -100,7 +124,7 @@ function bindRepeaterField(field: HTMLElement): () => void {
100
124
  });
101
125
  };
102
126
 
103
- const addRow = async() => {
127
+ const addRow = async(focusNewRow = false) => {
104
128
  if (!(addButton instanceof HTMLButtonElement)) {
105
129
  return;
106
130
  }
@@ -130,11 +154,16 @@ function bindRepeaterField(field: HTMLElement): () => void {
130
154
  return;
131
155
  }
132
156
 
157
+ const addedGeneration = generation;
133
158
  container.appendChild(row);
134
159
  // Give downstream field modules a tick to see the new row before we emit
135
160
  // the public append/init-row lifecycle events used for nested enhancements.
136
161
  await sleep(50);
162
+ if (disposed || addedGeneration !== generation || !row.isConnected) return;
137
163
  bindRemoveButtons();
164
+ // Rows are inserted before Add, so leaving focus there skips the new
165
+ // inputs on Tab. Do not steal focus if the user moved on while mounting.
166
+ if (focusNewRow && document.activeElement === addButton) focusRow(row);
138
167
  syncAddButton(addButton, getRowCount(field));
139
168
  debug.log('Row appended.', {
140
169
  rowCount: getRowCount(field),
@@ -151,7 +180,10 @@ function bindRepeaterField(field: HTMLElement): () => void {
151
180
 
152
181
  const addHandler = (event: Event) => {
153
182
  event.preventDefault();
154
- void addRow();
183
+ // Pointer additions in the CP should not focus Craft's field wrappers
184
+ // and show a focus ring around the newly inserted field.
185
+ const focusNewRow = !isCpEdit || (event instanceof MouseEvent && event.detail === 0);
186
+ void addRow(focusNewRow);
155
187
  };
156
188
 
157
189
  if (addButton instanceof HTMLButtonElement) {
@@ -170,6 +202,23 @@ function bindRepeaterField(field: HTMLElement): () => void {
170
202
  }
171
203
  }
172
204
 
205
+ const initialRows = Array.from(container.children).map((row) => row.cloneNode(true));
206
+
207
+ const onReset = (event: Event) => {
208
+ queueMicrotask(() => {
209
+ if (event.defaultPrevented || disposed) return;
210
+ generation++;
211
+ removeHandlers.forEach((handler, button) => button.removeEventListener('click', handler));
212
+ removeHandlers.clear();
213
+ // Replace rows so removed initial rows return and added rows disappear.
214
+ // The module loader disposes old controls and hydrates the new rows.
215
+ container.replaceChildren(...initialRows.map((row) => row.cloneNode(true)));
216
+ bindRemoveButtons();
217
+ syncAddButton(addButton instanceof HTMLButtonElement ? addButton : null, getRowCount(field));
218
+ });
219
+ };
220
+ form?.addEventListener('reset', onReset);
221
+
173
222
  dispatchFieldEvent(field, MODULE_ID, 'init', {
174
223
  repeater: field,
175
224
  });
@@ -178,6 +227,8 @@ function bindRepeaterField(field: HTMLElement): () => void {
178
227
  });
179
228
 
180
229
  return () => {
230
+ disposed = true;
231
+ form?.removeEventListener('reset', onReset);
181
232
  if (addButton instanceof HTMLButtonElement) {
182
233
  addButton.removeEventListener('click', addHandler);
183
234
  }
@@ -211,7 +262,7 @@ export const repeaterModule: BrowserModuleDefinition = {
211
262
  });
212
263
 
213
264
  const destroyBindings = fields.map((field) => {
214
- return bindRepeaterField(field);
265
+ return bindRepeaterField(field, ctx.surface === 'cp-edit');
215
266
  });
216
267
  debug.log('Module setup.', { fieldCount: fields.length });
217
268
 
@@ -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
  });
@@ -42,9 +42,9 @@ type UploadManagerOptions = {
42
42
  uploadEndpoint?: string;
43
43
  deleteEndpoint?: string;
44
44
  hydrateEndpoint?: string;
45
- limitFiles?: number | null;
46
- sizeMinLimit?: number | null;
47
- sizeLimit?: number | null;
45
+ maxFiles?: number | null;
46
+ minFileSize?: number | null;
47
+ maxFileSize?: number | null;
48
48
  accept?: string | null;
49
49
  restrictFiles?: boolean;
50
50
  };
@@ -508,6 +508,19 @@ function markUploadComplete(listItem: HTMLElement): void {
508
508
  }, UPLOAD_COMPLETE_DISPLAY_MS);
509
509
  }
510
510
 
511
+ function markUploadFailed(listItem: HTMLElement): void {
512
+ const { track, bar, label } = getProgressElements(listItem);
513
+
514
+ listItem.classList.add('is-error');
515
+ listItem.classList.remove('is-uploading', 'is-upload-complete', 'is-processing', 'is-preparing');
516
+ track?.setAttribute('data-indeterminate', 'false');
517
+ bar?.setAttribute('data-progress', 'error');
518
+
519
+ if (label) {
520
+ label.textContent = 'Upload failed';
521
+ }
522
+ }
523
+
511
524
  function createSortButton(direction: 'up' | 'down'): HTMLButtonElement {
512
525
  const label = direction === 'up' ? 'Move file up' : 'Move file down';
513
526
  const button = document.createElement('button');
@@ -763,9 +776,9 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
763
776
  const uploadEndpoint = getEndpoint(dropzone, options.uploadEndpoint, 'data-formie-file-upload-upload-endpoint', '/actions/formie/file-upload/upload');
764
777
  const deleteEndpoint = getEndpoint(dropzone, options.deleteEndpoint, 'data-formie-file-upload-delete-endpoint', '/actions/formie/file-upload/delete');
765
778
  const hydrateEndpoint = getEndpoint(dropzone, options.hydrateEndpoint, 'data-formie-file-upload-hydrate-endpoint', '/actions/formie/file-upload/hydrate');
766
- const limitFiles = options.limitFiles ?? toPositiveInt(dropzone.getAttribute('data-formie-file-limit'));
767
- const sizeLimit = options.sizeLimit ?? parseFloat(dropzone.getAttribute('data-formie-size-max-limit') || '');
768
- const sizeMinLimit = options.sizeMinLimit ?? parseFloat(dropzone.getAttribute('data-formie-size-min-limit') || '');
779
+ const maxFiles = options.maxFiles ?? toPositiveInt(dropzone.getAttribute('data-formie-file-limit'));
780
+ const maxFileSize = options.maxFileSize ?? parseFloat(dropzone.getAttribute('data-formie-size-max-limit') || '');
781
+ const minFileSize = options.minFileSize ?? parseFloat(dropzone.getAttribute('data-formie-size-min-limit') || '');
769
782
  const accept = options.accept ?? dropzone.getAttribute('accept') ?? browseInput.accept;
770
783
  const allowedFileTypes = parseAcceptTypes(accept);
771
784
 
@@ -776,16 +789,16 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
776
789
  allowedFileTypes?: string[] | null;
777
790
  } = {};
778
791
 
779
- if (limitFiles) {
780
- restrictions.maxNumberOfFiles = limitFiles;
792
+ if (maxFiles) {
793
+ restrictions.maxNumberOfFiles = maxFiles;
781
794
  }
782
795
 
783
- if (sizeLimit) {
784
- restrictions.maxFileSize = sizeLimit * 1000 * 1000;
796
+ if (maxFileSize) {
797
+ restrictions.maxFileSize = maxFileSize * 1000 * 1000;
785
798
  }
786
799
 
787
- if (sizeMinLimit) {
788
- restrictions.minFileSize = sizeMinLimit * 1000 * 1000;
800
+ if (minFileSize) {
801
+ restrictions.minFileSize = minFileSize * 1000 * 1000;
789
802
  }
790
803
 
791
804
  if (allowedFileTypes) {
@@ -797,6 +810,27 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
797
810
  restrictions,
798
811
  });
799
812
 
813
+ const selectionError = document.createElement('div');
814
+ selectionError.className = 'formie-upload-manager-error';
815
+ selectionError.setAttribute('role', 'alert');
816
+ selectionError.hidden = true;
817
+ root.append(selectionError);
818
+
819
+ // Uppy stores restriction messages for UI plugins; this custom UI must render them.
820
+ uppy.on('info-visible', () => {
821
+ const messages = uppy.getState().info.filter((info) => info.type === 'error');
822
+
823
+ if (messages.length) {
824
+ selectionError.textContent = [...new Set(messages.map((info) => info.message))].join(' ');
825
+ selectionError.hidden = false;
826
+ }
827
+ });
828
+
829
+ const clearSelectionError = () => {
830
+ selectionError.textContent = '';
831
+ selectionError.hidden = true;
832
+ };
833
+
800
834
  // Resolve once for Uppy's allow-list; per-request meta/header refresh
801
835
  // still re-reads the live form token (including custom csrfTokenName).
802
836
  const initialCsrf = getFormCsrfToken(resolvedForm);
@@ -886,11 +920,11 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
886
920
  };
887
921
 
888
922
  const remainingUploadCapacity = (): number | null => {
889
- if (!limitFiles) {
923
+ if (!maxFiles) {
890
924
  return null;
891
925
  }
892
926
 
893
- return Math.max(0, limitFiles - countManagedAssets(state));
927
+ return Math.max(0, maxFiles - countManagedAssets(state));
894
928
  };
895
929
 
896
930
  const setDropzoneActive = (active: boolean) => {
@@ -1147,8 +1181,7 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
1147
1181
  const assetId = toPositiveInt(body.assetId);
1148
1182
 
1149
1183
  if (!body.success || !assetId) {
1150
- managedFile.listItem.classList.add('is-error');
1151
- managedFile.listItem.classList.remove('is-uploading', 'is-upload-complete', 'is-processing', 'is-preparing');
1184
+ markUploadFailed(managedFile.listItem);
1152
1185
 
1153
1186
  if (errorEl instanceof HTMLElement) {
1154
1187
  errorEl.hidden = false;
@@ -1184,8 +1217,7 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
1184
1217
  }
1185
1218
 
1186
1219
  const errorEl = managedFile.listItem.querySelector('[data-formie-upload-manager-error]');
1187
- managedFile.listItem.classList.add('is-error');
1188
- managedFile.listItem.classList.remove('is-uploading', 'is-upload-complete', 'is-processing', 'is-preparing');
1220
+ markUploadFailed(managedFile.listItem);
1189
1221
 
1190
1222
  if (errorEl instanceof HTMLElement) {
1191
1223
  errorEl.hidden = false;
@@ -1254,6 +1286,7 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
1254
1286
 
1255
1287
  const files = Array.from(browseInput.files);
1256
1288
  browseInput.value = '';
1289
+ clearSelectionError();
1257
1290
 
1258
1291
  try {
1259
1292
  uppy.addFiles(files.map((file) => {
@@ -1294,6 +1327,7 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
1294
1327
  }
1295
1328
 
1296
1329
  const files = Array.from(transfer.files);
1330
+ clearSelectionError();
1297
1331
 
1298
1332
  try {
1299
1333
  uppy.addFiles(files.map((file) => {
@@ -1311,6 +1345,7 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
1311
1345
  });
1312
1346
 
1313
1347
  const resetHandler = () => {
1348
+ clearSelectionError();
1314
1349
  state.files.forEach((managedFile) => {
1315
1350
  managedFile.listItem.remove();
1316
1351
  });
@@ -1326,6 +1361,7 @@ function bindUploadManagerField(field: HTMLElement, form: HTMLFormElement | null
1326
1361
  return () => {
1327
1362
  resolvedForm?.removeEventListener(FORM_RESET_EVENT, resetHandler);
1328
1363
  uppy.destroy();
1364
+ selectionError.remove();
1329
1365
  state.files = [];
1330
1366
  };
1331
1367
  }