@verbb/formie-browser 1.0.2 → 1.0.3

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 (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/chunks/{address-finder-CQW81kY0.js → address-finder-BjMuEiwF.js} +2 -2
  3. package/dist/chunks/{bpoint-D8OjLmsn.js → bpoint-KudsBCo2.js} +1 -1
  4. package/dist/chunks/{calculations-BqZsENCq.js → calculations-DlGSkQ3W.js} +2 -2
  5. package/dist/chunks/{captcha-eu-DIa6aUWz.js → captcha-eu-CterYFok.js} +2 -2
  6. package/dist/chunks/{checkbox-radio-BI-UjgMF.js → checkbox-radio-CBvgk3hR.js} +2 -2
  7. package/dist/chunks/{conditions-BVl6jTYo.js → conditions-DYy1-Hwt.js} +189 -163
  8. package/dist/chunks/{date-picker-69TgvErA.js → date-picker-BlD--zfe.js} +2 -2
  9. package/dist/chunks/{eway-1GPxH6DU.js → eway-qwc-B5Us.js} +2 -2
  10. package/dist/chunks/file-upload-CIgHrXfp.js +316 -0
  11. package/dist/chunks/{friendly-captcha-v1-DA2QPKA3.js → friendly-captcha-v1-EDFac33c.js} +1 -1
  12. package/dist/chunks/{friendly-captcha-v2-BorOWiz_.js → friendly-captcha-v2-BDpSvFGR.js} +1 -1
  13. package/dist/chunks/{go-cardless-DwBgERcA.js → go-cardless-DuDMVsnz.js} +1 -1
  14. package/dist/chunks/{google-address-BcTBncyS.js → google-address-Bmjp01pH.js} +77 -58
  15. package/dist/chunks/{hcaptcha-BchdVCqq.js → hcaptcha-BTQ6An6g.js} +2 -2
  16. package/dist/chunks/{hidden-D8RqSPt7.js → hidden-DbPdOxKb.js} +1 -1
  17. package/dist/chunks/{index-C1ZOKiAi.js → index-BcqwpPNE.js} +1261 -1176
  18. package/dist/chunks/{loqate-nXuTX7In.js → loqate-BdNGv93o.js} +2 -2
  19. package/dist/chunks/{mollie-qdCynQCd.js → mollie-DFOjTuGX.js} +1 -1
  20. package/dist/chunks/{moneris-BNEA-laO.js → moneris-BbFfl-EL.js} +1 -1
  21. package/dist/chunks/{opayo-BJtXOkuK.js → opayo-CmgYa9ya.js} +2 -2
  22. package/dist/chunks/{paddle-D72Pa8kr.js → paddle-BUi7w1_o.js} +2 -2
  23. package/dist/chunks/{paypal-Cqt2bned.js → paypal-A-ePfh6k.js} +2 -2
  24. package/dist/chunks/{payway-BjCqsc0H.js → payway-BqFCbhyo.js} +2 -2
  25. package/dist/chunks/{phone-country-K9pYAlzM.js → phone-country-DPMiqk4L.js} +2 -2
  26. package/dist/chunks/{place-kit-Dg6f_UoM.js → place-kit-CX7tamAg.js} +1 -1
  27. package/dist/chunks/{recaptcha-enterprise--VYAeeqz.js → recaptcha-enterprise-DxF6cW95.js} +2 -2
  28. package/dist/chunks/{recaptcha-shared-PSIEEEe1.js → recaptcha-shared-DhGcbiaq.js} +2 -2
  29. package/dist/chunks/{recaptcha-v2-checkbox-atBNe5ml.js → recaptcha-v2-checkbox-R9EfbZ4k.js} +2 -2
  30. package/dist/chunks/{recaptcha-v2-invisible-DnBufNPT.js → recaptcha-v2-invisible-DV9hswAp.js} +2 -2
  31. package/dist/chunks/{recaptcha-v3-CF6O8rDj.js → recaptcha-v3-V0xc-Edh.js} +2 -2
  32. package/dist/chunks/{repeater-CB7Yojvx.js → repeater-DKUT6TGI.js} +2 -2
  33. package/dist/chunks/{rich-text-nAV_SQZo.js → rich-text-SH7KiaKH.js} +1 -1
  34. package/dist/chunks/{scripts-BkAAux0i.js → scripts-D9N2d5rN.js} +1 -1
  35. package/dist/chunks/{shared-DGqeMvcJ.js → shared-BmntPmnW.js} +4 -4
  36. package/dist/chunks/{signature-BT6CsQ6n.js → signature-BA3tQsGT.js} +1 -1
  37. package/dist/chunks/{snaptcha-C7qQo02f.js → snaptcha-DV7p6J9k.js} +1 -1
  38. package/dist/chunks/{square-C5AH3trb.js → square-Bs-ndqYK.js} +2 -2
  39. package/dist/chunks/{stripe-EJK0RyUv.js → stripe-DkZAHxtn.js} +2 -2
  40. package/dist/chunks/{summary-DC_ipXCU.js → summary-Dq2i9TQk.js} +2 -2
  41. package/dist/chunks/{table-BEAI99qo.js → table-Cu4WfVcw.js} +2 -2
  42. package/dist/chunks/{text-limit-Blgq5G-G.js → text-limit-2ycmB-Ae.js} +1 -1
  43. package/dist/chunks/{turnstile-_uCRwnU-.js → turnstile-CId90tRr.js} +2 -2
  44. package/dist/css/theme/forms/_form.css +28 -0
  45. package/dist/index.js +1 -1
  46. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  47. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  48. package/dist/js/modules/address/google-address.d.ts +7 -0
  49. package/dist/js/modules/address/google-address.d.ts.map +1 -1
  50. package/dist/js/modules/fields/conditions/effects.d.ts +3 -1
  51. package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
  52. package/dist/js/modules/fields/conditions.d.ts.map +1 -1
  53. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  54. package/dist/js/submit/pipeline.d.ts.map +1 -1
  55. package/dist/js/validation/scope.d.ts +6 -0
  56. package/dist/js/validation/scope.d.ts.map +1 -0
  57. package/dist/js/validation/submit-readiness.d.ts +4 -0
  58. package/dist/js/validation/submit-readiness.d.ts.map +1 -0
  59. package/dist/js/validation/validator.d.ts +1 -0
  60. package/dist/js/validation/validator.d.ts.map +1 -1
  61. package/package.json +2 -2
  62. package/src/css/theme/forms/_form.css +28 -0
  63. package/src/js/core/create-formie-client.ts +6 -0
  64. package/src/js/core/submit-result-state.ts +17 -0
  65. package/src/js/modules/address/google-address.ts +33 -1
  66. package/src/js/modules/fields/conditions/effects.ts +79 -2
  67. package/src/js/modules/fields/conditions.ts +53 -11
  68. package/src/js/modules/fields/file-upload.ts +188 -96
  69. package/src/js/submit/pipeline.ts +3 -28
  70. package/src/js/validation/scope.ts +25 -0
  71. package/src/js/validation/submit-readiness.ts +157 -0
  72. package/src/js/validation/validator.ts +4 -0
  73. package/dist/chunks/file-upload-BDZZftgL.js +0 -281
@@ -8,6 +8,7 @@ type GoogleMaps = typeof google;
8
8
  type GoogleAddressProviderOptions = {
9
9
  apiKey?: string;
10
10
  options?: Record<string, unknown>;
11
+ countryDefaultValue?: string;
11
12
  };
12
13
 
13
14
  const SCRIPT_ID = 'FORMIE_GOOGLE_ADDRESS_SCRIPT';
@@ -194,6 +195,37 @@ function buildAddressFromComponents(formData: Record<string, string>): {
194
195
  };
195
196
  }
196
197
 
198
+ function hasCountryRestriction(options: Record<string, unknown>): boolean {
199
+ const componentRestrictions = options.componentRestrictions;
200
+
201
+ if (componentRestrictions && typeof componentRestrictions === 'object') {
202
+ const country = (componentRestrictions as { country?: unknown }).country;
203
+
204
+ if (Array.isArray(country) ? country.length > 0 : Boolean(country)) {
205
+ return true;
206
+ }
207
+ }
208
+
209
+ const includedRegionCodes = options.includedRegionCodes;
210
+
211
+ return Array.isArray(includedRegionCodes) && includedRegionCodes.length > 0;
212
+ }
213
+
214
+ export function buildGoogleAutocompleteOptions(provider: GoogleAddressProviderOptions): Record<string, unknown> {
215
+ const options = { types: ['geocode'], ...(provider.options || {}) };
216
+ const country = provider.countryDefaultValue?.trim().toLowerCase();
217
+
218
+ if (!country || hasCountryRestriction(options)) {
219
+ return options;
220
+ }
221
+
222
+ return {
223
+ ...options,
224
+ componentRestrictions: { country },
225
+ includedRegionCodes: [country.toUpperCase()],
226
+ };
227
+ }
228
+
197
229
  export const googleAddressModule = defineAddressModule<
198
230
  GoogleAddressProviderOptions,
199
231
  GoogleMaps,
@@ -218,7 +250,7 @@ export const googleAddressModule = defineAddressModule<
218
250
  return null;
219
251
  }
220
252
 
221
- const options = { types: ['geocode'], ...(provider.options || {}) };
253
+ const options = buildGoogleAutocompleteOptions(provider);
222
254
  const autocomplete = new PlaceAutocompleteElement(options);
223
255
  const inputHeight = window.getComputedStyle(input).height;
224
256
 
@@ -3,6 +3,9 @@ const PRESERVED_DISABLED_ATTR = 'data-formie-preserve-disabled';
3
3
  const CONDITIONAL_HIDDEN_ATTR = 'data-formie-conditionally-hidden';
4
4
  const PAGE_HIDDEN_ATTR = 'data-formie-page-hidden';
5
5
  const CONDITIONAL_HIDDEN_CLASS = 'formie-conditionally-hidden';
6
+ const CP_MUTED_CLASS = 'fui-cp-muted-conditional-field';
7
+ const CP_MUTED_EXPANDED_CLASS = 'fui-cp-muted-conditional-field--expanded';
8
+ const CP_MUTED_ATTR = 'data-formie-cp-muted';
6
9
  const PAGE_HIDDEN_CLASS = 'formie-page-hidden';
7
10
  const ROW_HIDDEN_ATTR = 'data-formie-row-hidden';
8
11
  const ROW_HIDDEN_CLASS = 'formie-row-hidden';
@@ -143,6 +146,19 @@ function syncRowState(row: Element): void {
143
146
  }
144
147
  }
145
148
 
149
+ function clearCpMutedState(node: Element): void {
150
+ node.removeAttribute(CP_MUTED_ATTR);
151
+ node.classList.remove(CP_MUTED_CLASS);
152
+ node.classList.remove(CP_MUTED_EXPANDED_CLASS);
153
+ }
154
+
155
+ function clearHideState(node: Element): void {
156
+ node.removeAttribute(CONDITIONAL_HIDDEN_ATTR);
157
+ node.removeAttribute(PAGE_HIDDEN_ATTR);
158
+ node.classList.remove(CONDITIONAL_HIDDEN_CLASS);
159
+ node.classList.remove(PAGE_HIDDEN_CLASS);
160
+ }
161
+
146
162
  function syncAncestorRows(node: Element): void {
147
163
  let currentRow = node.closest(ROW_SELECTOR);
148
164
 
@@ -152,8 +168,26 @@ function syncAncestorRows(node: Element): void {
152
168
  }
153
169
  }
154
170
 
155
- export function applyConditionVisibility(node: Element, hidden: boolean, clearOnHide: boolean): boolean {
156
- const stateChanged = setVisibilityState(node, hidden);
171
+ export function applyConditionVisibility(
172
+ node: Element,
173
+ hidden: boolean,
174
+ clearOnHide: boolean,
175
+ options: { displayMode?: 'hide' | 'muted' } = {},
176
+ ): boolean {
177
+ if (options.displayMode === 'muted') {
178
+ return applyMutedConditionVisibility(node, hidden);
179
+ }
180
+
181
+ let stateChanged = false;
182
+
183
+ if (node.hasAttribute(CP_MUTED_ATTR)
184
+ || node.classList.contains(CP_MUTED_CLASS)
185
+ || node.classList.contains(CP_MUTED_EXPANDED_CLASS)) {
186
+ clearCpMutedState(node);
187
+ stateChanged = true;
188
+ }
189
+
190
+ stateChanged = setVisibilityState(node, hidden) || stateChanged;
157
191
 
158
192
  syncDisabledState(node, hidden);
159
193
  syncAncestorRows(node);
@@ -164,3 +198,46 @@ export function applyConditionVisibility(node: Element, hidden: boolean, clearOn
164
198
 
165
199
  return stateChanged;
166
200
  }
201
+
202
+ function applyMutedConditionVisibility(node: Element, hidden: boolean): boolean {
203
+ let stateChanged = false;
204
+
205
+ if (hidden) {
206
+ if (node.hasAttribute(CONDITIONAL_HIDDEN_ATTR)
207
+ || node.hasAttribute(PAGE_HIDDEN_ATTR)
208
+ || node.classList.contains(CONDITIONAL_HIDDEN_CLASS)
209
+ || node.classList.contains(PAGE_HIDDEN_CLASS)) {
210
+ clearHideState(node);
211
+ stateChanged = true;
212
+ }
213
+
214
+ if (!node.hasAttribute(CP_MUTED_ATTR)) {
215
+ node.setAttribute(CP_MUTED_ATTR, 'true');
216
+ stateChanged = true;
217
+ }
218
+
219
+ if (!node.classList.contains(CP_MUTED_CLASS)) {
220
+ node.classList.add(CP_MUTED_CLASS);
221
+ stateChanged = true;
222
+ }
223
+ } else {
224
+ if (node.hasAttribute(CP_MUTED_ATTR)
225
+ || node.classList.contains(CP_MUTED_CLASS)
226
+ || node.classList.contains(CP_MUTED_EXPANDED_CLASS)) {
227
+ clearCpMutedState(node);
228
+ stateChanged = true;
229
+ }
230
+
231
+ if (node.hasAttribute(CONDITIONAL_HIDDEN_ATTR)
232
+ || node.hasAttribute(PAGE_HIDDEN_ATTR)
233
+ || node.classList.contains(CONDITIONAL_HIDDEN_CLASS)
234
+ || node.classList.contains(PAGE_HIDDEN_CLASS)) {
235
+ clearHideState(node);
236
+ stateChanged = true;
237
+ }
238
+ }
239
+
240
+ syncAncestorRows(node);
241
+
242
+ return stateChanged;
243
+ }
@@ -72,6 +72,9 @@ export const conditionsModule: FormieModuleDefinition = {
72
72
  });
73
73
  };
74
74
 
75
+ const cpDisplayMode = ctx.options?.cpDisplayMode === 'muted' ? 'muted' : 'hide';
76
+ let isApplyingConditions = false;
77
+
75
78
  const runEvaluationPass = (): boolean => {
76
79
  let hasStateChanges = false;
77
80
 
@@ -80,7 +83,16 @@ export const conditionsModule: FormieModuleDefinition = {
80
83
  return queryConditionInputs(scopeRoot, entry.node, condition);
81
84
  });
82
85
 
83
- const stateChanged = applyConditionVisibility(entry.node, result.shouldHide, entry.settings.clearOnHide);
86
+ const displayMode = entry.node.hasAttribute('data-formie-page')
87
+ ? 'hide'
88
+ : cpDisplayMode;
89
+
90
+ const stateChanged = applyConditionVisibility(
91
+ entry.node,
92
+ result.shouldHide,
93
+ entry.settings.clearOnHide,
94
+ { displayMode },
95
+ );
84
96
  hasStateChanges = hasStateChanges || stateChanged;
85
97
  debug.log('Condition evaluated.', {
86
98
  shouldHide: result.shouldHide,
@@ -100,26 +112,45 @@ export const conditionsModule: FormieModuleDefinition = {
100
112
  };
101
113
 
102
114
  const evaluateAll = (): void => {
103
- for (let pass = 0; pass < MAX_EVALUATION_PASSES; pass += 1) {
104
- if (!runEvaluationPass()) {
105
- break;
106
- }
115
+ if (isApplyingConditions) {
116
+ return;
117
+ }
118
+
119
+ isApplyingConditions = true;
107
120
 
108
- if (pass === MAX_EVALUATION_PASSES - 1) {
109
- debug.warn('Reached max evaluation passes.', { maxPasses: MAX_EVALUATION_PASSES });
121
+ try {
122
+ for (let pass = 0; pass < MAX_EVALUATION_PASSES; pass += 1) {
123
+ if (!runEvaluationPass()) {
124
+ break;
125
+ }
126
+
127
+ if (pass === MAX_EVALUATION_PASSES - 1) {
128
+ debug.warn('Reached max evaluation passes.', { maxPasses: MAX_EVALUATION_PASSES });
129
+ }
110
130
  }
131
+ } finally {
132
+ isApplyingConditions = false;
111
133
  }
112
134
  };
113
135
 
114
136
  const scheduleEvaluateAll = (): void => {
137
+ if (isApplyingConditions) {
138
+ return;
139
+ }
140
+
115
141
  if (evaluationQueued) {
116
142
  return;
117
143
  }
118
144
 
119
145
  evaluationQueued = true;
120
146
 
121
- queueMicrotask(() => {
147
+ requestAnimationFrame(() => {
122
148
  evaluationQueued = false;
149
+
150
+ if (isApplyingConditions) {
151
+ return;
152
+ }
153
+
123
154
  evaluateAll();
124
155
  });
125
156
  };
@@ -168,19 +199,32 @@ export const conditionsModule: FormieModuleDefinition = {
168
199
  };
169
200
 
170
201
  const scheduleRebuild = (): void => {
202
+ if (isApplyingConditions) {
203
+ return;
204
+ }
205
+
171
206
  if (rebuildQueued) {
172
207
  return;
173
208
  }
174
209
 
175
210
  rebuildQueued = true;
176
211
 
177
- queueMicrotask(() => {
212
+ requestAnimationFrame(() => {
178
213
  rebuildQueued = false;
214
+
215
+ if (isApplyingConditions) {
216
+ return;
217
+ }
218
+
179
219
  rebuild();
180
220
  });
181
221
  };
182
222
 
183
223
  const observer = new MutationObserver((mutations) => {
224
+ if (isApplyingConditions) {
225
+ return;
226
+ }
227
+
184
228
  const shouldRebuild = mutations.some((mutation) => {
185
229
  return mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0);
186
230
  });
@@ -200,8 +244,6 @@ export const conditionsModule: FormieModuleDefinition = {
200
244
  subtree: true,
201
245
  attributes: true,
202
246
  attributeFilter: [
203
- 'class',
204
- 'style',
205
247
  'hidden',
206
248
  'aria-hidden',
207
249
  'data-formie-conditionally-hidden',
@@ -1,9 +1,9 @@
1
1
  import fileUploadCss from '#theme-css/fields/_file.css?inline';
2
2
 
3
3
  import type { FormieModuleDefinition } from '#contracts/modules';
4
- import { dispatchFieldEvent, getModuleFieldTarget, releaseFormValidators, retainFormValidators } from '#modules/fields/shared';
4
+ import { dispatchFieldEvent, releaseFormValidators, retainFormValidators } from '#modules/fields/shared';
5
5
  import { ensureModuleStyles } from '#modules/styles';
6
- import { getFileUploadEventName, getFormStateEventName } from '#utils/event-names';
6
+ import { getFieldModuleEventName, getFileUploadEventName, getFormStateEventName } from '#utils/event-names';
7
7
  import { requestJson } from '#utils/http';
8
8
 
9
9
  const INPUT_SELECTOR = 'input[type="file"][data-formie-file-input]';
@@ -20,6 +20,8 @@ const FILE_VALIDATORS = [
20
20
  ] as const;
21
21
  const VALIDATOR_SCOPE = 'file-upload';
22
22
  const MODULE_ID = 'file-upload';
23
+ const REPEATER_INIT_ROW_EVENT = getFieldModuleEventName('repeater', 'init-row');
24
+ const FIELD_HANDLE_SELECTOR = '[data-formie-field-handle]';
23
25
 
24
26
  ensureModuleStyles(MODULE_ID, [fileUploadCss]);
25
27
 
@@ -366,6 +368,47 @@ function updateUploadedAssetInputs(field: HTMLElement, input: HTMLInputElement,
366
368
  input.value = '';
367
369
  }
368
370
 
371
+ function getFormHandle(form: HTMLFormElement | null): string {
372
+ if (!form) {
373
+ return '';
374
+ }
375
+
376
+ const handleInput = form.querySelector('input[name="handle"]');
377
+
378
+ if (handleInput instanceof HTMLInputElement && handleInput.value.trim()) {
379
+ return handleInput.value.trim();
380
+ }
381
+
382
+ return form.getAttribute('data-formie-handle')?.trim() || '';
383
+ }
384
+
385
+ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assetIds: number[]): FormData {
386
+ const form = input.form;
387
+ const body = new FormData();
388
+ const handle = getFormHandle(form);
389
+ const fieldHandle = getFieldHandle(field);
390
+
391
+ if (handle) {
392
+ body.append('handle', handle);
393
+ }
394
+
395
+ if (fieldHandle) {
396
+ body.append('fieldHandle', fieldHandle);
397
+ }
398
+
399
+ const submissionUidInput = form?.querySelector('input[name="submissionUid"]');
400
+
401
+ if (submissionUidInput instanceof HTMLInputElement && submissionUidInput.value.trim()) {
402
+ body.append('submissionUid', submissionUidInput.value.trim());
403
+ }
404
+
405
+ assetIds.forEach((assetId) => {
406
+ body.append('assetIds[]', String(assetId));
407
+ });
408
+
409
+ return body;
410
+ }
411
+
369
412
  async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement, uploadedAssets: UploadedAsset[]): Promise<UploadedAsset[]> {
370
413
  const assetIds = uploadedAssets.map((asset) => {
371
414
  return asset.assetId;
@@ -378,14 +421,9 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
378
421
  }
379
422
 
380
423
  const hydrateEndpoint = input.getAttribute(HYDRATE_ENDPOINT_ATTR)?.trim() || '/actions/formie/file-upload/hydrate';
381
- const body = new FormData();
382
- assetIds.forEach((assetId) => {
383
- body.append('assetIds[]', String(assetId));
384
- });
385
-
386
424
  const response = await requestJson<FileUploadHydrateResponse>(hydrateEndpoint, {
387
425
  method: 'POST',
388
- body,
426
+ body: buildHydrateFormData(field, input, assetIds),
389
427
  });
390
428
  const hydratedAssets = normalizeUploadedAssets(response.assets);
391
429
 
@@ -504,6 +542,118 @@ function getUploadedAssetsFromEvent(event: Event, matcher: FileUploadEventMatche
504
542
  return [];
505
543
  }
506
544
 
545
+ function getFieldContainer(input: HTMLInputElement): HTMLElement | null {
546
+ const field = input.closest(FIELD_HANDLE_SELECTOR);
547
+
548
+ return field instanceof HTMLElement ? field : null;
549
+ }
550
+
551
+ function collectFileUploadInputs(root: ParentNode): HTMLInputElement[] {
552
+ return Array.from(root.querySelectorAll(INPUT_SELECTOR)).filter((input): input is HTMLInputElement => {
553
+ return input instanceof HTMLInputElement;
554
+ });
555
+ }
556
+
557
+ function bindFileUploadInput(field: HTMLElement, input: HTMLInputElement, form: HTMLFormElement | null): () => void {
558
+ const state: FileUploadState = {
559
+ field,
560
+ input,
561
+ summaryRoot: ensureSummaryRoot(field, input),
562
+ uploadedAssets: mergeUploadedAssets(
563
+ readUploadedAssetsFromHiddenInputs(field, input),
564
+ readSummaryItems(field.querySelector('[data-formie-file-summary]') as HTMLElement | null),
565
+ ),
566
+ pendingFiles: [],
567
+ hydrationToken: 0,
568
+ };
569
+ const syncPendingFiles = () => {
570
+ state.pendingFiles = Array.from(input.files || []).map((file) => {
571
+ return file.name;
572
+ });
573
+ renderSummary(state);
574
+ };
575
+ const syncUploadedAssets = async(uploadedAssets: UploadedAsset[]) => {
576
+ state.hydrationToken += 1;
577
+ const hydrationToken = state.hydrationToken;
578
+ state.pendingFiles = [];
579
+ state.uploadedAssets = uploadedAssets;
580
+ updateUploadedAssetInputs(field, input, uploadedAssets);
581
+ renderSummary(state);
582
+
583
+ try {
584
+ const hydratedAssets = await hydrateUploadedAssets(field, input, uploadedAssets);
585
+
586
+ if (hydrationToken !== state.hydrationToken) {
587
+ return;
588
+ }
589
+
590
+ state.uploadedAssets = hydratedAssets;
591
+ renderSummary(state);
592
+ dispatchFieldEvent(field, MODULE_ID, 'uploaded-assets-sync', {
593
+ fileUpload: field,
594
+ assets: hydratedAssets,
595
+ });
596
+ } catch (error) {
597
+ console.error('[formie] Failed to hydrate uploaded file details.', error);
598
+ }
599
+ };
600
+ const resetUploadedAssets = () => {
601
+ state.hydrationToken += 1;
602
+ state.pendingFiles = [];
603
+ state.uploadedAssets = [];
604
+ updateUploadedAssetInputs(field, input, []);
605
+ renderSummary(state);
606
+ };
607
+ const uploadedAssetHandler = (event: Event) => {
608
+ const fieldHandle = getFieldHandle(field);
609
+
610
+ if (!fieldHandle) {
611
+ return;
612
+ }
613
+
614
+ const uploadedAssets = getUploadedAssetsFromEvent(event, {
615
+ fieldHandle,
616
+ inputKey: getFileUploadKey(input),
617
+ inputName: input.name,
618
+ pendingFiles: state.pendingFiles,
619
+ });
620
+
621
+ if (!uploadedAssets.length) {
622
+ return;
623
+ }
624
+
625
+ void syncUploadedAssets(uploadedAssets);
626
+ };
627
+ const resetHandler = () => {
628
+ resetUploadedAssets();
629
+ };
630
+
631
+ input.addEventListener('change', syncPendingFiles);
632
+ form?.addEventListener(FILE_UPLOAD_EVENT, uploadedAssetHandler as EventListener);
633
+ form?.addEventListener(FORM_RESET_EVENT, resetHandler as EventListener);
634
+
635
+ const syncInitialState = () => {
636
+ const assetIdsFromDom = readUploadedAssetsFromHiddenInputs(field, input);
637
+ const summaryItems = readSummaryItems(state.summaryRoot);
638
+ state.uploadedAssets = mergeUploadedAssets(assetIdsFromDom, summaryItems);
639
+
640
+ if (state.uploadedAssets.some((asset) => asset.assetId && !asset.filename)) {
641
+ void syncUploadedAssets(state.uploadedAssets);
642
+ return;
643
+ }
644
+
645
+ renderSummary(state);
646
+ };
647
+
648
+ syncInitialState();
649
+
650
+ return () => {
651
+ input.removeEventListener('change', syncPendingFiles);
652
+ form?.removeEventListener(FILE_UPLOAD_EVENT, uploadedAssetHandler as EventListener);
653
+ form?.removeEventListener(FORM_RESET_EVENT, resetHandler as EventListener);
654
+ };
655
+ }
656
+
507
657
  export const fileUploadModule: FormieModuleDefinition = {
508
658
  id: MODULE_ID,
509
659
  kind: 'field',
@@ -511,111 +661,53 @@ export const fileUploadModule: FormieModuleDefinition = {
511
661
  return !!ctx.target.querySelector(INPUT_SELECTOR);
512
662
  },
513
663
  setup: async(ctx) => {
514
- const field = getModuleFieldTarget(ctx);
515
- const scope = field || ctx.target;
516
- const inputs = Array.from(scope.querySelectorAll(INPUT_SELECTOR)).filter((input): input is HTMLInputElement => {
517
- return input instanceof HTMLInputElement;
518
- });
519
664
  const form = ctx.form;
665
+ const boundInputs = new WeakSet<HTMLInputElement>();
666
+ const unbinds: Array<() => void> = [];
520
667
 
521
- registerValidators(form);
522
-
523
- const unbinds = inputs.map((input) => {
524
- if (!(field instanceof HTMLElement)) {
525
- return () => {};
526
- }
527
-
528
- const state: FileUploadState = {
529
- field,
530
- input,
531
- summaryRoot: ensureSummaryRoot(field, input),
532
- uploadedAssets: mergeUploadedAssets(
533
- readUploadedAssetsFromHiddenInputs(field, input),
534
- readSummaryItems(field.querySelector('[data-formie-file-summary]') as HTMLElement | null)
535
- ),
536
- pendingFiles: [],
537
- hydrationToken: 0,
538
- };
539
- const syncPendingFiles = () => {
540
- state.pendingFiles = Array.from(input.files || []).map((file) => {
541
- return file.name;
542
- });
543
- renderSummary(state);
544
- };
545
- const syncUploadedAssets = async(uploadedAssets: UploadedAsset[]) => {
546
- state.hydrationToken += 1;
547
- const hydrationToken = state.hydrationToken;
548
- state.pendingFiles = [];
549
- state.uploadedAssets = uploadedAssets;
550
- updateUploadedAssetInputs(field, input, uploadedAssets);
551
- renderSummary(state);
552
-
553
- try {
554
- const hydratedAssets = await hydrateUploadedAssets(field, input, uploadedAssets);
555
-
556
- if (hydrationToken !== state.hydrationToken) {
557
- return;
558
- }
559
-
560
- state.uploadedAssets = hydratedAssets;
561
- renderSummary(state);
562
- dispatchFieldEvent(field, MODULE_ID, 'uploaded-assets-sync', {
563
- fileUpload: field,
564
- assets: hydratedAssets,
565
- });
566
- } catch (error) {
567
- console.error('[formie] Failed to hydrate uploaded file details.', error);
568
- }
569
- };
570
- const resetUploadedAssets = () => {
571
- state.hydrationToken += 1;
572
- state.pendingFiles = [];
573
- state.uploadedAssets = [];
574
- updateUploadedAssetInputs(field, input, []);
575
- renderSummary(state);
576
- };
577
- const uploadedAssetHandler = (event: Event) => {
578
- const fieldHandle = getFieldHandle(field);
579
-
580
- if (!fieldHandle) {
668
+ const bindInputsInRoot = (root: ParentNode) => {
669
+ collectFileUploadInputs(root).forEach((input) => {
670
+ if (boundInputs.has(input)) {
581
671
  return;
582
672
  }
583
673
 
584
- const uploadedAssets = getUploadedAssetsFromEvent(event, {
585
- fieldHandle,
586
- inputKey: getFileUploadKey(input),
587
- inputName: input.name,
588
- pendingFiles: state.pendingFiles,
589
- });
674
+ const field = getFieldContainer(input);
590
675
 
591
- if (!uploadedAssets.length) {
676
+ if (!field) {
592
677
  return;
593
678
  }
594
679
 
595
- void syncUploadedAssets(uploadedAssets);
596
- };
597
- const resetHandler = () => {
598
- resetUploadedAssets();
599
- };
680
+ boundInputs.add(input);
681
+ unbinds.push(bindFileUploadInput(field, input, form));
682
+ });
683
+ };
684
+
685
+ registerValidators(form);
686
+ bindInputsInRoot(ctx.target);
600
687
 
601
- input.addEventListener('change', syncPendingFiles);
602
- form?.addEventListener(FILE_UPLOAD_EVENT, uploadedAssetHandler as EventListener);
603
- form?.addEventListener(FORM_RESET_EVENT, resetHandler as EventListener);
604
- renderSummary(state);
688
+ const repeaterInitRowHandler = (event: Event) => {
689
+ const detail = (event as CustomEvent<unknown>).detail;
605
690
 
606
- return () => {
607
- input.removeEventListener('change', syncPendingFiles);
608
- form?.removeEventListener(FILE_UPLOAD_EVENT, uploadedAssetHandler as EventListener);
609
- form?.removeEventListener(FORM_RESET_EVENT, resetHandler as EventListener);
610
- };
611
- });
691
+ if (!isRecord(detail)) {
692
+ return;
693
+ }
694
+
695
+ const row = detail.row;
696
+
697
+ if (row instanceof HTMLElement) {
698
+ bindInputsInRoot(row);
699
+ }
700
+ };
701
+
702
+ form?.addEventListener(REPEATER_INIT_ROW_EVENT, repeaterInitRowHandler as EventListener);
612
703
 
613
704
  await ctx.emit('formie:module:file-upload:init', {
614
- count: inputs.length,
705
+ count: unbinds.length,
615
706
  });
616
707
 
617
708
  return {
618
709
  destroy: () => {
710
+ form?.removeEventListener(REPEATER_INIT_ROW_EVENT, repeaterInitRowHandler as EventListener);
619
711
  unbinds.forEach((unbind) => {
620
712
  unbind();
621
713
  });
@@ -3,6 +3,7 @@ import type { FormSubmitResult } from '#contracts/schema';
3
3
  import { EventBus } from '#events/event-bus';
4
4
  import { submitForm } from '#transport/forms-api';
5
5
  import { createDebug } from '#utils/debug';
6
+ import { getFormPages, getValidationScope } from '#validation/scope';
6
7
  import type { FormieValidator } from '#validation/validator';
7
8
 
8
9
  export type SubmitPipelineContext = {
@@ -30,32 +31,6 @@ function getAbortedResult(stage: SubmitStage, reason?: string): FormSubmitResult
30
31
  };
31
32
  }
32
33
 
33
- function getPages(form: HTMLFormElement): HTMLElement[] {
34
- return Array.from(form.querySelectorAll('[data-formie-page]')) as HTMLElement[];
35
- }
36
-
37
- function getValidationScope(form: HTMLFormElement): { scope: Element; final: boolean } {
38
- const pages = getPages(form);
39
-
40
- if (!pages.length) {
41
- return {
42
- scope: form,
43
- final: true,
44
- };
45
- }
46
-
47
- // Multi-page submits validate the visible page until the user reaches the
48
- // final step, where hidden earlier pages are included again.
49
- const currentPage = pages.find((page) => {
50
- return !page.hasAttribute('data-formie-page-hidden');
51
- }) || pages[pages.length - 1];
52
-
53
- return {
54
- scope: currentPage,
55
- final: currentPage === pages[pages.length - 1],
56
- };
57
- }
58
-
59
34
  function isSubmittableControl(element: Element): element is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement {
60
35
  return element instanceof HTMLInputElement
61
36
  || element instanceof HTMLSelectElement
@@ -172,7 +147,7 @@ function appendMissingFieldClears(formData: FormData, fieldNames: Set<string>):
172
147
  }
173
148
 
174
149
  function buildSubmitFormData(form: HTMLFormElement, action: FormAction): FormData {
175
- const pages = getPages(form);
150
+ const pages = getFormPages(form);
176
151
  const currentPage = pages.find((page) => {
177
152
  return !page.hasAttribute('data-formie-page-hidden');
178
153
  }) || null;
@@ -200,7 +175,7 @@ function isFinalSubmitAttempt(form: HTMLFormElement, action: FormAction): boolea
200
175
  return false;
201
176
  }
202
177
 
203
- const pages = getPages(form);
178
+ const pages = getFormPages(form);
204
179
 
205
180
  if (!pages.length) {
206
181
  return true;