@verbb/formie-browser 1.0.2 → 1.0.4

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 (94) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/chunks/{address-finder-CQW81kY0.js → address-finder-BU4roxT6.js} +2 -2
  3. package/dist/chunks/{bpoint-D8OjLmsn.js → bpoint-uNg5j6rt.js} +1 -1
  4. package/dist/chunks/{calculations-BqZsENCq.js → calculations-C9t6Zop9.js} +2 -2
  5. package/dist/chunks/{captcha-eu-DIa6aUWz.js → captcha-eu-BAPzHNts.js} +2 -2
  6. package/dist/chunks/{checkbox-radio-BI-UjgMF.js → checkbox-radio-CHeLNVIK.js} +39 -34
  7. package/dist/chunks/{conditions-BVl6jTYo.js → conditions-DKxVCsjD.js} +189 -163
  8. package/dist/chunks/{date-picker-69TgvErA.js → date-picker-B3iQ797Z.js} +2 -2
  9. package/dist/chunks/{eway-1GPxH6DU.js → eway-DCielIML.js} +2 -2
  10. package/dist/chunks/file-upload-DP2TOUYV.js +316 -0
  11. package/dist/chunks/{friendly-captcha-v1-DA2QPKA3.js → friendly-captcha-v1-DdlcGiFY.js} +1 -1
  12. package/dist/chunks/{friendly-captcha-v2-BorOWiz_.js → friendly-captcha-v2-76w-YnFm.js} +1 -1
  13. package/dist/chunks/{go-cardless-DwBgERcA.js → go-cardless-rwCkzbNK.js} +1 -1
  14. package/dist/chunks/{google-address-BcTBncyS.js → google-address-BD8IZ86V.js} +77 -58
  15. package/dist/chunks/{hcaptcha-BchdVCqq.js → hcaptcha-BNNTL6uf.js} +2 -2
  16. package/dist/chunks/{hidden-D8RqSPt7.js → hidden-CW6Rcf1N.js} +1 -1
  17. package/dist/chunks/{index-C1ZOKiAi.js → index-D6npYxvr.js} +1372 -1178
  18. package/dist/chunks/{loqate-nXuTX7In.js → loqate-B-ZNS65B.js} +2 -2
  19. package/dist/chunks/{mollie-qdCynQCd.js → mollie-BDAZ-1X4.js} +1 -1
  20. package/dist/chunks/{moneris-BNEA-laO.js → moneris-B1b0uEtY.js} +1 -1
  21. package/dist/chunks/{opayo-BJtXOkuK.js → opayo-CC5hraHo.js} +2 -2
  22. package/dist/chunks/{paddle-D72Pa8kr.js → paddle-B4GmrDsq.js} +2 -2
  23. package/dist/chunks/{paypal-Cqt2bned.js → paypal-BFS6XsIS.js} +2 -2
  24. package/dist/chunks/{payway-BjCqsc0H.js → payway-DekoJead.js} +2 -2
  25. package/dist/chunks/{phone-country-K9pYAlzM.js → phone-country-Cm5YUeXI.js} +2 -2
  26. package/dist/chunks/{place-kit-Dg6f_UoM.js → place-kit-DizZWV_e.js} +1 -1
  27. package/dist/chunks/{recaptcha-enterprise--VYAeeqz.js → recaptcha-enterprise-BbOvO7JZ.js} +2 -2
  28. package/dist/chunks/{recaptcha-shared-PSIEEEe1.js → recaptcha-shared-Dwg1eVFS.js} +2 -2
  29. package/dist/chunks/{recaptcha-v2-checkbox-atBNe5ml.js → recaptcha-v2-checkbox-SY3ti5iQ.js} +2 -2
  30. package/dist/chunks/{recaptcha-v2-invisible-DnBufNPT.js → recaptcha-v2-invisible-DSmQnn6c.js} +2 -2
  31. package/dist/chunks/{recaptcha-v3-CF6O8rDj.js → recaptcha-v3-DNh7YCnR.js} +2 -2
  32. package/dist/chunks/{repeater-CB7Yojvx.js → repeater-DM0vuFNQ.js} +2 -2
  33. package/dist/chunks/{rich-text-nAV_SQZo.js → rich-text-C8aGYp70.js} +1 -1
  34. package/dist/chunks/{scripts-BkAAux0i.js → scripts-CKTFtxPO.js} +1 -1
  35. package/dist/chunks/{shared-DGqeMvcJ.js → shared--BU5CFoL.js} +4 -4
  36. package/dist/chunks/{signature-BT6CsQ6n.js → signature-pFhZfH7d.js} +1 -1
  37. package/dist/chunks/{snaptcha-C7qQo02f.js → snaptcha-VuOUOaLj.js} +1 -1
  38. package/dist/chunks/{square-C5AH3trb.js → square-BR9pvToy.js} +4 -4
  39. package/dist/chunks/{stripe-EJK0RyUv.js → stripe-OHUwoV9g.js} +2 -2
  40. package/dist/chunks/{summary-DC_ipXCU.js → summary-Da7Ktg3N.js} +2 -2
  41. package/dist/chunks/{table-BEAI99qo.js → table-C0PmR9bI.js} +2 -2
  42. package/dist/chunks/text-limit-mLEwZDIa.js +144 -0
  43. package/dist/chunks/{turnstile-_uCRwnU-.js → turnstile-9tM7q_LK.js} +2 -2
  44. package/dist/css/theme/forms/_form.css +28 -0
  45. package/dist/index.js +9 -9
  46. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  47. package/dist/js/core/field-error-container.d.ts +4 -0
  48. package/dist/js/core/field-error-container.d.ts.map +1 -0
  49. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  50. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  51. package/dist/js/modules/address/google-address.d.ts +7 -0
  52. package/dist/js/modules/address/google-address.d.ts.map +1 -1
  53. package/dist/js/modules/fields/checkbox-radio.d.ts.map +1 -1
  54. package/dist/js/modules/fields/conditions/effects.d.ts +3 -1
  55. package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
  56. package/dist/js/modules/fields/conditions.d.ts.map +1 -1
  57. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  58. package/dist/js/modules/fields/text-limit.d.ts.map +1 -1
  59. package/dist/js/submit/pipeline.d.ts.map +1 -1
  60. package/dist/js/utils/i18n.d.ts.map +1 -1
  61. package/dist/js/validation/rules/email.d.ts.map +1 -1
  62. package/dist/js/validation/rules/number.d.ts.map +1 -1
  63. package/dist/js/validation/scope.d.ts +6 -0
  64. package/dist/js/validation/scope.d.ts.map +1 -0
  65. package/dist/js/validation/submit-readiness.d.ts +4 -0
  66. package/dist/js/validation/submit-readiness.d.ts.map +1 -0
  67. package/dist/js/validation/validator.d.ts +1 -0
  68. package/dist/js/validation/validator.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/css/theme/forms/_form.css +28 -0
  71. package/src/js/core/create-formie-client.ts +6 -0
  72. package/src/js/core/field-error-container.ts +39 -0
  73. package/src/js/core/submit-result-state.ts +29 -2
  74. package/src/js/core/submit-result-ui.ts +3 -9
  75. package/src/js/modules/address/google-address.ts +33 -1
  76. package/src/js/modules/fields/checkbox-radio.ts +21 -15
  77. package/src/js/modules/fields/conditions/effects.ts +79 -2
  78. package/src/js/modules/fields/conditions.ts +53 -11
  79. package/src/js/modules/fields/file-upload.ts +200 -105
  80. package/src/js/modules/fields/text-limit.ts +87 -22
  81. package/src/js/submit/pipeline.ts +3 -28
  82. package/src/js/utils/i18n.ts +184 -5
  83. package/src/js/validation/rules/email.ts +3 -2
  84. package/src/js/validation/rules/match.ts +2 -2
  85. package/src/js/validation/rules/minmax.ts +4 -4
  86. package/src/js/validation/rules/number.ts +9 -5
  87. package/src/js/validation/rules/pattern.ts +4 -4
  88. package/src/js/validation/rules/required.ts +1 -1
  89. package/src/js/validation/rules/url.ts +1 -1
  90. package/src/js/validation/scope.ts +25 -0
  91. package/src/js/validation/submit-readiness.ts +157 -0
  92. package/src/js/validation/validator.ts +11 -7
  93. package/dist/chunks/file-upload-BDZZftgL.js +0 -281
  94. package/dist/chunks/text-limit-Blgq5G-G.js +0 -135
@@ -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
 
@@ -237,9 +239,10 @@ function registerValidators(form: HTMLFormElement | null): void {
237
239
 
238
240
  return input.files.length <= limit;
239
241
  }, ({ input, t }) => {
240
- return t('Choose up to {files} files.', {
241
- files: input.getAttribute('data-formie-file-limit') || '',
242
- });
242
+ return input.getAttribute('data-formie-validation-max-files-message')
243
+ ?? t('Choose up to {files} files.', {
244
+ files: input.getAttribute('data-formie-file-limit') || '',
245
+ });
243
246
  });
244
247
 
245
248
  validator.addValidator('fileSizeMinLimit', ({ input }) => {
@@ -254,9 +257,10 @@ function registerValidators(form: HTMLFormElement | null): void {
254
257
  return file.size >= sizeBytes;
255
258
  });
256
259
  }, ({ input, t }) => {
257
- return t('File must be larger than {filesize} MB.', {
258
- filesize: input.getAttribute('data-formie-size-min-limit') || '',
259
- });
260
+ return input.getAttribute('data-formie-validation-min-file-size-message')
261
+ ?? t('File must be larger than {filesize} MB.', {
262
+ filesize: input.getAttribute('data-formie-size-min-limit') || '',
263
+ });
260
264
  });
261
265
 
262
266
  validator.addValidator('fileSizeMaxLimit', ({ input }) => {
@@ -271,9 +275,10 @@ function registerValidators(form: HTMLFormElement | null): void {
271
275
  return file.size <= sizeBytes;
272
276
  });
273
277
  }, ({ input, t }) => {
274
- return t('File must be smaller than {filesize} MB.', {
275
- filesize: input.getAttribute('data-formie-size-max-limit') || '',
276
- });
278
+ return input.getAttribute('data-formie-validation-max-file-size-message')
279
+ ?? t('File must be smaller than {filesize} MB.', {
280
+ filesize: input.getAttribute('data-formie-size-max-limit') || '',
281
+ });
277
282
  });
278
283
  });
279
284
  }
@@ -366,6 +371,47 @@ function updateUploadedAssetInputs(field: HTMLElement, input: HTMLInputElement,
366
371
  input.value = '';
367
372
  }
368
373
 
374
+ function getFormHandle(form: HTMLFormElement | null): string {
375
+ if (!form) {
376
+ return '';
377
+ }
378
+
379
+ const handleInput = form.querySelector('input[name="handle"]');
380
+
381
+ if (handleInput instanceof HTMLInputElement && handleInput.value.trim()) {
382
+ return handleInput.value.trim();
383
+ }
384
+
385
+ return form.getAttribute('data-formie-handle')?.trim() || '';
386
+ }
387
+
388
+ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assetIds: number[]): FormData {
389
+ const form = input.form;
390
+ const body = new FormData();
391
+ const handle = getFormHandle(form);
392
+ const fieldHandle = getFieldHandle(field);
393
+
394
+ if (handle) {
395
+ body.append('handle', handle);
396
+ }
397
+
398
+ if (fieldHandle) {
399
+ body.append('fieldHandle', fieldHandle);
400
+ }
401
+
402
+ const submissionUidInput = form?.querySelector('input[name="submissionUid"]');
403
+
404
+ if (submissionUidInput instanceof HTMLInputElement && submissionUidInput.value.trim()) {
405
+ body.append('submissionUid', submissionUidInput.value.trim());
406
+ }
407
+
408
+ assetIds.forEach((assetId) => {
409
+ body.append('assetIds[]', String(assetId));
410
+ });
411
+
412
+ return body;
413
+ }
414
+
369
415
  async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement, uploadedAssets: UploadedAsset[]): Promise<UploadedAsset[]> {
370
416
  const assetIds = uploadedAssets.map((asset) => {
371
417
  return asset.assetId;
@@ -378,14 +424,9 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
378
424
  }
379
425
 
380
426
  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
427
  const response = await requestJson<FileUploadHydrateResponse>(hydrateEndpoint, {
387
428
  method: 'POST',
388
- body,
429
+ body: buildHydrateFormData(field, input, assetIds),
389
430
  });
390
431
  const hydratedAssets = normalizeUploadedAssets(response.assets);
391
432
 
@@ -504,6 +545,118 @@ function getUploadedAssetsFromEvent(event: Event, matcher: FileUploadEventMatche
504
545
  return [];
505
546
  }
506
547
 
548
+ function getFieldContainer(input: HTMLInputElement): HTMLElement | null {
549
+ const field = input.closest(FIELD_HANDLE_SELECTOR);
550
+
551
+ return field instanceof HTMLElement ? field : null;
552
+ }
553
+
554
+ function collectFileUploadInputs(root: ParentNode): HTMLInputElement[] {
555
+ return Array.from(root.querySelectorAll(INPUT_SELECTOR)).filter((input): input is HTMLInputElement => {
556
+ return input instanceof HTMLInputElement;
557
+ });
558
+ }
559
+
560
+ function bindFileUploadInput(field: HTMLElement, input: HTMLInputElement, form: HTMLFormElement | null): () => void {
561
+ const state: FileUploadState = {
562
+ field,
563
+ input,
564
+ summaryRoot: ensureSummaryRoot(field, input),
565
+ uploadedAssets: mergeUploadedAssets(
566
+ readUploadedAssetsFromHiddenInputs(field, input),
567
+ readSummaryItems(field.querySelector('[data-formie-file-summary]') as HTMLElement | null),
568
+ ),
569
+ pendingFiles: [],
570
+ hydrationToken: 0,
571
+ };
572
+ const syncPendingFiles = () => {
573
+ state.pendingFiles = Array.from(input.files || []).map((file) => {
574
+ return file.name;
575
+ });
576
+ renderSummary(state);
577
+ };
578
+ const syncUploadedAssets = async(uploadedAssets: UploadedAsset[]) => {
579
+ state.hydrationToken += 1;
580
+ const hydrationToken = state.hydrationToken;
581
+ state.pendingFiles = [];
582
+ state.uploadedAssets = uploadedAssets;
583
+ updateUploadedAssetInputs(field, input, uploadedAssets);
584
+ renderSummary(state);
585
+
586
+ try {
587
+ const hydratedAssets = await hydrateUploadedAssets(field, input, uploadedAssets);
588
+
589
+ if (hydrationToken !== state.hydrationToken) {
590
+ return;
591
+ }
592
+
593
+ state.uploadedAssets = hydratedAssets;
594
+ renderSummary(state);
595
+ dispatchFieldEvent(field, MODULE_ID, 'uploaded-assets-sync', {
596
+ fileUpload: field,
597
+ assets: hydratedAssets,
598
+ });
599
+ } catch (error) {
600
+ console.error('[formie] Failed to hydrate uploaded file details.', error);
601
+ }
602
+ };
603
+ const resetUploadedAssets = () => {
604
+ state.hydrationToken += 1;
605
+ state.pendingFiles = [];
606
+ state.uploadedAssets = [];
607
+ updateUploadedAssetInputs(field, input, []);
608
+ renderSummary(state);
609
+ };
610
+ const uploadedAssetHandler = (event: Event) => {
611
+ const fieldHandle = getFieldHandle(field);
612
+
613
+ if (!fieldHandle) {
614
+ return;
615
+ }
616
+
617
+ const uploadedAssets = getUploadedAssetsFromEvent(event, {
618
+ fieldHandle,
619
+ inputKey: getFileUploadKey(input),
620
+ inputName: input.name,
621
+ pendingFiles: state.pendingFiles,
622
+ });
623
+
624
+ if (!uploadedAssets.length) {
625
+ return;
626
+ }
627
+
628
+ void syncUploadedAssets(uploadedAssets);
629
+ };
630
+ const resetHandler = () => {
631
+ resetUploadedAssets();
632
+ };
633
+
634
+ input.addEventListener('change', syncPendingFiles);
635
+ form?.addEventListener(FILE_UPLOAD_EVENT, uploadedAssetHandler as EventListener);
636
+ form?.addEventListener(FORM_RESET_EVENT, resetHandler as EventListener);
637
+
638
+ const syncInitialState = () => {
639
+ const assetIdsFromDom = readUploadedAssetsFromHiddenInputs(field, input);
640
+ const summaryItems = readSummaryItems(state.summaryRoot);
641
+ state.uploadedAssets = mergeUploadedAssets(assetIdsFromDom, summaryItems);
642
+
643
+ if (state.uploadedAssets.some((asset) => asset.assetId && !asset.filename)) {
644
+ void syncUploadedAssets(state.uploadedAssets);
645
+ return;
646
+ }
647
+
648
+ renderSummary(state);
649
+ };
650
+
651
+ syncInitialState();
652
+
653
+ return () => {
654
+ input.removeEventListener('change', syncPendingFiles);
655
+ form?.removeEventListener(FILE_UPLOAD_EVENT, uploadedAssetHandler as EventListener);
656
+ form?.removeEventListener(FORM_RESET_EVENT, resetHandler as EventListener);
657
+ };
658
+ }
659
+
507
660
  export const fileUploadModule: FormieModuleDefinition = {
508
661
  id: MODULE_ID,
509
662
  kind: 'field',
@@ -511,111 +664,53 @@ export const fileUploadModule: FormieModuleDefinition = {
511
664
  return !!ctx.target.querySelector(INPUT_SELECTOR);
512
665
  },
513
666
  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
667
  const form = ctx.form;
668
+ const boundInputs = new WeakSet<HTMLInputElement>();
669
+ const unbinds: Array<() => void> = [];
520
670
 
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) {
671
+ const bindInputsInRoot = (root: ParentNode) => {
672
+ collectFileUploadInputs(root).forEach((input) => {
673
+ if (boundInputs.has(input)) {
581
674
  return;
582
675
  }
583
676
 
584
- const uploadedAssets = getUploadedAssetsFromEvent(event, {
585
- fieldHandle,
586
- inputKey: getFileUploadKey(input),
587
- inputName: input.name,
588
- pendingFiles: state.pendingFiles,
589
- });
677
+ const field = getFieldContainer(input);
590
678
 
591
- if (!uploadedAssets.length) {
679
+ if (!field) {
592
680
  return;
593
681
  }
594
682
 
595
- void syncUploadedAssets(uploadedAssets);
596
- };
597
- const resetHandler = () => {
598
- resetUploadedAssets();
599
- };
683
+ boundInputs.add(input);
684
+ unbinds.push(bindFileUploadInput(field, input, form));
685
+ });
686
+ };
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
+ registerValidators(form);
689
+ bindInputsInRoot(ctx.target);
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
+ const repeaterInitRowHandler = (event: Event) => {
692
+ const detail = (event as CustomEvent<unknown>).detail;
693
+
694
+ if (!isRecord(detail)) {
695
+ return;
696
+ }
697
+
698
+ const row = detail.row;
699
+
700
+ if (row instanceof HTMLElement) {
701
+ bindInputsInRoot(row);
702
+ }
703
+ };
704
+
705
+ form?.addEventListener(REPEATER_INIT_ROW_EVENT, repeaterInitRowHandler as EventListener);
612
706
 
613
707
  await ctx.emit('formie:module:file-upload:init', {
614
- count: inputs.length,
708
+ count: unbinds.length,
615
709
  });
616
710
 
617
711
  return {
618
712
  destroy: () => {
713
+ form?.removeEventListener(REPEATER_INIT_ROW_EVENT, repeaterInitRowHandler as EventListener);
619
714
  unbinds.forEach((unbind) => {
620
715
  unbind();
621
716
  });
@@ -5,6 +5,7 @@ import { getTextLimitMetrics } from '@verbb/formie-core';
5
5
  import type { FormieModuleDefinition } from '#contracts/modules';
6
6
  import { getModuleFieldTarget, releaseFormValidators, retainFormValidators } from '#modules/fields/shared';
7
7
  import { ensureModuleStyles } from '#modules/styles';
8
+ import { t } from '#utils/i18n';
8
9
 
9
10
  const INPUT_SELECTOR = 'input[data-formie-single-line-text-input], textarea[data-formie-multi-line-text-input]';
10
11
  const TEXT_LIMIT_VALIDATORS = [
@@ -15,8 +16,17 @@ const TEXT_LIMIT_VALIDATORS = [
15
16
  ] as const;
16
17
  const VALIDATOR_SCOPE = 'text-limit';
17
18
  const ALLOW_OVERTYPE_ATTR = 'data-formie-text-limit-allow-overtype';
19
+ const TEXT_LIMIT_CHARACTERS_ALLOWED = '{count, plural, one{character allowed} other{characters allowed}}';
20
+ const TEXT_LIMIT_CHARACTERS_LEFT = '{count, plural, one{character left} other{characters left}}';
21
+ const TEXT_LIMIT_CHARACTERS_OVER = '{count, plural, one{character over limit} other{characters over limit}}';
22
+ const TEXT_LIMIT_WORDS_ALLOWED = '{count, plural, one{word allowed} other{words allowed}}';
23
+ const TEXT_LIMIT_WORDS_LEFT = '{count, plural, one{word left} other{words left}}';
24
+ const TEXT_LIMIT_WORDS_OVER = '{count, plural, one{word over limit} other{words over limit}}';
18
25
  const limitTargetCache = new WeakMap<HTMLInputElement | HTMLTextAreaElement, HTMLElement | null>();
19
26
 
27
+ type TextLimitUnit = 'character' | 'word';
28
+ type TextLimitCounterState = 'allowed' | 'left' | 'over';
29
+
20
30
  ensureModuleStyles('text-limit', [textLimitCss]);
21
31
 
22
32
  type TextLimitOptions = {
@@ -69,10 +79,11 @@ function registerValidators(form: HTMLFormElement | null): void {
69
79
 
70
80
  return getTextLimitMetrics(input.value).graphemeCount >= limit;
71
81
  }, ({ label, input, t }) => {
72
- return t('{attribute} must be no less than {min} characters.', {
73
- attribute: label,
74
- min: input.getAttribute('data-formie-min-chars') || '',
75
- });
82
+ return input.getAttribute('data-formie-validation-min-characters-message')
83
+ || t('{label} must be no less than {min} characters.', {
84
+ label,
85
+ min: input.getAttribute('data-formie-min-chars') || '',
86
+ });
76
87
  });
77
88
 
78
89
  validator.addValidator('textMaxCharacterLimit', ({ input }) => {
@@ -92,10 +103,11 @@ function registerValidators(form: HTMLFormElement | null): void {
92
103
 
93
104
  return getTextLimitMetrics(input.value).graphemeCount <= limit;
94
105
  }, ({ label, input, t }) => {
95
- return t('{attribute} must be no greater than {max} characters.', {
96
- attribute: label,
97
- max: input.getAttribute('data-formie-max-chars') || '',
98
- });
106
+ return input.getAttribute('data-formie-validation-max-characters-message')
107
+ || t('{label} must be no greater than {max} characters.', {
108
+ label,
109
+ max: input.getAttribute('data-formie-max-chars') || '',
110
+ });
99
111
  });
100
112
 
101
113
  validator.addValidator('textMinWordLimit', ({ input }) => {
@@ -111,10 +123,11 @@ function registerValidators(form: HTMLFormElement | null): void {
111
123
 
112
124
  return getTextLimitMetrics(input.value).wordCount >= limit;
113
125
  }, ({ label, input, t }) => {
114
- return t('{attribute} must be no less than {min} words.', {
115
- attribute: label,
116
- min: input.getAttribute('data-formie-min-words') || '',
117
- });
126
+ return input.getAttribute('data-formie-validation-min-words-message')
127
+ || t('{label} must be no less than {min} words.', {
128
+ label,
129
+ min: input.getAttribute('data-formie-min-words') || '',
130
+ });
118
131
  });
119
132
 
120
133
  validator.addValidator('textMaxWordLimit', ({ input }) => {
@@ -134,10 +147,11 @@ function registerValidators(form: HTMLFormElement | null): void {
134
147
 
135
148
  return getTextLimitMetrics(input.value).wordCount <= limit;
136
149
  }, ({ label, input, t }) => {
137
- return t('{attribute} must be no greater than {max} words.', {
138
- attribute: label,
139
- max: input.getAttribute('data-formie-max-words') || '',
140
- });
150
+ return input.getAttribute('data-formie-validation-max-words-message')
151
+ || t('{label} must be no greater than {max} words.', {
152
+ label,
153
+ max: input.getAttribute('data-formie-max-words') || '',
154
+ });
141
155
  });
142
156
  });
143
157
  }
@@ -182,14 +196,65 @@ function getLimitTarget(input: HTMLInputElement | HTMLTextAreaElement): HTMLElem
182
196
  return target;
183
197
  }
184
198
 
185
- function renderCounter(target: HTMLElement, remaining: number, unit: 'character' | 'word'): void {
199
+ function getTextLimitCounterState(
200
+ input: HTMLInputElement | HTMLTextAreaElement,
201
+ remaining: number,
202
+ unit: TextLimitUnit,
203
+ ): TextLimitCounterState {
204
+ const isEmpty = unit === 'character' ? input.value === '' : input.value.trim() === '';
205
+
206
+ if (isEmpty) {
207
+ return 'allowed';
208
+ }
209
+
210
+ if (remaining < 0) {
211
+ return 'over';
212
+ }
213
+
214
+ return 'left';
215
+ }
216
+
217
+ function getTextLimitCounterMessageKey(unit: TextLimitUnit, state: TextLimitCounterState): string {
218
+ if (unit === 'character') {
219
+ if (state === 'allowed') {
220
+ return TEXT_LIMIT_CHARACTERS_ALLOWED;
221
+ }
222
+
223
+ if (state === 'over') {
224
+ return TEXT_LIMIT_CHARACTERS_OVER;
225
+ }
226
+
227
+ return TEXT_LIMIT_CHARACTERS_LEFT;
228
+ }
229
+
230
+ if (state === 'allowed') {
231
+ return TEXT_LIMIT_WORDS_ALLOWED;
232
+ }
233
+
234
+ if (state === 'over') {
235
+ return TEXT_LIMIT_WORDS_OVER;
236
+ }
237
+
238
+ return TEXT_LIMIT_WORDS_LEFT;
239
+ }
240
+
241
+ function renderCounter(
242
+ target: HTMLElement,
243
+ input: HTMLInputElement | HTMLTextAreaElement,
244
+ remaining: number,
245
+ limit: number,
246
+ unit: TextLimitUnit,
247
+ ): void {
248
+ const state = getTextLimitCounterState(input, remaining, unit);
249
+ const displayCount = state === 'allowed' ? limit : Math.abs(remaining);
186
250
  const number = document.createElement('span');
187
- number.className = remaining < 0 ? 'formie-limit-number formie-limit-number-error' : 'formie-limit-number';
188
- number.textContent = String(remaining);
251
+ number.className = state === 'over' ? 'formie-limit-number formie-limit-number-error' : 'formie-limit-number';
252
+ number.textContent = String(displayCount);
253
+ const messageKey = getTextLimitCounterMessageKey(unit, state);
189
254
 
190
255
  target.replaceChildren(
191
256
  number,
192
- document.createTextNode(` ${Math.abs(remaining) === 1 ? unit : `${unit}s`} left`)
257
+ document.createTextNode(` ${t(messageKey, { count: displayCount })}`)
193
258
  );
194
259
  }
195
260
 
@@ -206,14 +271,14 @@ function updateCounter(input: HTMLInputElement | HTMLTextAreaElement): void {
206
271
 
207
272
  if (maxChars > 0) {
208
273
  const remaining = maxChars - metrics.graphemeCount;
209
- renderCounter(target, remaining, 'character');
274
+ renderCounter(target, input, remaining, maxChars, 'character');
210
275
 
211
276
  return;
212
277
  }
213
278
 
214
279
  if (maxWords > 0) {
215
280
  const remaining = maxWords - metrics.wordCount;
216
- renderCounter(target, remaining, 'word');
281
+ renderCounter(target, input, remaining, maxWords, 'word');
217
282
  }
218
283
  }
219
284
 
@@ -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;