@form-engine-ts/react 2.6.0 → 2.7.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/README.md CHANGED
@@ -116,3 +116,10 @@ translation lifecycle.
116
116
  Validation runs before `beforeSubmit`. A `"cancel"` result does not call `onSubmit` and preserves values and drafts.
117
117
  Header, page-header, field, navigation, submit, validation-summary, completion, and submit-error slots can replace the
118
118
  default UI. The localized `completionMessage` is displayed after a successful submission.
119
+
120
+ Pass ordered `submissionGuards` to allow, block, or require confirmation before `onSubmit`. Confirmation receives all
121
+ guard findings through `renderSubmissionConfirmation`. A `receiptStore` prevents accidental repeat submissions and can
122
+ be created with the SSR-safe `createLocalStorageSubmissionReceiptStore`; `renderAlreadySubmitted` customizes its return
123
+ state. Text controls forward schema `minLength`, `maxLength`, and `pattern` constraints to the DOM, and
124
+ `renderCharacterCount` can replace the default count. Guard evaluation, confirmation, receipt persistence, and provider
125
+ submission share an in-flight lock so rapid clicks cannot submit twice.
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  FormBuilder: () => FormBuilder,
24
24
  FormProvider: () => FormProvider,
25
25
  FormRenderer: () => FormRenderer,
26
+ createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
26
27
  resolveInitialFieldType: () => resolveInitialFieldType,
27
28
  useField: () => useField,
28
29
  useForm: () => useForm,
@@ -2225,6 +2226,7 @@ function FormProvider({
2225
2226
  const [submitStatus, setSubmitStatus] = (0, import_react3.useState)("idle");
2226
2227
  const [submitError, setSubmitError] = (0, import_react3.useState)(null);
2227
2228
  const [validationPageIndex, setValidationPageIndex] = (0, import_react3.useState)(null);
2229
+ const submissionInFlight = (0, import_react3.useRef)(false);
2228
2230
  const visibility = (0, import_react3.useMemo)(() => (0, import_core3.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
2229
2231
  const pageVisibility = (0, import_react3.useMemo)(() => (0, import_core3.calculatePageVisibility)(validSchema, values), [validSchema, values]);
2230
2232
  (0, import_react3.useEffect)(() => {
@@ -2278,6 +2280,7 @@ function FormProvider({
2278
2280
  }, [initialValues]);
2279
2281
  const submit = (0, import_react3.useCallback)(
2280
2282
  async (beforeSubmit) => {
2283
+ if (submissionInFlight.current) return { status: "cancelled" };
2281
2284
  const validation = (0, import_core3.validateAnswers)(validSchema, values);
2282
2285
  if (!validation.valid) {
2283
2286
  setErrors(issuesByField(validation.issues));
@@ -2290,12 +2293,13 @@ function FormProvider({
2290
2293
  setValidationPageIndex(null);
2291
2294
  setSubmitError(null);
2292
2295
  const visibleValues = (0, import_core3.selectVisibleAnswers)(validSchema, values);
2296
+ submissionInFlight.current = true;
2293
2297
  try {
2298
+ setSubmitStatus("submitting");
2294
2299
  if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
2295
2300
  setSubmitStatus("idle");
2296
2301
  return { status: "cancelled" };
2297
2302
  }
2298
- setSubmitStatus("submitting");
2299
2303
  await onSubmit(visibleValues);
2300
2304
  if (resetOnSuccess) setValues({ ...initialValues });
2301
2305
  setSubmitStatus("success");
@@ -2305,6 +2309,8 @@ function FormProvider({
2305
2309
  setSubmitError(error);
2306
2310
  setSubmitStatus("error");
2307
2311
  return { status: "error", error };
2312
+ } finally {
2313
+ submissionInFlight.current = false;
2308
2314
  }
2309
2315
  },
2310
2316
  [initialValues, onSubmit, resetOnSuccess, validSchema, values]
@@ -2365,6 +2371,64 @@ function useField(fieldId) {
2365
2371
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
2366
2372
  }
2367
2373
 
2374
+ // src/receipt.ts
2375
+ function receiptKey(namespace, formId, formVersion) {
2376
+ return `${namespace}:${formId}:v${formVersion}`;
2377
+ }
2378
+ function browserStorage() {
2379
+ if (typeof window === "undefined") return null;
2380
+ try {
2381
+ return window.localStorage;
2382
+ } catch {
2383
+ return null;
2384
+ }
2385
+ }
2386
+ function parseReceipt(serialized) {
2387
+ try {
2388
+ const value = JSON.parse(serialized);
2389
+ 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") {
2390
+ return null;
2391
+ }
2392
+ const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
2393
+ return {
2394
+ formId: value.formId,
2395
+ formVersion: value.formVersion,
2396
+ submittedAt: value.submittedAt,
2397
+ ...submissionId === void 0 ? {} : { submissionId }
2398
+ };
2399
+ } catch {
2400
+ return null;
2401
+ }
2402
+ }
2403
+ function createLocalStorageSubmissionReceiptStore(options = {}) {
2404
+ const namespace = options.namespace ?? "form_engine_receipt";
2405
+ if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
2406
+ return {
2407
+ async get(formId, formVersion) {
2408
+ const storage = browserStorage();
2409
+ if (storage === null) return null;
2410
+ try {
2411
+ const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
2412
+ if (serialized === null) return null;
2413
+ const receipt = parseReceipt(serialized);
2414
+ return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
2415
+ } catch {
2416
+ return null;
2417
+ }
2418
+ },
2419
+ async save(receipt) {
2420
+ const storage = browserStorage();
2421
+ if (storage === null) return;
2422
+ storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
2423
+ },
2424
+ async remove(formId, formVersion) {
2425
+ const storage = browserStorage();
2426
+ if (storage === null) return;
2427
+ storage.removeItem(receiptKey(namespace, formId, formVersion));
2428
+ }
2429
+ };
2430
+ }
2431
+
2368
2432
  // src/renderer.tsx
2369
2433
  var import_core4 = require("@form-engine-ts/core");
2370
2434
  var import_react4 = require("react");
@@ -2482,11 +2546,17 @@ function DefaultField(props) {
2482
2546
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required })
2483
2547
  ] });
2484
2548
  let control;
2549
+ const textConstraints = field.type === "text" || field.type === "textarea" ? {
2550
+ minLength: field.minLength,
2551
+ maxLength: field.maxLength,
2552
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
2553
+ } : {};
2485
2554
  if (field.type === "textarea") {
2486
2555
  control = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2487
2556
  "textarea",
2488
2557
  {
2489
2558
  ...ariaProps,
2559
+ ...textConstraints,
2490
2560
  id: inputId,
2491
2561
  name: field.id,
2492
2562
  placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
@@ -2499,6 +2569,7 @@ function DefaultField(props) {
2499
2569
  "input",
2500
2570
  {
2501
2571
  ...ariaProps,
2572
+ ...textConstraints,
2502
2573
  id: inputId,
2503
2574
  name: field.id,
2504
2575
  type: "number",
@@ -2543,6 +2614,11 @@ function DefaultField(props) {
2543
2614
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
2544
2615
  label,
2545
2616
  control,
2617
+ (field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
2618
+ fieldId: field.id,
2619
+ current: typeof value === "string" ? value.length : 0,
2620
+ max: field.maxLength
2621
+ }) : null,
2546
2622
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
2547
2623
  ] });
2548
2624
  }
@@ -2578,6 +2654,8 @@ function ContextFormRenderer({
2578
2654
  autoSaveKey,
2579
2655
  beforeSubmit,
2580
2656
  onDraftSave,
2657
+ submissionGuards = [],
2658
+ receiptStore,
2581
2659
  slots = {}
2582
2660
  }) {
2583
2661
  const form = useForm();
@@ -2587,6 +2665,11 @@ function ContextFormRenderer({
2587
2665
  const [draftRestored, setDraftRestored] = (0, import_react4.useState)(false);
2588
2666
  const [currentPageIndex, setCurrentPageIndex] = (0, import_react4.useState)(0);
2589
2667
  const [focusFieldId, setFocusFieldId] = (0, import_react4.useState)(null);
2668
+ const [confirmation, setConfirmation] = (0, import_react4.useState)(null);
2669
+ const [guardMessage, setGuardMessage] = (0, import_react4.useState)(null);
2670
+ const [receipt, setReceipt] = (0, import_react4.useState)(null);
2671
+ const [receiptLoaded, setReceiptLoaded] = (0, import_react4.useState)(receiptStore === void 0);
2672
+ const rendererSubmissionInFlight = (0, import_react4.useRef)(false);
2590
2673
  const pages = form.schema.pages;
2591
2674
  const visiblePageIndexes = (0, import_react4.useMemo)(
2592
2675
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
@@ -2595,6 +2678,29 @@ function ContextFormRenderer({
2595
2678
  const activePage = pages?.[currentPageIndex];
2596
2679
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
2597
2680
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
2681
+ const submitState = confirmation === null ? form.submitStatus : "confirming";
2682
+ const interactionLocked = submitState === "confirming" || submitState === "submitting";
2683
+ (0, import_react4.useEffect)(() => {
2684
+ let active = true;
2685
+ if (receiptStore === void 0) {
2686
+ setReceipt(null);
2687
+ setReceiptLoaded(true);
2688
+ return () => {
2689
+ active = false;
2690
+ };
2691
+ }
2692
+ setReceiptLoaded(false);
2693
+ void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
2694
+ if (active) setReceipt(stored);
2695
+ }).catch(() => {
2696
+ if (active) setReceipt(null);
2697
+ }).finally(() => {
2698
+ if (active) setReceiptLoaded(true);
2699
+ });
2700
+ return () => {
2701
+ active = false;
2702
+ };
2703
+ }, [form.schema.id, form.schema.version, receiptStore]);
2598
2704
  (0, import_react4.useEffect)(() => {
2599
2705
  if (pages === void 0 || visiblePageIndexes.length === 0) {
2600
2706
  setCurrentPageIndex(0);
@@ -2644,6 +2750,7 @@ function ContextFormRenderer({
2644
2750
  if (fieldId !== void 0) setFocusFieldId(fieldId);
2645
2751
  };
2646
2752
  const handleNext = () => {
2753
+ if (interactionLocked) return;
2647
2754
  const result = form.validatePage(currentPageIndex);
2648
2755
  if (!result.valid) {
2649
2756
  focusFirstIssue(result.issues[0]?.fieldId);
@@ -2652,34 +2759,120 @@ function ContextFormRenderer({
2652
2759
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
2653
2760
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
2654
2761
  };
2655
- const submitValues = async () => {
2762
+ const runSubmissionGuards = async (guards) => {
2763
+ const findings = [];
2764
+ let confirmationMessage;
2765
+ let requiresConfirmation = false;
2766
+ for (const guard of guards) {
2767
+ const result = await guard(form.schema, (0, import_core4.selectVisibleAnswers)(form.schema, form.values));
2768
+ if (result.status === "allow") continue;
2769
+ findings.push(...result.findings);
2770
+ if (result.status === "block") {
2771
+ return {
2772
+ status: "block",
2773
+ findings,
2774
+ ...result.message === void 0 ? {} : { message: result.message }
2775
+ };
2776
+ }
2777
+ requiresConfirmation = true;
2778
+ confirmationMessage ??= result.message;
2779
+ }
2780
+ return !requiresConfirmation ? { status: "allow" } : {
2781
+ status: "confirm",
2782
+ findings,
2783
+ ...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
2784
+ };
2785
+ };
2786
+ const submitValues = async (guardsConfirmed = false) => {
2787
+ if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
2788
+ return { status: "cancelled" };
2789
+ }
2656
2790
  const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
2657
2791
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
2658
- const result = await form.submit(beforeSubmit);
2659
- if (result.status === "invalid") {
2660
- const invalidPageIndex = pages?.findIndex(
2661
- (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
2662
- );
2663
- if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
2664
- focusFirstIssue(firstInvalidFieldId);
2665
- return result;
2792
+ if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
2793
+ rendererSubmissionInFlight.current = true;
2794
+ try {
2795
+ const guardResult = await runSubmissionGuards(submissionGuards);
2796
+ if (guardResult.status === "block") {
2797
+ setGuardMessage(guardResult.message ?? "Submission blocked because sensitive data was detected.");
2798
+ return { status: "cancelled" };
2799
+ }
2800
+ if (guardResult.status === "confirm") {
2801
+ setGuardMessage(null);
2802
+ setConfirmation({
2803
+ findings: guardResult.findings,
2804
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
2805
+ });
2806
+ return { status: "cancelled" };
2807
+ }
2808
+ } finally {
2809
+ rendererSubmissionInFlight.current = false;
2810
+ }
2666
2811
  }
2667
- if (result.status !== "success") return result;
2668
- if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2669
- globalThis.localStorage.removeItem(autoSaveKey);
2670
- setDraftRestored(false);
2812
+ setGuardMessage(null);
2813
+ rendererSubmissionInFlight.current = true;
2814
+ try {
2815
+ const result = await form.submit(beforeSubmit);
2816
+ if (result.status === "invalid") {
2817
+ const invalidPageIndex = pages?.findIndex(
2818
+ (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
2819
+ );
2820
+ if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
2821
+ focusFirstIssue(firstInvalidFieldId);
2822
+ return result;
2823
+ }
2824
+ if (result.status !== "success") return result;
2825
+ if (receiptStore !== void 0) {
2826
+ const storedReceipt = {
2827
+ formId: form.schema.id,
2828
+ formVersion: form.schema.version,
2829
+ submittedAt: (/* @__PURE__ */ new Date()).toISOString()
2830
+ };
2831
+ await receiptStore.save(storedReceipt);
2832
+ setReceipt(storedReceipt);
2833
+ }
2834
+ if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2835
+ globalThis.localStorage.removeItem(autoSaveKey);
2836
+ setDraftRestored(false);
2837
+ }
2838
+ setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2839
+ return result;
2840
+ } finally {
2841
+ rendererSubmissionInFlight.current = false;
2671
2842
  }
2672
- setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2673
- return result;
2674
2843
  };
2675
2844
  const handleSubmit = (event) => {
2676
2845
  event.preventDefault();
2846
+ if (interactionLocked) return;
2677
2847
  void submitValues();
2678
2848
  };
2849
+ const confirmSubmission = () => {
2850
+ setConfirmation(null);
2851
+ void submitValues(true);
2852
+ };
2853
+ const cancelSubmission = () => {
2854
+ setConfirmation(null);
2855
+ };
2856
+ const resetReceipt = async () => {
2857
+ if (receiptStore === void 0) return;
2858
+ await receiptStore.remove(form.schema.id, form.schema.version);
2859
+ setReceipt(null);
2860
+ form.reset();
2861
+ };
2679
2862
  const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
2680
2863
  const canPrev = pages !== void 0 && activeVisibleIndex > 0;
2681
2864
  const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
2682
- const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: form.isSubmitting, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
2865
+ const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: interactionLocked, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "fe-submit", type: "submit", disabled: interactionLocked, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
2866
+ if (!receiptLoaded) return null;
2867
+ if (receipt !== null) {
2868
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
2869
+ receipt,
2870
+ ...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
2871
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { role: "status", children: [
2872
+ "Already submitted.",
2873
+ receiptStore === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: () => void resetReceipt(), children: "Submit another response" })
2874
+ ] }) });
2875
+ }
2683
2876
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
2684
2877
  slots.renderHeader?.({
2685
2878
  title: form.schema.title,
@@ -2727,7 +2920,8 @@ function ContextFormRenderer({
2727
2920
  translate: form.translate,
2728
2921
  inputId: `${prefix}-${field.id}`,
2729
2922
  errorId: `${prefix}-${field.id}-error`,
2730
- helpId: `${prefix}-${field.id}-help`
2923
+ helpId: `${prefix}-${field.id}-help`,
2924
+ ...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
2731
2925
  };
2732
2926
  if (slots.renderField !== void 0) {
2733
2927
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react4.Fragment, { children: slots.renderField({
@@ -2742,6 +2936,16 @@ function ContextFormRenderer({
2742
2936
  const Component = components[field.type];
2743
2937
  return Component === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DefaultField, { ...props }, field.id) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Component, { ...props }, field.id);
2744
2938
  }) }),
2939
+ guardMessage === null ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: guardMessage }),
2940
+ confirmation === null ? null : slots.renderSubmissionConfirmation?.({
2941
+ findings: confirmation.findings,
2942
+ onConfirm: confirmSubmission,
2943
+ onCancel: cancelSubmission
2944
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
2945
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation.message ?? "Sensitive data may be included. Confirm before submitting." }),
2946
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: confirmSubmission, children: "Confirm submission" }),
2947
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: cancelSubmission, children: "Cancel" })
2948
+ ] }),
2745
2949
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
2746
2950
  validationIssues.length,
2747
2951
  " validation error",
@@ -2764,7 +2968,9 @@ function ContextFormRenderer({
2764
2968
  totalPages: visiblePageIndexes.length,
2765
2969
  canPrev,
2766
2970
  canNext,
2767
- onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
2971
+ onPrev: () => {
2972
+ if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
2973
+ },
2768
2974
  onNext: handleNext
2769
2975
  }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
2770
2976
  canPrev ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -2772,11 +2978,12 @@ function ContextFormRenderer({
2772
2978
  {
2773
2979
  className: "btn-prev",
2774
2980
  type: "button",
2981
+ disabled: interactionLocked,
2775
2982
  onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
2776
2983
  children: form.translate("form.back")
2777
2984
  }
2778
2985
  ) : null,
2779
- canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
2986
+ canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
2780
2987
  ] }),
2781
2988
  canNext ? null : renderSubmitButton()
2782
2989
  ] }),
@@ -2833,6 +3040,7 @@ function FormRenderer(props) {
2833
3040
  FormBuilder,
2834
3041
  FormProvider,
2835
3042
  FormRenderer,
3043
+ createLocalStorageSubmissionReceiptStore,
2836
3044
  resolveInitialFieldType,
2837
3045
  useField,
2838
3046
  useForm,
package/dist/index.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ComponentType, MouseEvent } from 'react';
3
3
  import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, FormValues, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
4
+ import { SensitiveDataFinding } from '@form-engine-ts/privacy';
4
5
 
5
6
  /** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
6
7
  type BuilderPolicy = FormPolicy;
@@ -84,6 +85,21 @@ interface FormBuilderResult {
84
85
  }
85
86
  declare function useFormBuilder({ schema, onChange, policy, idFactory, factories }: FormBuilderOptions): FormBuilderResult;
86
87
 
88
+ interface SubmissionReceipt {
89
+ readonly formId: string;
90
+ readonly formVersion: number;
91
+ readonly submissionId?: string;
92
+ readonly submittedAt: string;
93
+ }
94
+ interface SubmissionReceiptStore {
95
+ get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
96
+ save(receipt: SubmissionReceipt): Promise<void>;
97
+ remove(formId: string, formVersion: number): Promise<void>;
98
+ }
99
+ declare function createLocalStorageSubmissionReceiptStore(options?: {
100
+ readonly namespace?: string;
101
+ }): SubmissionReceiptStore;
102
+
87
103
  interface ComponentBaseProps {
88
104
  readonly id?: string;
89
105
  readonly className?: string;
@@ -249,6 +265,19 @@ type SubmitResult = {
249
265
  readonly status: "error";
250
266
  readonly error: Error;
251
267
  };
268
+ type SubmissionGuardResult = {
269
+ readonly status: "allow";
270
+ } | {
271
+ readonly status: "confirm";
272
+ readonly findings: readonly SensitiveDataFinding[];
273
+ readonly message?: string;
274
+ } | {
275
+ readonly status: "block";
276
+ readonly findings: readonly SensitiveDataFinding[];
277
+ readonly message?: string;
278
+ };
279
+ type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
280
+ type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
252
281
  interface FormRendererSlots {
253
282
  readonly renderHeader?: (props: {
254
283
  readonly title: string;
@@ -287,6 +316,24 @@ interface FormRendererSlots {
287
316
  readonly error: Error;
288
317
  readonly onRetry?: () => void;
289
318
  }) => ReactNode;
319
+ readonly renderSubmissionConfirmation?: (props: {
320
+ readonly findings: readonly SensitiveDataFinding[];
321
+ readonly onConfirm: () => void;
322
+ readonly onCancel: () => void;
323
+ }) => ReactNode;
324
+ readonly renderAlreadySubmitted?: (props: {
325
+ readonly receipt: SubmissionReceipt;
326
+ readonly onReset?: () => void;
327
+ }) => ReactNode;
328
+ readonly renderCharacterCount?: (props: {
329
+ readonly fieldId: string;
330
+ readonly current: number;
331
+ readonly max: number;
332
+ }) => ReactNode;
333
+ }
334
+ interface SubmissionProtectionProps {
335
+ readonly submissionGuards?: readonly SubmissionGuard[];
336
+ readonly receiptStore?: SubmissionReceiptStore;
290
337
  }
291
338
  type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
292
339
 
@@ -365,9 +412,10 @@ interface FieldComponentProps {
365
412
  readonly inputId: string;
366
413
  readonly errorId: string;
367
414
  readonly helpId: string;
415
+ readonly renderCharacterCount?: FormRendererSlots["renderCharacterCount"];
368
416
  }
369
417
  type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentProps>>>;
370
- interface FormRendererPresentationProps {
418
+ interface FormRendererPresentationProps extends SubmissionProtectionProps {
371
419
  readonly components?: FieldComponents;
372
420
  readonly className?: string;
373
421
  readonly successMessageKey?: string;
@@ -388,4 +436,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
388
436
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
389
437
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
390
438
 
391
- export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
439
+ export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptStore, type SubmitResult, type SubmitStatus, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, useField, useForm, useFormBuilder };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ComponentType, MouseEvent } from 'react';
3
3
  import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, FormValues, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
4
+ import { SensitiveDataFinding } from '@form-engine-ts/privacy';
4
5
 
5
6
  /** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
6
7
  type BuilderPolicy = FormPolicy;
@@ -84,6 +85,21 @@ interface FormBuilderResult {
84
85
  }
85
86
  declare function useFormBuilder({ schema, onChange, policy, idFactory, factories }: FormBuilderOptions): FormBuilderResult;
86
87
 
88
+ interface SubmissionReceipt {
89
+ readonly formId: string;
90
+ readonly formVersion: number;
91
+ readonly submissionId?: string;
92
+ readonly submittedAt: string;
93
+ }
94
+ interface SubmissionReceiptStore {
95
+ get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
96
+ save(receipt: SubmissionReceipt): Promise<void>;
97
+ remove(formId: string, formVersion: number): Promise<void>;
98
+ }
99
+ declare function createLocalStorageSubmissionReceiptStore(options?: {
100
+ readonly namespace?: string;
101
+ }): SubmissionReceiptStore;
102
+
87
103
  interface ComponentBaseProps {
88
104
  readonly id?: string;
89
105
  readonly className?: string;
@@ -249,6 +265,19 @@ type SubmitResult = {
249
265
  readonly status: "error";
250
266
  readonly error: Error;
251
267
  };
268
+ type SubmissionGuardResult = {
269
+ readonly status: "allow";
270
+ } | {
271
+ readonly status: "confirm";
272
+ readonly findings: readonly SensitiveDataFinding[];
273
+ readonly message?: string;
274
+ } | {
275
+ readonly status: "block";
276
+ readonly findings: readonly SensitiveDataFinding[];
277
+ readonly message?: string;
278
+ };
279
+ type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
280
+ type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
252
281
  interface FormRendererSlots {
253
282
  readonly renderHeader?: (props: {
254
283
  readonly title: string;
@@ -287,6 +316,24 @@ interface FormRendererSlots {
287
316
  readonly error: Error;
288
317
  readonly onRetry?: () => void;
289
318
  }) => ReactNode;
319
+ readonly renderSubmissionConfirmation?: (props: {
320
+ readonly findings: readonly SensitiveDataFinding[];
321
+ readonly onConfirm: () => void;
322
+ readonly onCancel: () => void;
323
+ }) => ReactNode;
324
+ readonly renderAlreadySubmitted?: (props: {
325
+ readonly receipt: SubmissionReceipt;
326
+ readonly onReset?: () => void;
327
+ }) => ReactNode;
328
+ readonly renderCharacterCount?: (props: {
329
+ readonly fieldId: string;
330
+ readonly current: number;
331
+ readonly max: number;
332
+ }) => ReactNode;
333
+ }
334
+ interface SubmissionProtectionProps {
335
+ readonly submissionGuards?: readonly SubmissionGuard[];
336
+ readonly receiptStore?: SubmissionReceiptStore;
290
337
  }
291
338
  type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
292
339
 
@@ -365,9 +412,10 @@ interface FieldComponentProps {
365
412
  readonly inputId: string;
366
413
  readonly errorId: string;
367
414
  readonly helpId: string;
415
+ readonly renderCharacterCount?: FormRendererSlots["renderCharacterCount"];
368
416
  }
369
417
  type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentProps>>>;
370
- interface FormRendererPresentationProps {
418
+ interface FormRendererPresentationProps extends SubmissionProtectionProps {
371
419
  readonly components?: FieldComponents;
372
420
  readonly className?: string;
373
421
  readonly successMessageKey?: string;
@@ -388,4 +436,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
388
436
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
389
437
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
390
438
 
391
- export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
439
+ export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptStore, type SubmitResult, type SubmitStatus, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, useField, useForm, useFormBuilder };
package/dist/index.js CHANGED
@@ -2179,7 +2179,7 @@ import {
2179
2179
  validateAnswers,
2180
2180
  validatePageAnswers
2181
2181
  } 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";
2182
+ import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useRef, useState as useState2 } from "react";
2183
2183
  import { jsx as jsx2 } from "react/jsx-runtime";
2184
2184
  var FormContext = createContext2(null);
2185
2185
  function issuesByField(issues) {
@@ -2207,6 +2207,7 @@ function FormProvider({
2207
2207
  const [submitStatus, setSubmitStatus] = useState2("idle");
2208
2208
  const [submitError, setSubmitError] = useState2(null);
2209
2209
  const [validationPageIndex, setValidationPageIndex] = useState2(null);
2210
+ const submissionInFlight = useRef(false);
2210
2211
  const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
2211
2212
  const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
2212
2213
  useEffect(() => {
@@ -2260,6 +2261,7 @@ function FormProvider({
2260
2261
  }, [initialValues]);
2261
2262
  const submit = useCallback2(
2262
2263
  async (beforeSubmit) => {
2264
+ if (submissionInFlight.current) return { status: "cancelled" };
2263
2265
  const validation = validateAnswers(validSchema, values);
2264
2266
  if (!validation.valid) {
2265
2267
  setErrors(issuesByField(validation.issues));
@@ -2272,12 +2274,13 @@ function FormProvider({
2272
2274
  setValidationPageIndex(null);
2273
2275
  setSubmitError(null);
2274
2276
  const visibleValues = selectVisibleAnswers(validSchema, values);
2277
+ submissionInFlight.current = true;
2275
2278
  try {
2279
+ setSubmitStatus("submitting");
2276
2280
  if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
2277
2281
  setSubmitStatus("idle");
2278
2282
  return { status: "cancelled" };
2279
2283
  }
2280
- setSubmitStatus("submitting");
2281
2284
  await onSubmit(visibleValues);
2282
2285
  if (resetOnSuccess) setValues({ ...initialValues });
2283
2286
  setSubmitStatus("success");
@@ -2287,6 +2290,8 @@ function FormProvider({
2287
2290
  setSubmitError(error);
2288
2291
  setSubmitStatus("error");
2289
2292
  return { status: "error", error };
2293
+ } finally {
2294
+ submissionInFlight.current = false;
2290
2295
  }
2291
2296
  },
2292
2297
  [initialValues, onSubmit, resetOnSuccess, validSchema, values]
@@ -2347,8 +2352,67 @@ function useField(fieldId) {
2347
2352
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
2348
2353
  }
2349
2354
 
2355
+ // src/receipt.ts
2356
+ function receiptKey(namespace, formId, formVersion) {
2357
+ return `${namespace}:${formId}:v${formVersion}`;
2358
+ }
2359
+ function browserStorage() {
2360
+ if (typeof window === "undefined") return null;
2361
+ try {
2362
+ return window.localStorage;
2363
+ } catch {
2364
+ return null;
2365
+ }
2366
+ }
2367
+ function parseReceipt(serialized) {
2368
+ try {
2369
+ const value = JSON.parse(serialized);
2370
+ 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") {
2371
+ return null;
2372
+ }
2373
+ const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
2374
+ return {
2375
+ formId: value.formId,
2376
+ formVersion: value.formVersion,
2377
+ submittedAt: value.submittedAt,
2378
+ ...submissionId === void 0 ? {} : { submissionId }
2379
+ };
2380
+ } catch {
2381
+ return null;
2382
+ }
2383
+ }
2384
+ function createLocalStorageSubmissionReceiptStore(options = {}) {
2385
+ const namespace = options.namespace ?? "form_engine_receipt";
2386
+ if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
2387
+ return {
2388
+ async get(formId, formVersion) {
2389
+ const storage = browserStorage();
2390
+ if (storage === null) return null;
2391
+ try {
2392
+ const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
2393
+ if (serialized === null) return null;
2394
+ const receipt = parseReceipt(serialized);
2395
+ return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
2396
+ } catch {
2397
+ return null;
2398
+ }
2399
+ },
2400
+ async save(receipt) {
2401
+ const storage = browserStorage();
2402
+ if (storage === null) return;
2403
+ storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
2404
+ },
2405
+ async remove(formId, formVersion) {
2406
+ const storage = browserStorage();
2407
+ if (storage === null) return;
2408
+ storage.removeItem(receiptKey(namespace, formId, formVersion));
2409
+ }
2410
+ };
2411
+ }
2412
+
2350
2413
  // src/renderer.tsx
2351
2414
  import {
2415
+ selectVisibleAnswers as selectVisibleAnswers2,
2352
2416
  validateAnswers as validateAnswers2
2353
2417
  } from "@form-engine-ts/core";
2354
2418
  import {
@@ -2356,7 +2420,7 @@ import {
2356
2420
  useEffect as useEffect2,
2357
2421
  useId,
2358
2422
  useMemo as useMemo3,
2359
- useRef,
2423
+ useRef as useRef2,
2360
2424
  useState as useState3
2361
2425
  } from "react";
2362
2426
  import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -2473,11 +2537,17 @@ function DefaultField(props) {
2473
2537
  /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
2474
2538
  ] });
2475
2539
  let control;
2540
+ const textConstraints = field.type === "text" || field.type === "textarea" ? {
2541
+ minLength: field.minLength,
2542
+ maxLength: field.maxLength,
2543
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
2544
+ } : {};
2476
2545
  if (field.type === "textarea") {
2477
2546
  control = /* @__PURE__ */ jsx3(
2478
2547
  "textarea",
2479
2548
  {
2480
2549
  ...ariaProps,
2550
+ ...textConstraints,
2481
2551
  id: inputId,
2482
2552
  name: field.id,
2483
2553
  placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
@@ -2490,6 +2560,7 @@ function DefaultField(props) {
2490
2560
  "input",
2491
2561
  {
2492
2562
  ...ariaProps,
2563
+ ...textConstraints,
2493
2564
  id: inputId,
2494
2565
  name: field.id,
2495
2566
  type: "number",
@@ -2534,6 +2605,11 @@ function DefaultField(props) {
2534
2605
  return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
2535
2606
  label,
2536
2607
  control,
2608
+ (field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
2609
+ fieldId: field.id,
2610
+ current: typeof value === "string" ? value.length : 0,
2611
+ max: field.maxLength
2612
+ }) : null,
2537
2613
  /* @__PURE__ */ jsx3(FieldMessage, { props })
2538
2614
  ] });
2539
2615
  }
@@ -2569,15 +2645,22 @@ function ContextFormRenderer({
2569
2645
  autoSaveKey,
2570
2646
  beforeSubmit,
2571
2647
  onDraftSave,
2648
+ submissionGuards = [],
2649
+ receiptStore,
2572
2650
  slots = {}
2573
2651
  }) {
2574
2652
  const form = useForm();
2575
2653
  const prefix = useId().replace(/:/g, "");
2576
- const formRef = useRef(null);
2577
- const loadedDraftKey = useRef(null);
2654
+ const formRef = useRef2(null);
2655
+ const loadedDraftKey = useRef2(null);
2578
2656
  const [draftRestored, setDraftRestored] = useState3(false);
2579
2657
  const [currentPageIndex, setCurrentPageIndex] = useState3(0);
2580
2658
  const [focusFieldId, setFocusFieldId] = useState3(null);
2659
+ const [confirmation, setConfirmation] = useState3(null);
2660
+ const [guardMessage, setGuardMessage] = useState3(null);
2661
+ const [receipt, setReceipt] = useState3(null);
2662
+ const [receiptLoaded, setReceiptLoaded] = useState3(receiptStore === void 0);
2663
+ const rendererSubmissionInFlight = useRef2(false);
2581
2664
  const pages = form.schema.pages;
2582
2665
  const visiblePageIndexes = useMemo3(
2583
2666
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
@@ -2586,6 +2669,29 @@ function ContextFormRenderer({
2586
2669
  const activePage = pages?.[currentPageIndex];
2587
2670
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
2588
2671
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
2672
+ const submitState = confirmation === null ? form.submitStatus : "confirming";
2673
+ const interactionLocked = submitState === "confirming" || submitState === "submitting";
2674
+ useEffect2(() => {
2675
+ let active = true;
2676
+ if (receiptStore === void 0) {
2677
+ setReceipt(null);
2678
+ setReceiptLoaded(true);
2679
+ return () => {
2680
+ active = false;
2681
+ };
2682
+ }
2683
+ setReceiptLoaded(false);
2684
+ void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
2685
+ if (active) setReceipt(stored);
2686
+ }).catch(() => {
2687
+ if (active) setReceipt(null);
2688
+ }).finally(() => {
2689
+ if (active) setReceiptLoaded(true);
2690
+ });
2691
+ return () => {
2692
+ active = false;
2693
+ };
2694
+ }, [form.schema.id, form.schema.version, receiptStore]);
2589
2695
  useEffect2(() => {
2590
2696
  if (pages === void 0 || visiblePageIndexes.length === 0) {
2591
2697
  setCurrentPageIndex(0);
@@ -2635,6 +2741,7 @@ function ContextFormRenderer({
2635
2741
  if (fieldId !== void 0) setFocusFieldId(fieldId);
2636
2742
  };
2637
2743
  const handleNext = () => {
2744
+ if (interactionLocked) return;
2638
2745
  const result = form.validatePage(currentPageIndex);
2639
2746
  if (!result.valid) {
2640
2747
  focusFirstIssue(result.issues[0]?.fieldId);
@@ -2643,34 +2750,120 @@ function ContextFormRenderer({
2643
2750
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
2644
2751
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
2645
2752
  };
2646
- const submitValues = async () => {
2753
+ const runSubmissionGuards = async (guards) => {
2754
+ const findings = [];
2755
+ let confirmationMessage;
2756
+ let requiresConfirmation = false;
2757
+ for (const guard of guards) {
2758
+ const result = await guard(form.schema, selectVisibleAnswers2(form.schema, form.values));
2759
+ if (result.status === "allow") continue;
2760
+ findings.push(...result.findings);
2761
+ if (result.status === "block") {
2762
+ return {
2763
+ status: "block",
2764
+ findings,
2765
+ ...result.message === void 0 ? {} : { message: result.message }
2766
+ };
2767
+ }
2768
+ requiresConfirmation = true;
2769
+ confirmationMessage ??= result.message;
2770
+ }
2771
+ return !requiresConfirmation ? { status: "allow" } : {
2772
+ status: "confirm",
2773
+ findings,
2774
+ ...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
2775
+ };
2776
+ };
2777
+ const submitValues = async (guardsConfirmed = false) => {
2778
+ if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
2779
+ return { status: "cancelled" };
2780
+ }
2647
2781
  const validation = validateAnswers2(form.schema, form.values);
2648
2782
  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;
2783
+ if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
2784
+ rendererSubmissionInFlight.current = true;
2785
+ try {
2786
+ const guardResult = await runSubmissionGuards(submissionGuards);
2787
+ if (guardResult.status === "block") {
2788
+ setGuardMessage(guardResult.message ?? "Submission blocked because sensitive data was detected.");
2789
+ return { status: "cancelled" };
2790
+ }
2791
+ if (guardResult.status === "confirm") {
2792
+ setGuardMessage(null);
2793
+ setConfirmation({
2794
+ findings: guardResult.findings,
2795
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
2796
+ });
2797
+ return { status: "cancelled" };
2798
+ }
2799
+ } finally {
2800
+ rendererSubmissionInFlight.current = false;
2801
+ }
2657
2802
  }
2658
- if (result.status !== "success") return result;
2659
- if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2660
- globalThis.localStorage.removeItem(autoSaveKey);
2661
- setDraftRestored(false);
2803
+ setGuardMessage(null);
2804
+ rendererSubmissionInFlight.current = true;
2805
+ try {
2806
+ const result = await form.submit(beforeSubmit);
2807
+ if (result.status === "invalid") {
2808
+ const invalidPageIndex = pages?.findIndex(
2809
+ (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
2810
+ );
2811
+ if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
2812
+ focusFirstIssue(firstInvalidFieldId);
2813
+ return result;
2814
+ }
2815
+ if (result.status !== "success") return result;
2816
+ if (receiptStore !== void 0) {
2817
+ const storedReceipt = {
2818
+ formId: form.schema.id,
2819
+ formVersion: form.schema.version,
2820
+ submittedAt: (/* @__PURE__ */ new Date()).toISOString()
2821
+ };
2822
+ await receiptStore.save(storedReceipt);
2823
+ setReceipt(storedReceipt);
2824
+ }
2825
+ if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2826
+ globalThis.localStorage.removeItem(autoSaveKey);
2827
+ setDraftRestored(false);
2828
+ }
2829
+ setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2830
+ return result;
2831
+ } finally {
2832
+ rendererSubmissionInFlight.current = false;
2662
2833
  }
2663
- setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2664
- return result;
2665
2834
  };
2666
2835
  const handleSubmit = (event) => {
2667
2836
  event.preventDefault();
2837
+ if (interactionLocked) return;
2668
2838
  void submitValues();
2669
2839
  };
2840
+ const confirmSubmission = () => {
2841
+ setConfirmation(null);
2842
+ void submitValues(true);
2843
+ };
2844
+ const cancelSubmission = () => {
2845
+ setConfirmation(null);
2846
+ };
2847
+ const resetReceipt = async () => {
2848
+ if (receiptStore === void 0) return;
2849
+ await receiptStore.remove(form.schema.id, form.schema.version);
2850
+ setReceipt(null);
2851
+ form.reset();
2852
+ };
2670
2853
  const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
2671
2854
  const canPrev = pages !== void 0 && activeVisibleIndex > 0;
2672
2855
  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") });
2856
+ 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") });
2857
+ if (!receiptLoaded) return null;
2858
+ if (receipt !== null) {
2859
+ return /* @__PURE__ */ jsx3("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
2860
+ receipt,
2861
+ ...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
2862
+ }) ?? /* @__PURE__ */ jsxs2("div", { role: "status", children: [
2863
+ "Already submitted.",
2864
+ receiptStore === void 0 ? null : /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => void resetReceipt(), children: "Submit another response" })
2865
+ ] }) });
2866
+ }
2674
2867
  return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
2675
2868
  slots.renderHeader?.({
2676
2869
  title: form.schema.title,
@@ -2718,7 +2911,8 @@ function ContextFormRenderer({
2718
2911
  translate: form.translate,
2719
2912
  inputId: `${prefix}-${field.id}`,
2720
2913
  errorId: `${prefix}-${field.id}-error`,
2721
- helpId: `${prefix}-${field.id}-help`
2914
+ helpId: `${prefix}-${field.id}-help`,
2915
+ ...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
2722
2916
  };
2723
2917
  if (slots.renderField !== void 0) {
2724
2918
  return /* @__PURE__ */ jsx3(Fragment2, { children: slots.renderField({
@@ -2733,6 +2927,16 @@ function ContextFormRenderer({
2733
2927
  const Component = components[field.type];
2734
2928
  return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
2735
2929
  }) }),
2930
+ guardMessage === null ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: guardMessage }),
2931
+ confirmation === null ? null : slots.renderSubmissionConfirmation?.({
2932
+ findings: confirmation.findings,
2933
+ onConfirm: confirmSubmission,
2934
+ onCancel: cancelSubmission
2935
+ }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
2936
+ /* @__PURE__ */ jsx3("p", { children: confirmation.message ?? "Sensitive data may be included. Confirm before submitting." }),
2937
+ /* @__PURE__ */ jsx3("button", { type: "button", onClick: confirmSubmission, children: "Confirm submission" }),
2938
+ /* @__PURE__ */ jsx3("button", { type: "button", onClick: cancelSubmission, children: "Cancel" })
2939
+ ] }),
2736
2940
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
2737
2941
  validationIssues.length,
2738
2942
  " validation error",
@@ -2755,7 +2959,9 @@ function ContextFormRenderer({
2755
2959
  totalPages: visiblePageIndexes.length,
2756
2960
  canPrev,
2757
2961
  canNext,
2758
- onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
2962
+ onPrev: () => {
2963
+ if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
2964
+ },
2759
2965
  onNext: handleNext
2760
2966
  }) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
2761
2967
  canPrev ? /* @__PURE__ */ jsx3(
@@ -2763,11 +2969,12 @@ function ContextFormRenderer({
2763
2969
  {
2764
2970
  className: "btn-prev",
2765
2971
  type: "button",
2972
+ disabled: interactionLocked,
2766
2973
  onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
2767
2974
  children: form.translate("form.back")
2768
2975
  }
2769
2976
  ) : null,
2770
- canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
2977
+ canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
2771
2978
  ] }),
2772
2979
  canNext ? null : renderSubmitButton()
2773
2980
  ] }),
@@ -2823,6 +3030,7 @@ export {
2823
3030
  FormBuilder,
2824
3031
  FormProvider,
2825
3032
  FormRenderer,
3033
+ createLocalStorageSubmissionReceiptStore,
2826
3034
  resolveInitialFieldType,
2827
3035
  useField,
2828
3036
  useForm,
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.7.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.7.0",
46
+ "@form-engine-ts/privacy": "2.7.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"