@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.
- package/CHANGELOG.md +25 -0
- package/dist/chunks/{address-finder-yXU5yMjG.js → address-finder-k7_BXaRe.js} +1 -1
- package/dist/chunks/{api-DMK8NSUI.js → api-BUb6P-xu.js} +57 -51
- package/dist/chunks/{api-CmwLRq_n.js → api-DztsCJ4r.js} +178 -170
- package/dist/chunks/{bpoint-DSjqRv63.js → bpoint-2qDxlKD9.js} +1 -1
- package/dist/chunks/{calculations-CgVJcQ2P.js → calculations-CeDgB9jt.js} +13 -13
- package/dist/chunks/{captcha-eu-CWJb64_j.js → captcha-eu-CGm8Lbrj.js} +2 -2
- package/dist/chunks/{conditions-CZ3FU5QO.js → conditions-B6_nzpeE.js} +97 -85
- package/dist/chunks/dist-BQ4a9hmy.js +2659 -0
- package/dist/chunks/{eway-D3ctTbEk.js → eway-CImXrB4g.js} +2 -2
- package/dist/chunks/{friendly-captcha-v1-ByXzENYZ.js → friendly-captcha-v1-C_2reOXr.js} +1 -1
- package/dist/chunks/{friendly-captcha-v2-BPdlmhQu.js → friendly-captcha-v2-CLSAbkqx.js} +1 -1
- package/dist/chunks/{go-cardless-BwASSYSK.js → go-cardless-Bz7HHtH0.js} +1 -1
- package/dist/chunks/{google-address-OhKilPWE.js → google-address-BH3Zhi06.js} +1 -1
- package/dist/chunks/{hcaptcha-CdCd9oEi.js → hcaptcha-CGLAmgjO.js} +2 -2
- package/dist/chunks/{loqate-Cp2zvwpE.js → loqate-CJPzBmSm.js} +1 -1
- package/dist/chunks/{mollie-COG-ko2D.js → mollie-BtnB3KGh.js} +1 -1
- package/dist/chunks/{moneris-CMH8PlRc.js → moneris-Bd4vUk8b.js} +1 -1
- package/dist/chunks/{opayo-D2jsR5vh.js → opayo-B-eRIXo-.js} +4 -4
- package/dist/chunks/{paddle-DxF88RfC.js → paddle-BUFeEU4d.js} +2 -2
- package/dist/chunks/{paypal-b5pnwrA2.js → paypal-8Euw7Hrn.js} +2 -2
- package/dist/chunks/{payway-BJYu9DIR.js → payway-dV0SzJxy.js} +2 -2
- package/dist/chunks/{recaptcha-enterprise-BSwi8IVX.js → recaptcha-enterprise-XPId5-bS.js} +19 -20
- package/dist/chunks/{recaptcha-shared-B4zAescW.js → recaptcha-shared-CLYJvojk.js} +15 -3
- package/dist/chunks/{recaptcha-v2-checkbox-CnDQmsKa.js → recaptcha-v2-checkbox-Cy-0PEPc.js} +2 -2
- package/dist/chunks/{recaptcha-v2-invisible-Cz5uIlp-.js → recaptcha-v2-invisible-BwRH540O.js} +2 -2
- package/dist/chunks/recaptcha-v3-No4Ykrhk.js +28 -0
- package/dist/chunks/rich-text-D0sb8bmX.js +349 -0
- package/dist/chunks/scripts-CbQ7agX3.js +30 -0
- package/dist/chunks/{signature-CpgqHCEx.js → signature-xz4QO7CR.js} +107 -34
- package/dist/chunks/{snaptcha-vz1RnZ15.js → snaptcha-DKx98oon.js} +1 -1
- package/dist/chunks/{square-BP8RWAKB.js → square-reyJukb7.js} +2 -2
- package/dist/chunks/{stripe-MihN63uC.js → stripe-BC-2PBvY.js} +2 -2
- package/dist/chunks/{text-limit-B09elPDB.js → text-limit-DbgEFHLO.js} +1 -1
- package/dist/chunks/{turnstile-D_Pi1CKM.js → turnstile-DJakPzQD.js} +3 -3
- package/dist/chunks/upload-manager-BpqWSHmU.js +2558 -0
- package/dist/css/theme/_buttons.css +16 -0
- package/dist/css/theme/_tokens.css +2 -0
- package/dist/css/theme/fields/_signature.css +19 -0
- package/dist/css/theme/fields/_upload-manager.css +279 -0
- package/dist/index.js +629 -578
- package/dist/js/core/create-formie-client.d.ts.map +1 -1
- package/dist/js/core/error-aria-live.d.ts +6 -0
- package/dist/js/core/error-aria-live.d.ts.map +1 -0
- package/dist/js/core/submit-result-state.d.ts.map +1 -1
- package/dist/js/core/submit-result-ui.d.ts.map +1 -1
- package/dist/js/modules/captchas/factories.d.ts.map +1 -1
- package/dist/js/modules/captchas/host.d.ts +1 -0
- package/dist/js/modules/captchas/host.d.ts.map +1 -1
- package/dist/js/modules/captchas/recaptcha-enterprise.d.ts.map +1 -1
- package/dist/js/modules/captchas/recaptcha-shared.d.ts +1 -0
- package/dist/js/modules/captchas/recaptcha-shared.d.ts.map +1 -1
- package/dist/js/modules/captchas/recaptcha-v3.d.ts.map +1 -1
- package/dist/js/modules/captchas/turnstile.d.ts.map +1 -1
- package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
- package/dist/js/modules/fields/index.d.ts.map +1 -1
- package/dist/js/modules/fields/signature.d.ts.map +1 -1
- package/dist/js/modules/fields/upload-manager.d.ts +3 -0
- package/dist/js/modules/fields/upload-manager.d.ts.map +1 -0
- package/dist/js/utils/form-started-at.d.ts +2 -0
- package/dist/js/utils/form-started-at.d.ts.map +1 -0
- package/dist/js/utils/scripts.d.ts.map +1 -1
- package/dist/js/utils/unload-warning.d.ts.map +1 -1
- package/dist/js/validation/enter-key-guard.d.ts +2 -0
- package/dist/js/validation/enter-key-guard.d.ts.map +1 -0
- package/dist/js/validation/types.d.ts +1 -0
- package/dist/js/validation/types.d.ts.map +1 -1
- package/dist/js/validation/validator.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/css/theme/_buttons.css +16 -0
- package/src/css/theme/_tokens.css +2 -0
- package/src/css/theme/fields/_signature.css +19 -0
- package/src/css/theme/fields/_upload-manager.css +279 -0
- package/src/js/core/create-formie-client.ts +7 -0
- package/src/js/core/error-aria-live.ts +45 -0
- package/src/js/core/submit-result-state.ts +4 -0
- package/src/js/core/submit-result-ui.ts +8 -4
- package/src/js/modules/captchas/factories.ts +4 -0
- package/src/js/modules/captchas/host.ts +14 -2
- package/src/js/modules/captchas/recaptcha-enterprise.ts +14 -11
- package/src/js/modules/captchas/recaptcha-shared.ts +14 -0
- package/src/js/modules/captchas/recaptcha-v3.ts +7 -4
- package/src/js/modules/captchas/turnstile.ts +9 -3
- package/src/js/modules/fields/conditions/effects.ts +42 -0
- package/src/js/modules/fields/index.ts +1 -0
- package/src/js/modules/fields/signature.ts +199 -15
- package/src/js/modules/fields/upload-manager.ts +1277 -0
- package/src/js/utils/form-started-at.ts +25 -0
- package/src/js/utils/scripts.ts +16 -0
- package/src/js/utils/unload-warning.ts +1 -0
- package/src/js/validation/enter-key-guard.ts +63 -0
- package/src/js/validation/types.ts +1 -0
- package/src/js/validation/validator.ts +14 -5
- package/src/vendor.d.ts +1 -1
- package/dist/chunks/dist-BF5GU3_9.js +0 -2674
- package/dist/chunks/recaptcha-v3-BtbbP111.js +0 -27
- package/dist/chunks/rich-text-Cv8ADyk-.js +0 -349
- 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
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
44
|
+
await whenRecaptchaReady(api, async() => {
|
|
45
|
+
const token = await api.execute(provider.siteKey || '', { action: provider.action || 'submit' });
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
127
|
-
//
|
|
128
|
-
api.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
void attemptResize();
|
|
94
247
|
});
|
|
95
248
|
}, delay);
|
|
96
249
|
};
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
}
|