@verbb/formie-browser 1.0.5 → 1.0.7

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 (98) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/chunks/{address-finder-yXU5yMjG.js → address-finder-k7_BXaRe.js} +1 -1
  3. package/dist/chunks/{api-DMK8NSUI.js → api-BUb6P-xu.js} +57 -51
  4. package/dist/chunks/{api-CmwLRq_n.js → api-DztsCJ4r.js} +178 -170
  5. package/dist/chunks/{bpoint-DSjqRv63.js → bpoint-2qDxlKD9.js} +1 -1
  6. package/dist/chunks/{calculations-CgVJcQ2P.js → calculations-CeDgB9jt.js} +13 -13
  7. package/dist/chunks/{captcha-eu-CWJb64_j.js → captcha-eu-CGm8Lbrj.js} +2 -2
  8. package/dist/chunks/{conditions-CZ3FU5QO.js → conditions-B6_nzpeE.js} +97 -85
  9. package/dist/chunks/dist-BQ4a9hmy.js +2659 -0
  10. package/dist/chunks/{eway-D3ctTbEk.js → eway-CImXrB4g.js} +2 -2
  11. package/dist/chunks/{friendly-captcha-v1-ByXzENYZ.js → friendly-captcha-v1-C_2reOXr.js} +1 -1
  12. package/dist/chunks/{friendly-captcha-v2-BPdlmhQu.js → friendly-captcha-v2-CLSAbkqx.js} +1 -1
  13. package/dist/chunks/{go-cardless-BwASSYSK.js → go-cardless-Bz7HHtH0.js} +1 -1
  14. package/dist/chunks/{google-address-OhKilPWE.js → google-address-BH3Zhi06.js} +1 -1
  15. package/dist/chunks/{hcaptcha-CdCd9oEi.js → hcaptcha-CGLAmgjO.js} +2 -2
  16. package/dist/chunks/{loqate-Cp2zvwpE.js → loqate-CJPzBmSm.js} +1 -1
  17. package/dist/chunks/{mollie-COG-ko2D.js → mollie-BtnB3KGh.js} +1 -1
  18. package/dist/chunks/{moneris-CMH8PlRc.js → moneris-Bd4vUk8b.js} +1 -1
  19. package/dist/chunks/{opayo-D2jsR5vh.js → opayo-B-eRIXo-.js} +4 -4
  20. package/dist/chunks/{paddle-DxF88RfC.js → paddle-BUFeEU4d.js} +2 -2
  21. package/dist/chunks/{paypal-b5pnwrA2.js → paypal-8Euw7Hrn.js} +2 -2
  22. package/dist/chunks/{payway-BJYu9DIR.js → payway-dV0SzJxy.js} +2 -2
  23. package/dist/chunks/{recaptcha-enterprise-BSwi8IVX.js → recaptcha-enterprise-XPId5-bS.js} +19 -20
  24. package/dist/chunks/{recaptcha-shared-B4zAescW.js → recaptcha-shared-CLYJvojk.js} +15 -3
  25. package/dist/chunks/{recaptcha-v2-checkbox-CnDQmsKa.js → recaptcha-v2-checkbox-Cy-0PEPc.js} +2 -2
  26. package/dist/chunks/{recaptcha-v2-invisible-Cz5uIlp-.js → recaptcha-v2-invisible-BwRH540O.js} +2 -2
  27. package/dist/chunks/recaptcha-v3-No4Ykrhk.js +28 -0
  28. package/dist/chunks/rich-text-D0sb8bmX.js +349 -0
  29. package/dist/chunks/scripts-CbQ7agX3.js +30 -0
  30. package/dist/chunks/{signature-CpgqHCEx.js → signature-xz4QO7CR.js} +107 -34
  31. package/dist/chunks/{snaptcha-vz1RnZ15.js → snaptcha-DKx98oon.js} +1 -1
  32. package/dist/chunks/{square-BP8RWAKB.js → square-reyJukb7.js} +2 -2
  33. package/dist/chunks/{stripe-MihN63uC.js → stripe-BC-2PBvY.js} +2 -2
  34. package/dist/chunks/{text-limit-B09elPDB.js → text-limit-DbgEFHLO.js} +1 -1
  35. package/dist/chunks/{turnstile-D_Pi1CKM.js → turnstile-DJakPzQD.js} +3 -3
  36. package/dist/chunks/upload-manager-BpqWSHmU.js +2558 -0
  37. package/dist/css/theme/_buttons.css +16 -0
  38. package/dist/css/theme/_tokens.css +2 -0
  39. package/dist/css/theme/fields/_signature.css +19 -0
  40. package/dist/css/theme/fields/_upload-manager.css +279 -0
  41. package/dist/index.js +629 -578
  42. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  43. package/dist/js/core/error-aria-live.d.ts +6 -0
  44. package/dist/js/core/error-aria-live.d.ts.map +1 -0
  45. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  46. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  47. package/dist/js/modules/captchas/factories.d.ts.map +1 -1
  48. package/dist/js/modules/captchas/host.d.ts +1 -0
  49. package/dist/js/modules/captchas/host.d.ts.map +1 -1
  50. package/dist/js/modules/captchas/recaptcha-enterprise.d.ts.map +1 -1
  51. package/dist/js/modules/captchas/recaptcha-shared.d.ts +1 -0
  52. package/dist/js/modules/captchas/recaptcha-shared.d.ts.map +1 -1
  53. package/dist/js/modules/captchas/recaptcha-v3.d.ts.map +1 -1
  54. package/dist/js/modules/captchas/turnstile.d.ts.map +1 -1
  55. package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
  56. package/dist/js/modules/fields/index.d.ts.map +1 -1
  57. package/dist/js/modules/fields/signature.d.ts.map +1 -1
  58. package/dist/js/modules/fields/upload-manager.d.ts +3 -0
  59. package/dist/js/modules/fields/upload-manager.d.ts.map +1 -0
  60. package/dist/js/utils/form-started-at.d.ts +2 -0
  61. package/dist/js/utils/form-started-at.d.ts.map +1 -0
  62. package/dist/js/utils/scripts.d.ts.map +1 -1
  63. package/dist/js/utils/unload-warning.d.ts.map +1 -1
  64. package/dist/js/validation/enter-key-guard.d.ts +2 -0
  65. package/dist/js/validation/enter-key-guard.d.ts.map +1 -0
  66. package/dist/js/validation/types.d.ts +1 -0
  67. package/dist/js/validation/types.d.ts.map +1 -1
  68. package/dist/js/validation/validator.d.ts.map +1 -1
  69. package/package.json +4 -2
  70. package/src/css/theme/_buttons.css +16 -0
  71. package/src/css/theme/_tokens.css +2 -0
  72. package/src/css/theme/fields/_signature.css +19 -0
  73. package/src/css/theme/fields/_upload-manager.css +279 -0
  74. package/src/js/core/create-formie-client.ts +7 -0
  75. package/src/js/core/error-aria-live.ts +45 -0
  76. package/src/js/core/submit-result-state.ts +4 -0
  77. package/src/js/core/submit-result-ui.ts +8 -4
  78. package/src/js/modules/captchas/factories.ts +4 -0
  79. package/src/js/modules/captchas/host.ts +14 -2
  80. package/src/js/modules/captchas/recaptcha-enterprise.ts +14 -11
  81. package/src/js/modules/captchas/recaptcha-shared.ts +14 -0
  82. package/src/js/modules/captchas/recaptcha-v3.ts +7 -4
  83. package/src/js/modules/captchas/turnstile.ts +9 -3
  84. package/src/js/modules/fields/conditions/effects.ts +42 -0
  85. package/src/js/modules/fields/index.ts +1 -0
  86. package/src/js/modules/fields/signature.ts +199 -15
  87. package/src/js/modules/fields/upload-manager.ts +1277 -0
  88. package/src/js/utils/form-started-at.ts +25 -0
  89. package/src/js/utils/scripts.ts +16 -0
  90. package/src/js/utils/unload-warning.ts +1 -0
  91. package/src/js/validation/enter-key-guard.ts +63 -0
  92. package/src/js/validation/types.ts +1 -0
  93. package/src/js/validation/validator.ts +14 -5
  94. package/src/vendor.d.ts +1 -1
  95. package/dist/chunks/dist-BF5GU3_9.js +0 -2674
  96. package/dist/chunks/recaptcha-v3-BtbbP111.js +0 -27
  97. package/dist/chunks/rich-text-Cv8ADyk-.js +0 -349
  98. package/dist/chunks/scripts-6CYSUGKQ.js +0 -24
@@ -0,0 +1,45 @@
1
+ export type ErrorAriaLivePreference = 'polite' | 'assertive' | 'off';
2
+
3
+ export function getErrorAriaLivePreference(form: HTMLFormElement): ErrorAriaLivePreference {
4
+ const value = (form.dataset.formieErrorAriaLive || 'polite').trim().toLowerCase();
5
+
6
+ if (value === 'assertive' || value === 'off') {
7
+ return value;
8
+ }
9
+
10
+ return 'polite';
11
+ }
12
+
13
+ export function resolveValidationErrorAriaLive(
14
+ preference: ErrorAriaLivePreference,
15
+ submitted: boolean,
16
+ ): 'polite' | 'assertive' | null {
17
+ if (preference === 'off') {
18
+ return null;
19
+ }
20
+
21
+ if (!submitted) {
22
+ return 'polite';
23
+ }
24
+
25
+ return preference;
26
+ }
27
+
28
+ export function resolveSubmitErrorAriaLive(preference: ErrorAriaLivePreference): 'polite' | 'assertive' | null {
29
+ if (preference === 'off') {
30
+ return null;
31
+ }
32
+
33
+ return preference;
34
+ }
35
+
36
+ export function applyErrorAriaLive(element: HTMLElement, ariaLive: 'polite' | 'assertive' | null): void {
37
+ if (ariaLive) {
38
+ element.setAttribute('aria-live', ariaLive);
39
+ element.setAttribute('aria-atomic', 'true');
40
+ return;
41
+ }
42
+
43
+ element.removeAttribute('aria-live');
44
+ element.removeAttribute('aria-atomic');
45
+ }
@@ -1,5 +1,6 @@
1
1
  import type { FormAction } from '#contracts/common';
2
2
  import type { FormSubmitResult } from '#contracts/schema';
3
+ import { dispatchFormieDomEvent } from '#core/dom-events';
3
4
  import { dispatchPageClientEventForSubmit } from '#core/page-client-event';
4
5
  import { syncPageTabErrors } from '#core/page-tab-errors';
5
6
  import { addThemeClasses, removeThemeClasses, toggleThemeClasses } from '#theme/theme-classes';
@@ -576,6 +577,9 @@ export function applySubmitResultState(form: HTMLFormElement, result: FormSubmit
576
577
  const validator = formWithValidationApi.formieValidation;
577
578
  validator?.resetLiveState();
578
579
  applyPageState(form, result.nextPage.id);
580
+ dispatchFormieDomEvent(form, 'formie:page:navigate:after', {
581
+ pageId: result.nextPage.id,
582
+ });
579
583
  debug.log('Advanced to next page.', {
580
584
  nextPageId: result.nextPage.id,
581
585
  });
@@ -1,4 +1,5 @@
1
1
  import type { FormSubmitResult } from '#contracts/schema';
2
+ import { applyErrorAriaLive, getErrorAriaLivePreference, resolveSubmitErrorAriaLive } from '#core/error-aria-live';
2
3
  import { ensureFieldErrorContainer as resolveFieldErrorContainer } from '#core/field-error-container';
3
4
  import { syncPageTabErrors } from '#core/page-tab-errors';
4
5
  import { focusFirstValidationError } from '#core/validation-focus';
@@ -124,8 +125,10 @@ function ensureFormErrorMessageContainer(form: HTMLFormElement, container: HTMLE
124
125
  // Form-level errors use a live region because they are often the only output
125
126
  // for submit-stage failures such as provider or transport errors.
126
127
  messageContainer.setAttribute('role', 'alert');
127
- messageContainer.setAttribute('aria-live', 'polite');
128
- messageContainer.setAttribute('aria-atomic', 'true');
128
+ applyErrorAriaLive(
129
+ messageContainer,
130
+ resolveSubmitErrorAriaLive(getErrorAriaLivePreference(form)),
131
+ );
129
132
 
130
133
  return messageContainer;
131
134
  }
@@ -295,6 +298,8 @@ function appendDescribedBy(input: HTMLElement, describedById: string): void {
295
298
  }
296
299
 
297
300
  export function renderFieldErrors(form: HTMLFormElement, fieldErrors: Record<string, string[]>): void {
301
+ const errorAriaLive = resolveSubmitErrorAriaLive(getErrorAriaLivePreference(form));
302
+
298
303
  Object.entries(fieldErrors).forEach(([handle, messages]) => {
299
304
  const fieldNode = form.querySelector(`[data-formie-field-handle="${handle}"]`);
300
305
 
@@ -307,8 +312,7 @@ export function renderFieldErrors(form: HTMLFormElement, fieldErrors: Record<str
307
312
  ? container.id
308
313
  : `${handle}-errors`;
309
314
  container.id = containerId;
310
- container.setAttribute('aria-live', 'polite');
311
- container.setAttribute('aria-atomic', 'true');
315
+ applyErrorAriaLive(container, errorAriaLive);
312
316
  addThemeClasses(fieldNode as HTMLElement, form, 'fieldLayoutError');
313
317
  (fieldNode as HTMLElement).setAttribute('data-formie-field-has-error', 'true');
314
318
 
@@ -436,6 +436,10 @@ export function createManagedCaptchaModule<
436
436
  return;
437
437
  }
438
438
 
439
+ // Reconcile immediately so captcha placeholders on the newly
440
+ // visible page are mounted before screen-stage policy runs.
441
+ visibility.reconcileImmediate();
442
+
439
443
  // By the time provider `screen()` runs, the widget is
440
444
  // guaranteed to be mounted and the active placeholder is
441
445
  // resolved. That lets provider code focus on challenge
@@ -17,6 +17,7 @@ type Cleanup = () => void;
17
17
  type ObserveVisiblePlaceholdersResult = {
18
18
  cleanup: Cleanup;
19
19
  reconcile: () => void;
20
+ reconcileImmediate: () => void;
20
21
  getVisible: () => HTMLElement[];
21
22
  };
22
23
 
@@ -115,7 +116,12 @@ const CAPTCHA_VISIBILITY_EVENTS = [
115
116
  'formie:page:navigate',
116
117
  'formie:page:navigate:after',
117
118
  'formie:submit:result',
118
- ];
119
+ ] as const;
120
+
121
+ const CAPTCHA_IMMEDIATE_RECONCILE_EVENTS = new Set<string>([
122
+ 'formie:page:navigate',
123
+ 'formie:page:navigate:after',
124
+ ]);
119
125
 
120
126
  function bindDomEvent(target: EventTarget, eventName: string, callback: EventListener): Cleanup {
121
127
  target.addEventListener(eventName, callback);
@@ -152,7 +158,7 @@ function isElementVisible(target: Element): target is HTMLElement {
152
158
 
153
159
  const style = window.getComputedStyle(target);
154
160
 
155
- return style.display !== 'none' && style.visibility !== 'hidden';
161
+ return style.display !== 'none' && style.visibility !== 'hidden' && target.getClientRects().length > 0;
156
162
  }
157
163
 
158
164
  function getPrimaryPlaceholder(root: Element, selector: string): HTMLElement | null {
@@ -271,6 +277,11 @@ function observeVisiblePlaceholders(
271
277
  }),
272
278
  ...CAPTCHA_VISIBILITY_EVENTS.map((eventName) => {
273
279
  return bindDomEvent(root, eventName, () => {
280
+ if (CAPTCHA_IMMEDIATE_RECONCILE_EVENTS.has(eventName)) {
281
+ reconcileNow();
282
+ return;
283
+ }
284
+
274
285
  reconcile();
275
286
  });
276
287
  }),
@@ -292,6 +303,7 @@ function observeVisiblePlaceholders(
292
303
  visible.clear();
293
304
  },
294
305
  reconcile,
306
+ reconcileImmediate: reconcileNow,
295
307
  getVisible: () => {
296
308
  return queryCaptchaPlaceholders(root, selector).filter((placeholder) => isElementVisible(placeholder));
297
309
  },
@@ -4,6 +4,7 @@ import {
4
4
  import { CAPTCHA_EXECUTE_WAIT_FOR_VALUE_MS } from '#modules/captchas/constants';
5
5
  import {
6
6
  loadRecaptchaGlobal,
7
+ whenRecaptchaReady,
7
8
  type RecaptchaGlobal,
8
9
  type RecaptchaProviderOptions,
9
10
  } from '#modules/captchas/recaptcha-shared';
@@ -94,19 +95,21 @@ export const recaptchaEnterpriseModule = defineCaptchaModule<RecaptchaEnterprise
94
95
  return;
95
96
  }
96
97
 
97
- if (provider.enterpriseType === 'score' || provider.enterpriseType === 'policy') {
98
- // Score and policy-based keys execute by site key and can resolve
99
- // directly to a token. We write that token into the shared transport
100
- // layer so the backend can validate it in the usual way.
101
- const token = await enterpriseApi.execute(provider.siteKey || '', { action: provider.action || 'submit' });
98
+ await whenRecaptchaReady(api, async() => {
99
+ if (provider.enterpriseType === 'score' || provider.enterpriseType === 'policy') {
100
+ // Score and policy-based keys execute by site key and can resolve
101
+ // directly to a token. We write that token into the shared transport
102
+ // layer so the backend can validate it in the usual way.
103
+ const token = await enterpriseApi.execute(provider.siteKey || '', { action: provider.action || 'submit' });
102
104
 
103
- if (typeof token === 'string' && token.trim() !== '') {
104
- services.tokens.write(token.trim());
105
+ if (typeof token === 'string' && token.trim() !== '') {
106
+ services.tokens.write(token.trim());
107
+ }
108
+ } else {
109
+ // Fallback branch for any future Enterprise widget-like mode.
110
+ enterpriseApi.execute(widget);
105
111
  }
106
- } else {
107
- // Fallback branch for any future Enterprise widget-like mode.
108
- enterpriseApi.execute(widget);
109
- }
112
+ });
110
113
 
111
114
  const hasToken = await services.tokens.wait(CAPTCHA_EXECUTE_WAIT_FOR_VALUE_MS);
112
115
 
@@ -67,3 +67,17 @@ export async function loadRecaptchaGlobal(
67
67
  timeoutMs: CAPTCHA_PROVIDER_LOAD_TIMEOUT_MS,
68
68
  });
69
69
  }
70
+
71
+ export function whenRecaptchaReady(api: RecaptchaGlobal, callback: () => void | Promise<void>): Promise<void> {
72
+ const readyApi = api.enterprise || api;
73
+
74
+ return new Promise((resolve, reject) => {
75
+ try {
76
+ readyApi.ready(() => {
77
+ Promise.resolve(callback()).then(resolve).catch(reject);
78
+ });
79
+ } catch (error) {
80
+ reject(error);
81
+ }
82
+ });
83
+ }
@@ -4,6 +4,7 @@ import {
4
4
  import { CAPTCHA_EXECUTE_WAIT_FOR_VALUE_MS } from '#modules/captchas/constants';
5
5
  import {
6
6
  loadRecaptchaGlobal,
7
+ whenRecaptchaReady,
7
8
  type RecaptchaGlobal,
8
9
  type RecaptchaProviderOptions,
9
10
  } from '#modules/captchas/recaptcha-shared';
@@ -40,11 +41,13 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
40
41
 
41
42
  // v3 returns its token directly from `execute()`, unlike widget-based
42
43
  // captchas that signal completion later via callbacks.
43
- const token = await api.execute(provider.siteKey || '', { action: provider.action || 'submit' });
44
+ await whenRecaptchaReady(api, async() => {
45
+ const token = await api.execute(provider.siteKey || '', { action: provider.action || 'submit' });
44
46
 
45
- if (typeof token === 'string' && token.trim() !== '') {
46
- services.tokens.write(token.trim());
47
- }
47
+ if (typeof token === 'string' && token.trim() !== '') {
48
+ services.tokens.write(token.trim());
49
+ }
50
+ });
48
51
 
49
52
  // We still wait on the shared token layer so the module follows the
50
53
  // same transport contract as every other captcha provider.
@@ -14,6 +14,7 @@ type TurnstileGlobal = {
14
14
  render: (container: HTMLElement, options: Record<string, unknown>) => string;
15
15
  execute: (widgetId: string) => void;
16
16
  reset: (widgetId: string) => void;
17
+ remove: (widgetId: string) => void;
17
18
  };
18
19
 
19
20
  // Provider-specific options only. Generic captcha concerns are handled by the
@@ -123,9 +124,14 @@ export const turnstileModule = defineCaptchaModule<TurnstileProviderOptions, Tur
123
124
  });
124
125
  },
125
126
  unmount: ({ api, widget, services }) => {
126
- // Reset provider state whenever the widget is torn down so the DOM
127
- // transport layer and the provider widget stay in sync.
128
- api.reset(widget);
127
+ // Remove the widget before clearing placeholder DOM so Turnstile does not
128
+ // log errors about orphaned challenge iframes during remounts.
129
+ if (typeof api.remove === 'function') {
130
+ api.remove(widget);
131
+ } else {
132
+ api.reset(widget);
133
+ }
134
+
129
135
  services.tokens.clear();
130
136
  },
131
137
  });
@@ -72,7 +72,49 @@ function setVisibilityState(node: Element, hidden: boolean): boolean {
72
72
  return wasHidden !== hidden;
73
73
  }
74
74
 
75
+ const SUBMIT_CONTROL_SELECTOR = 'button[type="submit"], button[data-formie-action], input[type="submit"]';
76
+
77
+ function syncControlDisabledState(control: Element, hidden: boolean): void {
78
+ if (!(control instanceof HTMLButtonElement || control instanceof HTMLInputElement)) {
79
+ return;
80
+ }
81
+
82
+ if (hidden) {
83
+ if (!control.hasAttribute(CONDITION_DISABLED_ATTR)) {
84
+ if (control.hasAttribute('disabled')) {
85
+ control.setAttribute(PRESERVED_DISABLED_ATTR, 'true');
86
+ }
87
+
88
+ control.setAttribute(CONDITION_DISABLED_ATTR, 'true');
89
+ }
90
+
91
+ control.setAttribute('disabled', 'true');
92
+ return;
93
+ }
94
+
95
+ if (!control.hasAttribute(CONDITION_DISABLED_ATTR)) {
96
+ return;
97
+ }
98
+
99
+ if (control.hasAttribute(PRESERVED_DISABLED_ATTR)) {
100
+ control.setAttribute('disabled', 'true');
101
+ control.removeAttribute(PRESERVED_DISABLED_ATTR);
102
+ } else {
103
+ control.removeAttribute('disabled');
104
+ }
105
+
106
+ control.removeAttribute(CONDITION_DISABLED_ATTR);
107
+ }
108
+
75
109
  function syncDisabledState(node: Element, hidden: boolean): void {
110
+ if (node.matches(SUBMIT_CONTROL_SELECTOR)) {
111
+ syncControlDisabledState(node, hidden);
112
+ }
113
+
114
+ node.querySelectorAll(SUBMIT_CONTROL_SELECTOR).forEach((control) => {
115
+ syncControlDisabledState(control, hidden);
116
+ });
117
+
76
118
  node.querySelectorAll('input, textarea, select').forEach((input) => {
77
119
  if (hidden) {
78
120
  if (!input.hasAttribute(CONDITION_DISABLED_ATTR)) {
@@ -12,6 +12,7 @@ export const builtinFieldModuleLoaders: Record<string, () => Promise<FormieModul
12
12
  'custom-maps': () => import('#modules/fields/custom-maps').then((module) => module.customMapsModule),
13
13
  'date-picker': () => import('#modules/fields/date-picker').then((module) => module.datePickerModule),
14
14
  'file-upload': () => import('#modules/fields/file-upload').then((module) => module.fileUploadModule),
15
+ 'upload-manager': () => import('#modules/fields/upload-manager').then((module) => module.uploadManagerModule),
15
16
  'hidden': () => import('#modules/fields/hidden').then((module) => module.hiddenModule),
16
17
  'phone-country': () => import('#modules/fields/phone-country').then((module) => module.phoneCountryModule),
17
18
  'address-country': () => import('#modules/fields/address-country').then((module) => module.addressCountryModule),
@@ -8,7 +8,16 @@ import { ensureModuleStyles } from '#modules/styles';
8
8
  const INPUT_SELECTOR = 'input[data-formie-signature-input]';
9
9
  const CANVAS_SELECTOR = 'canvas[data-formie-signature-canvas]';
10
10
  const CLEAR_SELECTOR = '[data-formie-signature-clear]';
11
+ const MESSAGE_SELECTOR = '[data-formie-signature-message]';
11
12
  const MODULE_ID = 'signature';
13
+ const MAX_SIZE_ATTEMPTS = 20;
14
+ const SIZE_RETRY_MS = 100;
15
+ const VISIBILITY_ATTRS = [
16
+ 'hidden',
17
+ 'data-formie-conditionally-hidden',
18
+ 'data-formie-page-hidden',
19
+ 'data-formie-row-hidden',
20
+ ] as const;
12
21
 
13
22
  ensureModuleStyles(MODULE_ID, [signatureCss]);
14
23
 
@@ -18,6 +27,11 @@ type SignatureOptions = {
18
27
  penWeight?: string;
19
28
  };
20
29
 
30
+ type SignatureMessages = {
31
+ noCanvas: string;
32
+ initFailed: string;
33
+ };
34
+
21
35
  function getCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } {
22
36
  const rect = canvas.getBoundingClientRect();
23
37
 
@@ -27,6 +41,58 @@ function getCanvasSize(canvas: HTMLCanvasElement): { width: number; height: numb
27
41
  };
28
42
  }
29
43
 
44
+ function supportsCanvas(): boolean {
45
+ const probe = document.createElement('canvas');
46
+
47
+ return typeof probe.getContext === 'function' && !!probe.getContext('2d');
48
+ }
49
+
50
+ function getMessageElement(field: HTMLElement): HTMLElement | null {
51
+ const message = field.querySelector(MESSAGE_SELECTOR);
52
+
53
+ return message instanceof HTMLElement ? message : null;
54
+ }
55
+
56
+ function getSignatureMessages(field: HTMLElement): SignatureMessages {
57
+ const message = getMessageElement(field);
58
+
59
+ return {
60
+ noCanvas: message?.dataset.formieSignatureMessageNoCanvas
61
+ || 'This browser does not support canvas, which is required for signatures.',
62
+ initFailed: message?.dataset.formieSignatureMessageInitFailed
63
+ || 'The signature pad could not be loaded. Try refreshing the page.',
64
+ };
65
+ }
66
+
67
+ function setSignatureMessage(field: HTMLElement, canvas: HTMLCanvasElement, message: string | null): void {
68
+ const messageElement = getMessageElement(field);
69
+
70
+ if (!messageElement) {
71
+ return;
72
+ }
73
+
74
+ if (message) {
75
+ messageElement.textContent = message;
76
+ messageElement.hidden = false;
77
+ canvas.setAttribute('aria-hidden', 'true');
78
+ field.classList.add('formie-signature-has-message');
79
+ return;
80
+ }
81
+
82
+ messageElement.textContent = '';
83
+ messageElement.hidden = true;
84
+ canvas.removeAttribute('aria-hidden');
85
+ field.classList.remove('formie-signature-has-message');
86
+ }
87
+
88
+ function isFieldVisible(field: HTMLElement): boolean {
89
+ return !field.hasAttribute('hidden')
90
+ && !field.hasAttribute('data-formie-conditionally-hidden')
91
+ && !field.hasAttribute('data-formie-page-hidden')
92
+ && !field.hasAttribute('data-formie-row-hidden')
93
+ && field.getClientRects().length > 0;
94
+ }
95
+
30
96
  function drawValueOnCanvas(canvas: HTMLCanvasElement, value: string): void {
31
97
  if (!value) {
32
98
  return;
@@ -45,6 +111,18 @@ function drawValueOnCanvas(canvas: HTMLCanvasElement, value: string): void {
45
111
  };
46
112
  }
47
113
 
114
+ async function restoreSignatureValue(signaturePad: SignaturePad, canvas: HTMLCanvasElement, value: string): Promise<void> {
115
+ if (!value) {
116
+ return;
117
+ }
118
+
119
+ try {
120
+ await signaturePad.fromDataURL(value);
121
+ } catch {
122
+ drawValueOnCanvas(canvas, value);
123
+ }
124
+ }
125
+
48
126
  function initSignatureField(
49
127
  root: HTMLElement,
50
128
  field: HTMLElement,
@@ -53,8 +131,21 @@ function initSignatureField(
53
131
  clearButton: HTMLElement | null,
54
132
  options: SignatureOptions,
55
133
  ): () => void {
134
+ const messages = getSignatureMessages(field);
135
+
136
+ if (!supportsCanvas()) {
137
+ setSignatureMessage(field, canvas, messages.noCanvas);
138
+
139
+ return () => {};
140
+ }
141
+
56
142
  const penWeight = parseFloat(options.penWeight || '2') || 2;
57
143
  const resizeTarget = canvas.parentElement instanceof HTMLElement ? canvas.parentElement : field;
144
+ let sizeAttempts = 0;
145
+ let sizeRetryTimer: number | null = null;
146
+ let readyDispatched = false;
147
+ let initFailed = false;
148
+
58
149
  const signaturePad = new SignaturePad(canvas, {
59
150
  backgroundColor: options.backgroundColor || 'rgba(255, 255, 255, 0)',
60
151
  penColor: options.penColor || '#000000',
@@ -63,51 +154,142 @@ function initSignatureField(
63
154
  maxWidth: penWeight,
64
155
  });
65
156
 
66
- const resizeCanvas = () => {
157
+ const clearSizeRetry = () => {
158
+ if (sizeRetryTimer !== null) {
159
+ window.clearTimeout(sizeRetryTimer);
160
+ sizeRetryTimer = null;
161
+ }
162
+ };
163
+
164
+ const markReady = () => {
165
+ if (readyDispatched) {
166
+ return;
167
+ }
168
+
169
+ readyDispatched = true;
170
+ initFailed = false;
171
+ setSignatureMessage(field, canvas, null);
172
+ dispatchFieldEvent(field, MODULE_ID, 'init', {
173
+ signature: signaturePad,
174
+ });
175
+ };
176
+
177
+ const markInitFailed = () => {
178
+ if (readyDispatched || initFailed) {
179
+ return;
180
+ }
181
+
182
+ initFailed = true;
183
+ setSignatureMessage(field, canvas, messages.initFailed);
184
+ };
185
+
186
+ const resizeCanvas = async(): Promise<boolean> => {
67
187
  const { width, height } = getCanvasSize(canvas);
68
188
 
69
189
  if (!(width > 0) || !(height > 0)) {
70
- return;
190
+ return false;
71
191
  }
72
192
 
73
193
  const ratio = Math.max(window.devicePixelRatio || 1, 1);
74
194
  const context = canvas.getContext('2d');
75
195
  if (!context) {
76
- return;
196
+ return false;
77
197
  }
78
198
 
79
- // SignaturePad clears bitmap state when the canvas size changes, so resize
80
- // captures the current data URL first and redraws it onto the new surface.
81
199
  const existingValue = input.value || (signaturePad.isEmpty() ? '' : signaturePad.toDataURL());
200
+
82
201
  canvas.width = width * ratio;
83
202
  canvas.height = height * ratio;
84
203
  context.setTransform(1, 0, 0, 1, 0, 0);
85
204
  context.scale(ratio, ratio);
86
205
  signaturePad.clear();
87
- drawValueOnCanvas(canvas, existingValue);
206
+
207
+ await restoreSignatureValue(signaturePad, canvas, existingValue);
208
+ markReady();
209
+
210
+ return true;
211
+ };
212
+
213
+ const scheduleSizeRetry = () => {
214
+ if (readyDispatched || initFailed) {
215
+ return;
216
+ }
217
+
218
+ if (sizeAttempts >= MAX_SIZE_ATTEMPTS) {
219
+ markInitFailed();
220
+ return;
221
+ }
222
+
223
+ clearSizeRetry();
224
+ sizeRetryTimer = window.setTimeout(() => {
225
+ sizeRetryTimer = null;
226
+ sizeAttempts += 1;
227
+ void attemptResize();
228
+ }, SIZE_RETRY_MS);
229
+ };
230
+
231
+ const attemptResize = async() => {
232
+ const sized = await resizeCanvas();
233
+
234
+ if (!sized) {
235
+ scheduleSizeRetry();
236
+ return;
237
+ }
238
+
239
+ clearSizeRetry();
240
+ sizeAttempts = 0;
88
241
  };
89
242
 
90
243
  const scheduleResize = (delay = 0) => {
91
244
  window.setTimeout(() => {
92
245
  window.requestAnimationFrame(() => {
93
- resizeCanvas();
246
+ void attemptResize();
94
247
  });
95
248
  }, delay);
96
249
  };
97
- const resizeHandler = () => {
250
+
251
+ const requestResize = () => {
252
+ if (isFieldVisible(field) && !readyDispatched) {
253
+ initFailed = false;
254
+ }
255
+
98
256
  scheduleResize();
99
257
  };
258
+
259
+ const resizeHandler = () => {
260
+ requestResize();
261
+ };
262
+
100
263
  const pageNavigateHandler = () => {
101
- // Multi-page forms can reveal a signature pad after layout changes; delay
102
- // slightly so the newly visible page has settled before measuring.
264
+ if (!isFieldVisible(field)) {
265
+ return;
266
+ }
267
+
268
+ sizeAttempts = 0;
269
+ initFailed = false;
103
270
  scheduleResize(100);
104
271
  };
272
+
273
+ const visibilityHandler = () => {
274
+ if (!isFieldVisible(field)) {
275
+ return;
276
+ }
277
+
278
+ sizeAttempts = 0;
279
+ initFailed = false;
280
+ requestResize();
281
+ };
282
+
105
283
  const resizeObserver = typeof ResizeObserver === 'undefined'
106
284
  ? null
107
285
  : new ResizeObserver(() => {
108
- scheduleResize();
286
+ requestResize();
109
287
  });
110
288
 
289
+ const visibilityObserver = new MutationObserver(() => {
290
+ visibilityHandler();
291
+ });
292
+
111
293
  const syncInputValue = (nextValue: string) => {
112
294
  const valueChanged = input.value !== nextValue;
113
295
  input.value = nextValue;
@@ -133,21 +315,23 @@ function initSignatureField(
133
315
  window.addEventListener('resize', resizeHandler);
134
316
  root.addEventListener('formie:page:navigate:after', pageNavigateHandler as EventListener);
135
317
  resizeObserver?.observe(resizeTarget);
318
+ visibilityObserver.observe(field, {
319
+ attributes: true,
320
+ attributeFilter: [...VISIBILITY_ATTRS],
321
+ });
136
322
  scheduleResize();
137
323
 
138
324
  if (clearButton) {
139
325
  clearButton.addEventListener('click', clearSignature);
140
326
  }
141
327
 
142
- dispatchFieldEvent(field, MODULE_ID, 'init', {
143
- signature: signaturePad,
144
- });
145
-
146
328
  return () => {
329
+ clearSizeRetry();
147
330
  signaturePad.removeEventListener('endStroke', syncValue);
148
331
  window.removeEventListener('resize', resizeHandler);
149
332
  root.removeEventListener('formie:page:navigate:after', pageNavigateHandler as EventListener);
150
333
  resizeObserver?.disconnect();
334
+ visibilityObserver.disconnect();
151
335
  if (clearButton) {
152
336
  clearButton.removeEventListener('click', clearSignature);
153
337
  }