@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/README.md CHANGED
@@ -116,3 +116,15 @@ 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.
126
+
127
+ The Builder basic-settings section edits source `title` and `description` through the same policy-aware action pipeline.
128
+ Submission confirmation slots receive the effective message, localized schema, and visible answers. An `onSubmit` result
129
+ may provide `submissionId` and `submittedAt`, which Renderer copies into its receipt. Receipt stores support `getBatch`,
130
+ and `useSubmissionReceipts` loads multiple form/version receipts for list and dashboard surfaces.
package/dist/index.cjs CHANGED
@@ -23,10 +23,13 @@ __export(index_exports, {
23
23
  FormBuilder: () => FormBuilder,
24
24
  FormProvider: () => FormProvider,
25
25
  FormRenderer: () => FormRenderer,
26
+ createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
26
27
  resolveInitialFieldType: () => resolveInitialFieldType,
28
+ submissionReceiptQueryKey: () => submissionReceiptQueryKey,
27
29
  useField: () => useField,
28
30
  useForm: () => useForm,
29
- useFormBuilder: () => useFormBuilder
31
+ useFormBuilder: () => useFormBuilder,
32
+ useSubmissionReceipts: () => useSubmissionReceipts
30
33
  });
31
34
  module.exports = __toCommonJS(index_exports);
32
35
 
@@ -971,6 +974,9 @@ var FIELD_TYPES = [
971
974
  ];
972
975
  var BUILDER_DEFAULTS = {
973
976
  "builder.formBuilder": "Form builder",
977
+ "builder.basicSettings": "Basic settings",
978
+ "builder.formTitle": "Form title",
979
+ "builder.formDescription": "Form description",
974
980
  "builder.moveUp": "Move {{title}} up",
975
981
  "builder.moveDown": "Move {{title}} down",
976
982
  "builder.delete": "Delete {{title}}",
@@ -1136,7 +1142,7 @@ function FormBuilder({
1136
1142
  }) {
1137
1143
  const resolvedComponents = { ...DEFAULT_COMPONENTS, ...componentOverrides };
1138
1144
  const components = GUARDED_COMPONENTS;
1139
- const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextInput } = components;
1145
+ const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextArea, TextInput } = components;
1140
1146
  const ToolbarSlot = slots?.toolbar;
1141
1147
  const FieldEditorSlot = slots?.fieldEditor;
1142
1148
  const OptionEditorSlot = slots?.optionEditor;
@@ -1439,6 +1445,42 @@ function FormBuilder({
1439
1445
  }
1440
1446
  },
1441
1447
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Fieldset, { className: "form-engine-builder__controls", disabled: readOnly, children: [
1448
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1449
+ Section,
1450
+ {
1451
+ className: "form-engine-builder__basic-settings",
1452
+ headingId: "builder-basic-settings-heading",
1453
+ title: translate("builder.basicSettings"),
1454
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
1455
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1456
+ translate("builder.formTitle"),
1457
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1458
+ TextInput,
1459
+ {
1460
+ name: "title",
1461
+ required: true,
1462
+ error: schema.title.trim().length === 0,
1463
+ helperText: schema.title.trim().length === 0 ? translate("builder.required") : "",
1464
+ value: schema.title,
1465
+ onChange: (value) => setSourceText({ kind: "form" }, "title", value)
1466
+ }
1467
+ )
1468
+ ] }),
1469
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1470
+ translate("builder.formDescription"),
1471
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1472
+ TextArea,
1473
+ {
1474
+ name: "description",
1475
+ rows: 3,
1476
+ value: schema.description ?? "",
1477
+ onChange: (value) => setSourceText({ kind: "form" }, "description", value)
1478
+ }
1479
+ )
1480
+ ] })
1481
+ ] })
1482
+ }
1483
+ ),
1442
1484
  pagesEnabled ? PagesSlot === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1443
1485
  Section,
1444
1486
  {
@@ -2225,6 +2267,7 @@ function FormProvider({
2225
2267
  const [submitStatus, setSubmitStatus] = (0, import_react3.useState)("idle");
2226
2268
  const [submitError, setSubmitError] = (0, import_react3.useState)(null);
2227
2269
  const [validationPageIndex, setValidationPageIndex] = (0, import_react3.useState)(null);
2270
+ const submissionInFlight = (0, import_react3.useRef)(false);
2228
2271
  const visibility = (0, import_react3.useMemo)(() => (0, import_core3.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
2229
2272
  const pageVisibility = (0, import_react3.useMemo)(() => (0, import_core3.calculatePageVisibility)(validSchema, values), [validSchema, values]);
2230
2273
  (0, import_react3.useEffect)(() => {
@@ -2278,6 +2321,7 @@ function FormProvider({
2278
2321
  }, [initialValues]);
2279
2322
  const submit = (0, import_react3.useCallback)(
2280
2323
  async (beforeSubmit) => {
2324
+ if (submissionInFlight.current) return { status: "cancelled" };
2281
2325
  const validation = (0, import_core3.validateAnswers)(validSchema, values);
2282
2326
  if (!validation.valid) {
2283
2327
  setErrors(issuesByField(validation.issues));
@@ -2290,21 +2334,24 @@ function FormProvider({
2290
2334
  setValidationPageIndex(null);
2291
2335
  setSubmitError(null);
2292
2336
  const visibleValues = (0, import_core3.selectVisibleAnswers)(validSchema, values);
2337
+ submissionInFlight.current = true;
2293
2338
  try {
2339
+ setSubmitStatus("submitting");
2294
2340
  if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
2295
2341
  setSubmitStatus("idle");
2296
2342
  return { status: "cancelled" };
2297
2343
  }
2298
- setSubmitStatus("submitting");
2299
- await onSubmit(visibleValues);
2344
+ const response = await onSubmit(visibleValues);
2300
2345
  if (resetOnSuccess) setValues({ ...initialValues });
2301
2346
  setSubmitStatus("success");
2302
- return { status: "success" };
2347
+ return response === void 0 ? { status: "success" } : { status: "success", response };
2303
2348
  } catch (cause) {
2304
2349
  const error = cause instanceof Error ? cause : new Error(String(cause));
2305
2350
  setSubmitError(error);
2306
2351
  setSubmitStatus("error");
2307
2352
  return { status: "error", error };
2353
+ } finally {
2354
+ submissionInFlight.current = false;
2308
2355
  }
2309
2356
  },
2310
2357
  [initialValues, onSubmit, resetOnSuccess, validSchema, values]
@@ -2365,9 +2412,119 @@ function useField(fieldId) {
2365
2412
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
2366
2413
  }
2367
2414
 
2415
+ // src/receipt.ts
2416
+ var import_react4 = require("react");
2417
+ function submissionReceiptQueryKey(formId, formVersion) {
2418
+ return `${formId}:v${formVersion}`;
2419
+ }
2420
+ function receiptKey(namespace, formId, formVersion) {
2421
+ return `${namespace}:${formId}:v${formVersion}`;
2422
+ }
2423
+ function browserStorage() {
2424
+ if (typeof window === "undefined") return null;
2425
+ try {
2426
+ return window.localStorage;
2427
+ } catch {
2428
+ return null;
2429
+ }
2430
+ }
2431
+ function parseReceipt(serialized) {
2432
+ try {
2433
+ const value = JSON.parse(serialized);
2434
+ 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") {
2435
+ return null;
2436
+ }
2437
+ const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
2438
+ return {
2439
+ formId: value.formId,
2440
+ formVersion: value.formVersion,
2441
+ submittedAt: value.submittedAt,
2442
+ ...submissionId === void 0 ? {} : { submissionId }
2443
+ };
2444
+ } catch {
2445
+ return null;
2446
+ }
2447
+ }
2448
+ function createLocalStorageSubmissionReceiptStore(options = {}) {
2449
+ const namespace = options.namespace ?? "form_engine_receipt";
2450
+ if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
2451
+ const get = async (formId, formVersion) => {
2452
+ const storage = browserStorage();
2453
+ if (storage === null) return null;
2454
+ try {
2455
+ const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
2456
+ if (serialized === null) return null;
2457
+ const receipt = parseReceipt(serialized);
2458
+ return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
2459
+ } catch {
2460
+ return null;
2461
+ }
2462
+ };
2463
+ return {
2464
+ get,
2465
+ async getBatch(queries) {
2466
+ const receipts = await Promise.all(queries.map((query) => get(query.formId, query.formVersion)));
2467
+ return new Map(
2468
+ receipts.flatMap(
2469
+ (receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
2470
+ )
2471
+ );
2472
+ },
2473
+ async save(receipt) {
2474
+ const storage = browserStorage();
2475
+ if (storage === null) return;
2476
+ storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
2477
+ },
2478
+ async remove(formId, formVersion) {
2479
+ const storage = browserStorage();
2480
+ if (storage === null) return;
2481
+ storage.removeItem(receiptKey(namespace, formId, formVersion));
2482
+ }
2483
+ };
2484
+ }
2485
+ function useSubmissionReceipts(store, queries) {
2486
+ const querySignature = JSON.stringify(queries.map(({ formId, formVersion }) => [formId, formVersion]));
2487
+ const stableQueries = (0, import_react4.useMemo)(() => {
2488
+ const parsed = JSON.parse(querySignature);
2489
+ if (!Array.isArray(parsed)) return [];
2490
+ return parsed.flatMap(
2491
+ (entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number" && Number.isSafeInteger(entry[1]) ? [{ formId: entry[0], formVersion: entry[1] }] : []
2492
+ );
2493
+ }, [querySignature]);
2494
+ const [state, setState] = (0, import_react4.useState)({
2495
+ receipts: /* @__PURE__ */ new Map(),
2496
+ isLoading: stableQueries.length > 0,
2497
+ error: null
2498
+ });
2499
+ (0, import_react4.useEffect)(() => {
2500
+ let active = true;
2501
+ if (stableQueries.length === 0) {
2502
+ setState({ receipts: /* @__PURE__ */ new Map(), isLoading: false, error: null });
2503
+ return () => {
2504
+ active = false;
2505
+ };
2506
+ }
2507
+ setState((current) => ({ ...current, isLoading: true, error: null }));
2508
+ void store.getBatch(stableQueries).then((receipts) => {
2509
+ if (active) setState({ receipts, isLoading: false, error: null });
2510
+ }).catch((cause) => {
2511
+ if (!active) return;
2512
+ setState({
2513
+ receipts: /* @__PURE__ */ new Map(),
2514
+ isLoading: false,
2515
+ error: cause instanceof Error ? cause : new Error(String(cause))
2516
+ });
2517
+ });
2518
+ return () => {
2519
+ active = false;
2520
+ };
2521
+ }, [stableQueries, store]);
2522
+ return state;
2523
+ }
2524
+
2368
2525
  // src/renderer.tsx
2369
2526
  var import_core4 = require("@form-engine-ts/core");
2370
- var import_react4 = require("react");
2527
+ var import_react5 = require("react");
2371
2528
  var import_jsx_runtime3 = require("react/jsx-runtime");
2372
2529
  function describedBy(field, error, helpId, errorId) {
2373
2530
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
@@ -2482,11 +2639,17 @@ function DefaultField(props) {
2482
2639
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required })
2483
2640
  ] });
2484
2641
  let control;
2642
+ const textConstraints = field.type === "text" || field.type === "textarea" ? {
2643
+ minLength: field.minLength,
2644
+ maxLength: field.maxLength,
2645
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
2646
+ } : {};
2485
2647
  if (field.type === "textarea") {
2486
2648
  control = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2487
2649
  "textarea",
2488
2650
  {
2489
2651
  ...ariaProps,
2652
+ ...textConstraints,
2490
2653
  id: inputId,
2491
2654
  name: field.id,
2492
2655
  placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
@@ -2499,6 +2662,7 @@ function DefaultField(props) {
2499
2662
  "input",
2500
2663
  {
2501
2664
  ...ariaProps,
2665
+ ...textConstraints,
2502
2666
  id: inputId,
2503
2667
  name: field.id,
2504
2668
  type: "number",
@@ -2543,6 +2707,11 @@ function DefaultField(props) {
2543
2707
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
2544
2708
  label,
2545
2709
  control,
2710
+ (field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
2711
+ fieldId: field.id,
2712
+ current: typeof value === "string" ? value.length : 0,
2713
+ max: field.maxLength
2714
+ }) : null,
2546
2715
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
2547
2716
  ] });
2548
2717
  }
@@ -2578,31 +2747,62 @@ function ContextFormRenderer({
2578
2747
  autoSaveKey,
2579
2748
  beforeSubmit,
2580
2749
  onDraftSave,
2750
+ submissionGuards = [],
2751
+ receiptStore,
2581
2752
  slots = {}
2582
2753
  }) {
2583
2754
  const form = useForm();
2584
- const prefix = (0, import_react4.useId)().replace(/:/g, "");
2585
- const formRef = (0, import_react4.useRef)(null);
2586
- const loadedDraftKey = (0, import_react4.useRef)(null);
2587
- const [draftRestored, setDraftRestored] = (0, import_react4.useState)(false);
2588
- const [currentPageIndex, setCurrentPageIndex] = (0, import_react4.useState)(0);
2589
- const [focusFieldId, setFocusFieldId] = (0, import_react4.useState)(null);
2755
+ const prefix = (0, import_react5.useId)().replace(/:/g, "");
2756
+ const formRef = (0, import_react5.useRef)(null);
2757
+ const loadedDraftKey = (0, import_react5.useRef)(null);
2758
+ const [draftRestored, setDraftRestored] = (0, import_react5.useState)(false);
2759
+ const [currentPageIndex, setCurrentPageIndex] = (0, import_react5.useState)(0);
2760
+ const [focusFieldId, setFocusFieldId] = (0, import_react5.useState)(null);
2761
+ const [confirmation, setConfirmation] = (0, import_react5.useState)(null);
2762
+ const [guardMessage, setGuardMessage] = (0, import_react5.useState)(null);
2763
+ const [receipt, setReceipt] = (0, import_react5.useState)(null);
2764
+ const [receiptLoaded, setReceiptLoaded] = (0, import_react5.useState)(receiptStore === void 0);
2765
+ const rendererSubmissionInFlight = (0, import_react5.useRef)(false);
2590
2766
  const pages = form.schema.pages;
2591
- const visiblePageIndexes = (0, import_react4.useMemo)(
2767
+ const visiblePageIndexes = (0, import_react5.useMemo)(
2592
2768
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
2593
2769
  [form.pageVisibility, pages]
2594
2770
  );
2595
2771
  const activePage = pages?.[currentPageIndex];
2596
2772
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
2597
2773
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
2598
- (0, import_react4.useEffect)(() => {
2774
+ const visibleValues = (0, import_react5.useMemo)(() => (0, import_core4.selectVisibleAnswers)(form.schema, form.values), [form.schema, form.values]);
2775
+ const submitState = confirmation === null ? form.submitStatus : "confirming";
2776
+ const interactionLocked = submitState === "confirming" || submitState === "submitting";
2777
+ (0, import_react5.useEffect)(() => {
2778
+ let active = true;
2779
+ if (receiptStore === void 0) {
2780
+ setReceipt(null);
2781
+ setReceiptLoaded(true);
2782
+ return () => {
2783
+ active = false;
2784
+ };
2785
+ }
2786
+ setReceiptLoaded(false);
2787
+ void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
2788
+ if (active) setReceipt(stored);
2789
+ }).catch(() => {
2790
+ if (active) setReceipt(null);
2791
+ }).finally(() => {
2792
+ if (active) setReceiptLoaded(true);
2793
+ });
2794
+ return () => {
2795
+ active = false;
2796
+ };
2797
+ }, [form.schema.id, form.schema.version, receiptStore]);
2798
+ (0, import_react5.useEffect)(() => {
2599
2799
  if (pages === void 0 || visiblePageIndexes.length === 0) {
2600
2800
  setCurrentPageIndex(0);
2601
2801
  return;
2602
2802
  }
2603
2803
  if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2604
2804
  }, [currentPageIndex, pages, visiblePageIndexes]);
2605
- (0, import_react4.useEffect)(() => {
2805
+ (0, import_react5.useEffect)(() => {
2606
2806
  if (focusFieldId === null) return;
2607
2807
  const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
2608
2808
  (element) => element.dataset.fieldId === focusFieldId
@@ -2613,7 +2813,7 @@ function ContextFormRenderer({
2613
2813
  setFocusFieldId(null);
2614
2814
  }
2615
2815
  }, [focusFieldId]);
2616
- (0, import_react4.useEffect)(() => {
2816
+ (0, import_react5.useEffect)(() => {
2617
2817
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
2618
2818
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
2619
2819
  if (loadedDraftKey.current === loadIdentity) return;
@@ -2625,7 +2825,7 @@ function ContextFormRenderer({
2625
2825
  form.restoreValues(draft.values);
2626
2826
  setDraftRestored(true);
2627
2827
  }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
2628
- (0, import_react4.useEffect)(() => {
2828
+ (0, import_react5.useEffect)(() => {
2629
2829
  if (form.submitStatus === "success") return;
2630
2830
  const timeout = globalThis.setTimeout(() => {
2631
2831
  onDraftSave?.(form.values);
@@ -2644,6 +2844,7 @@ function ContextFormRenderer({
2644
2844
  if (fieldId !== void 0) setFocusFieldId(fieldId);
2645
2845
  };
2646
2846
  const handleNext = () => {
2847
+ if (interactionLocked) return;
2647
2848
  const result = form.validatePage(currentPageIndex);
2648
2849
  if (!result.valid) {
2649
2850
  focusFirstIssue(result.issues[0]?.fieldId);
@@ -2652,34 +2853,122 @@ function ContextFormRenderer({
2652
2853
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
2653
2854
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
2654
2855
  };
2655
- const submitValues = async () => {
2856
+ const runSubmissionGuards = async (guards) => {
2857
+ const findings = [];
2858
+ let confirmationMessage;
2859
+ let requiresConfirmation = false;
2860
+ for (const guard of guards) {
2861
+ const result = await guard(form.schema, visibleValues);
2862
+ if (result.status === "allow") continue;
2863
+ findings.push(...result.findings);
2864
+ if (result.status === "block") {
2865
+ return {
2866
+ status: "block",
2867
+ findings,
2868
+ ...result.message === void 0 ? {} : { message: result.message }
2869
+ };
2870
+ }
2871
+ requiresConfirmation = true;
2872
+ confirmationMessage ??= result.message;
2873
+ }
2874
+ return !requiresConfirmation ? { status: "allow" } : {
2875
+ status: "confirm",
2876
+ findings,
2877
+ ...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
2878
+ };
2879
+ };
2880
+ const submitValues = async (guardsConfirmed = false) => {
2881
+ if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
2882
+ return { status: "cancelled" };
2883
+ }
2656
2884
  const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
2657
2885
  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;
2886
+ if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
2887
+ rendererSubmissionInFlight.current = true;
2888
+ try {
2889
+ const guardResult = await runSubmissionGuards(submissionGuards);
2890
+ if (guardResult.status === "block") {
2891
+ setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
2892
+ return { status: "cancelled" };
2893
+ }
2894
+ if (guardResult.status === "confirm") {
2895
+ setGuardMessage(null);
2896
+ setConfirmation({
2897
+ findings: guardResult.findings,
2898
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
2899
+ });
2900
+ return { status: "cancelled" };
2901
+ }
2902
+ } finally {
2903
+ rendererSubmissionInFlight.current = false;
2904
+ }
2666
2905
  }
2667
- if (result.status !== "success") return result;
2668
- if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2669
- globalThis.localStorage.removeItem(autoSaveKey);
2670
- setDraftRestored(false);
2906
+ setGuardMessage(null);
2907
+ rendererSubmissionInFlight.current = true;
2908
+ try {
2909
+ const result = await form.submit(beforeSubmit);
2910
+ if (result.status === "invalid") {
2911
+ const invalidPageIndex = pages?.findIndex(
2912
+ (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
2913
+ );
2914
+ if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
2915
+ focusFirstIssue(firstInvalidFieldId);
2916
+ return result;
2917
+ }
2918
+ if (result.status !== "success") return result;
2919
+ if (receiptStore !== void 0) {
2920
+ const response = result.response;
2921
+ const storedReceipt = {
2922
+ formId: form.schema.id,
2923
+ formVersion: form.schema.version,
2924
+ submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2925
+ ...response?.submissionId === void 0 ? {} : { submissionId: response.submissionId }
2926
+ };
2927
+ await receiptStore.save(storedReceipt);
2928
+ setReceipt(storedReceipt);
2929
+ }
2930
+ if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2931
+ globalThis.localStorage.removeItem(autoSaveKey);
2932
+ setDraftRestored(false);
2933
+ }
2934
+ setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2935
+ return result;
2936
+ } finally {
2937
+ rendererSubmissionInFlight.current = false;
2671
2938
  }
2672
- setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
2673
- return result;
2674
2939
  };
2675
2940
  const handleSubmit = (event) => {
2676
2941
  event.preventDefault();
2942
+ if (interactionLocked) return;
2677
2943
  void submitValues();
2678
2944
  };
2945
+ const confirmSubmission = () => {
2946
+ setConfirmation(null);
2947
+ void submitValues(true);
2948
+ };
2949
+ const cancelSubmission = () => {
2950
+ setConfirmation(null);
2951
+ };
2952
+ const resetReceipt = async () => {
2953
+ if (receiptStore === void 0) return;
2954
+ await receiptStore.remove(form.schema.id, form.schema.version);
2955
+ setReceipt(null);
2956
+ form.reset();
2957
+ };
2679
2958
  const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
2680
2959
  const canPrev = pages !== void 0 && activeVisibleIndex > 0;
2681
2960
  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") });
2961
+ 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") });
2962
+ if (!receiptLoaded) return null;
2963
+ if (receipt !== null) {
2964
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
2965
+ receipt,
2966
+ ...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
2967
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { role: "status", children: [
2968
+ form.translate("form.alreadySubmitted"),
2969
+ receiptStore === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: () => void resetReceipt(), children: form.translate("form.submitAnother") })
2970
+ ] }) });
2971
+ }
2683
2972
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
2684
2973
  slots.renderHeader?.({
2685
2974
  title: form.schema.title,
@@ -2727,10 +3016,11 @@ function ContextFormRenderer({
2727
3016
  translate: form.translate,
2728
3017
  inputId: `${prefix}-${field.id}`,
2729
3018
  errorId: `${prefix}-${field.id}-error`,
2730
- helpId: `${prefix}-${field.id}-help`
3019
+ helpId: `${prefix}-${field.id}-help`,
3020
+ ...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
2731
3021
  };
2732
3022
  if (slots.renderField !== void 0) {
2733
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react4.Fragment, { children: slots.renderField({
3023
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react5.Fragment, { children: slots.renderField({
2734
3024
  question: field,
2735
3025
  value: form.values[field.id],
2736
3026
  onChange: (value) => {
@@ -2742,6 +3032,19 @@ function ContextFormRenderer({
2742
3032
  const Component = components[field.type];
2743
3033
  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
3034
  }) }),
3035
+ guardMessage === null ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: guardMessage }),
3036
+ confirmation === null ? null : slots.renderSubmissionConfirmation?.({
3037
+ findings: confirmation.findings,
3038
+ message: confirmation.message ?? form.translate("form.confirmSensitiveData"),
3039
+ schema: form.schema,
3040
+ visibleValues,
3041
+ onConfirm: confirmSubmission,
3042
+ onCancel: cancelSubmission
3043
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
3044
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation.message ?? form.translate("form.confirmSensitiveData") }),
3045
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: confirmSubmission, children: form.translate("form.confirmSubmission") }),
3046
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: cancelSubmission, children: form.translate("form.cancelSubmission") })
3047
+ ] }),
2745
3048
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
2746
3049
  validationIssues.length,
2747
3050
  " validation error",
@@ -2764,7 +3067,9 @@ function ContextFormRenderer({
2764
3067
  totalPages: visiblePageIndexes.length,
2765
3068
  canPrev,
2766
3069
  canNext,
2767
- onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
3070
+ onPrev: () => {
3071
+ if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
3072
+ },
2768
3073
  onNext: handleNext
2769
3074
  }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
2770
3075
  canPrev ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -2772,11 +3077,12 @@ function ContextFormRenderer({
2772
3077
  {
2773
3078
  className: "btn-prev",
2774
3079
  type: "button",
3080
+ disabled: interactionLocked,
2775
3081
  onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
2776
3082
  children: form.translate("form.back")
2777
3083
  }
2778
3084
  ) : null,
2779
- canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
3085
+ canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
2780
3086
  ] }),
2781
3087
  canNext ? null : renderSubmitButton()
2782
3088
  ] }),
@@ -2794,6 +3100,12 @@ var RENDERER_MESSAGES = {
2794
3100
  "form.next": "Next",
2795
3101
  "form.step": "Step {{current}} / {{total}}",
2796
3102
  "form.draftRestored": "Draft restored",
3103
+ "form.submissionBlocked": "Submission blocked because sensitive data was detected.",
3104
+ "form.confirmSensitiveData": "Sensitive data may be included. Confirm before submitting.",
3105
+ "form.confirmSubmission": "Confirm submission",
3106
+ "form.cancelSubmission": "Cancel",
3107
+ "form.alreadySubmitted": "Already submitted.",
3108
+ "form.submitAnother": "Submit another response",
2797
3109
  "validation.required": "This field is required."
2798
3110
  };
2799
3111
  var defaultRendererTranslator = {
@@ -2833,8 +3145,11 @@ function FormRenderer(props) {
2833
3145
  FormBuilder,
2834
3146
  FormProvider,
2835
3147
  FormRenderer,
3148
+ createLocalStorageSubmissionReceiptStore,
2836
3149
  resolveInitialFieldType,
3150
+ submissionReceiptQueryKey,
2837
3151
  useField,
2838
3152
  useForm,
2839
- useFormBuilder
3153
+ useFormBuilder,
3154
+ useSubmissionReceipts
2840
3155
  });