@form-engine-ts/react 2.6.0 → 2.8.0

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/dist/index.js CHANGED
@@ -945,6 +945,9 @@ var FIELD_TYPES = [
945
945
  ];
946
946
  var BUILDER_DEFAULTS = {
947
947
  "builder.formBuilder": "Form builder",
948
+ "builder.basicSettings": "Basic settings",
949
+ "builder.formTitle": "Form title",
950
+ "builder.formDescription": "Form description",
948
951
  "builder.moveUp": "Move {{title}} up",
949
952
  "builder.moveDown": "Move {{title}} down",
950
953
  "builder.delete": "Delete {{title}}",
@@ -1110,7 +1113,7 @@ function FormBuilder({
1110
1113
  }) {
1111
1114
  const resolvedComponents = { ...DEFAULT_COMPONENTS, ...componentOverrides };
1112
1115
  const components = GUARDED_COMPONENTS;
1113
- const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextInput } = components;
1116
+ const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextArea, TextInput } = components;
1114
1117
  const ToolbarSlot = slots?.toolbar;
1115
1118
  const FieldEditorSlot = slots?.fieldEditor;
1116
1119
  const OptionEditorSlot = slots?.optionEditor;
@@ -1413,6 +1416,42 @@ function FormBuilder({
1413
1416
  }
1414
1417
  },
1415
1418
  children: /* @__PURE__ */ jsxs(Fieldset, { className: "form-engine-builder__controls", disabled: readOnly, children: [
1419
+ /* @__PURE__ */ jsx(
1420
+ Section,
1421
+ {
1422
+ className: "form-engine-builder__basic-settings",
1423
+ headingId: "builder-basic-settings-heading",
1424
+ title: translate("builder.basicSettings"),
1425
+ children: /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
1426
+ /* @__PURE__ */ jsxs("label", { children: [
1427
+ translate("builder.formTitle"),
1428
+ /* @__PURE__ */ jsx(
1429
+ TextInput,
1430
+ {
1431
+ name: "title",
1432
+ required: true,
1433
+ error: schema.title.trim().length === 0,
1434
+ helperText: schema.title.trim().length === 0 ? translate("builder.required") : "",
1435
+ value: schema.title,
1436
+ onChange: (value) => setSourceText({ kind: "form" }, "title", value)
1437
+ }
1438
+ )
1439
+ ] }),
1440
+ /* @__PURE__ */ jsxs("label", { children: [
1441
+ translate("builder.formDescription"),
1442
+ /* @__PURE__ */ jsx(
1443
+ TextArea,
1444
+ {
1445
+ name: "description",
1446
+ rows: 3,
1447
+ value: schema.description ?? "",
1448
+ onChange: (value) => setSourceText({ kind: "form" }, "description", value)
1449
+ }
1450
+ )
1451
+ ] })
1452
+ ] })
1453
+ }
1454
+ ),
1416
1455
  pagesEnabled ? PagesSlot === void 0 ? /* @__PURE__ */ jsx(
1417
1456
  Section,
1418
1457
  {
@@ -2179,7 +2218,7 @@ import {
2179
2218
  validateAnswers,
2180
2219
  validatePageAnswers
2181
2220
  } from "@form-engine-ts/core";
2182
- import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useState as useState2 } from "react";
2221
+ import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useRef, useState as useState2 } from "react";
2183
2222
  import { jsx as jsx2 } from "react/jsx-runtime";
2184
2223
  var FormContext = createContext2(null);
2185
2224
  function issuesByField(issues) {
@@ -2207,6 +2246,7 @@ function FormProvider({
2207
2246
  const [submitStatus, setSubmitStatus] = useState2("idle");
2208
2247
  const [submitError, setSubmitError] = useState2(null);
2209
2248
  const [validationPageIndex, setValidationPageIndex] = useState2(null);
2249
+ const submissionInFlight = useRef(false);
2210
2250
  const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
2211
2251
  const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
2212
2252
  useEffect(() => {
@@ -2260,6 +2300,7 @@ function FormProvider({
2260
2300
  }, [initialValues]);
2261
2301
  const submit = useCallback2(
2262
2302
  async (beforeSubmit) => {
2303
+ if (submissionInFlight.current) return { status: "cancelled" };
2263
2304
  const validation = validateAnswers(validSchema, values);
2264
2305
  if (!validation.valid) {
2265
2306
  setErrors(issuesByField(validation.issues));
@@ -2272,21 +2313,24 @@ function FormProvider({
2272
2313
  setValidationPageIndex(null);
2273
2314
  setSubmitError(null);
2274
2315
  const visibleValues = selectVisibleAnswers(validSchema, values);
2316
+ submissionInFlight.current = true;
2275
2317
  try {
2318
+ setSubmitStatus("submitting");
2276
2319
  if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
2277
2320
  setSubmitStatus("idle");
2278
2321
  return { status: "cancelled" };
2279
2322
  }
2280
- setSubmitStatus("submitting");
2281
- await onSubmit(visibleValues);
2323
+ const response = await onSubmit(visibleValues);
2282
2324
  if (resetOnSuccess) setValues({ ...initialValues });
2283
2325
  setSubmitStatus("success");
2284
- return { status: "success" };
2326
+ return response === void 0 ? { status: "success" } : { status: "success", response };
2285
2327
  } catch (cause) {
2286
2328
  const error = cause instanceof Error ? cause : new Error(String(cause));
2287
2329
  setSubmitError(error);
2288
2330
  setSubmitStatus("error");
2289
2331
  return { status: "error", error };
2332
+ } finally {
2333
+ submissionInFlight.current = false;
2290
2334
  }
2291
2335
  },
2292
2336
  [initialValues, onSubmit, resetOnSuccess, validSchema, values]
@@ -2347,17 +2391,128 @@ function useField(fieldId) {
2347
2391
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
2348
2392
  }
2349
2393
 
2394
+ // src/receipt.ts
2395
+ import { useEffect as useEffect2, useMemo as useMemo3, useState as useState3 } from "react";
2396
+ function submissionReceiptQueryKey(formId, formVersion) {
2397
+ return `${formId}:v${formVersion}`;
2398
+ }
2399
+ function receiptKey(namespace, formId, formVersion) {
2400
+ return `${namespace}:${formId}:v${formVersion}`;
2401
+ }
2402
+ function browserStorage() {
2403
+ if (typeof window === "undefined") return null;
2404
+ try {
2405
+ return window.localStorage;
2406
+ } catch {
2407
+ return null;
2408
+ }
2409
+ }
2410
+ function parseReceipt(serialized) {
2411
+ try {
2412
+ const value = JSON.parse(serialized);
2413
+ if (typeof value !== "object" || value === null || !("formId" in value) || typeof value.formId !== "string" || !("formVersion" in value) || typeof value.formVersion !== "number" || !Number.isSafeInteger(value.formVersion) || !("submittedAt" in value) || typeof value.submittedAt !== "string" || !Number.isFinite(Date.parse(value.submittedAt)) || "submissionId" in value && value.submissionId !== void 0 && typeof value.submissionId !== "string") {
2414
+ return null;
2415
+ }
2416
+ const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
2417
+ return {
2418
+ formId: value.formId,
2419
+ formVersion: value.formVersion,
2420
+ submittedAt: value.submittedAt,
2421
+ ...submissionId === void 0 ? {} : { submissionId }
2422
+ };
2423
+ } catch {
2424
+ return null;
2425
+ }
2426
+ }
2427
+ function createLocalStorageSubmissionReceiptStore(options = {}) {
2428
+ const namespace = options.namespace ?? "form_engine_receipt";
2429
+ if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
2430
+ const get = async (formId, formVersion) => {
2431
+ const storage = browserStorage();
2432
+ if (storage === null) return null;
2433
+ try {
2434
+ const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
2435
+ if (serialized === null) return null;
2436
+ const receipt = parseReceipt(serialized);
2437
+ return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
2438
+ } catch {
2439
+ return null;
2440
+ }
2441
+ };
2442
+ return {
2443
+ get,
2444
+ async getBatch(queries) {
2445
+ const receipts = await Promise.all(queries.map((query) => get(query.formId, query.formVersion)));
2446
+ return new Map(
2447
+ receipts.flatMap(
2448
+ (receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
2449
+ )
2450
+ );
2451
+ },
2452
+ async save(receipt) {
2453
+ const storage = browserStorage();
2454
+ if (storage === null) return;
2455
+ storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
2456
+ },
2457
+ async remove(formId, formVersion) {
2458
+ const storage = browserStorage();
2459
+ if (storage === null) return;
2460
+ storage.removeItem(receiptKey(namespace, formId, formVersion));
2461
+ }
2462
+ };
2463
+ }
2464
+ function useSubmissionReceipts(store, queries) {
2465
+ const querySignature = JSON.stringify(queries.map(({ formId, formVersion }) => [formId, formVersion]));
2466
+ const stableQueries = useMemo3(() => {
2467
+ const parsed = JSON.parse(querySignature);
2468
+ if (!Array.isArray(parsed)) return [];
2469
+ return parsed.flatMap(
2470
+ (entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number" && Number.isSafeInteger(entry[1]) ? [{ formId: entry[0], formVersion: entry[1] }] : []
2471
+ );
2472
+ }, [querySignature]);
2473
+ const [state, setState] = useState3({
2474
+ receipts: /* @__PURE__ */ new Map(),
2475
+ isLoading: stableQueries.length > 0,
2476
+ error: null
2477
+ });
2478
+ useEffect2(() => {
2479
+ let active = true;
2480
+ if (stableQueries.length === 0) {
2481
+ setState({ receipts: /* @__PURE__ */ new Map(), isLoading: false, error: null });
2482
+ return () => {
2483
+ active = false;
2484
+ };
2485
+ }
2486
+ setState((current) => ({ ...current, isLoading: true, error: null }));
2487
+ void store.getBatch(stableQueries).then((receipts) => {
2488
+ if (active) setState({ receipts, isLoading: false, error: null });
2489
+ }).catch((cause) => {
2490
+ if (!active) return;
2491
+ setState({
2492
+ receipts: /* @__PURE__ */ new Map(),
2493
+ isLoading: false,
2494
+ error: cause instanceof Error ? cause : new Error(String(cause))
2495
+ });
2496
+ });
2497
+ return () => {
2498
+ active = false;
2499
+ };
2500
+ }, [stableQueries, store]);
2501
+ return state;
2502
+ }
2503
+
2350
2504
  // src/renderer.tsx
2351
2505
  import {
2506
+ selectVisibleAnswers as selectVisibleAnswers2,
2352
2507
  validateAnswers as validateAnswers2
2353
2508
  } from "@form-engine-ts/core";
2354
2509
  import {
2355
2510
  Fragment as Fragment2,
2356
- useEffect as useEffect2,
2511
+ useEffect as useEffect3,
2357
2512
  useId,
2358
- useMemo as useMemo3,
2359
- useRef,
2360
- useState as useState3
2513
+ useMemo as useMemo4,
2514
+ useRef as useRef2,
2515
+ useState as useState4
2361
2516
  } from "react";
2362
2517
  import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
2363
2518
  function describedBy(field, error, helpId, errorId) {
@@ -2473,11 +2628,17 @@ function DefaultField(props) {
2473
2628
  /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
2474
2629
  ] });
2475
2630
  let control;
2631
+ const textConstraints = field.type === "text" || field.type === "textarea" ? {
2632
+ minLength: field.minLength,
2633
+ maxLength: field.maxLength,
2634
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
2635
+ } : {};
2476
2636
  if (field.type === "textarea") {
2477
2637
  control = /* @__PURE__ */ jsx3(
2478
2638
  "textarea",
2479
2639
  {
2480
2640
  ...ariaProps,
2641
+ ...textConstraints,
2481
2642
  id: inputId,
2482
2643
  name: field.id,
2483
2644
  placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
@@ -2490,6 +2651,7 @@ function DefaultField(props) {
2490
2651
  "input",
2491
2652
  {
2492
2653
  ...ariaProps,
2654
+ ...textConstraints,
2493
2655
  id: inputId,
2494
2656
  name: field.id,
2495
2657
  type: "number",
@@ -2534,6 +2696,11 @@ function DefaultField(props) {
2534
2696
  return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
2535
2697
  label,
2536
2698
  control,
2699
+ (field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
2700
+ fieldId: field.id,
2701
+ current: typeof value === "string" ? value.length : 0,
2702
+ max: field.maxLength
2703
+ }) : null,
2537
2704
  /* @__PURE__ */ jsx3(FieldMessage, { props })
2538
2705
  ] });
2539
2706
  }
@@ -2569,31 +2736,62 @@ function ContextFormRenderer({
2569
2736
  autoSaveKey,
2570
2737
  beforeSubmit,
2571
2738
  onDraftSave,
2739
+ submissionGuards = [],
2740
+ receiptStore,
2572
2741
  slots = {}
2573
2742
  }) {
2574
2743
  const form = useForm();
2575
2744
  const prefix = useId().replace(/:/g, "");
2576
- const formRef = useRef(null);
2577
- const loadedDraftKey = useRef(null);
2578
- const [draftRestored, setDraftRestored] = useState3(false);
2579
- const [currentPageIndex, setCurrentPageIndex] = useState3(0);
2580
- const [focusFieldId, setFocusFieldId] = useState3(null);
2745
+ const formRef = useRef2(null);
2746
+ const loadedDraftKey = useRef2(null);
2747
+ const [draftRestored, setDraftRestored] = useState4(false);
2748
+ const [currentPageIndex, setCurrentPageIndex] = useState4(0);
2749
+ const [focusFieldId, setFocusFieldId] = useState4(null);
2750
+ const [confirmation, setConfirmation] = useState4(null);
2751
+ const [guardMessage, setGuardMessage] = useState4(null);
2752
+ const [receipt, setReceipt] = useState4(null);
2753
+ const [receiptLoaded, setReceiptLoaded] = useState4(receiptStore === void 0);
2754
+ const rendererSubmissionInFlight = useRef2(false);
2581
2755
  const pages = form.schema.pages;
2582
- const visiblePageIndexes = useMemo3(
2756
+ const visiblePageIndexes = useMemo4(
2583
2757
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
2584
2758
  [form.pageVisibility, pages]
2585
2759
  );
2586
2760
  const activePage = pages?.[currentPageIndex];
2587
2761
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
2588
2762
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
2589
- useEffect2(() => {
2763
+ const visibleValues = useMemo4(() => selectVisibleAnswers2(form.schema, form.values), [form.schema, form.values]);
2764
+ const submitState = confirmation === null ? form.submitStatus : "confirming";
2765
+ const interactionLocked = submitState === "confirming" || submitState === "submitting";
2766
+ useEffect3(() => {
2767
+ let active = true;
2768
+ if (receiptStore === void 0) {
2769
+ setReceipt(null);
2770
+ setReceiptLoaded(true);
2771
+ return () => {
2772
+ active = false;
2773
+ };
2774
+ }
2775
+ setReceiptLoaded(false);
2776
+ void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
2777
+ if (active) setReceipt(stored);
2778
+ }).catch(() => {
2779
+ if (active) setReceipt(null);
2780
+ }).finally(() => {
2781
+ if (active) setReceiptLoaded(true);
2782
+ });
2783
+ return () => {
2784
+ active = false;
2785
+ };
2786
+ }, [form.schema.id, form.schema.version, receiptStore]);
2787
+ useEffect3(() => {
2590
2788
  if (pages === void 0 || visiblePageIndexes.length === 0) {
2591
2789
  setCurrentPageIndex(0);
2592
2790
  return;
2593
2791
  }
2594
2792
  if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2595
2793
  }, [currentPageIndex, pages, visiblePageIndexes]);
2596
- useEffect2(() => {
2794
+ useEffect3(() => {
2597
2795
  if (focusFieldId === null) return;
2598
2796
  const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
2599
2797
  (element) => element.dataset.fieldId === focusFieldId
@@ -2604,7 +2802,7 @@ function ContextFormRenderer({
2604
2802
  setFocusFieldId(null);
2605
2803
  }
2606
2804
  }, [focusFieldId]);
2607
- useEffect2(() => {
2805
+ useEffect3(() => {
2608
2806
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
2609
2807
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
2610
2808
  if (loadedDraftKey.current === loadIdentity) return;
@@ -2616,7 +2814,7 @@ function ContextFormRenderer({
2616
2814
  form.restoreValues(draft.values);
2617
2815
  setDraftRestored(true);
2618
2816
  }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
2619
- useEffect2(() => {
2817
+ useEffect3(() => {
2620
2818
  if (form.submitStatus === "success") return;
2621
2819
  const timeout = globalThis.setTimeout(() => {
2622
2820
  onDraftSave?.(form.values);
@@ -2635,6 +2833,7 @@ function ContextFormRenderer({
2635
2833
  if (fieldId !== void 0) setFocusFieldId(fieldId);
2636
2834
  };
2637
2835
  const handleNext = () => {
2836
+ if (interactionLocked) return;
2638
2837
  const result = form.validatePage(currentPageIndex);
2639
2838
  if (!result.valid) {
2640
2839
  focusFirstIssue(result.issues[0]?.fieldId);
@@ -2643,34 +2842,122 @@ function ContextFormRenderer({
2643
2842
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
2644
2843
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
2645
2844
  };
2646
- const submitValues = async () => {
2845
+ const runSubmissionGuards = async (guards) => {
2846
+ const findings = [];
2847
+ let confirmationMessage;
2848
+ let requiresConfirmation = false;
2849
+ for (const guard of guards) {
2850
+ const result = await guard(form.schema, visibleValues);
2851
+ if (result.status === "allow") continue;
2852
+ findings.push(...result.findings);
2853
+ if (result.status === "block") {
2854
+ return {
2855
+ status: "block",
2856
+ findings,
2857
+ ...result.message === void 0 ? {} : { message: result.message }
2858
+ };
2859
+ }
2860
+ requiresConfirmation = true;
2861
+ confirmationMessage ??= result.message;
2862
+ }
2863
+ return !requiresConfirmation ? { status: "allow" } : {
2864
+ status: "confirm",
2865
+ findings,
2866
+ ...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
2867
+ };
2868
+ };
2869
+ const submitValues = async (guardsConfirmed = false) => {
2870
+ if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
2871
+ return { status: "cancelled" };
2872
+ }
2647
2873
  const validation = validateAnswers2(form.schema, form.values);
2648
2874
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
2649
- const result = await form.submit(beforeSubmit);
2650
- if (result.status === "invalid") {
2651
- const invalidPageIndex = pages?.findIndex(
2652
- (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
2653
- );
2654
- if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
2655
- focusFirstIssue(firstInvalidFieldId);
2656
- return result;
2875
+ if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
2876
+ rendererSubmissionInFlight.current = true;
2877
+ try {
2878
+ const guardResult = await runSubmissionGuards(submissionGuards);
2879
+ if (guardResult.status === "block") {
2880
+ setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
2881
+ return { status: "cancelled" };
2882
+ }
2883
+ if (guardResult.status === "confirm") {
2884
+ setGuardMessage(null);
2885
+ setConfirmation({
2886
+ findings: guardResult.findings,
2887
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
2888
+ });
2889
+ return { status: "cancelled" };
2890
+ }
2891
+ } finally {
2892
+ rendererSubmissionInFlight.current = false;
2893
+ }
2657
2894
  }
2658
- if (result.status !== "success") return result;
2659
- if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2660
- globalThis.localStorage.removeItem(autoSaveKey);
2661
- setDraftRestored(false);
2895
+ setGuardMessage(null);
2896
+ rendererSubmissionInFlight.current = true;
2897
+ try {
2898
+ const result = await form.submit(beforeSubmit);
2899
+ if (result.status === "invalid") {
2900
+ const invalidPageIndex = pages?.findIndex(
2901
+ (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
2902
+ );
2903
+ if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
2904
+ focusFirstIssue(firstInvalidFieldId);
2905
+ return result;
2906
+ }
2907
+ if (result.status !== "success") return result;
2908
+ if (receiptStore !== void 0) {
2909
+ const response = result.response;
2910
+ const storedReceipt = {
2911
+ formId: form.schema.id,
2912
+ formVersion: form.schema.version,
2913
+ submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2914
+ ...response?.submissionId === void 0 ? {} : { submissionId: response.submissionId }
2915
+ };
2916
+ await receiptStore.save(storedReceipt);
2917
+ setReceipt(storedReceipt);
2918
+ }
2919
+ if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2920
+ globalThis.localStorage.removeItem(autoSaveKey);
2921
+ setDraftRestored(false);
2922
+ }
2923
+ setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2924
+ return result;
2925
+ } finally {
2926
+ rendererSubmissionInFlight.current = false;
2662
2927
  }
2663
- setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2664
- return result;
2665
2928
  };
2666
2929
  const handleSubmit = (event) => {
2667
2930
  event.preventDefault();
2931
+ if (interactionLocked) return;
2668
2932
  void submitValues();
2669
2933
  };
2934
+ const confirmSubmission = () => {
2935
+ setConfirmation(null);
2936
+ void submitValues(true);
2937
+ };
2938
+ const cancelSubmission = () => {
2939
+ setConfirmation(null);
2940
+ };
2941
+ const resetReceipt = async () => {
2942
+ if (receiptStore === void 0) return;
2943
+ await receiptStore.remove(form.schema.id, form.schema.version);
2944
+ setReceipt(null);
2945
+ form.reset();
2946
+ };
2670
2947
  const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
2671
2948
  const canPrev = pages !== void 0 && activeVisibleIndex > 0;
2672
2949
  const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
2673
- const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: form.isSubmitting, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
2950
+ const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: interactionLocked, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: interactionLocked, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
2951
+ if (!receiptLoaded) return null;
2952
+ if (receipt !== null) {
2953
+ return /* @__PURE__ */ jsx3("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
2954
+ receipt,
2955
+ ...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
2956
+ }) ?? /* @__PURE__ */ jsxs2("div", { role: "status", children: [
2957
+ form.translate("form.alreadySubmitted"),
2958
+ receiptStore === void 0 ? null : /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => void resetReceipt(), children: form.translate("form.submitAnother") })
2959
+ ] }) });
2960
+ }
2674
2961
  return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
2675
2962
  slots.renderHeader?.({
2676
2963
  title: form.schema.title,
@@ -2718,7 +3005,8 @@ function ContextFormRenderer({
2718
3005
  translate: form.translate,
2719
3006
  inputId: `${prefix}-${field.id}`,
2720
3007
  errorId: `${prefix}-${field.id}-error`,
2721
- helpId: `${prefix}-${field.id}-help`
3008
+ helpId: `${prefix}-${field.id}-help`,
3009
+ ...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
2722
3010
  };
2723
3011
  if (slots.renderField !== void 0) {
2724
3012
  return /* @__PURE__ */ jsx3(Fragment2, { children: slots.renderField({
@@ -2733,6 +3021,19 @@ function ContextFormRenderer({
2733
3021
  const Component = components[field.type];
2734
3022
  return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
2735
3023
  }) }),
3024
+ guardMessage === null ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: guardMessage }),
3025
+ confirmation === null ? null : slots.renderSubmissionConfirmation?.({
3026
+ findings: confirmation.findings,
3027
+ message: confirmation.message ?? form.translate("form.confirmSensitiveData"),
3028
+ schema: form.schema,
3029
+ visibleValues,
3030
+ onConfirm: confirmSubmission,
3031
+ onCancel: cancelSubmission
3032
+ }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
3033
+ /* @__PURE__ */ jsx3("p", { children: confirmation.message ?? form.translate("form.confirmSensitiveData") }),
3034
+ /* @__PURE__ */ jsx3("button", { type: "button", onClick: confirmSubmission, children: form.translate("form.confirmSubmission") }),
3035
+ /* @__PURE__ */ jsx3("button", { type: "button", onClick: cancelSubmission, children: form.translate("form.cancelSubmission") })
3036
+ ] }),
2736
3037
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
2737
3038
  validationIssues.length,
2738
3039
  " validation error",
@@ -2755,7 +3056,9 @@ function ContextFormRenderer({
2755
3056
  totalPages: visiblePageIndexes.length,
2756
3057
  canPrev,
2757
3058
  canNext,
2758
- onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
3059
+ onPrev: () => {
3060
+ if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
3061
+ },
2759
3062
  onNext: handleNext
2760
3063
  }) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
2761
3064
  canPrev ? /* @__PURE__ */ jsx3(
@@ -2763,11 +3066,12 @@ function ContextFormRenderer({
2763
3066
  {
2764
3067
  className: "btn-prev",
2765
3068
  type: "button",
3069
+ disabled: interactionLocked,
2766
3070
  onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
2767
3071
  children: form.translate("form.back")
2768
3072
  }
2769
3073
  ) : null,
2770
- canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
3074
+ canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
2771
3075
  ] }),
2772
3076
  canNext ? null : renderSubmitButton()
2773
3077
  ] }),
@@ -2785,6 +3089,12 @@ var RENDERER_MESSAGES = {
2785
3089
  "form.next": "Next",
2786
3090
  "form.step": "Step {{current}} / {{total}}",
2787
3091
  "form.draftRestored": "Draft restored",
3092
+ "form.submissionBlocked": "Submission blocked because sensitive data was detected.",
3093
+ "form.confirmSensitiveData": "Sensitive data may be included. Confirm before submitting.",
3094
+ "form.confirmSubmission": "Confirm submission",
3095
+ "form.cancelSubmission": "Cancel",
3096
+ "form.alreadySubmitted": "Already submitted.",
3097
+ "form.submitAnother": "Submit another response",
2788
3098
  "validation.required": "This field is required."
2789
3099
  };
2790
3100
  var defaultRendererTranslator = {
@@ -2823,8 +3133,11 @@ export {
2823
3133
  FormBuilder,
2824
3134
  FormProvider,
2825
3135
  FormRenderer,
3136
+ createLocalStorageSubmissionReceiptStore,
2826
3137
  resolveInitialFieldType,
3138
+ submissionReceiptQueryKey,
2827
3139
  useField,
2828
3140
  useForm,
2829
- useFormBuilder
3141
+ useFormBuilder,
3142
+ useSubmissionReceipts
2830
3143
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/react",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,14 +42,15 @@
42
42
  "typescript"
43
43
  ],
44
44
  "dependencies": {
45
- "@form-engine-ts/core": "2.6.0"
45
+ "@form-engine-ts/core": "2.8.0",
46
+ "@form-engine-ts/privacy": "2.8.0"
46
47
  },
47
48
  "peerDependencies": {
48
49
  "react": ">=18.2 <20",
49
50
  "react-dom": ">=18.2 <20"
50
51
  },
51
52
  "scripts": {
52
- "build": "tsup src/index.ts src/styles.css --format esm,cjs --dts --clean --external react --external react/jsx-runtime --external @form-engine-ts/core",
53
+ "build": "tsup src/index.ts src/styles.css --format esm,cjs --dts --clean --external react --external react/jsx-runtime --external @form-engine-ts/core --external @form-engine-ts/privacy",
53
54
  "check": "biome check . && tsc --noEmit",
54
55
  "test": "vitest run --globals",
55
56
  "typecheck": "tsc --noEmit"