@form-engine-ts/react 4.3.1 → 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:
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,6 +3695,8 @@ function ContextFormRenderer({
3567
3695
  beforeSubmit,
3568
3696
  onDraftSave,
3569
3697
  successRenderMode = "append",
3698
+ appearance,
3699
+ groupedChoiceFields = false,
3570
3700
  submissionConfirmation,
3571
3701
  submissionConfirmationRenderMode,
3572
3702
  showHiddenFieldsInSummary = false,
@@ -3579,6 +3709,7 @@ function ContextFormRenderer({
3579
3709
  slots = {}
3580
3710
  }) {
3581
3711
  const form = useForm();
3712
+ const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3582
3713
  const prefix = (0, import_react5.useId)().replace(/:/g, "");
3583
3714
  const formRef = (0, import_react5.useRef)(null);
3584
3715
  const loadedDraftKey = (0, import_react5.useRef)(null);
@@ -4085,7 +4216,7 @@ function ContextFormRenderer({
4085
4216
  }) }, field.id);
4086
4217
  }
4087
4218
  const Component = components[field.type];
4088
- 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);
4089
4220
  });
4090
4221
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4091
4222
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: fieldClassName, children: fieldChildren });
package/dist/index.d.cts CHANGED
@@ -477,6 +477,15 @@ 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";
481
490
  interface SubmissionConfirmationOptions {
482
491
  readonly enabled?: boolean;
@@ -671,6 +680,9 @@ type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentPro
671
680
  interface FormRendererPresentationProps extends SubmissionProtectionProps {
672
681
  readonly components?: FieldComponents;
673
682
  readonly className?: string;
683
+ readonly appearance?: FormRendererAppearance;
684
+ /** @deprecated Use appearance.choiceField="grouped" instead. */
685
+ readonly groupedChoiceFields?: boolean;
674
686
  /**
675
687
  * Controls where the completion message is rendered after a successful submission.
676
688
  * Defaults to "append" for backwards compatibility.
@@ -704,4 +716,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
704
716
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
705
717
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
706
718
 
707
- 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 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 };
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,6 +477,15 @@ 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";
481
490
  interface SubmissionConfirmationOptions {
482
491
  readonly enabled?: boolean;
@@ -671,6 +680,9 @@ type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentPro
671
680
  interface FormRendererPresentationProps extends SubmissionProtectionProps {
672
681
  readonly components?: FieldComponents;
673
682
  readonly className?: string;
683
+ readonly appearance?: FormRendererAppearance;
684
+ /** @deprecated Use appearance.choiceField="grouped" instead. */
685
+ readonly groupedChoiceFields?: boolean;
674
686
  /**
675
687
  * Controls where the completion message is rendered after a successful submission.
676
688
  * Defaults to "append" for backwards compatibility.
@@ -704,4 +716,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
704
716
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
705
717
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
706
718
 
707
- 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 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 };
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,6 +3678,8 @@ function ContextFormRenderer({
3550
3678
  beforeSubmit,
3551
3679
  onDraftSave,
3552
3680
  successRenderMode = "append",
3681
+ appearance,
3682
+ groupedChoiceFields = false,
3553
3683
  submissionConfirmation,
3554
3684
  submissionConfirmationRenderMode,
3555
3685
  showHiddenFieldsInSummary = false,
@@ -3562,6 +3692,7 @@ function ContextFormRenderer({
3562
3692
  slots = {}
3563
3693
  }) {
3564
3694
  const form = useForm();
3695
+ const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3565
3696
  const prefix = useId().replace(/:/g, "");
3566
3697
  const formRef = useRef2(null);
3567
3698
  const loadedDraftKey = useRef2(null);
@@ -4068,7 +4199,7 @@ function ContextFormRenderer({
4068
4199
  }) }, field.id);
4069
4200
  }
4070
4201
  const Component = components[field.type];
4071
- 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);
4072
4203
  });
4073
4204
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4074
4205
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ jsx3("div", { className: fieldClassName, children: fieldChildren });
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.3.1",
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.3.1",
46
- "@form-engine-ts/privacy": "4.3.1"
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",