@verbb/formie-browser 1.0.3 → 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 (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/chunks/{address-finder-BjMuEiwF.js → address-finder-BU4roxT6.js} +2 -2
  3. package/dist/chunks/{bpoint-KudsBCo2.js → bpoint-uNg5j6rt.js} +1 -1
  4. package/dist/chunks/{calculations-DlGSkQ3W.js → calculations-C9t6Zop9.js} +2 -2
  5. package/dist/chunks/{captcha-eu-CterYFok.js → captcha-eu-BAPzHNts.js} +2 -2
  6. package/dist/chunks/{checkbox-radio-CBvgk3hR.js → checkbox-radio-CHeLNVIK.js} +39 -34
  7. package/dist/chunks/{conditions-DYy1-Hwt.js → conditions-DKxVCsjD.js} +2 -2
  8. package/dist/chunks/{date-picker-BlD--zfe.js → date-picker-B3iQ797Z.js} +2 -2
  9. package/dist/chunks/{eway-qwc-B5Us.js → eway-DCielIML.js} +2 -2
  10. package/dist/chunks/{file-upload-CIgHrXfp.js → file-upload-DP2TOUYV.js} +34 -34
  11. package/dist/chunks/{friendly-captcha-v1-EDFac33c.js → friendly-captcha-v1-DdlcGiFY.js} +1 -1
  12. package/dist/chunks/{friendly-captcha-v2-BDpSvFGR.js → friendly-captcha-v2-76w-YnFm.js} +1 -1
  13. package/dist/chunks/{go-cardless-DuDMVsnz.js → go-cardless-rwCkzbNK.js} +1 -1
  14. package/dist/chunks/{google-address-Bmjp01pH.js → google-address-BD8IZ86V.js} +2 -2
  15. package/dist/chunks/{hcaptcha-BTQ6An6g.js → hcaptcha-BNNTL6uf.js} +2 -2
  16. package/dist/chunks/{hidden-DbPdOxKb.js → hidden-CW6Rcf1N.js} +1 -1
  17. package/dist/chunks/{index-BcqwpPNE.js → index-D6npYxvr.js} +790 -681
  18. package/dist/chunks/{loqate-BdNGv93o.js → loqate-B-ZNS65B.js} +2 -2
  19. package/dist/chunks/{mollie-DFOjTuGX.js → mollie-BDAZ-1X4.js} +1 -1
  20. package/dist/chunks/{moneris-BbFfl-EL.js → moneris-B1b0uEtY.js} +1 -1
  21. package/dist/chunks/{opayo-CmgYa9ya.js → opayo-CC5hraHo.js} +2 -2
  22. package/dist/chunks/{paddle-BUi7w1_o.js → paddle-B4GmrDsq.js} +2 -2
  23. package/dist/chunks/{paypal-A-ePfh6k.js → paypal-BFS6XsIS.js} +2 -2
  24. package/dist/chunks/{payway-BqFCbhyo.js → payway-DekoJead.js} +2 -2
  25. package/dist/chunks/{phone-country-DPMiqk4L.js → phone-country-Cm5YUeXI.js} +2 -2
  26. package/dist/chunks/{place-kit-CX7tamAg.js → place-kit-DizZWV_e.js} +1 -1
  27. package/dist/chunks/{recaptcha-enterprise-DxF6cW95.js → recaptcha-enterprise-BbOvO7JZ.js} +2 -2
  28. package/dist/chunks/{recaptcha-shared-DhGcbiaq.js → recaptcha-shared-Dwg1eVFS.js} +2 -2
  29. package/dist/chunks/{recaptcha-v2-checkbox-R9EfbZ4k.js → recaptcha-v2-checkbox-SY3ti5iQ.js} +2 -2
  30. package/dist/chunks/{recaptcha-v2-invisible-DV9hswAp.js → recaptcha-v2-invisible-DSmQnn6c.js} +2 -2
  31. package/dist/chunks/{recaptcha-v3-V0xc-Edh.js → recaptcha-v3-DNh7YCnR.js} +2 -2
  32. package/dist/chunks/{repeater-DKUT6TGI.js → repeater-DM0vuFNQ.js} +2 -2
  33. package/dist/chunks/{rich-text-SH7KiaKH.js → rich-text-C8aGYp70.js} +1 -1
  34. package/dist/chunks/{scripts-D9N2d5rN.js → scripts-CKTFtxPO.js} +1 -1
  35. package/dist/chunks/{shared-BmntPmnW.js → shared--BU5CFoL.js} +1 -1
  36. package/dist/chunks/{signature-BA3tQsGT.js → signature-pFhZfH7d.js} +1 -1
  37. package/dist/chunks/{snaptcha-DV7p6J9k.js → snaptcha-VuOUOaLj.js} +1 -1
  38. package/dist/chunks/{square-Bs-ndqYK.js → square-BR9pvToy.js} +4 -4
  39. package/dist/chunks/{stripe-DkZAHxtn.js → stripe-OHUwoV9g.js} +2 -2
  40. package/dist/chunks/{summary-Dq2i9TQk.js → summary-Da7Ktg3N.js} +2 -2
  41. package/dist/chunks/{table-Cu4WfVcw.js → table-C0PmR9bI.js} +2 -2
  42. package/dist/chunks/text-limit-mLEwZDIa.js +144 -0
  43. package/dist/chunks/{turnstile-CId90tRr.js → turnstile-9tM7q_LK.js} +2 -2
  44. package/dist/index.js +9 -9
  45. package/dist/js/core/field-error-container.d.ts +4 -0
  46. package/dist/js/core/field-error-container.d.ts.map +1 -0
  47. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  48. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  49. package/dist/js/modules/fields/checkbox-radio.d.ts.map +1 -1
  50. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  51. package/dist/js/modules/fields/text-limit.d.ts.map +1 -1
  52. package/dist/js/utils/i18n.d.ts.map +1 -1
  53. package/dist/js/validation/rules/email.d.ts.map +1 -1
  54. package/dist/js/validation/rules/number.d.ts.map +1 -1
  55. package/dist/js/validation/validator.d.ts.map +1 -1
  56. package/package.json +2 -2
  57. package/src/js/core/field-error-container.ts +39 -0
  58. package/src/js/core/submit-result-state.ts +12 -2
  59. package/src/js/core/submit-result-ui.ts +3 -9
  60. package/src/js/modules/fields/checkbox-radio.ts +21 -15
  61. package/src/js/modules/fields/file-upload.ts +12 -9
  62. package/src/js/modules/fields/text-limit.ts +87 -22
  63. package/src/js/utils/i18n.ts +184 -5
  64. package/src/js/validation/rules/email.ts +3 -2
  65. package/src/js/validation/rules/match.ts +2 -2
  66. package/src/js/validation/rules/minmax.ts +4 -4
  67. package/src/js/validation/rules/number.ts +9 -5
  68. package/src/js/validation/rules/pattern.ts +4 -4
  69. package/src/js/validation/rules/required.ts +1 -1
  70. package/src/js/validation/rules/url.ts +1 -1
  71. package/src/js/validation/validator.ts +7 -7
  72. package/dist/chunks/text-limit-2ycmB-Ae.js +0 -135
@@ -102,6 +102,16 @@ function getConfiguredSubmitAction(form: HTMLFormElement): string {
102
102
  return (form.dataset.formieSubmitAction || '').trim();
103
103
  }
104
104
 
105
+ function getResolvedSubmitAction(form: HTMLFormElement, result: FormSubmitResult): string {
106
+ const fromResponse = result.meta?.effectiveSubmitAction;
107
+
108
+ if (typeof fromResponse === 'string' && fromResponse.trim() !== '') {
109
+ return fromResponse.trim();
110
+ }
111
+
112
+ return getConfiguredSubmitAction(form);
113
+ }
114
+
105
115
  function shouldHideFormOnSuccess(form: HTMLFormElement): boolean {
106
116
  const rawValue = form.dataset.formieSubmitActionFormHide;
107
117
 
@@ -580,7 +590,7 @@ export function applySubmitResultState(form: HTMLFormElement, result: FormSubmit
580
590
  }
581
591
 
582
592
  if (action === 'submit' && !result.redirect?.url) {
583
- const configuredSubmitAction = getConfiguredSubmitAction(form);
593
+ const configuredSubmitAction = getResolvedSubmitAction(form, result);
584
594
  const preserveHiddenState = configuredSubmitAction === 'message' && shouldHideFormOnSuccess(form);
585
595
 
586
596
  if (configuredSubmitAction === 'reload') {
@@ -603,7 +613,7 @@ export function applySubmitResultState(form: HTMLFormElement, result: FormSubmit
603
613
  }
604
614
 
605
615
  if (action === 'submit' && result.redirect?.url && result.redirect.target === 'new-tab') {
606
- const configuredSubmitAction = getConfiguredSubmitAction(form);
616
+ const configuredSubmitAction = getResolvedSubmitAction(form, result);
607
617
  const preserveHiddenState = configuredSubmitAction === 'message' && shouldHideFormOnSuccess(form);
608
618
  clearPendingFinalSubmitReset(form);
609
619
  resetSubmissionState(form, { preserveHiddenState });
@@ -1,4 +1,5 @@
1
1
  import type { FormSubmitResult } from '#contracts/schema';
2
+ import { ensureFieldErrorContainer as resolveFieldErrorContainer } from '#core/field-error-container';
2
3
  import { syncPageTabErrors } from '#core/page-tab-errors';
3
4
  import { setFormHiddenState } from '#core/submit-result-state';
4
5
  import { addThemeClasses, removeThemeClasses } from '#theme/theme-classes';
@@ -150,16 +151,9 @@ function ensureFormSuccessContainer(form: HTMLFormElement, position: string): HT
150
151
  }
151
152
 
152
153
  function ensureFieldErrorContainer(fieldNode: Element): HTMLElement {
153
- let container = fieldNode.querySelector('[data-formie-field-errors]') as HTMLElement | null;
154
-
155
- if (!container) {
156
- container = document.createElement('div');
157
- container.setAttribute('data-formie-field-errors', 'true');
154
+ return resolveFieldErrorContainer(fieldNode, (container) => {
158
155
  addThemeClasses(container, fieldNode, 'fieldErrors');
159
- fieldNode.appendChild(container);
160
- }
161
-
162
- return container;
156
+ });
163
157
  }
164
158
 
165
159
  function removeDescribedBy(input: HTMLElement, describedById: string): void {
@@ -31,6 +31,14 @@ function getMinMaxRule(getRule: (rule: string) => unknown): { min: number | null
31
31
  };
32
32
  }
33
33
 
34
+ function getSelectedOptionCount(field: HTMLElement): number {
35
+ return Array.from(field.querySelectorAll('input[type="checkbox"]')).filter((input): input is HTMLInputElement => {
36
+ return input instanceof HTMLInputElement && !isToggleCheckbox(input);
37
+ }).filter((input) => {
38
+ return input.checked;
39
+ }).length;
40
+ }
41
+
34
42
  function registerValidators(form: HTMLFormElement | null): void {
35
43
  // Several checkbox/radio module instances can exist in one form, but the
36
44
  // custom validator should only be registered once per form lifecycle.
@@ -42,12 +50,7 @@ function registerValidators(form: HTMLFormElement | null): void {
42
50
  return true;
43
51
  }
44
52
 
45
- const inputs = Array.from(field.querySelectorAll('input[type="checkbox"]')).filter((input): input is HTMLInputElement => {
46
- return input instanceof HTMLInputElement && !isToggleCheckbox(input);
47
- });
48
- const selected = inputs.filter((input) => {
49
- return input.checked;
50
- }).length;
53
+ const selected = getSelectedOptionCount(field);
51
54
  const { min, max } = getMinMaxRule(getRule);
52
55
 
53
56
  if (min !== null && selected < min) {
@@ -61,21 +64,24 @@ function registerValidators(form: HTMLFormElement | null): void {
61
64
  return true;
62
65
  },
63
66
  ({ field, label, t, getRule }) => {
64
- const { min, max } = field ? getMinMaxRule(getRule) : { min: null, max: null };
65
-
66
- if (min !== null && max !== null) {
67
- return t('{attribute} must select between {min} and {max}.', { attribute: label, min, max });
67
+ if (!field) {
68
+ return t('{label} has an invalid value.', { label });
68
69
  }
69
70
 
70
- if (min !== null) {
71
- return t('{attribute} must select no less than {min}.', { attribute: label, min });
71
+ const selected = getSelectedOptionCount(field);
72
+ const { min, max } = getMinMaxRule(getRule);
73
+
74
+ if (min !== null && selected < min) {
75
+ return field.getAttribute('data-formie-validation-min-options-message')
76
+ ?? t('{label} should contain at least {min, number} {min, plural, one{option} other{options}}.', { label, min });
72
77
  }
73
78
 
74
- if (max !== null) {
75
- return t('{attribute} must select no greater than {max}.', { attribute: label, max });
79
+ if (max !== null && selected > max) {
80
+ return field.getAttribute('data-formie-validation-max-options-message')
81
+ ?? t('{label} should contain at most {max, number} {max, plural, one{option} other{options}}.', { label, max });
76
82
  }
77
83
 
78
- return t('{attribute} has an invalid value.', { attribute: label });
84
+ return t('{label} has an invalid value.', { label });
79
85
  },
80
86
  );
81
87
  });
@@ -239,9 +239,10 @@ function registerValidators(form: HTMLFormElement | null): void {
239
239
 
240
240
  return input.files.length <= limit;
241
241
  }, ({ input, t }) => {
242
- return t('Choose up to {files} files.', {
243
- files: input.getAttribute('data-formie-file-limit') || '',
244
- });
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
+ });
245
246
  });
246
247
 
247
248
  validator.addValidator('fileSizeMinLimit', ({ input }) => {
@@ -256,9 +257,10 @@ function registerValidators(form: HTMLFormElement | null): void {
256
257
  return file.size >= sizeBytes;
257
258
  });
258
259
  }, ({ input, t }) => {
259
- return t('File must be larger than {filesize} MB.', {
260
- filesize: input.getAttribute('data-formie-size-min-limit') || '',
261
- });
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
+ });
262
264
  });
263
265
 
264
266
  validator.addValidator('fileSizeMaxLimit', ({ input }) => {
@@ -273,9 +275,10 @@ function registerValidators(form: HTMLFormElement | null): void {
273
275
  return file.size <= sizeBytes;
274
276
  });
275
277
  }, ({ input, t }) => {
276
- return t('File must be smaller than {filesize} MB.', {
277
- filesize: input.getAttribute('data-formie-size-max-limit') || '',
278
- });
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
+ });
279
282
  });
280
283
  });
281
284
  }
@@ -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
 
@@ -79,18 +79,197 @@ export function mergeFormieTranslations(translations: Record<string, string>): R
79
79
  return getFormieTranslations();
80
80
  }
81
81
 
82
- export function t(message: string, replacements: TranslationReplacements = {}): string {
83
- let output = getTranslationStore()[message] || message;
82
+ function parsePluralBranches(body: string): Record<string, string> {
83
+ const branches: Record<string, string> = {};
84
+ let index = 0;
85
+
86
+ while (index < body.length) {
87
+ while (index < body.length && /\s/.test(body[index])) {
88
+ index++;
89
+ }
90
+
91
+ if (index >= body.length) {
92
+ break;
93
+ }
94
+
95
+ const branchMatch = body.slice(index).match(/^(\w+|=\d+)\{/);
96
+
97
+ if (!branchMatch) {
98
+ break;
99
+ }
100
+
101
+ const key = branchMatch[1];
102
+ index += branchMatch[0].length;
103
+
104
+ let depth = 1;
105
+ const contentStart = index;
106
+
107
+ while (index < body.length && depth > 0) {
108
+ if (body[index] === '{') {
109
+ depth++;
110
+ } else if (body[index] === '}') {
111
+ depth--;
112
+ }
113
+
114
+ if (depth > 0) {
115
+ index++;
116
+ }
117
+ }
118
+
119
+ branches[key] = body.slice(contentStart, index);
120
+ index++;
121
+ }
122
+
123
+ return branches;
124
+ }
125
+
126
+ function selectPluralBranch(count: number, branches: Record<string, string>): string {
127
+ const exactKey = `=${count}`;
128
+
129
+ if (Object.prototype.hasOwnProperty.call(branches, exactKey)) {
130
+ return branches[exactKey];
131
+ }
132
+
133
+ if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
134
+ const category = new Intl.PluralRules().select(count);
135
+
136
+ if (Object.prototype.hasOwnProperty.call(branches, category)) {
137
+ return branches[category];
138
+ }
139
+ }
140
+
141
+ if (count === 1 && Object.prototype.hasOwnProperty.call(branches, 'one')) {
142
+ return branches.one;
143
+ }
144
+
145
+ if (Object.prototype.hasOwnProperty.call(branches, 'other')) {
146
+ return branches.other;
147
+ }
148
+
149
+ const firstKey = Object.keys(branches)[0];
150
+
151
+ return firstKey ? branches[firstKey] : '';
152
+ }
153
+
154
+ function parsePluralExpression(message: string, startIndex: number): { param: string; body: string; endIndex: number } | null {
155
+ const headerMatch = message.slice(startIndex).match(/^\{(\w+),\s*plural,\s*/);
156
+
157
+ if (!headerMatch) {
158
+ return null;
159
+ }
160
+
161
+ const param = headerMatch[1];
162
+ const bodyStart = startIndex + headerMatch[0].length;
163
+ let index = bodyStart;
164
+
165
+ while (index < message.length) {
166
+ while (index < message.length && /\s/.test(message[index])) {
167
+ index++;
168
+ }
169
+
170
+ if (index >= message.length || message[index] === '}') {
171
+ break;
172
+ }
173
+
174
+ const branchMatch = message.slice(index).match(/^(\w+|=\d+)\{/);
84
175
 
85
- // Keep replacement syntax intentionally small and predictable because these
86
- // messages are often shared across PHP-rendered and JS-rendered validation UI.
87
- output = output.replace(/{([a-zA-Z0-9]+)}/g, (match, key) => {
176
+ if (!branchMatch) {
177
+ return null;
178
+ }
179
+
180
+ index += branchMatch[0].length;
181
+
182
+ let depth = 1;
183
+
184
+ while (index < message.length && depth > 0) {
185
+ if (message[index] === '{') {
186
+ depth++;
187
+ } else if (message[index] === '}') {
188
+ depth--;
189
+ }
190
+
191
+ if (depth > 0) {
192
+ index++;
193
+ }
194
+ }
195
+
196
+ index++;
197
+ }
198
+
199
+ if (index >= message.length || message[index] !== '}') {
200
+ return null;
201
+ }
202
+
203
+ return {
204
+ param,
205
+ body: message.slice(bodyStart, index),
206
+ endIndex: index,
207
+ };
208
+ }
209
+
210
+ function formatPluralBlocks(message: string, replacements: TranslationReplacements): string {
211
+ let output = '';
212
+ let index = 0;
213
+
214
+ while (index < message.length) {
215
+ if (message[index] !== '{') {
216
+ output += message[index];
217
+ index++;
218
+ continue;
219
+ }
220
+
221
+ const parsed = parsePluralExpression(message, index);
222
+
223
+ if (!parsed) {
224
+ output += message[index];
225
+ index++;
226
+ continue;
227
+ }
228
+
229
+ const rawValue = replacements[parsed.param];
230
+ const count = typeof rawValue === 'number'
231
+ ? rawValue
232
+ : Number.parseInt(String(rawValue ?? ''), 10) || 0;
233
+ const branches = parsePluralBranches(parsed.body);
234
+ let selected = selectPluralBranch(count, branches);
235
+
236
+ selected = selected.replace(/#/g, String(count));
237
+
238
+ output += selected;
239
+ index = parsed.endIndex + 1;
240
+ }
241
+
242
+ return output;
243
+ }
244
+
245
+ function formatNumberBlocks(message: string, replacements: TranslationReplacements): string {
246
+ return message.replace(/\{(\w+),\s*number\}/g, (match, key) => {
247
+ if (!Object.prototype.hasOwnProperty.call(replacements, key)) {
248
+ return match;
249
+ }
250
+
251
+ const value = replacements[key];
252
+
253
+ return typeof value === 'number' ? value.toLocaleString() : String(value);
254
+ });
255
+ }
256
+
257
+ function replaceSimpleTokens(message: string, replacements: TranslationReplacements): string {
258
+ return message.replace(/\{(\w+)\}/g, (match, key) => {
88
259
  if (Object.prototype.hasOwnProperty.call(replacements, key)) {
89
260
  return String(replacements[key]);
90
261
  }
91
262
 
92
263
  return match;
93
264
  });
265
+ }
266
+
267
+ export function t(message: string, replacements: TranslationReplacements = {}): string {
268
+ let output = getTranslationStore()[message] || message;
269
+
270
+ output = formatPluralBlocks(output, replacements);
271
+ output = formatNumberBlocks(output, replacements);
272
+ output = replaceSimpleTokens(output, replacements);
94
273
 
95
274
  return output;
96
275
  }
@@ -9,9 +9,10 @@ const email: ValidationRuleDefinition = {
9
9
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value);
10
10
  },
11
11
  message: ({ input, label, t }) => {
12
- return input.getAttribute('data-formie-pattern-email-message')
12
+ return input.getAttribute('data-formie-validation-email-message')
13
+ ?? input.getAttribute('data-formie-pattern-email-message')
13
14
  ?? input.getAttribute('data-pattern-email-message')
14
- ?? t('{attribute} is not a valid email address.', { attribute: label });
15
+ ?? t('{label} is not a valid email address.', { label });
15
16
  },
16
17
  };
17
18
 
@@ -16,8 +16,8 @@ const match: ValidationRuleDefinition = {
16
16
  const sourceField = sourceInput?.closest('[data-formie-field-handle]') as HTMLElement | null;
17
17
  const sourceLabel = getLabelText(sourceField);
18
18
 
19
- return ctx.t('{name} must match {value}.', {
20
- name: ctx.label,
19
+ return ctx.t('{label} must match {value}.', {
20
+ label: ctx.label,
21
21
  value: sourceLabel,
22
22
  });
23
23
  },
@@ -29,18 +29,18 @@ const minmax: ValidationRuleDefinition = {
29
29
  const max = input.hasAttribute('max') ? parseFloat(input.getAttribute('max') || '') : null;
30
30
 
31
31
  if (min !== null && max !== null) {
32
- return t('{attribute} must be between {min} and {max}.', { attribute: label, min, max });
32
+ return t('{label} must be between {min} and {max}.', { label, min, max });
33
33
  }
34
34
 
35
35
  if (min !== null) {
36
- return t('{attribute} must be no less than {min}.', { attribute: label, min });
36
+ return t('{label} must be no less than {min}.', { label, min });
37
37
  }
38
38
 
39
39
  if (max !== null) {
40
- return t('{attribute} must be no greater than {max}.', { attribute: label, max });
40
+ return t('{label} must be no greater than {max}.', { label, max });
41
41
  }
42
42
 
43
- return t('{attribute} has an invalid value.', { attribute: label });
43
+ return t('{label} has an invalid value.', { label });
44
44
  },
45
45
  };
46
46
 
@@ -35,20 +35,24 @@ const number: ValidationRuleDefinition = {
35
35
  const max = rule !== true && rule && typeof rule === 'object' && typeof rule.max === 'number' ? rule.max : null;
36
36
 
37
37
  if (min !== null && max !== null) {
38
- return t('{attribute} must be between {min} and {max}.', { attribute: label, min, max });
38
+ return input.getAttribute('data-formie-validation-number-min-message')
39
+ ?? t('{label} must be between {min} and {max}.', { label, min, max });
39
40
  }
40
41
 
41
42
  if (min !== null) {
42
- return t('{attribute} must be no less than {min}.', { attribute: label, min });
43
+ return input.getAttribute('data-formie-validation-number-min-message')
44
+ ?? t('{label} must be no less than {min}.', { label, min });
43
45
  }
44
46
 
45
47
  if (max !== null) {
46
- return t('{attribute} must be no greater than {max}.', { attribute: label, max });
48
+ return input.getAttribute('data-formie-validation-number-max-message')
49
+ ?? t('{label} must be no greater than {max}.', { label, max });
47
50
  }
48
51
 
49
- return input.getAttribute('data-formie-pattern-number-message')
52
+ return input.getAttribute('data-formie-validation-number-message')
53
+ ?? input.getAttribute('data-formie-pattern-number-message')
50
54
  ?? input.getAttribute('data-pattern-number-message')
51
- ?? t('{attribute} is not a valid number.', { attribute: label });
55
+ ?? t('{label} is not a valid number.', { label });
52
56
  },
53
57
  };
54
58
 
@@ -13,10 +13,10 @@ const pattern: ValidationRuleDefinition = {
13
13
  },
14
14
  message: ({ input, label, t }) => {
15
15
  const messages = {
16
- email: t('{attribute} is not a valid email address.', { attribute: label }),
17
- url: t('{attribute} is not a valid URL.', { attribute: label }),
18
- number: t('{attribute} is not a valid number.', { attribute: label }),
19
- default: t('{attribute} is not a valid format.', { attribute: label }),
16
+ email: t('{label} is not a valid email address.', { label }),
17
+ url: t('{label} is not a valid URL.', { label }),
18
+ number: t('{label} is not a valid number.', { label }),
19
+ default: t('{label} is not a valid format.', { label }),
20
20
  };
21
21
 
22
22
  return input.getAttribute(`data-formie-pattern-${input.type}-message`)
@@ -23,7 +23,7 @@ const required: ValidationRuleDefinition = {
23
23
  message: ({ input, label, t }) => {
24
24
  return input.getAttribute('data-formie-required-message')
25
25
  ?? input.getAttribute('data-required-message')
26
- ?? t('{attribute} cannot be blank.', { attribute: label });
26
+ ?? t('{label} cannot be blank.', { label });
27
27
  },
28
28
  };
29
29
 
@@ -16,7 +16,7 @@ const url: ValidationRuleDefinition = {
16
16
  message: ({ input, label, t }) => {
17
17
  return input.getAttribute('data-formie-pattern-url-message')
18
18
  ?? input.getAttribute('data-pattern-url-message')
19
- ?? t('{attribute} is not a valid URL.', { attribute: label });
19
+ ?? t('{label} is not a valid URL.', { label });
20
20
  },
21
21
  };
22
22