@form-engine-ts/react 4.3.2 → 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
@@ -41,13 +41,24 @@ Use any compatible `TranslationAdapter` in place of the mock translator.
41
41
 
42
42
  ## Choice field layout
43
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:
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
46
 
47
47
  ```tsx
48
48
  <FormRenderer appearance={{ choiceField: "grouped" }} />
49
49
  ```
50
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
+
51
62
  `groupedChoiceFields={true}` remains available as a deprecated compatibility alias.
52
63
 
53
64
  Define `schema.pages` to enable Back/Next navigation, page validation, conditional page skipping, and an accessible
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,6 +3271,14 @@ 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
@@ -3297,51 +3306,98 @@ function GroupedChoiceDescription({ props }) {
3297
3306
  function GroupedChoiceError({ props }) {
3298
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) });
3299
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
+ }
3300
3357
  function DefaultField({
3301
3358
  groupedChoiceFields,
3359
+ appearance,
3360
+ choiceGroupSlotProps,
3361
+ renderChoiceGroup,
3362
+ disabled,
3363
+ readOnly,
3302
3364
  ...props
3303
3365
  }) {
3304
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(" ");
3305
3369
  const ariaProps = {
3306
3370
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3307
3371
  "aria-invalid": error === void 0 ? void 0 : true
3308
3372
  };
3309
3373
  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
- )
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
+ }
3345
3401
  );
3346
3402
  }
3347
3403
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
@@ -3367,56 +3423,55 @@ function DefaultField({
3367
3423
  }
3368
3424
  if (field.type === "radio" || field.type === "multi-select") {
3369
3425
  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
- )
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
+ }
3420
3475
  );
3421
3476
  }
3422
3477
  if (field.type === "radio") {
@@ -3553,6 +3608,7 @@ function DefaultField({
3553
3608
  ...ariaProps,
3554
3609
  id: inputId,
3555
3610
  name: field.id,
3611
+ disabled,
3556
3612
  value: typeof value === "string" ? value : "",
3557
3613
  onChange: (event) => setValue(event.currentTarget.value || void 0),
3558
3614
  children: [
@@ -3576,6 +3632,20 @@ function DefaultField({
3576
3632
  }
3577
3633
  );
3578
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
+ }
3579
3649
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
3580
3650
  label,
3581
3651
  control,
@@ -3696,6 +3766,7 @@ function ContextFormRenderer({
3696
3766
  onDraftSave,
3697
3767
  successRenderMode = "append",
3698
3768
  appearance,
3769
+ slotProps,
3699
3770
  groupedChoiceFields = false,
3700
3771
  submissionConfirmation,
3701
3772
  submissionConfirmationRenderMode,
@@ -3709,7 +3780,6 @@ function ContextFormRenderer({
3709
3780
  slots = {}
3710
3781
  }) {
3711
3782
  const form = useForm();
3712
- const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3713
3783
  const prefix = (0, import_react5.useId)().replace(/:/g, "");
3714
3784
  const formRef = (0, import_react5.useRef)(null);
3715
3785
  const loadedDraftKey = (0, import_react5.useRef)(null);
@@ -4216,7 +4286,18 @@ function ContextFormRenderer({
4216
4286
  }) }, field.id);
4217
4287
  }
4218
4288
  const Component = components[field.type];
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);
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);
4220
4301
  });
4221
4302
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4222
4303
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: fieldClassName, children: fieldChildren });
@@ -4356,6 +4437,7 @@ function FormRenderer(props) {
4356
4437
  createLocalStorageSubmissionAttemptStore,
4357
4438
  createLocalStorageSubmissionReceiptStore,
4358
4439
  isTranslationUnresolved,
4440
+ resolveChoiceFieldLayout,
4359
4441
  resolveFieldEditorControls,
4360
4442
  resolveFieldTypeSelectOptions,
4361
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 {
@@ -478,13 +478,23 @@ type SubmissionGuardResult = {
478
478
  type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
479
479
  type FormSuccessRenderMode = "append" | "replace";
480
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
+ };
481
490
  interface FormRendererAppearance {
482
491
  /**
483
- * Layout preset for radio and checkbox questions.
492
+ * Layout preset for choice questions (radio, checkbox, and multi-select).
484
493
  * - "default": keep the flat question layout.
485
494
  * - "grouped": render a bordered fieldset and legend.
495
+ * - An object can configure each choice question type independently.
486
496
  */
487
- readonly choiceField?: ChoiceFieldLayoutMode;
497
+ readonly choiceField?: ChoiceFieldLayoutMode | ChoiceFieldTypeLayoutMap;
488
498
  }
489
499
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
490
500
  interface SubmissionConfirmationOptions {
@@ -513,6 +523,23 @@ interface FormFieldsSlotProps {
513
523
  readonly children: ReactNode;
514
524
  readonly className?: string;
515
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
+ }
516
543
  interface FormRendererSlots {
517
544
  readonly renderHeader?: (props: {
518
545
  readonly title: string;
@@ -563,6 +590,7 @@ interface FormRendererSlots {
563
590
  readonly current: number;
564
591
  readonly max: number;
565
592
  }) => ReactNode;
593
+ readonly renderChoiceGroup?: (props: ChoiceGroupSlotProps) => ReactNode;
566
594
  }
567
595
  interface SubmissionProtectionProps {
568
596
  readonly submissionGuards?: readonly SubmissionGuard[];
@@ -665,6 +693,7 @@ declare const BUILDER_TRANSLATION_ALIASES: Readonly<Record<string, string>>;
665
693
  declare function isTranslationUnresolved(result: unknown, key: string, aliases?: readonly string[]): boolean;
666
694
  declare function resolveTranslation(key: string, aliases: readonly string[], adapter?: TranslationAdapter, defaultCatalog?: Readonly<Record<string, string>>, params?: Readonly<Record<string, unknown>>, locale?: string): string;
667
695
 
696
+ declare function resolveChoiceFieldLayout(type: FieldType, appearance?: FormRendererAppearance, groupedChoiceFieldsLegacy?: boolean): "default" | "grouped";
668
697
  interface FieldComponentProps {
669
698
  readonly field: FormField;
670
699
  readonly value: FormValue;
@@ -681,6 +710,7 @@ interface FormRendererPresentationProps extends SubmissionProtectionProps {
681
710
  readonly components?: FieldComponents;
682
711
  readonly className?: string;
683
712
  readonly appearance?: FormRendererAppearance;
713
+ readonly slotProps?: FormRendererSlotProps;
684
714
  /** @deprecated Use appearance.choiceField="grouped" instead. */
685
715
  readonly groupedChoiceFields?: boolean;
686
716
  /**
@@ -716,4 +746,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
716
746
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
717
747
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
718
748
 
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 };
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 {
@@ -478,13 +478,23 @@ type SubmissionGuardResult = {
478
478
  type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
479
479
  type FormSuccessRenderMode = "append" | "replace";
480
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
+ };
481
490
  interface FormRendererAppearance {
482
491
  /**
483
- * Layout preset for radio and checkbox questions.
492
+ * Layout preset for choice questions (radio, checkbox, and multi-select).
484
493
  * - "default": keep the flat question layout.
485
494
  * - "grouped": render a bordered fieldset and legend.
495
+ * - An object can configure each choice question type independently.
486
496
  */
487
- readonly choiceField?: ChoiceFieldLayoutMode;
497
+ readonly choiceField?: ChoiceFieldLayoutMode | ChoiceFieldTypeLayoutMap;
488
498
  }
489
499
  type SubmissionConfirmationRenderMode = "inline" | "replace" | "dialog";
490
500
  interface SubmissionConfirmationOptions {
@@ -513,6 +523,23 @@ interface FormFieldsSlotProps {
513
523
  readonly children: ReactNode;
514
524
  readonly className?: string;
515
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
+ }
516
543
  interface FormRendererSlots {
517
544
  readonly renderHeader?: (props: {
518
545
  readonly title: string;
@@ -563,6 +590,7 @@ interface FormRendererSlots {
563
590
  readonly current: number;
564
591
  readonly max: number;
565
592
  }) => ReactNode;
593
+ readonly renderChoiceGroup?: (props: ChoiceGroupSlotProps) => ReactNode;
566
594
  }
567
595
  interface SubmissionProtectionProps {
568
596
  readonly submissionGuards?: readonly SubmissionGuard[];
@@ -665,6 +693,7 @@ declare const BUILDER_TRANSLATION_ALIASES: Readonly<Record<string, string>>;
665
693
  declare function isTranslationUnresolved(result: unknown, key: string, aliases?: readonly string[]): boolean;
666
694
  declare function resolveTranslation(key: string, aliases: readonly string[], adapter?: TranslationAdapter, defaultCatalog?: Readonly<Record<string, string>>, params?: Readonly<Record<string, unknown>>, locale?: string): string;
667
695
 
696
+ declare function resolveChoiceFieldLayout(type: FieldType, appearance?: FormRendererAppearance, groupedChoiceFieldsLegacy?: boolean): "default" | "grouped";
668
697
  interface FieldComponentProps {
669
698
  readonly field: FormField;
670
699
  readonly value: FormValue;
@@ -681,6 +710,7 @@ interface FormRendererPresentationProps extends SubmissionProtectionProps {
681
710
  readonly components?: FieldComponents;
682
711
  readonly className?: string;
683
712
  readonly appearance?: FormRendererAppearance;
713
+ readonly slotProps?: FormRendererSlotProps;
684
714
  /** @deprecated Use appearance.choiceField="grouped" instead. */
685
715
  readonly groupedChoiceFields?: boolean;
686
716
  /**
@@ -716,4 +746,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
716
746
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
717
747
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
718
748
 
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 };
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,6 +3253,14 @@ 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
@@ -3280,51 +3288,98 @@ function GroupedChoiceDescription({ props }) {
3280
3288
  function GroupedChoiceError({ props }) {
3281
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) });
3282
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
+ }
3283
3339
  function DefaultField({
3284
3340
  groupedChoiceFields,
3341
+ appearance,
3342
+ choiceGroupSlotProps,
3343
+ renderChoiceGroup,
3344
+ disabled,
3345
+ readOnly,
3285
3346
  ...props
3286
3347
  }) {
3287
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(" ");
3288
3351
  const ariaProps = {
3289
3352
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3290
3353
  "aria-invalid": error === void 0 ? void 0 : true
3291
3354
  };
3292
3355
  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
- )
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
+ }
3328
3383
  );
3329
3384
  }
3330
3385
  return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
@@ -3350,56 +3405,55 @@ function DefaultField({
3350
3405
  }
3351
3406
  if (field.type === "radio" || field.type === "multi-select") {
3352
3407
  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
- )
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
+ }
3403
3457
  );
3404
3458
  }
3405
3459
  if (field.type === "radio") {
@@ -3536,6 +3590,7 @@ function DefaultField({
3536
3590
  ...ariaProps,
3537
3591
  id: inputId,
3538
3592
  name: field.id,
3593
+ disabled,
3539
3594
  value: typeof value === "string" ? value : "",
3540
3595
  onChange: (event) => setValue(event.currentTarget.value || void 0),
3541
3596
  children: [
@@ -3559,6 +3614,20 @@ function DefaultField({
3559
3614
  }
3560
3615
  );
3561
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
+ }
3562
3631
  return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
3563
3632
  label,
3564
3633
  control,
@@ -3679,6 +3748,7 @@ function ContextFormRenderer({
3679
3748
  onDraftSave,
3680
3749
  successRenderMode = "append",
3681
3750
  appearance,
3751
+ slotProps,
3682
3752
  groupedChoiceFields = false,
3683
3753
  submissionConfirmation,
3684
3754
  submissionConfirmationRenderMode,
@@ -3692,7 +3762,6 @@ function ContextFormRenderer({
3692
3762
  slots = {}
3693
3763
  }) {
3694
3764
  const form = useForm();
3695
- const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3696
3765
  const prefix = useId().replace(/:/g, "");
3697
3766
  const formRef = useRef2(null);
3698
3767
  const loadedDraftKey = useRef2(null);
@@ -4199,7 +4268,18 @@ function ContextFormRenderer({
4199
4268
  }) }, field.id);
4200
4269
  }
4201
4270
  const Component = components[field.type];
4202
- return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props, groupedChoiceFields: isGroupedMode }, 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);
4203
4283
  });
4204
4284
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4205
4285
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ jsx3("div", { className: fieldClassName, children: fieldChildren });
@@ -4338,6 +4418,7 @@ export {
4338
4418
  createLocalStorageSubmissionAttemptStore,
4339
4419
  createLocalStorageSubmissionReceiptStore,
4340
4420
  isTranslationUnresolved,
4421
+ resolveChoiceFieldLayout,
4341
4422
  resolveFieldEditorControls,
4342
4423
  resolveFieldTypeSelectOptions,
4343
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;
@@ -124,23 +135,23 @@
124
135
  margin-top: 0.22rem;
125
136
  }
126
137
  .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);
138
+ background-color: var(--fe-choice-group-bg);
139
+ border: var(--fe-choice-group-border);
140
+ border-radius: var(--fe-choice-group-radius);
130
141
  box-sizing: border-box;
131
- margin: 0 0 1.25rem;
132
- padding: 1rem 1.25rem;
142
+ margin: 0 0 var(--fe-choice-group-margin-bottom);
143
+ padding: var(--fe-choice-group-padding);
133
144
  }
134
145
  .fe-choice-legend {
135
- color: var(--fe-text-color, #1a202c);
136
- font-size: 0.95rem;
137
- font-weight: 600;
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);
138
149
  padding: 0 0.5rem;
139
150
  }
140
151
  .fe-choice-options {
141
152
  display: flex;
142
153
  flex-direction: column;
143
- gap: 0.625rem;
154
+ gap: var(--fe-choice-options-gap);
144
155
  margin-top: 0.5rem;
145
156
  }
146
157
  .fe-choice-option {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/react",
3
- "version": "4.3.2",
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.2",
46
- "@form-engine-ts/privacy": "4.3.2"
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",