@form-engine-ts/react 4.3.2 → 4.5.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/dist/index.js CHANGED
@@ -102,7 +102,7 @@ import {
102
102
  DEFAULT_FIELD_TYPE_DEFINITIONS,
103
103
  populateSchemaTranslations
104
104
  } from "@form-engine-ts/core";
105
- import { Children, createContext, isValidElement, useContext, useState } from "react";
105
+ import { Children, createContext, isValidElement, useContext, useState as useState2 } from "react";
106
106
 
107
107
  // src/hooks/useFormBuilder.ts
108
108
  import {
@@ -110,7 +110,7 @@ import {
110
110
  transformFieldType,
111
111
  validateFormSchema
112
112
  } from "@form-engine-ts/core";
113
- import { useCallback, useMemo } from "react";
113
+ import { useCallback, useMemo, useState } from "react";
114
114
  var DEFAULT_PREFIXES = { field: "q", option: "opt", page: "page" };
115
115
  var CHOICE_TYPES = ["select", "radio", "multi-select"];
116
116
  function defaultIdFactory(kind, existingIds) {
@@ -197,8 +197,20 @@ function move(items, sourceIndex, targetIndex) {
197
197
  result.splice(targetIndex, 0, item);
198
198
  return result;
199
199
  }
200
+ function displayRuleSourceIds(field) {
201
+ if (field.displayRule === void 0) return [];
202
+ const ids = [];
203
+ const visit = (group) => {
204
+ for (const condition of group.conditions) {
205
+ if ("logic" in condition) visit(condition);
206
+ else ids.push(condition.fieldId);
207
+ }
208
+ };
209
+ visit(field.displayRule.condition);
210
+ return ids;
211
+ }
200
212
  function withoutDisplayCondition(field) {
201
- const { displayCondition: _displayCondition, ...rest } = field;
213
+ const { displayCondition: _displayCondition, displayRule: _displayRule, ...rest } = field;
202
214
  return rest;
203
215
  }
204
216
  function removeLocalizedProperty(translations, locale, property) {
@@ -221,8 +233,23 @@ function useFormBuilder({
221
233
  onChange,
222
234
  policy,
223
235
  idFactory = defaultIdFactory,
224
- factories = {}
236
+ factories = {},
237
+ fieldEditorMode = "all",
238
+ activeFieldId: controlledActiveFieldId,
239
+ defaultActiveFieldId,
240
+ onActiveFieldChange
225
241
  }) {
242
+ const [internalActiveFieldId, setInternalActiveFieldId] = useState(
243
+ defaultActiveFieldId ?? (fieldEditorMode === "single" ? schema.fields[0]?.id : void 0)
244
+ );
245
+ const activeFieldId = controlledActiveFieldId ?? internalActiveFieldId;
246
+ const setActiveFieldId = useCallback(
247
+ (fieldId) => {
248
+ if (controlledActiveFieldId === void 0) setInternalActiveFieldId(fieldId);
249
+ onActiveFieldChange?.(fieldId);
250
+ },
251
+ [controlledActiveFieldId, onActiveFieldChange]
252
+ );
226
253
  const createId = useCallback(
227
254
  (kind, existingIds) => {
228
255
  const rawId = idFactory(kind, existingIds);
@@ -335,9 +362,10 @@ function useFormBuilder({
335
362
  questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
336
363
  }));
337
364
  onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
365
+ setActiveFieldId(field.id);
338
366
  return { success: true };
339
367
  },
340
- [createId, factories, onChange, policy, schema]
368
+ [createId, factories, onChange, policy, schema, setActiveFieldId]
341
369
  );
342
370
  const removeField = useCallback(
343
371
  (fieldId) => {
@@ -345,15 +373,19 @@ function useFormBuilder({
345
373
  return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
346
374
  if (schema.fields.length <= 1)
347
375
  return { success: false, error: { type: "invalid_operation", message: "A form must contain one field." } };
348
- const fields = schema.fields.filter((field) => field.id !== fieldId).map((field) => field.displayCondition?.questionId === fieldId ? withoutDisplayCondition(field) : field);
376
+ const removedIndex = schema.fields.findIndex((field) => field.id === fieldId);
377
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map(
378
+ (field) => field.displayCondition?.questionId === fieldId || displayRuleSourceIds(field).includes(fieldId) ? withoutDisplayCondition(field) : field
379
+ );
349
380
  const pages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
350
381
  if (schema.pages !== void 0 && pages?.length === 0) {
351
382
  const { pages: _pages, ...single } = schema;
352
383
  onChange({ ...single, fields });
353
384
  } else onChange({ ...schema, fields, ...pages === void 0 ? {} : { pages } });
385
+ if (activeFieldId === fieldId) setActiveFieldId(fields[removedIndex]?.id ?? fields.at(-1)?.id);
354
386
  return { success: true };
355
387
  },
356
- [onChange, schema]
388
+ [activeFieldId, onChange, schema, setActiveFieldId]
357
389
  );
358
390
  const moveField = useCallback(
359
391
  (fieldId, targetIndex) => {
@@ -366,8 +398,8 @@ function useFormBuilder({
366
398
  onChange({
367
399
  ...schema,
368
400
  fields: fields.map((field, index) => {
369
- const source = field.displayCondition?.questionId;
370
- return source === void 0 || (indexById.get(source) ?? index) < index ? field : withoutDisplayCondition(field);
401
+ const sources = field.displayCondition?.questionId === void 0 ? displayRuleSourceIds(field) : [field.displayCondition.questionId];
402
+ return sources.every((source) => (indexById.get(source) ?? index) < index) ? field : withoutDisplayCondition(field);
371
403
  })
372
404
  });
373
405
  return { success: true };
@@ -791,6 +823,14 @@ function useFormBuilder({
791
823
  const result = validateFormSchema(schema, policy === void 0 ? {} : { policy });
792
824
  return result.valid ? [] : result.issues;
793
825
  }, [policy, schema]);
826
+ const getFieldEditorProps = useCallback(
827
+ (fieldId) => ({
828
+ isActive: activeFieldId === fieldId,
829
+ isVisible: fieldEditorMode === "all" || activeFieldId === fieldId,
830
+ onSelect: () => setActiveFieldId(fieldId)
831
+ }),
832
+ [activeFieldId, fieldEditorMode, setActiveFieldId]
833
+ );
794
834
  return {
795
835
  schema,
796
836
  addField,
@@ -812,7 +852,10 @@ function useFormBuilder({
812
852
  setLocaleTranslation,
813
853
  addLocale,
814
854
  setDefaultLocale,
815
- validationIssues
855
+ validationIssues,
856
+ ...activeFieldId === void 0 ? {} : { activeFieldId },
857
+ setActiveFieldId,
858
+ getFieldEditorProps
816
859
  };
817
860
  }
818
861
 
@@ -1465,7 +1508,10 @@ var BUILDER_DEFAULTS = {
1465
1508
  "builder.operator.equals": "equals",
1466
1509
  "builder.operator.not_equals": "does not equal",
1467
1510
  "builder.operator.contains": "contains",
1468
- "builder.operator.not_empty": "is not empty"
1511
+ "builder.operator.not_empty": "is not empty",
1512
+ "builder.submissionSettings": "Submission settings",
1513
+ "builder.showConfirmationBeforeSubmit": "Show confirmation before submit",
1514
+ "builder.confirmationRenderMode": "Confirmation display mode"
1469
1515
  };
1470
1516
  function BuilderSectionGroup({ children }) {
1471
1517
  return children;
@@ -1642,7 +1688,12 @@ function FormBuilder({
1642
1688
  slots,
1643
1689
  sectionOrder,
1644
1690
  disableDefaultStyles = false,
1645
- unstyled = false
1691
+ unstyled = false,
1692
+ fieldEditorMode = "all",
1693
+ activeFieldId,
1694
+ defaultActiveFieldId,
1695
+ onActiveFieldChange,
1696
+ submissionSettingsOptions
1646
1697
  }) {
1647
1698
  const resolvedComponents = { ...DEFAULT_COMPONENTS, ...componentOverrides };
1648
1699
  const components = {
@@ -1665,14 +1716,18 @@ function FormBuilder({
1665
1716
  onChange,
1666
1717
  ...policy === void 0 ? {} : { policy },
1667
1718
  ...idFactory === void 0 ? {} : { idFactory },
1668
- ...factories === void 0 ? {} : { factories }
1719
+ ...factories === void 0 ? {} : { factories },
1720
+ fieldEditorMode,
1721
+ ...activeFieldId === void 0 ? {} : { activeFieldId },
1722
+ ...defaultActiveFieldId === void 0 ? {} : { defaultActiveFieldId },
1723
+ ...onActiveFieldChange === void 0 ? {} : { onActiveFieldChange }
1669
1724
  });
1670
- const [newPageQuestionId, setNewPageQuestionId] = useState("");
1671
- const [newLocale, setNewLocale] = useState("");
1672
- const [editingLocale, setEditingLocale] = useState("");
1673
- const [isTranslating, setIsTranslating] = useState(false);
1674
- const [translationError, setTranslationError] = useState(null);
1675
- const [translationReport, setTranslationReport] = useState();
1725
+ const [newPageQuestionId, setNewPageQuestionId] = useState2("");
1726
+ const [newLocale, setNewLocale] = useState2("");
1727
+ const [editingLocale, setEditingLocale] = useState2("");
1728
+ const [isTranslating, setIsTranslating] = useState2(false);
1729
+ const [translationError, setTranslationError] = useState2(null);
1730
+ const [translationReport, setTranslationReport] = useState2();
1676
1731
  const translate = (key, params = {}) => resolveTranslation(
1677
1732
  key,
1678
1733
  BUILDER_TRANSLATION_ALIASES[key] === void 0 ? [] : [BUILDER_TRANSLATION_ALIASES[key]],
@@ -2005,6 +2060,47 @@ function FormBuilder({
2005
2060
  ] })
2006
2061
  }
2007
2062
  ) }),
2063
+ /* @__PURE__ */ jsx(BuilderSectionGroup, { name: "submissionSettings", children: submissionSettingsOptions?.enabled ? /* @__PURE__ */ jsxs(
2064
+ Section,
2065
+ {
2066
+ className: builderClass("form-engine-builder__submission-settings"),
2067
+ title: translate("builder.submissionSettings"),
2068
+ children: [
2069
+ /* @__PURE__ */ jsx(
2070
+ Checkbox,
2071
+ {
2072
+ id: "builder-show-confirmation",
2073
+ label: translate("builder.showConfirmationBeforeSubmit"),
2074
+ checked: schema.submissionSettings?.showConfirmationBeforeSubmit === true,
2075
+ onChange: (checked) => onChange({
2076
+ ...schema,
2077
+ submissionSettings: { ...schema.submissionSettings, showConfirmationBeforeSubmit: checked }
2078
+ })
2079
+ }
2080
+ ),
2081
+ /* @__PURE__ */ jsx(
2082
+ Select,
2083
+ {
2084
+ id: "builder-confirmation-render-mode",
2085
+ label: translate("builder.confirmationRenderMode"),
2086
+ value: schema.submissionSettings?.confirmationRenderMode ?? "inline",
2087
+ options: [
2088
+ { value: "dialog", label: "Dialog" },
2089
+ { value: "inline", label: "Inline" },
2090
+ { value: "replace", label: "Replace" }
2091
+ ],
2092
+ onChange: (value) => {
2093
+ if (value !== "dialog" && value !== "inline" && value !== "replace") return;
2094
+ onChange({
2095
+ ...schema,
2096
+ submissionSettings: { ...schema.submissionSettings, confirmationRenderMode: value }
2097
+ });
2098
+ }
2099
+ }
2100
+ )
2101
+ ]
2102
+ }
2103
+ ) : null }),
2008
2104
  resolvedSectionOrder === void 0 ? null : /* @__PURE__ */ jsx(BuilderSectionGroup, { name: "completionMessage", children: /* @__PURE__ */ jsx(Section, { headingId: "builder-completion-message-heading", title: translate("builder.completionMessage"), children: /* @__PURE__ */ jsx(
2009
2105
  TextInput,
2010
2106
  {
@@ -2403,9 +2499,25 @@ function FormBuilder({
2403
2499
  ) : null }),
2404
2500
  /* @__PURE__ */ jsx(BuilderSectionGroup, { name: "questions", children: /* @__PURE__ */ jsx("div", { className: builderClass("form-engine-builder__list"), children: schema.fields.map((field, index) => {
2405
2501
  const controls = resolveFieldEditorControls(fieldEditorControls);
2502
+ const editorState = headless.getFieldEditorProps?.(field.id) ?? {
2503
+ isActive: true,
2504
+ isVisible: true,
2505
+ onSelect: () => void 0
2506
+ };
2406
2507
  const condition = field.displayCondition;
2407
2508
  const source = condition === void 0 ? void 0 : schema.fields.find((item) => item.id === condition.questionId);
2408
2509
  const availableSources = schema.fields.slice(0, index);
2510
+ if (!editorState.isVisible) {
2511
+ return /* @__PURE__ */ jsx(
2512
+ "div",
2513
+ {
2514
+ className: builderClass("form-engine-builder__question-preview"),
2515
+ "data-field-id": field.id,
2516
+ children: /* @__PURE__ */ jsx("button", { type: "button", onClick: editorState.onSelect, children: field.title })
2517
+ },
2518
+ field.id
2519
+ );
2520
+ }
2409
2521
  if (FieldEditorSlot !== void 0) {
2410
2522
  return /* @__PURE__ */ jsx(
2411
2523
  FieldEditorSlot,
@@ -2901,7 +3013,7 @@ import {
2901
3013
  validateAnswers,
2902
3014
  validatePageAnswers
2903
3015
  } from "@form-engine-ts/core";
2904
- import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useRef, useState as useState2 } from "react";
3016
+ import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useRef, useState as useState3 } from "react";
2905
3017
 
2906
3018
  // src/types.ts
2907
3019
  var FormSubmissionError = class extends Error {
@@ -2948,11 +3060,11 @@ function FormProvider({
2948
3060
  assertValidFormSchema(localized);
2949
3061
  return localized;
2950
3062
  }, [locale, schema]);
2951
- const [values, setValues] = useState2(() => ({ ...initialValues }));
2952
- const [errors, setErrors] = useState2({});
2953
- const [submitStatus, setSubmitStatus] = useState2("idle");
2954
- const [submitError, setSubmitError] = useState2(null);
2955
- const [validationPageIndex, setValidationPageIndex] = useState2(null);
3063
+ const [values, setValues] = useState3(() => ({ ...initialValues }));
3064
+ const [errors, setErrors] = useState3({});
3065
+ const [submitStatus, setSubmitStatus] = useState3("idle");
3066
+ const [submitError, setSubmitError] = useState3(null);
3067
+ const [validationPageIndex, setValidationPageIndex] = useState3(null);
2956
3068
  const submissionInFlight = useRef(false);
2957
3069
  const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
2958
3070
  const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
@@ -3121,8 +3233,237 @@ function useField(fieldId) {
3121
3233
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
3122
3234
  }
3123
3235
 
3236
+ // src/hooks/useTranslationWorkspace.ts
3237
+ import {
3238
+ collectSchemaLocales as collectSchemaLocales2,
3239
+ collectTranslationSlots,
3240
+ computeSourceTextHash,
3241
+ populateSchemaTranslations as populateSchemaTranslations2
3242
+ } from "@form-engine-ts/core";
3243
+ import { useCallback as useCallback3, useMemo as useMemo3, useState as useState4 } from "react";
3244
+ function updateTranslationMap(translations, locale, property, text) {
3245
+ if (property === "label") return translations ?? {};
3246
+ const current = translations?.[locale];
3247
+ const next = { ...current, [property]: text };
3248
+ return { ...translations, [locale]: next };
3249
+ }
3250
+ function asAsyncAdapter(adapter) {
3251
+ if ("translateBatch" in adapter) return adapter;
3252
+ return {
3253
+ translateText: async (text, locale, sourceLocale) => adapter.translate(text, locale, sourceLocale === void 0 ? void 0 : { sourceLocale }) ?? text,
3254
+ translateBatch: async (texts, locale, sourceLocale) => texts.map(
3255
+ (text) => adapter.translate(text, locale, sourceLocale === void 0 ? void 0 : { sourceLocale }) ?? text
3256
+ )
3257
+ };
3258
+ }
3259
+ function manualMetadata(sourceText, sourceLocale) {
3260
+ return {
3261
+ sourceLocale,
3262
+ sourceTextHash: computeSourceTextHash(sourceText),
3263
+ translationSource: "manual",
3264
+ editedAt: (/* @__PURE__ */ new Date()).toISOString()
3265
+ };
3266
+ }
3267
+ function setNodeMetadata(node, slot, text, sourceLocale) {
3268
+ const localeMetadata = node.translationMetadata?.[slot.locale];
3269
+ const nextMetadata = text.trim().length === 0 ? Object.fromEntries(Object.entries(localeMetadata ?? {}).filter(([key]) => key !== slot.property)) : { ...localeMetadata, [slot.property]: manualMetadata(slot.sourceText, sourceLocale) };
3270
+ return { ...node, translationMetadata: { ...node.translationMetadata, [slot.locale]: nextMetadata } };
3271
+ }
3272
+ function updateSchemaTranslation(schema, slot, text, sourceLocale) {
3273
+ if (slot.kind === "form") {
3274
+ return setNodeMetadata(
3275
+ { ...schema, translations: updateTranslationMap(schema.translations, slot.locale, slot.property, text) },
3276
+ slot,
3277
+ text,
3278
+ sourceLocale
3279
+ );
3280
+ }
3281
+ if (slot.kind === "field") {
3282
+ return {
3283
+ ...schema,
3284
+ fields: schema.fields.map((field) => {
3285
+ if (field.id !== slot.nodeId) return field;
3286
+ const translations = updateTranslationMap(field.translations, slot.locale, slot.property, text);
3287
+ return setNodeMetadata({ ...field, translations }, slot, text, sourceLocale);
3288
+ })
3289
+ };
3290
+ }
3291
+ if (slot.kind === "option") {
3292
+ return {
3293
+ ...schema,
3294
+ fields: schema.fields.map((field) => {
3295
+ if (!("options" in field)) return field;
3296
+ return {
3297
+ ...field,
3298
+ options: field.options.map((option) => {
3299
+ if (option.id !== slot.nodeId) return option;
3300
+ const translations = text.trim().length === 0 ? Object.fromEntries(
3301
+ Object.entries(option.translations ?? {}).filter(([locale]) => locale !== slot.locale)
3302
+ ) : { ...option.translations, [slot.locale]: text };
3303
+ return setNodeMetadata({ ...option, translations }, slot, text, sourceLocale);
3304
+ })
3305
+ };
3306
+ })
3307
+ };
3308
+ }
3309
+ return {
3310
+ ...schema,
3311
+ ...schema.pages === void 0 ? {} : {
3312
+ pages: schema.pages.map((page) => {
3313
+ if (page.id !== slot.nodeId) return page;
3314
+ const translations = updateTranslationMap(page.translations, slot.locale, slot.property, text);
3315
+ return setNodeMetadata({ ...page, translations }, slot, text, sourceLocale);
3316
+ })
3317
+ }
3318
+ };
3319
+ }
3320
+ function useTranslationWorkspace({
3321
+ schema,
3322
+ onChange,
3323
+ sourceLocale = schema.defaultLocale ?? "en",
3324
+ targetLocale,
3325
+ translationAdapter,
3326
+ readOnly = false
3327
+ }) {
3328
+ const [draftSchema, setDraftSchema] = useState4(schema);
3329
+ const [selectedLocale, setSelectedLocale] = useState4(targetLocale ?? "");
3330
+ const [isTranslating, setIsTranslating] = useState4(false);
3331
+ const [error, setError] = useState4();
3332
+ const currentSchema = onChange === void 0 ? draftSchema : schema;
3333
+ const targetLocales = useMemo3(() => {
3334
+ const locales = collectSchemaLocales2(currentSchema).allUniqueLocales;
3335
+ return [.../* @__PURE__ */ new Set([...currentSchema.supportedLocales ?? [], ...locales])].filter(
3336
+ (locale) => locale !== sourceLocale
3337
+ );
3338
+ }, [currentSchema, sourceLocale]);
3339
+ const activeLocale = selectedLocale.length > 0 ? selectedLocale : targetLocales[0] ?? "";
3340
+ const slots = useMemo3(
3341
+ () => activeLocale.length === 0 ? [] : collectTranslationSlots(currentSchema, activeLocale),
3342
+ [activeLocale, currentSchema]
3343
+ );
3344
+ const summary = useMemo3(() => {
3345
+ const counts = {
3346
+ missing: 0,
3347
+ translated: 0,
3348
+ stale: 0,
3349
+ manual: 0,
3350
+ "manual-stale": 0
3351
+ };
3352
+ for (const slot of slots) counts[slot.status ?? "missing"] += 1;
3353
+ const complete = counts.translated + counts.manual;
3354
+ return {
3355
+ totalSlots: slots.length,
3356
+ translatedCount: complete,
3357
+ missingCount: counts.missing,
3358
+ staleCount: counts.stale + counts["manual-stale"],
3359
+ manualCount: counts.manual + counts["manual-stale"],
3360
+ completionPercentage: slots.length === 0 ? 100 : Math.round(complete / slots.length * 100)
3361
+ };
3362
+ }, [slots]);
3363
+ const commit = useCallback3(
3364
+ (next) => {
3365
+ setDraftSchema(next);
3366
+ onChange?.(next);
3367
+ },
3368
+ [onChange]
3369
+ );
3370
+ const setTranslation = useCallback3(
3371
+ (slot, text) => {
3372
+ if (readOnly) return;
3373
+ commit(updateSchemaTranslation(currentSchema, slot, text, sourceLocale));
3374
+ },
3375
+ [commit, currentSchema, readOnly, sourceLocale]
3376
+ );
3377
+ const addLocale = useCallback3(
3378
+ (locale) => {
3379
+ if (readOnly || locale.trim().length === 0) return;
3380
+ const normalized = locale.trim();
3381
+ commit({
3382
+ ...currentSchema,
3383
+ supportedLocales: [.../* @__PURE__ */ new Set([...currentSchema.supportedLocales ?? [], normalized])]
3384
+ });
3385
+ setSelectedLocale(normalized);
3386
+ },
3387
+ [commit, currentSchema, readOnly]
3388
+ );
3389
+ const removeLocale = useCallback3(
3390
+ (locale) => {
3391
+ if (readOnly || locale === sourceLocale) return;
3392
+ commit({
3393
+ ...currentSchema,
3394
+ supportedLocales: (currentSchema.supportedLocales ?? []).filter((candidate) => candidate !== locale)
3395
+ });
3396
+ if (activeLocale === locale) setSelectedLocale("");
3397
+ },
3398
+ [activeLocale, commit, currentSchema, readOnly, sourceLocale]
3399
+ );
3400
+ const translateAll = useCallback3(
3401
+ async (options = {}) => {
3402
+ if (translationAdapter === void 0) throw new Error("A translation adapter is required.");
3403
+ if (activeLocale.length === 0) throw new Error("A target locale is required.");
3404
+ setIsTranslating(true);
3405
+ setError(void 0);
3406
+ try {
3407
+ const populated = await populateSchemaTranslations2(
3408
+ currentSchema,
3409
+ [activeLocale],
3410
+ asAsyncAdapter(translationAdapter),
3411
+ {
3412
+ overwrite: "stale-and-missing",
3413
+ preserveManualTranslations: true,
3414
+ ...options
3415
+ }
3416
+ );
3417
+ commit(populated.schema);
3418
+ return populated.report;
3419
+ } catch (cause) {
3420
+ const message = cause instanceof Error ? cause.message : String(cause);
3421
+ setError(message);
3422
+ throw cause;
3423
+ } finally {
3424
+ setIsTranslating(false);
3425
+ }
3426
+ },
3427
+ [activeLocale, commit, currentSchema, translationAdapter]
3428
+ );
3429
+ const translateSlot = useCallback3(
3430
+ async (slot) => {
3431
+ if (translationAdapter === void 0) throw new Error("A translation adapter is required.");
3432
+ if (readOnly) return;
3433
+ setIsTranslating(true);
3434
+ setError(void 0);
3435
+ try {
3436
+ const text = await asAsyncAdapter(translationAdapter).translateText(slot.sourceText, slot.locale, sourceLocale);
3437
+ commit(updateSchemaTranslation(currentSchema, slot, text, sourceLocale));
3438
+ } catch (cause) {
3439
+ const message = cause instanceof Error ? cause.message : String(cause);
3440
+ setError(message);
3441
+ throw cause;
3442
+ } finally {
3443
+ setIsTranslating(false);
3444
+ }
3445
+ },
3446
+ [commit, currentSchema, readOnly, sourceLocale, translationAdapter]
3447
+ );
3448
+ return {
3449
+ sourceLocale,
3450
+ targetLocale: activeLocale,
3451
+ targetLocales,
3452
+ setTargetLocale: setSelectedLocale,
3453
+ slots,
3454
+ summary,
3455
+ addLocale,
3456
+ removeLocale,
3457
+ setTranslation,
3458
+ translateAll,
3459
+ translateSlot,
3460
+ isTranslating,
3461
+ ...error === void 0 ? {} : { error }
3462
+ };
3463
+ }
3464
+
3124
3465
  // src/receipt.ts
3125
- import { useEffect as useEffect2, useMemo as useMemo3, useState as useState3 } from "react";
3466
+ import { useEffect as useEffect2, useMemo as useMemo4, useState as useState5 } from "react";
3126
3467
  function submissionReceiptQueryKey(formId, formVersion) {
3127
3468
  return `${formId}:v${formVersion}`;
3128
3469
  }
@@ -3193,14 +3534,14 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
3193
3534
  }
3194
3535
  function useSubmissionReceipts(store, queries) {
3195
3536
  const querySignature = JSON.stringify(queries.map(({ formId, formVersion }) => [formId, formVersion]));
3196
- const stableQueries = useMemo3(() => {
3537
+ const stableQueries = useMemo4(() => {
3197
3538
  const parsed = JSON.parse(querySignature);
3198
3539
  if (!Array.isArray(parsed)) return [];
3199
3540
  return parsed.flatMap(
3200
3541
  (entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number" && Number.isSafeInteger(entry[1]) ? [{ formId: entry[0], formVersion: entry[1] }] : []
3201
3542
  );
3202
3543
  }, [querySignature]);
3203
- const [state, setState] = useState3({
3544
+ const [state, setState] = useState5({
3204
3545
  receipts: /* @__PURE__ */ new Map(),
3205
3546
  isLoading: stableQueries.length > 0,
3206
3547
  error: null
@@ -3245,14 +3586,22 @@ import {
3245
3586
  } from "@form-engine-ts/core";
3246
3587
  import {
3247
3588
  Fragment as Fragment2,
3248
- useCallback as useCallback3,
3589
+ useCallback as useCallback4,
3249
3590
  useEffect as useEffect3,
3250
3591
  useId,
3251
- useMemo as useMemo4,
3592
+ useMemo as useMemo5,
3252
3593
  useRef as useRef2,
3253
- useState as useState4
3594
+ useState as useState6
3254
3595
  } from "react";
3255
3596
  import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
3597
+ var isChoiceFieldType = (type) => type === "radio" || type === "checkbox" || type === "multi-select" || type === "select";
3598
+ function resolveChoiceFieldLayout(type, appearance, groupedChoiceFieldsLegacy = false) {
3599
+ if (groupedChoiceFieldsLegacy) return "grouped";
3600
+ const config = appearance?.choiceField;
3601
+ if (config === void 0) return "default";
3602
+ if (typeof config === "string") return config;
3603
+ return config[type] ?? "default";
3604
+ }
3256
3605
  function describedBy(field, error, helpId, errorId) {
3257
3606
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
3258
3607
  Boolean
@@ -3280,51 +3629,98 @@ function GroupedChoiceDescription({ props }) {
3280
3629
  function GroupedChoiceError({ props }) {
3281
3630
  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
3631
  }
3632
+ function ChoiceGroupFrame({
3633
+ props,
3634
+ children,
3635
+ className,
3636
+ slotProps,
3637
+ renderChoiceGroup,
3638
+ disabled,
3639
+ readOnly
3640
+ }) {
3641
+ const { field, error, translate } = props;
3642
+ const groupError = error === void 0 ? void 0 : { ...error, message: translate(error.messageKey, error.params) };
3643
+ const groupProps = {
3644
+ field,
3645
+ title: field.title,
3646
+ ...field.description === void 0 ? {} : { description: field.description },
3647
+ ...field.required === void 0 ? {} : { required: field.required },
3648
+ ...groupError === void 0 ? {} : { error: groupError },
3649
+ ...disabled === void 0 ? {} : { disabled },
3650
+ ...readOnly === void 0 ? {} : { readOnly },
3651
+ children,
3652
+ className
3653
+ };
3654
+ if (renderChoiceGroup !== void 0) return /* @__PURE__ */ jsx3(Fragment3, { children: renderChoiceGroup(groupProps) });
3655
+ return (
3656
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: The grouped fieldset exposes the required state for the complete choice question.
3657
+ /* @__PURE__ */ jsxs2(
3658
+ "fieldset",
3659
+ {
3660
+ className,
3661
+ "data-field-id": field.id,
3662
+ disabled,
3663
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3664
+ "aria-invalid": Boolean(error),
3665
+ "aria-required": field.required,
3666
+ style: slotProps?.style,
3667
+ children: [
3668
+ /* @__PURE__ */ jsxs2("legend", { className: "fe-choice-legend", children: [
3669
+ field.title,
3670
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required, className: "fe-required-badge" })
3671
+ ] }),
3672
+ /* @__PURE__ */ jsx3(GroupedChoiceDescription, { props }),
3673
+ children,
3674
+ /* @__PURE__ */ jsx3(GroupedChoiceError, { props })
3675
+ ]
3676
+ }
3677
+ )
3678
+ );
3679
+ }
3283
3680
  function DefaultField({
3284
3681
  groupedChoiceFields,
3682
+ appearance,
3683
+ choiceGroupSlotProps,
3684
+ renderChoiceGroup,
3685
+ disabled,
3686
+ readOnly,
3285
3687
  ...props
3286
3688
  }) {
3287
3689
  const { field, value, setValue, inputId, error, translate } = props;
3690
+ const isGroupedChoiceField = isChoiceFieldType(field.type) && resolveChoiceFieldLayout(field.type, appearance, groupedChoiceFields) === "grouped";
3691
+ const choiceGroupClassName = ["fe-choice-group", `fe-field--${field.type}`, choiceGroupSlotProps?.className].filter((item) => item !== void 0 && item.length > 0).join(" ");
3288
3692
  const ariaProps = {
3289
3693
  "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
3290
3694
  "aria-invalid": error === void 0 ? void 0 : true
3291
3695
  };
3292
3696
  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
- )
3697
+ if (isGroupedChoiceField) {
3698
+ return /* @__PURE__ */ jsx3(
3699
+ ChoiceGroupFrame,
3700
+ {
3701
+ props,
3702
+ className: choiceGroupClassName,
3703
+ slotProps: choiceGroupSlotProps,
3704
+ renderChoiceGroup,
3705
+ disabled,
3706
+ readOnly,
3707
+ children: /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: /* @__PURE__ */ jsxs2("label", { className: "fe-choice-option", htmlFor: inputId, children: [
3708
+ /* @__PURE__ */ jsx3(
3709
+ "input",
3710
+ {
3711
+ id: inputId,
3712
+ name: field.id,
3713
+ type: "checkbox",
3714
+ checked: value === true,
3715
+ "aria-label": field.title,
3716
+ disabled,
3717
+ readOnly,
3718
+ onChange: (event) => setValue(event.currentTarget.checked)
3719
+ }
3720
+ ),
3721
+ /* @__PURE__ */ jsx3("span", { children: field.title })
3722
+ ] }) })
3723
+ }
3328
3724
  );
3329
3725
  }
3330
3726
  return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
@@ -3350,56 +3746,55 @@ function DefaultField({
3350
3746
  }
3351
3747
  if (field.type === "radio" || field.type === "multi-select") {
3352
3748
  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
- )
3749
+ if (isGroupedChoiceField) {
3750
+ const isRadio = field.type === "radio";
3751
+ return /* @__PURE__ */ jsx3(
3752
+ ChoiceGroupFrame,
3753
+ {
3754
+ props,
3755
+ className: choiceGroupClassName,
3756
+ slotProps: choiceGroupSlotProps,
3757
+ renderChoiceGroup,
3758
+ disabled,
3759
+ readOnly,
3760
+ children: /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: field.options.map((option, index) => {
3761
+ const optionId = `${inputId}-${index}`;
3762
+ const checked = isRadio ? value === option.id : selected.includes(option.id);
3763
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-choice-option", htmlFor: optionId, children: [
3764
+ /* @__PURE__ */ jsx3(
3765
+ "input",
3766
+ {
3767
+ id: optionId,
3768
+ name: field.id,
3769
+ type: isRadio ? "radio" : "checkbox",
3770
+ value: option.id,
3771
+ checked,
3772
+ disabled,
3773
+ readOnly,
3774
+ onKeyDown: isRadio ? (event) => {
3775
+ if (event.key !== "ArrowDown" && event.key !== "ArrowRight" && event.key !== "ArrowUp" && event.key !== "ArrowLeft")
3776
+ return;
3777
+ event.preventDefault();
3778
+ const offset = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
3779
+ const nextIndex = (index + offset + field.options.length) % field.options.length;
3780
+ const nextOption = field.options[nextIndex];
3781
+ if (nextOption === void 0) return;
3782
+ setValue(nextOption.id);
3783
+ document.getElementById(`${inputId}-${nextIndex}`)?.focus();
3784
+ } : void 0,
3785
+ onChange: (event) => {
3786
+ if (isRadio) setValue(option.id);
3787
+ else
3788
+ setValue(
3789
+ event.currentTarget.checked ? [...selected, option.id] : selected.filter((item) => item !== option.id)
3790
+ );
3791
+ }
3792
+ }
3793
+ ),
3794
+ /* @__PURE__ */ jsx3("span", { children: option.label })
3795
+ ] }, option.id);
3796
+ }) })
3797
+ }
3403
3798
  );
3404
3799
  }
3405
3800
  if (field.type === "radio") {
@@ -3536,6 +3931,7 @@ function DefaultField({
3536
3931
  ...ariaProps,
3537
3932
  id: inputId,
3538
3933
  name: field.id,
3934
+ disabled,
3539
3935
  value: typeof value === "string" ? value : "",
3540
3936
  onChange: (event) => setValue(event.currentTarget.value || void 0),
3541
3937
  children: [
@@ -3559,6 +3955,20 @@ function DefaultField({
3559
3955
  }
3560
3956
  );
3561
3957
  }
3958
+ if (isGroupedChoiceField) {
3959
+ return /* @__PURE__ */ jsx3(
3960
+ ChoiceGroupFrame,
3961
+ {
3962
+ props,
3963
+ className: choiceGroupClassName,
3964
+ slotProps: choiceGroupSlotProps,
3965
+ renderChoiceGroup,
3966
+ disabled,
3967
+ readOnly,
3968
+ children: /* @__PURE__ */ jsx3("div", { className: "fe-choice-options", children: control })
3969
+ }
3970
+ );
3971
+ }
3562
3972
  return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
3563
3973
  label,
3564
3974
  control,
@@ -3679,6 +4089,7 @@ function ContextFormRenderer({
3679
4089
  onDraftSave,
3680
4090
  successRenderMode = "append",
3681
4091
  appearance,
4092
+ slotProps,
3682
4093
  groupedChoiceFields = false,
3683
4094
  submissionConfirmation,
3684
4095
  submissionConfirmationRenderMode,
@@ -3692,42 +4103,41 @@ function ContextFormRenderer({
3692
4103
  slots = {}
3693
4104
  }) {
3694
4105
  const form = useForm();
3695
- const isGroupedMode = appearance?.choiceField === "grouped" || groupedChoiceFields;
3696
4106
  const prefix = useId().replace(/:/g, "");
3697
4107
  const formRef = useRef2(null);
3698
4108
  const loadedDraftKey = useRef2(null);
3699
- const [draftRestored, setDraftRestored] = useState4(false);
3700
- const [currentPageIndex, setCurrentPageIndex] = useState4(0);
3701
- const [focusFieldId, setFocusFieldId] = useState4(null);
3702
- const [confirmation, setConfirmation] = useState4(null);
3703
- const [guardMessage, setGuardMessage] = useState4(null);
3704
- const [guardsPending, setGuardsPending] = useState4(false);
3705
- const [receipt, setReceipt] = useState4(null);
3706
- const [completionData, setCompletionData] = useState4(null);
3707
- const [receiptLoaded, setReceiptLoaded] = useState4(receiptStore === void 0);
4109
+ const [draftRestored, setDraftRestored] = useState6(false);
4110
+ const [currentPageIndex, setCurrentPageIndex] = useState6(0);
4111
+ const [focusFieldId, setFocusFieldId] = useState6(null);
4112
+ const [confirmation, setConfirmation] = useState6(null);
4113
+ const [guardMessage, setGuardMessage] = useState6(null);
4114
+ const [guardsPending, setGuardsPending] = useState6(false);
4115
+ const [receipt, setReceipt] = useState6(null);
4116
+ const [completionData, setCompletionData] = useState6(null);
4117
+ const [receiptLoaded, setReceiptLoaded] = useState6(receiptStore === void 0);
3708
4118
  const rendererSubmissionInFlight = useRef2(false);
3709
4119
  const fallbackAttemptId = useRef2(null);
3710
4120
  const completionRef = useRef2(null);
3711
4121
  const confirmationRef = useRef2(null);
3712
4122
  const pages = form.schema.pages;
3713
- const visiblePageIndexes = useMemo4(
4123
+ const visiblePageIndexes = useMemo5(
3714
4124
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
3715
4125
  [form.pageVisibility, pages]
3716
4126
  );
3717
4127
  const activePage = pages?.[currentPageIndex];
3718
4128
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
3719
4129
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
3720
- const visibleValues = useMemo4(() => selectVisibleAnswers2(form.schema, form.values), [form.schema, form.values]);
3721
- const visibleItems = useMemo4(
4130
+ const visibleValues = useMemo5(() => selectVisibleAnswers2(form.schema, form.values), [form.schema, form.values]);
4131
+ const visibleItems = useMemo5(
3722
4132
  () => buildSubmittedItems(form.schema, form.values, form.visibility, (key) => form.translate(key), false),
3723
4133
  [form.schema, form.translate, form.values, form.visibility]
3724
4134
  );
3725
- const confirmationRenderMode = submissionConfirmation?.renderMode ?? submissionConfirmationRenderMode ?? "inline";
3726
- const confirmationEnabled = submissionConfirmation?.enabled === true;
4135
+ const confirmationRenderMode = submissionConfirmation?.renderMode ?? submissionConfirmationRenderMode ?? form.schema.submissionSettings?.confirmationRenderMode ?? "inline";
4136
+ const confirmationEnabled = submissionConfirmation?.enabled ?? form.schema.submissionSettings?.showConfirmationBeforeSubmit ?? false;
3727
4137
  const submitState = confirmation === null && !guardsPending ? form.submitStatus : "confirming";
3728
4138
  const interactionLocked = submitState === "confirming" || submitState === "submitting";
3729
4139
  const isReplaceMode = successRenderMode === "replace" || hideFormOnSuccess;
3730
- const resolveMessage = useCallback3(
4140
+ const resolveMessage = useCallback4(
3731
4141
  (key, fallback) => {
3732
4142
  const defaultText = fallback ?? DEFAULT_RENDERER_MESSAGES[form.locale.toLowerCase().startsWith("ja") ? "ja" : "en"][key] ?? key;
3733
4143
  const configured = messages[key];
@@ -3735,11 +4145,11 @@ function ContextFormRenderer({
3735
4145
  },
3736
4146
  [form.locale, messageResolver, messages]
3737
4147
  );
3738
- const fieldTranslate = useCallback3(
4148
+ const fieldTranslate = useCallback4(
3739
4149
  (key, params) => key === "validation.required" && (messages.requiredField !== void 0 || messageResolver !== void 0) ? resolveMessage("requiredField") : form.translate(key, params),
3740
4150
  [form.translate, messageResolver, messages.requiredField, resolveMessage]
3741
4151
  );
3742
- const focusSubmitButton = useCallback3(() => {
4152
+ const focusSubmitButton = useCallback4(() => {
3743
4153
  const button = formRef.current?.querySelector(".fe-submit, button[type='submit'], button");
3744
4154
  button?.focus();
3745
4155
  }, []);
@@ -4199,7 +4609,18 @@ function ContextFormRenderer({
4199
4609
  }) }, field.id);
4200
4610
  }
4201
4611
  const Component = components[field.type];
4202
- return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props, groupedChoiceFields: isGroupedMode }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
4612
+ return Component === void 0 ? /* @__PURE__ */ jsx3(
4613
+ DefaultField,
4614
+ {
4615
+ ...props,
4616
+ groupedChoiceFields,
4617
+ appearance,
4618
+ choiceGroupSlotProps: slotProps?.choiceGroup,
4619
+ renderChoiceGroup: slots.renderChoiceGroup,
4620
+ disabled: interactionLocked
4621
+ },
4622
+ field.id
4623
+ ) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
4203
4624
  });
4204
4625
  const fieldClassName = `fe-fields${fieldsClassName === void 0 ? "" : ` ${fieldsClassName}`}`;
4205
4626
  return slots.renderFields?.({ children: fieldChildren, className: fieldClassName }) ?? /* @__PURE__ */ jsx3("div", { className: fieldClassName, children: fieldChildren });
@@ -4338,6 +4759,7 @@ export {
4338
4759
  createLocalStorageSubmissionAttemptStore,
4339
4760
  createLocalStorageSubmissionReceiptStore,
4340
4761
  isTranslationUnresolved,
4762
+ resolveChoiceFieldLayout,
4341
4763
  resolveFieldEditorControls,
4342
4764
  resolveFieldTypeSelectOptions,
4343
4765
  resolveInitialFieldType,
@@ -4346,5 +4768,6 @@ export {
4346
4768
  useField,
4347
4769
  useForm,
4348
4770
  useFormBuilder,
4349
- useSubmissionReceipts
4771
+ useSubmissionReceipts,
4772
+ useTranslationWorkspace
4350
4773
  };