@form-engine-ts/react 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,28 @@ 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, checkbox, and multi-select questions use the flat layout by default. Set `appearance.choiceField` to `"grouped"`
45
+ to render them as bordered, accessible `<fieldset>` groups with `<legend>` titles:
46
+
47
+ ```tsx
48
+ <FormRenderer appearance={{ choiceField: "grouped" }} />
49
+ ```
50
+
51
+ Use an object to configure each choice type independently. Unspecified types remain flat:
52
+
53
+ ```tsx
54
+ <FormRenderer appearance={{ choiceField: { radio: "grouped", checkbox: "default" } }} />
55
+ ```
56
+
57
+ Grouped wrappers can be replaced with `slots.renderChoiceGroup`; `slotProps.choiceGroup` accepts a class name and
58
+ inline style for the default wrapper. The exported `ChoiceGroupSlotProps` includes the field, translated error, and
59
+ option-list children. Group styling is controlled by the public `--fe-choice-group-*`, `--fe-choice-legend-*`, and
60
+ `--fe-choice-options-gap` CSS custom properties.
61
+
62
+ `groupedChoiceFields={true}` remains available as a deprecated compatibility alias.
63
+
42
64
  Define `schema.pages` to enable Back/Next navigation, page validation, conditional page skipping, and an accessible
43
65
  progress indicator. Pass `autoSaveKey` to persist a versioned draft in `localStorage` after a 500ms debounce and restore it
44
66
  on the next mount:
package/dist/index.cjs CHANGED
@@ -29,6 +29,7 @@ __export(index_exports, {
29
29
  createLocalStorageSubmissionAttemptStore: () => createLocalStorageSubmissionAttemptStore,
30
30
  createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
31
31
  isTranslationUnresolved: () => isTranslationUnresolved,
32
+ resolveChoiceFieldLayout: () => resolveChoiceFieldLayout,
32
33
  resolveFieldEditorControls: () => resolveFieldEditorControls,
33
34
  resolveFieldTypeSelectOptions: () => resolveFieldTypeSelectOptions,
34
35
  resolveInitialFieldType: () => resolveInitialFieldType,
@@ -3270,14 +3271,25 @@ function useSubmissionReceipts(store, queries) {
3270
3271
  var import_core4 = require("@form-engine-ts/core");
3271
3272
  var import_react5 = require("react");
3272
3273
  var import_jsx_runtime3 = require("react/jsx-runtime");
3274
+ var isChoiceFieldType = (type) => type === "radio" || type === "checkbox" || type === "multi-select" || type === "select";
3275
+ function resolveChoiceFieldLayout(type, appearance, groupedChoiceFieldsLegacy = false) {
3276
+ if (groupedChoiceFieldsLegacy) return "grouped";
3277
+ const config = appearance?.choiceField;
3278
+ if (config === void 0) return "default";
3279
+ if (typeof config === "string") return config;
3280
+ return config[type] ?? "default";
3281
+ }
3273
3282
  function describedBy(field, error, helpId, errorId) {
3274
3283
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
3275
3284
  Boolean
3276
3285
  );
3277
3286
  return ids.length === 0 ? void 0 : ids.join(" ");
3278
3287
  }
3279
- function RequiredMark({ required }) {
3280
- return required ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "fe-required", "aria-hidden": "true", children: [
3288
+ function RequiredMark({
3289
+ required,
3290
+ className = "fe-required"
3291
+ }) {
3292
+ return required ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className, "aria-hidden": "true", children: [
3281
3293
  " ",
3282
3294
  "*"
3283
3295
  ] }) : null;
@@ -3288,13 +3300,106 @@ function FieldMessage({ props }) {
3288
3300
  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
3301
  ] });
3290
3302
  }
3291
- function DefaultField(props) {
3303
+ function GroupedChoiceDescription({ props }) {
3304
+ 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 });
3305
+ }
3306
+ function GroupedChoiceError({ props }) {
3307
+ 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) });
3308
+ }
3309
+ function ChoiceGroupFrame({
3310
+ props,
3311
+ children,
3312
+ className,
3313
+ slotProps,
3314
+ renderChoiceGroup,
3315
+ disabled,
3316
+ readOnly
3317
+ }) {
3318
+ const { field, error, translate } = props;
3319
+ const groupError = error === void 0 ? void 0 : { ...error, message: translate(error.messageKey, error.params) };
3320
+ const groupProps = {
3321
+ field,
3322
+ title: field.title,
3323
+ ...field.description === void 0 ? {} : { description: field.description },
3324
+ ...field.required === void 0 ? {} : { required: field.required },
3325
+ ...groupError === void 0 ? {} : { error: groupError },
3326
+ ...disabled === void 0 ? {} : { disabled },
3327
+ ...readOnly === void 0 ? {} : { readOnly },
3328
+ children,
3329
+ className
3330
+ };
3331
+ if (renderChoiceGroup !== void 0) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_jsx_runtime3.Fragment, { children: renderChoiceGroup(groupProps) });
3332
+ return (
3333
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3334
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
3335
+ "fieldset",
3336
+ {
3337
+ className,
3338
+ "data-field-id": field.id,
3339
+ disabled,
3340
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3341
+ "aria-invalid": Boolean(error),
3342
+ "aria-required": field.required,
3343
+ style: slotProps?.style,
3344
+ children: [
3345
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("legend", { className: "fe-choice-legend", children: [
3346
+ field.title,
3347
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required, className: "fe-required-badge" })
3348
+ ] }),
3349
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(GroupedChoiceDescription, { props }),
3350
+ children,
3351
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(GroupedChoiceError, { props })
3352
+ ]
3353
+ }
3354
+ )
3355
+ );
3356
+ }
3357
+ function DefaultField({
3358
+ groupedChoiceFields,
3359
+ appearance,
3360
+ choiceGroupSlotProps,
3361
+ renderChoiceGroup,
3362
+ disabled,
3363
+ readOnly,
3364
+ ...props
3365
+ }) {
3292
3366
  const { field, value, setValue, inputId, error, translate } = props;
3367
+ const isGroupedChoiceField = isChoiceFieldType(field.type) && resolveChoiceFieldLayout(field.type, appearance, groupedChoiceFields) === "grouped";
3368
+ const choiceGroupClassName = ["fe-choice-group", `fe-field--${field.type}`, choiceGroupSlotProps?.className].filter((item) => item !== void 0 && item.length > 0).join(" ");
3293
3369
  const ariaProps = {
3294
3370
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3295
3371
  "aria-invalid": error === void 0 ? void 0 : true
3296
3372
  };
3297
3373
  if (field.type === "checkbox") {
3374
+ if (isGroupedChoiceField) {
3375
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3376
+ ChoiceGroupFrame,
3377
+ {
3378
+ props,
3379
+ className: choiceGroupClassName,
3380
+ slotProps: choiceGroupSlotProps,
3381
+ renderChoiceGroup,
3382
+ disabled,
3383
+ readOnly,
3384
+ children: /* @__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: [
3385
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3386
+ "input",
3387
+ {
3388
+ id: inputId,
3389
+ name: field.id,
3390
+ type: "checkbox",
3391
+ checked: value === true,
3392
+ "aria-label": field.title,
3393
+ disabled,
3394
+ readOnly,
3395
+ onChange: (event) => setValue(event.currentTarget.checked)
3396
+ }
3397
+ ),
3398
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: field.title })
3399
+ ] }) })
3400
+ }
3401
+ );
3402
+ }
3298
3403
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
3299
3404
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-check-label", htmlFor: inputId, children: [
3300
3405
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -3318,6 +3423,84 @@ function DefaultField(props) {
3318
3423
  }
3319
3424
  if (field.type === "radio" || field.type === "multi-select") {
3320
3425
  const selected = Array.isArray(value) ? value : [];
3426
+ if (isGroupedChoiceField) {
3427
+ const isRadio = field.type === "radio";
3428
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3429
+ ChoiceGroupFrame,
3430
+ {
3431
+ props,
3432
+ className: choiceGroupClassName,
3433
+ slotProps: choiceGroupSlotProps,
3434
+ renderChoiceGroup,
3435
+ disabled,
3436
+ readOnly,
3437
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-choice-options", children: field.options.map((option, index) => {
3438
+ const optionId = `${inputId}-${index}`;
3439
+ const checked = isRadio ? value === option.id : selected.includes(option.id);
3440
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-choice-option", htmlFor: optionId, children: [
3441
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3442
+ "input",
3443
+ {
3444
+ id: optionId,
3445
+ name: field.id,
3446
+ type: isRadio ? "radio" : "checkbox",
3447
+ value: option.id,
3448
+ checked,
3449
+ disabled,
3450
+ readOnly,
3451
+ onKeyDown: isRadio ? (event) => {
3452
+ if (event.key !== "ArrowDown" && event.key !== "ArrowRight" && event.key !== "ArrowUp" && event.key !== "ArrowLeft")
3453
+ return;
3454
+ event.preventDefault();
3455
+ const offset = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
3456
+ const nextIndex = (index + offset + field.options.length) % field.options.length;
3457
+ const nextOption = field.options[nextIndex];
3458
+ if (nextOption === void 0) return;
3459
+ setValue(nextOption.id);
3460
+ document.getElementById(`${inputId}-${nextIndex}`)?.focus();
3461
+ } : void 0,
3462
+ onChange: (event) => {
3463
+ if (isRadio) setValue(option.id);
3464
+ else
3465
+ setValue(
3466
+ event.currentTarget.checked ? [...selected, option.id] : selected.filter((item) => item !== option.id)
3467
+ );
3468
+ }
3469
+ }
3470
+ ),
3471
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: option.label })
3472
+ ] }, option.id);
3473
+ }) })
3474
+ }
3475
+ );
3476
+ }
3477
+ if (field.type === "radio") {
3478
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-field fe-field--radio", "data-field-id": field.id, children: [
3479
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-label", children: [
3480
+ field.title,
3481
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required })
3482
+ ] }),
3483
+ field.options.map((option, index) => {
3484
+ const optionId = `${inputId}-${index}`;
3485
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { className: "fe-check-label", htmlFor: optionId, children: [
3486
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3487
+ "input",
3488
+ {
3489
+ ...ariaProps,
3490
+ id: optionId,
3491
+ name: field.id,
3492
+ type: "radio",
3493
+ value: option.id,
3494
+ checked: value === option.id,
3495
+ onChange: () => setValue(option.id)
3496
+ }
3497
+ ),
3498
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: option.label })
3499
+ ] }, option.id);
3500
+ }),
3501
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
3502
+ ] });
3503
+ }
3321
3504
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("fieldset", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, ...ariaProps, children: [
3322
3505
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("legend", { className: "fe-label", children: [
3323
3506
  field.title,
@@ -3425,6 +3608,7 @@ function DefaultField(props) {
3425
3608
  ...ariaProps,
3426
3609
  id: inputId,
3427
3610
  name: field.id,
3611
+ disabled,
3428
3612
  value: typeof value === "string" ? value : "",
3429
3613
  onChange: (event) => setValue(event.currentTarget.value || void 0),
3430
3614
  children: [
@@ -3448,6 +3632,20 @@ function DefaultField(props) {
3448
3632
  }
3449
3633
  );
3450
3634
  }
3635
+ if (isGroupedChoiceField) {
3636
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3637
+ ChoiceGroupFrame,
3638
+ {
3639
+ props,
3640
+ className: choiceGroupClassName,
3641
+ slotProps: choiceGroupSlotProps,
3642
+ renderChoiceGroup,
3643
+ disabled,
3644
+ readOnly,
3645
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-choice-options", children: control })
3646
+ }
3647
+ );
3648
+ }
3451
3649
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
3452
3650
  label,
3453
3651
  control,
@@ -3567,6 +3765,9 @@ function ContextFormRenderer({
3567
3765
  beforeSubmit,
3568
3766
  onDraftSave,
3569
3767
  successRenderMode = "append",
3768
+ appearance,
3769
+ slotProps,
3770
+ groupedChoiceFields = false,
3570
3771
  submissionConfirmation,
3571
3772
  submissionConfirmationRenderMode,
3572
3773
  showHiddenFieldsInSummary = false,
@@ -4085,7 +4286,18 @@ function ContextFormRenderer({
4085
4286
  }) }, field.id);
4086
4287
  }
4087
4288
  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);
4289
+ return Component === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
4290
+ DefaultField,
4291
+ {
4292
+ ...props,
4293
+ groupedChoiceFields,
4294
+ appearance,
4295
+ choiceGroupSlotProps: slotProps?.choiceGroup,
4296
+ renderChoiceGroup: slots.renderChoiceGroup,
4297
+ disabled: interactionLocked
4298
+ },
4299
+ field.id
4300
+ ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Component, { ...props }, field.id);
4089
4301
  });
4090
4302
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4091
4303
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: fieldClassName, children: fieldChildren });
@@ -4225,6 +4437,7 @@ function FormRenderer(props) {
4225
4437
  createLocalStorageSubmissionAttemptStore,
4226
4438
  createLocalStorageSubmissionReceiptStore,
4227
4439
  isTranslationUnresolved,
4440
+ resolveChoiceFieldLayout,
4228
4441
  resolveFieldEditorControls,
4229
4442
  resolveFieldTypeSelectOptions,
4230
4443
  resolveInitialFieldType,
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
- import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, Question, FieldOption, TranslationReport, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
1
+ import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, Question, FieldOption, TranslationReport, ValidationIssue, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
2
2
  export { QuestionType } from '@form-engine-ts/core';
3
3
  import * as react from 'react';
4
- import { ReactNode, ComponentType, KeyboardEvent, MouseEvent } from 'react';
4
+ import { ReactNode, ComponentType, KeyboardEvent, MouseEvent, CSSProperties } from 'react';
5
5
  import { SensitiveDataFinding } from '@form-engine-ts/privacy';
6
6
 
7
7
  interface SubmissionAttempt {
@@ -477,6 +477,25 @@ 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 ChoiceFieldTypeLayoutMap {
482
+ readonly radio?: ChoiceFieldLayoutMode;
483
+ readonly checkbox?: ChoiceFieldLayoutMode;
484
+ readonly "multi-select"?: ChoiceFieldLayoutMode;
485
+ readonly select?: ChoiceFieldLayoutMode;
486
+ }
487
+ type FieldError = ValidationIssue & {
488
+ readonly message: string;
489
+ };
490
+ interface FormRendererAppearance {
491
+ /**
492
+ * Layout preset for choice questions (radio, checkbox, and multi-select).
493
+ * - "default": keep the flat question layout.
494
+ * - "grouped": render a bordered fieldset and legend.
495
+ * - An object can configure each choice question type independently.
496
+ */
497
+ readonly choiceField?: ChoiceFieldLayoutMode | ChoiceFieldTypeLayoutMap;
498
+ }
480
499
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
481
500
  interface SubmissionConfirmationOptions {
482
501
  readonly enabled?: boolean;
@@ -504,6 +523,23 @@ interface FormFieldsSlotProps {
504
523
  readonly children: ReactNode;
505
524
  readonly className?: string;
506
525
  }
526
+ interface ChoiceGroupSlotProps {
527
+ readonly field: Question;
528
+ readonly title: string;
529
+ readonly description?: string;
530
+ readonly required?: boolean;
531
+ readonly error?: FieldError;
532
+ readonly disabled?: boolean;
533
+ readonly readOnly?: boolean;
534
+ readonly children: ReactNode;
535
+ readonly className?: string;
536
+ }
537
+ interface FormRendererSlotProps {
538
+ readonly choiceGroup?: {
539
+ readonly className?: string;
540
+ readonly style?: CSSProperties;
541
+ };
542
+ }
507
543
  interface FormRendererSlots {
508
544
  readonly renderHeader?: (props: {
509
545
  readonly title: string;
@@ -554,6 +590,7 @@ interface FormRendererSlots {
554
590
  readonly current: number;
555
591
  readonly max: number;
556
592
  }) => ReactNode;
593
+ readonly renderChoiceGroup?: (props: ChoiceGroupSlotProps) => ReactNode;
557
594
  }
558
595
  interface SubmissionProtectionProps {
559
596
  readonly submissionGuards?: readonly SubmissionGuard[];
@@ -656,6 +693,7 @@ declare const BUILDER_TRANSLATION_ALIASES: Readonly<Record<string, string>>;
656
693
  declare function isTranslationUnresolved(result: unknown, key: string, aliases?: readonly string[]): boolean;
657
694
  declare function resolveTranslation(key: string, aliases: readonly string[], adapter?: TranslationAdapter, defaultCatalog?: Readonly<Record<string, string>>, params?: Readonly<Record<string, unknown>>, locale?: string): string;
658
695
 
696
+ declare function resolveChoiceFieldLayout(type: FieldType, appearance?: FormRendererAppearance, groupedChoiceFieldsLegacy?: boolean): "default" | "grouped";
659
697
  interface FieldComponentProps {
660
698
  readonly field: FormField;
661
699
  readonly value: FormValue;
@@ -671,6 +709,10 @@ type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentPro
671
709
  interface FormRendererPresentationProps extends SubmissionProtectionProps {
672
710
  readonly components?: FieldComponents;
673
711
  readonly className?: string;
712
+ readonly appearance?: FormRendererAppearance;
713
+ readonly slotProps?: FormRendererSlotProps;
714
+ /** @deprecated Use appearance.choiceField="grouped" instead. */
715
+ readonly groupedChoiceFields?: boolean;
674
716
  /**
675
717
  * Controls where the completion message is rendered after a successful submission.
676
718
  * Defaults to "append" for backwards compatibility.
@@ -704,4 +746,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
704
746
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
705
747
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
706
748
 
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 };
749
+ 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 ChoiceFieldTypeLayoutMap, type ChoiceGroupSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldError, 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 FormRendererSlotProps, 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, resolveChoiceFieldLayout, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, Question, FieldOption, TranslationReport, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
1
+ import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, Question, FieldOption, TranslationReport, ValidationIssue, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
2
2
  export { QuestionType } from '@form-engine-ts/core';
3
3
  import * as react from 'react';
4
- import { ReactNode, ComponentType, KeyboardEvent, MouseEvent } from 'react';
4
+ import { ReactNode, ComponentType, KeyboardEvent, MouseEvent, CSSProperties } from 'react';
5
5
  import { SensitiveDataFinding } from '@form-engine-ts/privacy';
6
6
 
7
7
  interface SubmissionAttempt {
@@ -477,6 +477,25 @@ 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 ChoiceFieldTypeLayoutMap {
482
+ readonly radio?: ChoiceFieldLayoutMode;
483
+ readonly checkbox?: ChoiceFieldLayoutMode;
484
+ readonly "multi-select"?: ChoiceFieldLayoutMode;
485
+ readonly select?: ChoiceFieldLayoutMode;
486
+ }
487
+ type FieldError = ValidationIssue & {
488
+ readonly message: string;
489
+ };
490
+ interface FormRendererAppearance {
491
+ /**
492
+ * Layout preset for choice questions (radio, checkbox, and multi-select).
493
+ * - "default": keep the flat question layout.
494
+ * - "grouped": render a bordered fieldset and legend.
495
+ * - An object can configure each choice question type independently.
496
+ */
497
+ readonly choiceField?: ChoiceFieldLayoutMode | ChoiceFieldTypeLayoutMap;
498
+ }
480
499
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
481
500
  interface SubmissionConfirmationOptions {
482
501
  readonly enabled?: boolean;
@@ -504,6 +523,23 @@ interface FormFieldsSlotProps {
504
523
  readonly children: ReactNode;
505
524
  readonly className?: string;
506
525
  }
526
+ interface ChoiceGroupSlotProps {
527
+ readonly field: Question;
528
+ readonly title: string;
529
+ readonly description?: string;
530
+ readonly required?: boolean;
531
+ readonly error?: FieldError;
532
+ readonly disabled?: boolean;
533
+ readonly readOnly?: boolean;
534
+ readonly children: ReactNode;
535
+ readonly className?: string;
536
+ }
537
+ interface FormRendererSlotProps {
538
+ readonly choiceGroup?: {
539
+ readonly className?: string;
540
+ readonly style?: CSSProperties;
541
+ };
542
+ }
507
543
  interface FormRendererSlots {
508
544
  readonly renderHeader?: (props: {
509
545
  readonly title: string;
@@ -554,6 +590,7 @@ interface FormRendererSlots {
554
590
  readonly current: number;
555
591
  readonly max: number;
556
592
  }) => ReactNode;
593
+ readonly renderChoiceGroup?: (props: ChoiceGroupSlotProps) => ReactNode;
557
594
  }
558
595
  interface SubmissionProtectionProps {
559
596
  readonly submissionGuards?: readonly SubmissionGuard[];
@@ -656,6 +693,7 @@ declare const BUILDER_TRANSLATION_ALIASES: Readonly<Record<string, string>>;
656
693
  declare function isTranslationUnresolved(result: unknown, key: string, aliases?: readonly string[]): boolean;
657
694
  declare function resolveTranslation(key: string, aliases: readonly string[], adapter?: TranslationAdapter, defaultCatalog?: Readonly<Record<string, string>>, params?: Readonly<Record<string, unknown>>, locale?: string): string;
658
695
 
696
+ declare function resolveChoiceFieldLayout(type: FieldType, appearance?: FormRendererAppearance, groupedChoiceFieldsLegacy?: boolean): "default" | "grouped";
659
697
  interface FieldComponentProps {
660
698
  readonly field: FormField;
661
699
  readonly value: FormValue;
@@ -671,6 +709,10 @@ type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentPro
671
709
  interface FormRendererPresentationProps extends SubmissionProtectionProps {
672
710
  readonly components?: FieldComponents;
673
711
  readonly className?: string;
712
+ readonly appearance?: FormRendererAppearance;
713
+ readonly slotProps?: FormRendererSlotProps;
714
+ /** @deprecated Use appearance.choiceField="grouped" instead. */
715
+ readonly groupedChoiceFields?: boolean;
674
716
  /**
675
717
  * Controls where the completion message is rendered after a successful submission.
676
718
  * Defaults to "append" for backwards compatibility.
@@ -704,4 +746,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
704
746
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
705
747
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
706
748
 
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 };
749
+ 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 ChoiceFieldTypeLayoutMap, type ChoiceGroupSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldEditorControlsConfig, type FieldEditorHeaderSlotProps, type FieldError, 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 FormRendererSlotProps, 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, resolveChoiceFieldLayout, resolveFieldEditorControls, resolveFieldTypeSelectOptions, resolveInitialFieldType, resolveTranslation, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.js CHANGED
@@ -3253,14 +3253,25 @@ import {
3253
3253
  useState as useState4
3254
3254
  } from "react";
3255
3255
  import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
3256
+ var isChoiceFieldType = (type) => type === "radio" || type === "checkbox" || type === "multi-select" || type === "select";
3257
+ function resolveChoiceFieldLayout(type, appearance, groupedChoiceFieldsLegacy = false) {
3258
+ if (groupedChoiceFieldsLegacy) return "grouped";
3259
+ const config = appearance?.choiceField;
3260
+ if (config === void 0) return "default";
3261
+ if (typeof config === "string") return config;
3262
+ return config[type] ?? "default";
3263
+ }
3256
3264
  function describedBy(field, error, helpId, errorId) {
3257
3265
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
3258
3266
  Boolean
3259
3267
  );
3260
3268
  return ids.length === 0 ? void 0 : ids.join(" ");
3261
3269
  }
3262
- function RequiredMark({ required }) {
3263
- return required ? /* @__PURE__ */ jsxs2("span", { className: "fe-required", "aria-hidden": "true", children: [
3270
+ function RequiredMark({
3271
+ required,
3272
+ className = "fe-required"
3273
+ }) {
3274
+ return required ? /* @__PURE__ */ jsxs2("span", { className, "aria-hidden": "true", children: [
3264
3275
  " ",
3265
3276
  "*"
3266
3277
  ] }) : null;
@@ -3271,13 +3282,106 @@ function FieldMessage({ props }) {
3271
3282
  props.error === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.errorId, className: "fe-error", children: props.translate(props.error.messageKey, props.error.params) })
3272
3283
  ] });
3273
3284
  }
3274
- function DefaultField(props) {
3285
+ function GroupedChoiceDescription({ props }) {
3286
+ return props.field.description === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.helpId, className: "fe-field-description", children: props.field.description });
3287
+ }
3288
+ function GroupedChoiceError({ props }) {
3289
+ 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) });
3290
+ }
3291
+ function ChoiceGroupFrame({
3292
+ props,
3293
+ children,
3294
+ className,
3295
+ slotProps,
3296
+ renderChoiceGroup,
3297
+ disabled,
3298
+ readOnly
3299
+ }) {
3300
+ const { field, error, translate } = props;
3301
+ const groupError = error === void 0 ? void 0 : { ...error, message: translate(error.messageKey, error.params) };
3302
+ const groupProps = {
3303
+ field,
3304
+ title: field.title,
3305
+ ...field.description === void 0 ? {} : { description: field.description },
3306
+ ...field.required === void 0 ? {} : { required: field.required },
3307
+ ...groupError === void 0 ? {} : { error: groupError },
3308
+ ...disabled === void 0 ? {} : { disabled },
3309
+ ...readOnly === void 0 ? {} : { readOnly },
3310
+ children,
3311
+ className
3312
+ };
3313
+ if (renderChoiceGroup !== void 0) return /* @__PURE__ */ jsx3(Fragment3, { children: renderChoiceGroup(groupProps) });
3314
+ return (
3315
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3316
+ /* @__PURE__ */ jsxs2(
3317
+ "fieldset",
3318
+ {
3319
+ className,
3320
+ "data-field-id": field.id,
3321
+ disabled,
3322
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3323
+ "aria-invalid": Boolean(error),
3324
+ "aria-required": field.required,
3325
+ style: slotProps?.style,
3326
+ children: [
3327
+ /* @__PURE__ */ jsxs2("legend", { className: "fe-choice-legend", children: [
3328
+ field.title,
3329
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required, className: "fe-required-badge" })
3330
+ ] }),
3331
+ /* @__PURE__ */ jsx3(GroupedChoiceDescription, { props }),
3332
+ children,
3333
+ /* @__PURE__ */ jsx3(GroupedChoiceError, { props })
3334
+ ]
3335
+ }
3336
+ )
3337
+ );
3338
+ }
3339
+ function DefaultField({
3340
+ groupedChoiceFields,
3341
+ appearance,
3342
+ choiceGroupSlotProps,
3343
+ renderChoiceGroup,
3344
+ disabled,
3345
+ readOnly,
3346
+ ...props
3347
+ }) {
3275
3348
  const { field, value, setValue, inputId, error, translate } = props;
3349
+ const isGroupedChoiceField = isChoiceFieldType(field.type) && resolveChoiceFieldLayout(field.type, appearance, groupedChoiceFields) === "grouped";
3350
+ const choiceGroupClassName = ["fe-choice-group", `fe-field--${field.type}`, choiceGroupSlotProps?.className].filter((item) => item !== void 0 && item.length > 0).join(" ");
3276
3351
  const ariaProps = {
3277
3352
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3278
3353
  "aria-invalid": error === void 0 ? void 0 : true
3279
3354
  };
3280
3355
  if (field.type === "checkbox") {
3356
+ if (isGroupedChoiceField) {
3357
+ return /* @__PURE__ */ jsx3(
3358
+ ChoiceGroupFrame,
3359
+ {
3360
+ props,
3361
+ className: choiceGroupClassName,
3362
+ slotProps: choiceGroupSlotProps,
3363
+ renderChoiceGroup,
3364
+ disabled,
3365
+ readOnly,
3366
+ children: /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: /* @__PURE__ */ jsxs2("label", { className: "fe-choice-option", htmlFor: inputId, children: [
3367
+ /* @__PURE__ */ jsx3(
3368
+ "input",
3369
+ {
3370
+ id: inputId,
3371
+ name: field.id,
3372
+ type: "checkbox",
3373
+ checked: value === true,
3374
+ "aria-label": field.title,
3375
+ disabled,
3376
+ readOnly,
3377
+ onChange: (event) => setValue(event.currentTarget.checked)
3378
+ }
3379
+ ),
3380
+ /* @__PURE__ */ jsx3("span", { children: field.title })
3381
+ ] }) })
3382
+ }
3383
+ );
3384
+ }
3281
3385
  return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
3282
3386
  /* @__PURE__ */ jsxs2("label", { className: "fe-check-label", htmlFor: inputId, children: [
3283
3387
  /* @__PURE__ */ jsx3(
@@ -3301,6 +3405,84 @@ function DefaultField(props) {
3301
3405
  }
3302
3406
  if (field.type === "radio" || field.type === "multi-select") {
3303
3407
  const selected = Array.isArray(value) ? value : [];
3408
+ if (isGroupedChoiceField) {
3409
+ const isRadio = field.type === "radio";
3410
+ return /* @__PURE__ */ jsx3(
3411
+ ChoiceGroupFrame,
3412
+ {
3413
+ props,
3414
+ className: choiceGroupClassName,
3415
+ slotProps: choiceGroupSlotProps,
3416
+ renderChoiceGroup,
3417
+ disabled,
3418
+ readOnly,
3419
+ children: /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: field.options.map((option, index) => {
3420
+ const optionId = `${inputId}-${index}`;
3421
+ const checked = isRadio ? value === option.id : selected.includes(option.id);
3422
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-choice-option", htmlFor: optionId, children: [
3423
+ /* @__PURE__ */ jsx3(
3424
+ "input",
3425
+ {
3426
+ id: optionId,
3427
+ name: field.id,
3428
+ type: isRadio ? "radio" : "checkbox",
3429
+ value: option.id,
3430
+ checked,
3431
+ disabled,
3432
+ readOnly,
3433
+ onKeyDown: isRadio ? (event) => {
3434
+ if (event.key !== "ArrowDown" && event.key !== "ArrowRight" && event.key !== "ArrowUp" && event.key !== "ArrowLeft")
3435
+ return;
3436
+ event.preventDefault();
3437
+ const offset = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
3438
+ const nextIndex = (index + offset + field.options.length) % field.options.length;
3439
+ const nextOption = field.options[nextIndex];
3440
+ if (nextOption === void 0) return;
3441
+ setValue(nextOption.id);
3442
+ document.getElementById(`${inputId}-${nextIndex}`)?.focus();
3443
+ } : void 0,
3444
+ onChange: (event) => {
3445
+ if (isRadio) setValue(option.id);
3446
+ else
3447
+ setValue(
3448
+ event.currentTarget.checked ? [...selected, option.id] : selected.filter((item) => item !== option.id)
3449
+ );
3450
+ }
3451
+ }
3452
+ ),
3453
+ /* @__PURE__ */ jsx3("span", { children: option.label })
3454
+ ] }, option.id);
3455
+ }) })
3456
+ }
3457
+ );
3458
+ }
3459
+ if (field.type === "radio") {
3460
+ return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--radio", "data-field-id": field.id, children: [
3461
+ /* @__PURE__ */ jsxs2("div", { className: "fe-label", children: [
3462
+ field.title,
3463
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
3464
+ ] }),
3465
+ field.options.map((option, index) => {
3466
+ const optionId = `${inputId}-${index}`;
3467
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-check-label", htmlFor: optionId, children: [
3468
+ /* @__PURE__ */ jsx3(
3469
+ "input",
3470
+ {
3471
+ ...ariaProps,
3472
+ id: optionId,
3473
+ name: field.id,
3474
+ type: "radio",
3475
+ value: option.id,
3476
+ checked: value === option.id,
3477
+ onChange: () => setValue(option.id)
3478
+ }
3479
+ ),
3480
+ /* @__PURE__ */ jsx3("span", { children: option.label })
3481
+ ] }, option.id);
3482
+ }),
3483
+ /* @__PURE__ */ jsx3(FieldMessage, { props })
3484
+ ] });
3485
+ }
3304
3486
  return /* @__PURE__ */ jsxs2("fieldset", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, ...ariaProps, children: [
3305
3487
  /* @__PURE__ */ jsxs2("legend", { className: "fe-label", children: [
3306
3488
  field.title,
@@ -3408,6 +3590,7 @@ function DefaultField(props) {
3408
3590
  ...ariaProps,
3409
3591
  id: inputId,
3410
3592
  name: field.id,
3593
+ disabled,
3411
3594
  value: typeof value === "string" ? value : "",
3412
3595
  onChange: (event) => setValue(event.currentTarget.value || void 0),
3413
3596
  children: [
@@ -3431,6 +3614,20 @@ function DefaultField(props) {
3431
3614
  }
3432
3615
  );
3433
3616
  }
3617
+ if (isGroupedChoiceField) {
3618
+ return /* @__PURE__ */ jsx3(
3619
+ ChoiceGroupFrame,
3620
+ {
3621
+ props,
3622
+ className: choiceGroupClassName,
3623
+ slotProps: choiceGroupSlotProps,
3624
+ renderChoiceGroup,
3625
+ disabled,
3626
+ readOnly,
3627
+ children: /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: control })
3628
+ }
3629
+ );
3630
+ }
3434
3631
  return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
3435
3632
  label,
3436
3633
  control,
@@ -3550,6 +3747,9 @@ function ContextFormRenderer({
3550
3747
  beforeSubmit,
3551
3748
  onDraftSave,
3552
3749
  successRenderMode = "append",
3750
+ appearance,
3751
+ slotProps,
3752
+ groupedChoiceFields = false,
3553
3753
  submissionConfirmation,
3554
3754
  submissionConfirmationRenderMode,
3555
3755
  showHiddenFieldsInSummary = false,
@@ -4068,7 +4268,18 @@ function ContextFormRenderer({
4068
4268
  }) }, field.id);
4069
4269
  }
4070
4270
  const Component = components[field.type];
4071
- return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
4271
+ return Component === void 0 ? /* @__PURE__ */ jsx3(
4272
+ DefaultField,
4273
+ {
4274
+ ...props,
4275
+ groupedChoiceFields,
4276
+ appearance,
4277
+ choiceGroupSlotProps: slotProps?.choiceGroup,
4278
+ renderChoiceGroup: slots.renderChoiceGroup,
4279
+ disabled: interactionLocked
4280
+ },
4281
+ field.id
4282
+ ) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
4072
4283
  });
4073
4284
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4074
4285
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ jsx3("div", { className: fieldClassName, children: fieldChildren });
@@ -4207,6 +4418,7 @@ export {
4207
4418
  createLocalStorageSubmissionAttemptStore,
4208
4419
  createLocalStorageSubmissionReceiptStore,
4209
4420
  isTranslationUnresolved,
4421
+ resolveChoiceFieldLayout,
4210
4422
  resolveFieldEditorControls,
4211
4423
  resolveFieldTypeSelectOptions,
4212
4424
  resolveInitialFieldType,
package/dist/styles.css CHANGED
@@ -1,4 +1,15 @@
1
1
  /* src/styles.css */
2
+ :root {
3
+ --fe-choice-group-border: 1px solid var(--fe-border-color, #e2e8f0);
4
+ --fe-choice-group-radius: var(--fe-radius, 8px);
5
+ --fe-choice-group-padding: 1rem 1.25rem;
6
+ --fe-choice-group-margin-bottom: 1.25rem;
7
+ --fe-choice-group-bg: var(--fe-bg-color, transparent);
8
+ --fe-choice-legend-font-weight: 600;
9
+ --fe-choice-legend-font-size: 0.95rem;
10
+ --fe-choice-legend-color: var(--fe-text-color, inherit);
11
+ --fe-choice-options-gap: 0.625rem;
12
+ }
2
13
  .fe-form {
3
14
  color: #17202a;
4
15
  display: grid;
@@ -123,6 +134,41 @@
123
134
  .fe-check-label input {
124
135
  margin-top: 0.22rem;
125
136
  }
137
+ .fe-choice-group {
138
+ background-color: var(--fe-choice-group-bg);
139
+ border: var(--fe-choice-group-border);
140
+ border-radius: var(--fe-choice-group-radius);
141
+ box-sizing: border-box;
142
+ margin: 0 0 var(--fe-choice-group-margin-bottom);
143
+ padding: var(--fe-choice-group-padding);
144
+ }
145
+ .fe-choice-legend {
146
+ color: var(--fe-choice-legend-color);
147
+ font-size: var(--fe-choice-legend-font-size);
148
+ font-weight: var(--fe-choice-legend-font-weight);
149
+ padding: 0 0.5rem;
150
+ }
151
+ .fe-choice-options {
152
+ display: flex;
153
+ flex-direction: column;
154
+ gap: var(--fe-choice-options-gap);
155
+ margin-top: 0.5rem;
156
+ }
157
+ .fe-choice-option {
158
+ align-items: center;
159
+ cursor: pointer;
160
+ display: flex;
161
+ gap: 0.5rem;
162
+ }
163
+ .fe-field-description {
164
+ color: #667085;
165
+ font-size: 0.875rem;
166
+ }
167
+ .fe-field-error {
168
+ color: #b42318;
169
+ font-size: 0.875rem;
170
+ font-weight: 600;
171
+ }
126
172
  .fe-rating-options {
127
173
  display: flex;
128
174
  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.4.0",
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.4.0",
46
+ "@form-engine-ts/privacy": "4.4.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "react": ">=18.2 <20",