@form-engine-ts/react 4.2.0 → 4.3.2

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
@@ -39,6 +39,17 @@ export function ContactForm() {
39
39
 
40
40
  Use any compatible `TranslationAdapter` in place of the mock translator.
41
41
 
42
+ ## Choice field layout
43
+
44
+ Radio and checkbox questions use the flat layout by default. Set `appearance.choiceField` to `"grouped"` to render
45
+ them as bordered, accessible `<fieldset>` groups with `<legend>` titles:
46
+
47
+ ```tsx
48
+ <FormRenderer appearance={{ choiceField: "grouped" }} />
49
+ ```
50
+
51
+ `groupedChoiceFields={true}` remains available as a deprecated compatibility alias.
52
+
42
53
  Define `schema.pages` to enable Back/Next navigation, page validation, conditional page skipping, and an accessible
43
54
  progress indicator. Pass `autoSaveKey` to persist a versioned draft in `localStorage` after a 500ms debounce and restore it
44
55
  on the next mount:
@@ -168,8 +179,15 @@ state. Text controls forward schema `minLength`, `maxLength`, and `pattern` cons
168
179
  `renderCharacterCount` can replace the default count. Guard evaluation, confirmation, receipt persistence, and provider
169
180
  submission share an in-flight lock so rapid clicks cannot submit twice.
170
181
 
182
+ Set `submissionConfirmation={{ enabled: true, renderMode: "replace" }}` to show a standard answer review before
183
+ submission even when no submission guard is configured. The default `inline` mode keeps the form visible; `replace` and
184
+ `dialog` provide alternate presentations. The standard review lists visible answers with their resolved labels and
185
+ formatted display values. `renderSubmissionConfirmation` receives these as `visibleItems`; guard confirmations continue
186
+ to receive their findings, while generic confirmations provide an empty findings array.
187
+
171
188
  The Builder basic-settings section edits source `title` and `description` through the same policy-aware action pipeline.
172
- Submission confirmation slots receive the effective message, localized schema, and visible answers. An `onSubmit` result
189
+ Submission confirmation slots receive the effective message, localized schema, visible answers, and formatted
190
+ `visibleItems`. An `onSubmit` result
173
191
  may provide `submissionId` and `submittedAt`, which Renderer copies into its receipt. Receipt stores support `getBatch`,
174
192
  and `useSubmissionReceipts` loads multiple form/version receipts for list and dashboard surfaces.
175
193
 
package/dist/index.cjs CHANGED
@@ -3276,8 +3276,11 @@ function describedBy(field, error, helpId, errorId) {
3276
3276
  );
3277
3277
  return ids.length === 0 ? void 0 : ids.join(" ");
3278
3278
  }
3279
- function RequiredMark({ required }) {
3280
- return required ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "fe-required", "aria-hidden": "true", children: [
3279
+ function RequiredMark({
3280
+ required,
3281
+ className = "fe-required"
3282
+ }) {
3283
+ return required ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className, "aria-hidden": "true", children: [
3281
3284
  " ",
3282
3285
  "*"
3283
3286
  ] }) : null;
@@ -3288,13 +3291,59 @@ function FieldMessage({ props }) {
3288
3291
  props.error === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { id: props.errorId, className: "fe-error", children: props.translate(props.error.messageKey, props.error.params) })
3289
3292
  ] });
3290
3293
  }
3291
- function DefaultField(props) {
3294
+ function GroupedChoiceDescription({ props }) {
3295
+ return props.field.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { id: props.helpId, className: "fe-field-description", children: props.field.description });
3296
+ }
3297
+ function GroupedChoiceError({ props }) {
3298
+ return props.error === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { id: props.errorId, className: "fe-field-error", role: "alert", children: props.translate(props.error.messageKey, props.error.params) });
3299
+ }
3300
+ function DefaultField({
3301
+ groupedChoiceFields,
3302
+ ...props
3303
+ }) {
3292
3304
  const { field, value, setValue, inputId, error, translate } = props;
3293
3305
  const ariaProps = {
3294
3306
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3295
3307
  "aria-invalid": error === void 0 ? void 0 : true
3296
3308
  };
3297
3309
  if (field.type === "checkbox") {
3310
+ if (groupedChoiceFields) {
3311
+ return (
3312
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3313
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
3314
+ "fieldset",
3315
+ {
3316
+ className: "fe-choice-group fe-field--checkbox",
3317
+ "data-field-id": field.id,
3318
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3319
+ "aria-invalid": Boolean(error),
3320
+ "aria-required": field.required,
3321
+ children: [
3322
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("legend", { className: "fe-choice-legend", children: [
3323
+ field.title,
3324
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required, className: "fe-required-badge" })
3325
+ ] }),
3326
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(GroupedChoiceDescription, { props }),
3327
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-choice-options", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-choice-option", htmlFor: inputId, children: [
3328
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3329
+ "input",
3330
+ {
3331
+ id: inputId,
3332
+ name: field.id,
3333
+ type: "checkbox",
3334
+ checked: value === true,
3335
+ "aria-label": field.title,
3336
+ onChange: (event) => setValue(event.currentTarget.checked)
3337
+ }
3338
+ ),
3339
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: field.title })
3340
+ ] }) }),
3341
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(GroupedChoiceError, { props })
3342
+ ]
3343
+ }
3344
+ )
3345
+ );
3346
+ }
3298
3347
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
3299
3348
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-check-label", htmlFor: inputId, children: [
3300
3349
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -3318,6 +3367,85 @@ function DefaultField(props) {
3318
3367
  }
3319
3368
  if (field.type === "radio" || field.type === "multi-select") {
3320
3369
  const selected = Array.isArray(value) ? value : [];
3370
+ if (field.type === "radio" && groupedChoiceFields) {
3371
+ return (
3372
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3373
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
3374
+ "fieldset",
3375
+ {
3376
+ className: "fe-choice-group fe-field--radio",
3377
+ "data-field-id": field.id,
3378
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3379
+ "aria-invalid": Boolean(error),
3380
+ "aria-required": field.required,
3381
+ children: [
3382
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("legend", { className: "fe-choice-legend", children: [
3383
+ field.title,
3384
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required, className: "fe-required-badge" })
3385
+ ] }),
3386
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(GroupedChoiceDescription, { props }),
3387
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-choice-options", children: field.options.map((option, index) => {
3388
+ const optionId = `${inputId}-${index}`;
3389
+ const checked = value === option.id;
3390
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-choice-option", htmlFor: optionId, children: [
3391
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3392
+ "input",
3393
+ {
3394
+ id: optionId,
3395
+ name: field.id,
3396
+ type: "radio",
3397
+ value: option.id,
3398
+ checked,
3399
+ onKeyDown: (event) => {
3400
+ if (event.key !== "ArrowDown" && event.key !== "ArrowRight" && event.key !== "ArrowUp" && event.key !== "ArrowLeft")
3401
+ return;
3402
+ event.preventDefault();
3403
+ const offset = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
3404
+ const nextIndex = (index + offset + field.options.length) % field.options.length;
3405
+ const nextOption = field.options[nextIndex];
3406
+ if (nextOption === void 0) return;
3407
+ setValue(nextOption.id);
3408
+ document.getElementById(`${inputId}-${nextIndex}`)?.focus();
3409
+ },
3410
+ onChange: () => setValue(option.id)
3411
+ }
3412
+ ),
3413
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: option.label })
3414
+ ] }, option.id);
3415
+ }) }),
3416
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(GroupedChoiceError, { props })
3417
+ ]
3418
+ }
3419
+ )
3420
+ );
3421
+ }
3422
+ if (field.type === "radio") {
3423
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-field fe-field--radio", "data-field-id": field.id, children: [
3424
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-label", children: [
3425
+ field.title,
3426
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required })
3427
+ ] }),
3428
+ field.options.map((option, index) => {
3429
+ const optionId = `${inputId}-${index}`;
3430
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-check-label", htmlFor: optionId, children: [
3431
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3432
+ "input",
3433
+ {
3434
+ ...ariaProps,
3435
+ id: optionId,
3436
+ name: field.id,
3437
+ type: "radio",
3438
+ value: option.id,
3439
+ checked: value === option.id,
3440
+ onChange: () => setValue(option.id)
3441
+ }
3442
+ ),
3443
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: option.label })
3444
+ ] }, option.id);
3445
+ }),
3446
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
3447
+ ] });
3448
+ }
3321
3449
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("fieldset", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, ...ariaProps, children: [
3322
3450
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("legend", { className: "fe-label", children: [
3323
3451
  field.title,
@@ -3567,7 +3695,10 @@ function ContextFormRenderer({
3567
3695
  beforeSubmit,
3568
3696
  onDraftSave,
3569
3697
  successRenderMode = "append",
3570
- submissionConfirmationRenderMode = "inline",
3698
+ appearance,
3699
+ groupedChoiceFields = false,
3700
+ submissionConfirmation,
3701
+ submissionConfirmationRenderMode,
3571
3702
  showHiddenFieldsInSummary = false,
3572
3703
  fieldsClassName,
3573
3704
  hideFormOnSuccess = false,
@@ -3578,6 +3709,7 @@ function ContextFormRenderer({
3578
3709
  slots = {}
3579
3710
  }) {
3580
3711
  const form = useForm();
3712
+ const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3581
3713
  const prefix = (0, import_react5.useId)().replace(/:/g, "");
3582
3714
  const formRef = (0, import_react5.useRef)(null);
3583
3715
  const loadedDraftKey = (0, import_react5.useRef)(null);
@@ -3603,6 +3735,12 @@ function ContextFormRenderer({
3603
3735
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
3604
3736
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
3605
3737
  const visibleValues = (0, import_react5.useMemo)(() => (0, import_core4.selectVisibleAnswers)(form.schema, form.values), [form.schema, form.values]);
3738
+ const visibleItems = (0, import_react5.useMemo)(
3739
+ () => buildSubmittedItems(form.schema, form.values, form.visibility, (key) => form.translate(key), false),
3740
+ [form.schema, form.translate, form.values, form.visibility]
3741
+ );
3742
+ const confirmationRenderMode = submissionConfirmation?.renderMode ?? submissionConfirmationRenderMode ?? "inline";
3743
+ const confirmationEnabled = submissionConfirmation?.enabled === true;
3606
3744
  const submitState = confirmation === null && !guardsPending ? form.submitStatus : "confirming";
3607
3745
  const interactionLocked = submitState === "confirming" || submitState === "submitting";
3608
3746
  const isReplaceMode = successRenderMode === "replace" || hideFormOnSuccess;
@@ -3670,7 +3808,7 @@ function ContextFormRenderer({
3670
3808
  if (confirmation === null) return;
3671
3809
  const confirmButton = confirmationRef.current?.querySelector("[data-fe-confirm], button");
3672
3810
  confirmButton?.focus();
3673
- if (submissionConfirmationRenderMode !== "dialog") return;
3811
+ if (confirmationRenderMode !== "dialog") return;
3674
3812
  const onKeyDown = (event) => {
3675
3813
  if (event.key === "Escape") {
3676
3814
  event.preventDefault();
@@ -3697,7 +3835,7 @@ function ContextFormRenderer({
3697
3835
  };
3698
3836
  globalThis.addEventListener("keydown", onKeyDown);
3699
3837
  return () => globalThis.removeEventListener("keydown", onKeyDown);
3700
- }, [confirmation, focusSubmitButton, submissionConfirmationRenderMode]);
3838
+ }, [confirmation, confirmationRenderMode, focusSubmitButton]);
3701
3839
  (0, import_react5.useEffect)(() => {
3702
3840
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
3703
3841
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
@@ -3768,21 +3906,29 @@ function ContextFormRenderer({
3768
3906
  }
3769
3907
  const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
3770
3908
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
3771
- if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
3909
+ if (validation.valid && !guardsConfirmed) {
3772
3910
  rendererSubmissionInFlight.current = true;
3773
- setGuardsPending(true);
3911
+ setGuardsPending(submissionGuards.length > 0);
3774
3912
  try {
3775
- const guardResult = await runSubmissionGuards(submissionGuards);
3776
- if (guardResult.status === "block") {
3777
- setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3778
- return { status: "cancelled" };
3913
+ if (submissionGuards.length > 0) {
3914
+ const guardResult = await runSubmissionGuards(submissionGuards);
3915
+ if (guardResult.status === "block") {
3916
+ setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3917
+ return { status: "cancelled" };
3918
+ }
3919
+ if (guardResult.status === "confirm") {
3920
+ setGuardMessage(null);
3921
+ setConfirmation({
3922
+ findings: guardResult.findings,
3923
+ generic: false,
3924
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3925
+ });
3926
+ return { status: "cancelled" };
3927
+ }
3779
3928
  }
3780
- if (guardResult.status === "confirm") {
3929
+ if (confirmationEnabled) {
3781
3930
  setGuardMessage(null);
3782
- setConfirmation({
3783
- findings: guardResult.findings,
3784
- ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3785
- });
3931
+ setConfirmation({ findings: [], generic: true });
3786
3932
  return { status: "cancelled" };
3787
3933
  }
3788
3934
  } finally {
@@ -3945,17 +4091,18 @@ function ContextFormRenderer({
3945
4091
  {
3946
4092
  ref: confirmationRef,
3947
4093
  className: "fe-submission-confirmation",
3948
- role: submissionConfirmationRenderMode === "dialog" ? void 0 : "dialog",
4094
+ role: confirmationRenderMode === "dialog" ? void 0 : "dialog",
3949
4095
  children: slots.renderSubmissionConfirmation?.({
3950
4096
  findings: confirmation?.findings ?? [],
3951
- message: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")),
4097
+ message: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))),
3952
4098
  schema: form.schema,
3953
4099
  visibleValues,
4100
+ visibleItems,
3954
4101
  onConfirm: confirmSubmission,
3955
4102
  onCancel: cancelSubmission
3956
4103
  }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
3957
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { children: resolveMessage("confirmSensitiveDataTitle") }),
3958
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")) }),
4104
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { children: confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u306E\u78BA\u8A8D" : "Review your answers" : resolveMessage("confirmSensitiveDataTitle") }),
4105
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))) }),
3959
4106
  (confirmation?.findings ?? []).length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { children: (confirmation?.findings ?? []).map((finding, index) => {
3960
4107
  const field = form.schema.fields.find((candidate) => candidate.id === finding.fieldId);
3961
4108
  const typeLabels = form.locale.toLowerCase().startsWith("ja") ? { email: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9", phone: "\u96FB\u8A71\u756A\u53F7", url: "URL", postal_code: "\u90F5\u4FBF\u756A\u53F7" } : { email: "Email address", phone: "Phone number", url: "URL", postal_code: "Postal code" };
@@ -3971,6 +4118,11 @@ function ContextFormRenderer({
3971
4118
  ] })
3972
4119
  ] }, `${finding.fieldId}-${finding.type}-${finding.start ?? index}`);
3973
4120
  }) }),
4121
+ confirmation?.generic === true ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { className: "fe-submission-summary", children: visibleItems.map((item) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("li", { children: [
4122
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: item.title }),
4123
+ ": ",
4124
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: item.displayValue })
4125
+ ] }, item.fieldId)) }) : null,
3974
4126
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", "data-fe-confirm": "true", onClick: confirmSubmission, children: resolveMessage("confirmButton", form.translate("form.confirmSubmission")) }),
3975
4127
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: cancelSubmission, children: resolveMessage("cancelButton", form.translate("form.cancelSubmission")) })
3976
4128
  ] })
@@ -3990,7 +4142,7 @@ function ContextFormRenderer({
3990
4142
  if (form.submitStatus === "success" && isReplaceMode) {
3991
4143
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form ${className}`.trim(), children: completionRegion });
3992
4144
  }
3993
- if (confirmation !== null && submissionConfirmationRenderMode === "replace") {
4145
+ if (confirmation !== null && confirmationRenderMode === "replace") {
3994
4146
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form ${className}`.trim(), children: confirmationContent });
3995
4147
  }
3996
4148
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
@@ -4001,7 +4153,7 @@ function ContextFormRenderer({
4001
4153
  className: `fe-form ${className}`.trim(),
4002
4154
  noValidate: true,
4003
4155
  onSubmit: handleSubmit,
4004
- "aria-hidden": confirmation !== null && submissionConfirmationRenderMode === "dialog" ? true : void 0,
4156
+ "aria-hidden": confirmation !== null && confirmationRenderMode === "dialog" ? true : void 0,
4005
4157
  children: [
4006
4158
  slots.renderHeader?.({
4007
4159
  title: form.schema.title,
@@ -4064,13 +4216,13 @@ function ContextFormRenderer({
4064
4216
  }) }, field.id);
4065
4217
  }
4066
4218
  const Component = components[field.type];
4067
- return Component === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DefaultField, { ...props }, field.id) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Component, { ...props }, field.id);
4219
+ return Component === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DefaultField, { ...props, groupedChoiceFields: isGroupedMode }, field.id) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Component, { ...props }, field.id);
4068
4220
  });
4069
4221
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4070
4222
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: fieldClassName, children: fieldChildren });
4071
4223
  })(),
4072
4224
  guardMessage === null ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: guardMessage }),
4073
- confirmation !== null && submissionConfirmationRenderMode === "inline" ? confirmationContent : null,
4225
+ confirmation !== null && confirmationRenderMode === "inline" ? confirmationContent : null,
4074
4226
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
4075
4227
  validationIssues.length,
4076
4228
  " validation error",
@@ -4125,7 +4277,7 @@ function ContextFormRenderer({
4125
4277
  ]
4126
4278
  }
4127
4279
  ),
4128
- confirmation !== null && submissionConfirmationRenderMode === "dialog" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4280
+ confirmation !== null && confirmationRenderMode === "dialog" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4129
4281
  ] });
4130
4282
  }
4131
4283
  var RENDERER_MESSAGES = {
package/dist/index.d.cts CHANGED
@@ -477,7 +477,20 @@ type SubmissionGuardResult = {
477
477
  };
478
478
  type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
479
479
  type FormSuccessRenderMode = "append" | "replace";
480
+ type ChoiceFieldLayoutMode = "default" | "grouped";
481
+ interface FormRendererAppearance {
482
+ /**
483
+ * Layout preset for radio and checkbox questions.
484
+ * - "default": keep the flat question layout.
485
+ * - "grouped": render a bordered fieldset and legend.
486
+ */
487
+ readonly choiceField?: ChoiceFieldLayoutMode;
488
+ }
480
489
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
490
+ interface SubmissionConfirmationOptions {
491
+ readonly enabled?: boolean;
492
+ readonly renderMode?: SubmissionConfirmationRenderMode;
493
+ }
481
494
  type FormSubmitStatus = "idle" | "submitting" | "confirming" | "success" | "error";
482
495
  /** @deprecated Use FormSubmitStatus instead. */
483
496
  type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
@@ -488,10 +501,11 @@ interface RenderSubmitButtonProps {
488
501
  readonly onSubmit: () => void;
489
502
  }
490
503
  interface SubmissionConfirmationSlotProps {
491
- readonly findings: readonly SensitiveDataFinding[];
504
+ readonly findings?: readonly SensitiveDataFinding[];
492
505
  readonly message?: string;
493
506
  readonly schema: FormSchema;
494
507
  readonly visibleValues: Record<string, unknown>;
508
+ readonly visibleItems?: readonly FormSubmittedAnswerItem[];
495
509
  readonly onConfirm: () => void;
496
510
  readonly onCancel: () => void;
497
511
  }
@@ -666,11 +680,16 @@ type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentPro
666
680
  interface FormRendererPresentationProps extends SubmissionProtectionProps {
667
681
  readonly components?: FieldComponents;
668
682
  readonly className?: string;
683
+ readonly appearance?: FormRendererAppearance;
684
+ /** @deprecated Use appearance.choiceField="grouped" instead. */
685
+ readonly groupedChoiceFields?: boolean;
669
686
  /**
670
687
  * Controls where the completion message is rendered after a successful submission.
671
688
  * Defaults to "append" for backwards compatibility.
672
689
  */
673
690
  readonly successRenderMode?: FormSuccessRenderMode;
691
+ readonly submissionConfirmation?: SubmissionConfirmationOptions;
692
+ /** @deprecated Use submissionConfirmation.renderMode instead. */
674
693
  readonly submissionConfirmationRenderMode?: SubmissionConfirmationRenderMode;
675
694
  readonly showHiddenFieldsInSummary?: boolean;
676
695
  readonly fieldsClassName?: string;
@@ -697,4 +716,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
697
716
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
698
717
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
699
718
 
700
- export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, 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 BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
719
+ export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, 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 BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ChoiceFieldLayoutMode, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererAppearance, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationOptions, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.d.ts CHANGED
@@ -477,7 +477,20 @@ type SubmissionGuardResult = {
477
477
  };
478
478
  type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
479
479
  type FormSuccessRenderMode = "append" | "replace";
480
+ type ChoiceFieldLayoutMode = "default" | "grouped";
481
+ interface FormRendererAppearance {
482
+ /**
483
+ * Layout preset for radio and checkbox questions.
484
+ * - "default": keep the flat question layout.
485
+ * - "grouped": render a bordered fieldset and legend.
486
+ */
487
+ readonly choiceField?: ChoiceFieldLayoutMode;
488
+ }
480
489
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
490
+ interface SubmissionConfirmationOptions {
491
+ readonly enabled?: boolean;
492
+ readonly renderMode?: SubmissionConfirmationRenderMode;
493
+ }
481
494
  type FormSubmitStatus = "idle" | "submitting" | "confirming" | "success" | "error";
482
495
  /** @deprecated Use FormSubmitStatus instead. */
483
496
  type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
@@ -488,10 +501,11 @@ interface RenderSubmitButtonProps {
488
501
  readonly onSubmit: () => void;
489
502
  }
490
503
  interface SubmissionConfirmationSlotProps {
491
- readonly findings: readonly SensitiveDataFinding[];
504
+ readonly findings?: readonly SensitiveDataFinding[];
492
505
  readonly message?: string;
493
506
  readonly schema: FormSchema;
494
507
  readonly visibleValues: Record<string, unknown>;
508
+ readonly visibleItems?: readonly FormSubmittedAnswerItem[];
495
509
  readonly onConfirm: () => void;
496
510
  readonly onCancel: () => void;
497
511
  }
@@ -666,11 +680,16 @@ type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentPro
666
680
  interface FormRendererPresentationProps extends SubmissionProtectionProps {
667
681
  readonly components?: FieldComponents;
668
682
  readonly className?: string;
683
+ readonly appearance?: FormRendererAppearance;
684
+ /** @deprecated Use appearance.choiceField="grouped" instead. */
685
+ readonly groupedChoiceFields?: boolean;
669
686
  /**
670
687
  * Controls where the completion message is rendered after a successful submission.
671
688
  * Defaults to "append" for backwards compatibility.
672
689
  */
673
690
  readonly successRenderMode?: FormSuccessRenderMode;
691
+ readonly submissionConfirmation?: SubmissionConfirmationOptions;
692
+ /** @deprecated Use submissionConfirmation.renderMode instead. */
674
693
  readonly submissionConfirmationRenderMode?: SubmissionConfirmationRenderMode;
675
694
  readonly showHiddenFieldsInSummary?: boolean;
676
695
  readonly fieldsClassName?: string;
@@ -697,4 +716,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
697
716
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
698
717
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
699
718
 
700
- export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, 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 BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
719
+ export { BUILDER_TRANSLATION_ALIASES, BUILDER_TRANSLATION_KEYS, type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionIconType, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderErrorMessageProps, 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 BuilderSlotActions, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type BuilderTranslationKey, type ChoiceFieldLayoutMode, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldPropertyControlMode, type FieldState, type FieldTypeSelectOptionsConfig, type FieldTypeSelectOptionsContext, type FieldTypeSelectOptionsSorter, type FieldTypeSelectOptionsTransformer, type FieldTypeSelectSlotProps, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSectionName, type FormBuilderSlots, type FormCompletionSlotProps, type FormContextValue, type FormFieldsSlotProps, FormProvider, type FormProviderProps, FormRenderer, type FormRendererAppearance, type FormRendererMessages, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormServerErrorPayload, FormSubmissionError, type FormSubmitHandler, type FormSubmitState, type FormSubmitStatus, type FormSubmittedAnswerItem, type FormSuccessRenderMode, type IconButtonProps, type InputComponentProps, type LocalizationSummaryContext, type ManualTranslationContext, type ManualTranslationTarget, type RenderSubmitButtonProps, type SelectComponentProps, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationOptions, type SubmissionConfirmationRenderMode, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitContext, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, isTranslationUnresolved, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.js CHANGED
@@ -3259,8 +3259,11 @@ function describedBy(field, error, helpId, errorId) {
3259
3259
  );
3260
3260
  return ids.length === 0 ? void 0 : ids.join(" ");
3261
3261
  }
3262
- function RequiredMark({ required }) {
3263
- return required ? /* @__PURE__ */ jsxs2("span", { className: "fe-required", "aria-hidden": "true", children: [
3262
+ function RequiredMark({
3263
+ required,
3264
+ className = "fe-required"
3265
+ }) {
3266
+ return required ? /* @__PURE__ */ jsxs2("span", { className, "aria-hidden": "true", children: [
3264
3267
  " ",
3265
3268
  "*"
3266
3269
  ] }) : null;
@@ -3271,13 +3274,59 @@ function FieldMessage({ props }) {
3271
3274
  props.error === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.errorId, className: "fe-error", children: props.translate(props.error.messageKey, props.error.params) })
3272
3275
  ] });
3273
3276
  }
3274
- function DefaultField(props) {
3277
+ function GroupedChoiceDescription({ props }) {
3278
+ return props.field.description === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.helpId, className: "fe-field-description", children: props.field.description });
3279
+ }
3280
+ function GroupedChoiceError({ props }) {
3281
+ return props.error === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.errorId, className: "fe-field-error", role: "alert", children: props.translate(props.error.messageKey, props.error.params) });
3282
+ }
3283
+ function DefaultField({
3284
+ groupedChoiceFields,
3285
+ ...props
3286
+ }) {
3275
3287
  const { field, value, setValue, inputId, error, translate } = props;
3276
3288
  const ariaProps = {
3277
3289
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3278
3290
  "aria-invalid": error === void 0 ? void 0 : true
3279
3291
  };
3280
3292
  if (field.type === "checkbox") {
3293
+ if (groupedChoiceFields) {
3294
+ return (
3295
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3296
+ /* @__PURE__ */ jsxs2(
3297
+ "fieldset",
3298
+ {
3299
+ className: "fe-choice-group fe-field--checkbox",
3300
+ "data-field-id": field.id,
3301
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3302
+ "aria-invalid": Boolean(error),
3303
+ "aria-required": field.required,
3304
+ children: [
3305
+ /* @__PURE__ */ jsxs2("legend", { className: "fe-choice-legend", children: [
3306
+ field.title,
3307
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required, className: "fe-required-badge" })
3308
+ ] }),
3309
+ /* @__PURE__ */ jsx3(GroupedChoiceDescription, { props }),
3310
+ /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: /* @__PURE__ */ jsxs2("label", { className: "fe-choice-option", htmlFor: inputId, children: [
3311
+ /* @__PURE__ */ jsx3(
3312
+ "input",
3313
+ {
3314
+ id: inputId,
3315
+ name: field.id,
3316
+ type: "checkbox",
3317
+ checked: value === true,
3318
+ "aria-label": field.title,
3319
+ onChange: (event) => setValue(event.currentTarget.checked)
3320
+ }
3321
+ ),
3322
+ /* @__PURE__ */ jsx3("span", { children: field.title })
3323
+ ] }) }),
3324
+ /* @__PURE__ */ jsx3(GroupedChoiceError, { props })
3325
+ ]
3326
+ }
3327
+ )
3328
+ );
3329
+ }
3281
3330
  return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
3282
3331
  /* @__PURE__ */ jsxs2("label", { className: "fe-check-label", htmlFor: inputId, children: [
3283
3332
  /* @__PURE__ */ jsx3(
@@ -3301,6 +3350,85 @@ function DefaultField(props) {
3301
3350
  }
3302
3351
  if (field.type === "radio" || field.type === "multi-select") {
3303
3352
  const selected = Array.isArray(value) ? value : [];
3353
+ if (field.type === "radio" && groupedChoiceFields) {
3354
+ return (
3355
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3356
+ /* @__PURE__ */ jsxs2(
3357
+ "fieldset",
3358
+ {
3359
+ className: "fe-choice-group fe-field--radio",
3360
+ "data-field-id": field.id,
3361
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3362
+ "aria-invalid": Boolean(error),
3363
+ "aria-required": field.required,
3364
+ children: [
3365
+ /* @__PURE__ */ jsxs2("legend", { className: "fe-choice-legend", children: [
3366
+ field.title,
3367
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required, className: "fe-required-badge" })
3368
+ ] }),
3369
+ /* @__PURE__ */ jsx3(GroupedChoiceDescription, { props }),
3370
+ /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: field.options.map((option, index) => {
3371
+ const optionId = `${inputId}-${index}`;
3372
+ const checked = value === option.id;
3373
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-choice-option", htmlFor: optionId, children: [
3374
+ /* @__PURE__ */ jsx3(
3375
+ "input",
3376
+ {
3377
+ id: optionId,
3378
+ name: field.id,
3379
+ type: "radio",
3380
+ value: option.id,
3381
+ checked,
3382
+ onKeyDown: (event) => {
3383
+ if (event.key !== "ArrowDown" && event.key !== "ArrowRight" && event.key !== "ArrowUp" && event.key !== "ArrowLeft")
3384
+ return;
3385
+ event.preventDefault();
3386
+ const offset = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
3387
+ const nextIndex = (index + offset + field.options.length) % field.options.length;
3388
+ const nextOption = field.options[nextIndex];
3389
+ if (nextOption === void 0) return;
3390
+ setValue(nextOption.id);
3391
+ document.getElementById(`${inputId}-${nextIndex}`)?.focus();
3392
+ },
3393
+ onChange: () => setValue(option.id)
3394
+ }
3395
+ ),
3396
+ /* @__PURE__ */ jsx3("span", { children: option.label })
3397
+ ] }, option.id);
3398
+ }) }),
3399
+ /* @__PURE__ */ jsx3(GroupedChoiceError, { props })
3400
+ ]
3401
+ }
3402
+ )
3403
+ );
3404
+ }
3405
+ if (field.type === "radio") {
3406
+ return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--radio", "data-field-id": field.id, children: [
3407
+ /* @__PURE__ */ jsxs2("div", { className: "fe-label", children: [
3408
+ field.title,
3409
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
3410
+ ] }),
3411
+ field.options.map((option, index) => {
3412
+ const optionId = `${inputId}-${index}`;
3413
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-check-label", htmlFor: optionId, children: [
3414
+ /* @__PURE__ */ jsx3(
3415
+ "input",
3416
+ {
3417
+ ...ariaProps,
3418
+ id: optionId,
3419
+ name: field.id,
3420
+ type: "radio",
3421
+ value: option.id,
3422
+ checked: value === option.id,
3423
+ onChange: () => setValue(option.id)
3424
+ }
3425
+ ),
3426
+ /* @__PURE__ */ jsx3("span", { children: option.label })
3427
+ ] }, option.id);
3428
+ }),
3429
+ /* @__PURE__ */ jsx3(FieldMessage, { props })
3430
+ ] });
3431
+ }
3304
3432
  return /* @__PURE__ */ jsxs2("fieldset", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, ...ariaProps, children: [
3305
3433
  /* @__PURE__ */ jsxs2("legend", { className: "fe-label", children: [
3306
3434
  field.title,
@@ -3550,7 +3678,10 @@ function ContextFormRenderer({
3550
3678
  beforeSubmit,
3551
3679
  onDraftSave,
3552
3680
  successRenderMode = "append",
3553
- submissionConfirmationRenderMode = "inline",
3681
+ appearance,
3682
+ groupedChoiceFields = false,
3683
+ submissionConfirmation,
3684
+ submissionConfirmationRenderMode,
3554
3685
  showHiddenFieldsInSummary = false,
3555
3686
  fieldsClassName,
3556
3687
  hideFormOnSuccess = false,
@@ -3561,6 +3692,7 @@ function ContextFormRenderer({
3561
3692
  slots = {}
3562
3693
  }) {
3563
3694
  const form = useForm();
3695
+ const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3564
3696
  const prefix = useId().replace(/:/g, "");
3565
3697
  const formRef = useRef2(null);
3566
3698
  const loadedDraftKey = useRef2(null);
@@ -3586,6 +3718,12 @@ function ContextFormRenderer({
3586
3718
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
3587
3719
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
3588
3720
  const visibleValues = useMemo4(() => selectVisibleAnswers2(form.schema, form.values), [form.schema, form.values]);
3721
+ const visibleItems = useMemo4(
3722
+ () => buildSubmittedItems(form.schema, form.values, form.visibility, (key) => form.translate(key), false),
3723
+ [form.schema, form.translate, form.values, form.visibility]
3724
+ );
3725
+ const confirmationRenderMode = submissionConfirmation?.renderMode ?? submissionConfirmationRenderMode ?? "inline";
3726
+ const confirmationEnabled = submissionConfirmation?.enabled === true;
3589
3727
  const submitState = confirmation === null && !guardsPending ? form.submitStatus : "confirming";
3590
3728
  const interactionLocked = submitState === "confirming" || submitState === "submitting";
3591
3729
  const isReplaceMode = successRenderMode === "replace" || hideFormOnSuccess;
@@ -3653,7 +3791,7 @@ function ContextFormRenderer({
3653
3791
  if (confirmation === null) return;
3654
3792
  const confirmButton = confirmationRef.current?.querySelector("[data-fe-confirm], button");
3655
3793
  confirmButton?.focus();
3656
- if (submissionConfirmationRenderMode !== "dialog") return;
3794
+ if (confirmationRenderMode !== "dialog") return;
3657
3795
  const onKeyDown = (event) => {
3658
3796
  if (event.key === "Escape") {
3659
3797
  event.preventDefault();
@@ -3680,7 +3818,7 @@ function ContextFormRenderer({
3680
3818
  };
3681
3819
  globalThis.addEventListener("keydown", onKeyDown);
3682
3820
  return () => globalThis.removeEventListener("keydown", onKeyDown);
3683
- }, [confirmation, focusSubmitButton, submissionConfirmationRenderMode]);
3821
+ }, [confirmation, confirmationRenderMode, focusSubmitButton]);
3684
3822
  useEffect3(() => {
3685
3823
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
3686
3824
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
@@ -3751,21 +3889,29 @@ function ContextFormRenderer({
3751
3889
  }
3752
3890
  const validation = validateAnswers2(form.schema, form.values);
3753
3891
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
3754
- if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
3892
+ if (validation.valid && !guardsConfirmed) {
3755
3893
  rendererSubmissionInFlight.current = true;
3756
- setGuardsPending(true);
3894
+ setGuardsPending(submissionGuards.length > 0);
3757
3895
  try {
3758
- const guardResult = await runSubmissionGuards(submissionGuards);
3759
- if (guardResult.status === "block") {
3760
- setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3761
- return { status: "cancelled" };
3896
+ if (submissionGuards.length > 0) {
3897
+ const guardResult = await runSubmissionGuards(submissionGuards);
3898
+ if (guardResult.status === "block") {
3899
+ setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
3900
+ return { status: "cancelled" };
3901
+ }
3902
+ if (guardResult.status === "confirm") {
3903
+ setGuardMessage(null);
3904
+ setConfirmation({
3905
+ findings: guardResult.findings,
3906
+ generic: false,
3907
+ ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3908
+ });
3909
+ return { status: "cancelled" };
3910
+ }
3762
3911
  }
3763
- if (guardResult.status === "confirm") {
3912
+ if (confirmationEnabled) {
3764
3913
  setGuardMessage(null);
3765
- setConfirmation({
3766
- findings: guardResult.findings,
3767
- ...guardResult.message === void 0 ? {} : { message: guardResult.message }
3768
- });
3914
+ setConfirmation({ findings: [], generic: true });
3769
3915
  return { status: "cancelled" };
3770
3916
  }
3771
3917
  } finally {
@@ -3928,17 +4074,18 @@ function ContextFormRenderer({
3928
4074
  {
3929
4075
  ref: confirmationRef,
3930
4076
  className: "fe-submission-confirmation",
3931
- role: submissionConfirmationRenderMode === "dialog" ? void 0 : "dialog",
4077
+ role: confirmationRenderMode === "dialog" ? void 0 : "dialog",
3932
4078
  children: slots.renderSubmissionConfirmation?.({
3933
4079
  findings: confirmation?.findings ?? [],
3934
- message: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")),
4080
+ message: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))),
3935
4081
  schema: form.schema,
3936
4082
  visibleValues,
4083
+ visibleItems,
3937
4084
  onConfirm: confirmSubmission,
3938
4085
  onCancel: cancelSubmission
3939
4086
  }) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
3940
- /* @__PURE__ */ jsx3("h2", { children: resolveMessage("confirmSensitiveDataTitle") }),
3941
- /* @__PURE__ */ jsx3("p", { children: confirmation?.message ?? resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData")) }),
4087
+ /* @__PURE__ */ jsx3("h2", { children: confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u306E\u78BA\u8A8D" : "Review your answers" : resolveMessage("confirmSensitiveDataTitle") }),
4088
+ /* @__PURE__ */ jsx3("p", { children: confirmation?.message ?? (confirmation?.generic === true ? form.locale.toLowerCase().startsWith("ja") ? "\u56DE\u7B54\u5185\u5BB9\u3092\u3054\u78BA\u8A8D\u306E\u3046\u3048\u3001\u9001\u4FE1\u3057\u3066\u304F\u3060\u3055\u3044\u3002" : "Please review your answers before submitting." : resolveMessage("confirmSensitiveDataMessage", form.translate("form.confirmSensitiveData"))) }),
3942
4089
  (confirmation?.findings ?? []).length === 0 ? null : /* @__PURE__ */ jsx3("ul", { children: (confirmation?.findings ?? []).map((finding, index) => {
3943
4090
  const field = form.schema.fields.find((candidate) => candidate.id === finding.fieldId);
3944
4091
  const typeLabels = form.locale.toLowerCase().startsWith("ja") ? { email: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9", phone: "\u96FB\u8A71\u756A\u53F7", url: "URL", postal_code: "\u90F5\u4FBF\u756A\u53F7" } : { email: "Email address", phone: "Phone number", url: "URL", postal_code: "Postal code" };
@@ -3954,6 +4101,11 @@ function ContextFormRenderer({
3954
4101
  ] })
3955
4102
  ] }, `${finding.fieldId}-${finding.type}-${finding.start ?? index}`);
3956
4103
  }) }),
4104
+ confirmation?.generic === true ? /* @__PURE__ */ jsx3("ul", { className: "fe-submission-summary", children: visibleItems.map((item) => /* @__PURE__ */ jsxs2("li", { children: [
4105
+ /* @__PURE__ */ jsx3("span", { children: item.title }),
4106
+ ": ",
4107
+ /* @__PURE__ */ jsx3("span", { children: item.displayValue })
4108
+ ] }, item.fieldId)) }) : null,
3957
4109
  /* @__PURE__ */ jsx3("button", { type: "button", "data-fe-confirm": "true", onClick: confirmSubmission, children: resolveMessage("confirmButton", form.translate("form.confirmSubmission")) }),
3958
4110
  /* @__PURE__ */ jsx3("button", { type: "button", onClick: cancelSubmission, children: resolveMessage("cancelButton", form.translate("form.cancelSubmission")) })
3959
4111
  ] })
@@ -3973,7 +4125,7 @@ function ContextFormRenderer({
3973
4125
  if (form.submitStatus === "success" && isReplaceMode) {
3974
4126
  return /* @__PURE__ */ jsx3("div", { className: `fe-form ${className}`.trim(), children: completionRegion });
3975
4127
  }
3976
- if (confirmation !== null && submissionConfirmationRenderMode === "replace") {
4128
+ if (confirmation !== null && confirmationRenderMode === "replace") {
3977
4129
  return /* @__PURE__ */ jsx3("div", { className: `fe-form ${className}`.trim(), children: confirmationContent });
3978
4130
  }
3979
4131
  return /* @__PURE__ */ jsxs2(Fragment3, { children: [
@@ -3984,7 +4136,7 @@ function ContextFormRenderer({
3984
4136
  className: `fe-form ${className}`.trim(),
3985
4137
  noValidate: true,
3986
4138
  onSubmit: handleSubmit,
3987
- "aria-hidden": confirmation !== null && submissionConfirmationRenderMode === "dialog" ? true : void 0,
4139
+ "aria-hidden": confirmation !== null && confirmationRenderMode === "dialog" ? true : void 0,
3988
4140
  children: [
3989
4141
  slots.renderHeader?.({
3990
4142
  title: form.schema.title,
@@ -4047,13 +4199,13 @@ function ContextFormRenderer({
4047
4199
  }) }, field.id);
4048
4200
  }
4049
4201
  const Component = components[field.type];
4050
- return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
4202
+ return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props, groupedChoiceFields: isGroupedMode }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
4051
4203
  });
4052
4204
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4053
4205
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ jsx3("div", { className: fieldClassName, children: fieldChildren });
4054
4206
  })(),
4055
4207
  guardMessage === null ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: guardMessage }),
4056
- confirmation !== null && submissionConfirmationRenderMode === "inline" ? confirmationContent : null,
4208
+ confirmation !== null && confirmationRenderMode === "inline" ? confirmationContent : null,
4057
4209
  validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
4058
4210
  validationIssues.length,
4059
4211
  " validation error",
@@ -4108,7 +4260,7 @@ function ContextFormRenderer({
4108
4260
  ]
4109
4261
  }
4110
4262
  ),
4111
- confirmation !== null && submissionConfirmationRenderMode === "dialog" ? /* @__PURE__ */ jsx3("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4263
+ confirmation !== null && confirmationRenderMode === "dialog" ? /* @__PURE__ */ jsx3("div", { className: "fe-confirmation-dialog-backdrop", role: "dialog", "aria-modal": "true", children: confirmationContent }) : null
4112
4264
  ] });
4113
4265
  }
4114
4266
  var RENDERER_MESSAGES = {
package/dist/styles.css CHANGED
@@ -123,6 +123,41 @@
123
123
  .fe-check-label input {
124
124
  margin-top: 0.22rem;
125
125
  }
126
+ .fe-choice-group {
127
+ background-color: var(--fe-bg-color, #ffffff);
128
+ border: 1px solid var(--fe-border-color, #e2e8f0);
129
+ border-radius: var(--fe-radius, 8px);
130
+ box-sizing: border-box;
131
+ margin: 0 0 1.25rem;
132
+ padding: 1rem 1.25rem;
133
+ }
134
+ .fe-choice-legend {
135
+ color: var(--fe-text-color, #1a202c);
136
+ font-size: 0.95rem;
137
+ font-weight: 600;
138
+ padding: 0 0.5rem;
139
+ }
140
+ .fe-choice-options {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 0.625rem;
144
+ margin-top: 0.5rem;
145
+ }
146
+ .fe-choice-option {
147
+ align-items: center;
148
+ cursor: pointer;
149
+ display: flex;
150
+ gap: 0.5rem;
151
+ }
152
+ .fe-field-description {
153
+ color: #667085;
154
+ font-size: 0.875rem;
155
+ }
156
+ .fe-field-error {
157
+ color: #b42318;
158
+ font-size: 0.875rem;
159
+ font-weight: 600;
160
+ }
126
161
  .fe-rating-options {
127
162
  display: flex;
128
163
  flex-wrap: wrap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/react",
3
- "version": "4.2.0",
3
+ "version": "4.3.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,8 +42,8 @@
42
42
  "typescript"
43
43
  ],
44
44
  "dependencies": {
45
- "@form-engine-ts/core": "4.2.0",
46
- "@form-engine-ts/privacy": "4.2.0"
45
+ "@form-engine-ts/core": "4.3.2",
46
+ "@form-engine-ts/privacy": "4.3.2"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "react": ">=18.2 <20",