@form-engine-ts/react 1.0.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 ADDED
@@ -0,0 +1,816 @@
1
+ // src/builder.tsx
2
+ import { sanitizeSchema } from "@form-engine-ts/core";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ var FIELD_TYPES = [
5
+ "text",
6
+ "textarea",
7
+ "number",
8
+ "rating",
9
+ "select",
10
+ "multi-select",
11
+ "checkbox",
12
+ "radio"
13
+ ];
14
+ var BUILDER_DEFAULTS = {
15
+ "builder.formBuilder": "Form builder",
16
+ "builder.moveUp": "Move {{title}} up",
17
+ "builder.moveDown": "Move {{title}} down",
18
+ "builder.delete": "Delete {{title}}",
19
+ "builder.deleteAction": "Delete",
20
+ "builder.questionTitle": "\u8CEA\u554F\u6587 / Question Title",
21
+ "builder.questionTitlePlaceholder": "Example: Tell us what we could improve",
22
+ "builder.newQuestionTitle": "New question",
23
+ "builder.type": "Type",
24
+ "builder.required": "Required",
25
+ "builder.minimum": "Minimum",
26
+ "builder.maximum": "Maximum",
27
+ "builder.options": "Options",
28
+ "builder.optionLabel": "\u9078\u629E\u80A2 / Option Label {{index}}",
29
+ "builder.optionLabelPlaceholder": "Example: Very satisfied",
30
+ "builder.newOptionLabel": "Option {{index}}",
31
+ "builder.remove": "Remove",
32
+ "builder.addOption": "Add option",
33
+ "builder.displayCondition": "Display condition",
34
+ "builder.alwaysVisible": "Always visible",
35
+ "builder.conditionOperator": "Condition operator",
36
+ "builder.conditionValue": "Condition value",
37
+ "builder.conditionTrue": "true",
38
+ "builder.conditionFalse": "false",
39
+ "builder.addQuestion": "Add question",
40
+ "builder.fieldType.text": "Text",
41
+ "builder.fieldType.textarea": "Textarea",
42
+ "builder.fieldType.number": "Number",
43
+ "builder.fieldType.rating": "Rating",
44
+ "builder.fieldType.select": "Select",
45
+ "builder.fieldType.multi-select": "Multi-select",
46
+ "builder.fieldType.checkbox": "Checkbox",
47
+ "builder.fieldType.radio": "Radio",
48
+ "builder.operator.equals": "equals",
49
+ "builder.operator.not_equals": "does not equal",
50
+ "builder.operator.contains": "contains",
51
+ "builder.operator.not_empty": "is not empty"
52
+ };
53
+ function interpolate(template, params) {
54
+ return template.replace(
55
+ /\{\{(\w+)\}\}/g,
56
+ (token, name) => Object.hasOwn(params, name) ? String(params[name]) : token
57
+ );
58
+ }
59
+ function fieldTypeKey(type) {
60
+ return `builder.fieldType.${type}`;
61
+ }
62
+ function operatorKey(operator) {
63
+ return `builder.operator.${operator}`;
64
+ }
65
+ function createUniqueId(prefix, existingIds) {
66
+ let id;
67
+ do {
68
+ id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
69
+ } while (existingIds.has(id));
70
+ return id;
71
+ }
72
+ function baseField(field, type) {
73
+ return {
74
+ id: field.id,
75
+ type,
76
+ title: field.title,
77
+ ...field.description === void 0 ? {} : { description: field.description },
78
+ ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
79
+ required: field.required,
80
+ ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition }
81
+ };
82
+ }
83
+ function normalizeField(field, type, newOptionLabel) {
84
+ const base = baseField(field, type);
85
+ if (type === "text" || type === "textarea") return { ...base, type };
86
+ if (type === "number") return { ...base, type };
87
+ if (type === "rating") return { ...base, type, min: 1, max: 5 };
88
+ if (type === "checkbox") return { ...base, type };
89
+ const options = "options" in field && field.options.length > 0 ? field.options : [{ id: createUniqueId("opt", /* @__PURE__ */ new Set()), label: newOptionLabel }];
90
+ return { ...base, type, options };
91
+ }
92
+ function defaultConditionValue(field) {
93
+ if (field.type === "checkbox") return true;
94
+ if (field.type === "number" || field.type === "rating") return field.min ?? 1;
95
+ if ("options" in field) return field.options[0]?.id ?? "";
96
+ return "";
97
+ }
98
+ function conditionOperators(field) {
99
+ if (field.type === "multi-select") return ["contains", "not_empty"];
100
+ if (field.type === "text" || field.type === "textarea") {
101
+ return ["equals", "not_equals", "contains", "not_empty"];
102
+ }
103
+ return ["equals", "not_equals", "not_empty"];
104
+ }
105
+ function withoutDisplayCondition(field) {
106
+ const { displayCondition: _displayCondition, ...rest } = field;
107
+ return rest;
108
+ }
109
+ function sanitizeBuilderSchema(schema) {
110
+ const sanitized = sanitizeSchema(schema);
111
+ const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
112
+ return {
113
+ ...sanitized,
114
+ fields: sanitized.fields.map((field, index) => {
115
+ const sourceId = field.displayCondition?.questionId;
116
+ if (sourceId === void 0) return field;
117
+ const sourceIndex = indexById.get(sourceId);
118
+ return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
119
+ })
120
+ };
121
+ }
122
+ function conditionWithValue(questionId, operator, value) {
123
+ return operator === "not_empty" ? { questionId, operator } : { questionId, operator, value };
124
+ }
125
+ function ConditionValueEditor({
126
+ source,
127
+ condition,
128
+ onChange,
129
+ translate
130
+ }) {
131
+ if (condition.operator === "not_empty") return null;
132
+ const update = (value) => onChange({ ...condition, value });
133
+ if (source.type === "checkbox") {
134
+ return /* @__PURE__ */ jsxs("select", { value: String(condition.value), onChange: (event) => update(event.currentTarget.value === "true"), children: [
135
+ /* @__PURE__ */ jsx("option", { value: "true", children: translate("builder.conditionTrue") }),
136
+ /* @__PURE__ */ jsx("option", { value: "false", children: translate("builder.conditionFalse") })
137
+ ] });
138
+ }
139
+ if (source.type === "number" || source.type === "rating") {
140
+ return /* @__PURE__ */ jsx(
141
+ "input",
142
+ {
143
+ "aria-label": translate("builder.conditionValue"),
144
+ type: "number",
145
+ value: typeof condition.value === "number" ? condition.value : "",
146
+ onChange: (event) => update(event.currentTarget.value === "" ? 0 : event.currentTarget.valueAsNumber)
147
+ }
148
+ );
149
+ }
150
+ if ("options" in source) {
151
+ return /* @__PURE__ */ jsx("select", { value: String(condition.value ?? ""), onChange: (event) => update(event.currentTarget.value), children: source.options.map((option) => /* @__PURE__ */ jsx("option", { value: option.id, children: option.label }, option.id)) });
152
+ }
153
+ return /* @__PURE__ */ jsx(
154
+ "input",
155
+ {
156
+ "aria-label": translate("builder.conditionValue"),
157
+ type: "text",
158
+ value: typeof condition.value === "string" ? condition.value : "",
159
+ onChange: (event) => update(event.currentTarget.value)
160
+ }
161
+ );
162
+ }
163
+ function FormBuilder({ schema, onChange, locale = "en", translator }) {
164
+ const translate = (key, params = {}) => {
165
+ const translated = translator?.translate(key, locale, params);
166
+ return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
167
+ };
168
+ const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
169
+ const updateField = (fieldId, update) => {
170
+ emitSchema({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? update(field) : field) });
171
+ };
172
+ const changeType = (fieldId, type) => {
173
+ emitSchema({
174
+ ...schema,
175
+ fields: schema.fields.map((field) => {
176
+ if (field.id === fieldId) {
177
+ return normalizeField(field, type, translate("builder.newOptionLabel", { index: 1 }));
178
+ }
179
+ if (field.displayCondition?.questionId === fieldId) {
180
+ const { displayCondition: _condition, ...withoutCondition } = field;
181
+ return withoutCondition;
182
+ }
183
+ return field;
184
+ })
185
+ });
186
+ };
187
+ const removeField = (fieldId) => {
188
+ if (schema.fields.length === 1) return;
189
+ emitSchema({ ...schema, fields: schema.fields.filter((field) => field.id !== fieldId) });
190
+ };
191
+ const moveField = (index, offset) => {
192
+ const target = index + offset;
193
+ if (target < 0 || target >= schema.fields.length) return;
194
+ const fields = [...schema.fields];
195
+ const current = fields[index];
196
+ const other = fields[target];
197
+ if (current === void 0 || other === void 0) return;
198
+ fields[index] = other;
199
+ fields[target] = current;
200
+ emitSchema({ ...schema, fields });
201
+ };
202
+ const addField = () => {
203
+ const id = createUniqueId("q", new Set(schema.fields.map((field) => field.id)));
204
+ emitSchema({
205
+ ...schema,
206
+ fields: [...schema.fields, { id, type: "text", title: translate("builder.newQuestionTitle"), required: false }]
207
+ });
208
+ };
209
+ return /* @__PURE__ */ jsxs("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
210
+ /* @__PURE__ */ jsx("div", { className: "form-engine-builder__list", children: schema.fields.map((field, index) => {
211
+ const condition = field.displayCondition;
212
+ const source = condition === void 0 ? void 0 : schema.fields.find((item) => item.id === condition.questionId);
213
+ const availableSources = schema.fields.slice(0, index);
214
+ return /* @__PURE__ */ jsxs("fieldset", { className: "form-engine-builder__question", children: [
215
+ /* @__PURE__ */ jsx("legend", { children: field.title }),
216
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__toolbar", children: [
217
+ /* @__PURE__ */ jsx(
218
+ "button",
219
+ {
220
+ type: "button",
221
+ disabled: index === 0,
222
+ onClick: () => moveField(index, -1),
223
+ "aria-label": translate("builder.moveUp", { title: field.title }),
224
+ children: "\u2191"
225
+ }
226
+ ),
227
+ /* @__PURE__ */ jsx(
228
+ "button",
229
+ {
230
+ type: "button",
231
+ disabled: index === schema.fields.length - 1,
232
+ onClick: () => moveField(index, 1),
233
+ "aria-label": translate("builder.moveDown", { title: field.title }),
234
+ children: "\u2193"
235
+ }
236
+ ),
237
+ /* @__PURE__ */ jsx(
238
+ "button",
239
+ {
240
+ type: "button",
241
+ disabled: schema.fields.length === 1,
242
+ onClick: () => removeField(field.id),
243
+ "aria-label": translate("builder.delete", { title: field.title }),
244
+ children: translate("builder.deleteAction")
245
+ }
246
+ )
247
+ ] }),
248
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
249
+ /* @__PURE__ */ jsxs("label", { children: [
250
+ translate("builder.questionTitle"),
251
+ /* @__PURE__ */ jsx(
252
+ "input",
253
+ {
254
+ value: field.title,
255
+ placeholder: translate("builder.questionTitlePlaceholder"),
256
+ onChange: (event) => updateField(field.id, (current) => ({
257
+ ...current,
258
+ title: event.currentTarget.value.trim().length === 0 ? current.title : event.currentTarget.value
259
+ }))
260
+ }
261
+ )
262
+ ] }),
263
+ /* @__PURE__ */ jsxs("label", { children: [
264
+ translate("builder.type"),
265
+ /* @__PURE__ */ jsx(
266
+ "select",
267
+ {
268
+ value: field.type,
269
+ onChange: (event) => changeType(field.id, event.currentTarget.value),
270
+ children: FIELD_TYPES.map((type) => /* @__PURE__ */ jsx("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
271
+ }
272
+ )
273
+ ] }),
274
+ /* @__PURE__ */ jsxs("label", { className: "form-engine-builder__check", children: [
275
+ /* @__PURE__ */ jsx(
276
+ "input",
277
+ {
278
+ type: "checkbox",
279
+ checked: field.required === true,
280
+ onChange: (event) => updateField(field.id, (current) => ({ ...current, required: event.currentTarget.checked }))
281
+ }
282
+ ),
283
+ translate("builder.required")
284
+ ] })
285
+ ] }),
286
+ field.type === "rating" ? /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
287
+ /* @__PURE__ */ jsxs("label", { children: [
288
+ translate("builder.minimum"),
289
+ /* @__PURE__ */ jsx(
290
+ "input",
291
+ {
292
+ type: "number",
293
+ value: field.min ?? 1,
294
+ onChange: (event) => {
295
+ const min = event.currentTarget.valueAsNumber;
296
+ if (!Number.isInteger(min)) return;
297
+ updateField(
298
+ field.id,
299
+ (current) => current.type === "rating" ? { ...current, min, max: Math.max(min, current.max ?? 5) } : current
300
+ );
301
+ }
302
+ }
303
+ )
304
+ ] }),
305
+ /* @__PURE__ */ jsxs("label", { children: [
306
+ translate("builder.maximum"),
307
+ /* @__PURE__ */ jsx(
308
+ "input",
309
+ {
310
+ type: "number",
311
+ value: field.max ?? 5,
312
+ onChange: (event) => {
313
+ const max = event.currentTarget.valueAsNumber;
314
+ if (!Number.isInteger(max)) return;
315
+ updateField(
316
+ field.id,
317
+ (current) => current.type === "rating" ? { ...current, min: Math.min(current.min ?? 1, max), max } : current
318
+ );
319
+ }
320
+ }
321
+ )
322
+ ] })
323
+ ] }) : null,
324
+ "options" in field ? /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__options", children: [
325
+ /* @__PURE__ */ jsx("strong", { children: translate("builder.options") }),
326
+ field.options.map((option, optionIndex) => /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__option", children: [
327
+ /* @__PURE__ */ jsx(
328
+ "input",
329
+ {
330
+ "aria-label": translate("builder.optionLabel", { index: optionIndex + 1 }),
331
+ value: option.label,
332
+ placeholder: translate("builder.optionLabelPlaceholder"),
333
+ onChange: (event) => updateField(field.id, (current) => {
334
+ if (!("options" in current)) return current;
335
+ const label = event.currentTarget.value;
336
+ if (label.trim().length === 0) return current;
337
+ return {
338
+ ...current,
339
+ options: current.options.map(
340
+ (item, itemIndex) => itemIndex === optionIndex ? { ...item, label } : item
341
+ )
342
+ };
343
+ })
344
+ }
345
+ ),
346
+ /* @__PURE__ */ jsx(
347
+ "button",
348
+ {
349
+ type: "button",
350
+ disabled: field.options.length === 1,
351
+ onClick: () => updateField(
352
+ field.id,
353
+ (current) => "options" in current ? {
354
+ ...current,
355
+ options: current.options.filter((_item, itemIndex) => itemIndex !== optionIndex)
356
+ } : current
357
+ ),
358
+ children: translate("builder.remove")
359
+ }
360
+ )
361
+ ] }, option.id)),
362
+ /* @__PURE__ */ jsx(
363
+ "button",
364
+ {
365
+ type: "button",
366
+ onClick: () => updateField(
367
+ field.id,
368
+ (current) => "options" in current ? (() => {
369
+ const id = createUniqueId("opt", new Set(current.options.map((option) => option.id)));
370
+ return {
371
+ ...current,
372
+ options: [
373
+ ...current.options,
374
+ {
375
+ id,
376
+ label: translate("builder.newOptionLabel", { index: current.options.length + 1 })
377
+ }
378
+ ]
379
+ };
380
+ })() : current
381
+ ),
382
+ children: translate("builder.addOption")
383
+ }
384
+ )
385
+ ] }) : null,
386
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__condition", children: [
387
+ /* @__PURE__ */ jsxs("label", { children: [
388
+ translate("builder.displayCondition"),
389
+ /* @__PURE__ */ jsxs(
390
+ "select",
391
+ {
392
+ value: condition?.questionId ?? "",
393
+ onChange: (event) => {
394
+ const selected = schema.fields.find((item) => item.id === event.currentTarget.value);
395
+ updateField(field.id, (current) => {
396
+ if (selected === void 0) {
397
+ const { displayCondition: _condition, ...withoutCondition } = current;
398
+ return withoutCondition;
399
+ }
400
+ const operator = conditionOperators(selected)[0] ?? "not_empty";
401
+ return {
402
+ ...current,
403
+ displayCondition: conditionWithValue(selected.id, operator, defaultConditionValue(selected))
404
+ };
405
+ });
406
+ },
407
+ children: [
408
+ /* @__PURE__ */ jsx("option", { value: "", children: translate("builder.alwaysVisible") }),
409
+ availableSources.map((candidate) => /* @__PURE__ */ jsx("option", { value: candidate.id, children: candidate.title }, candidate.id))
410
+ ]
411
+ }
412
+ )
413
+ ] }),
414
+ condition !== void 0 && source !== void 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
415
+ /* @__PURE__ */ jsx(
416
+ "select",
417
+ {
418
+ "aria-label": translate("builder.conditionOperator"),
419
+ value: condition.operator,
420
+ onChange: (event) => {
421
+ const operator = event.currentTarget.value;
422
+ updateField(field.id, (current) => ({
423
+ ...current,
424
+ displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
425
+ }));
426
+ },
427
+ children: conditionOperators(source).map((operator) => /* @__PURE__ */ jsx("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
428
+ }
429
+ ),
430
+ /* @__PURE__ */ jsx(
431
+ ConditionValueEditor,
432
+ {
433
+ source,
434
+ condition,
435
+ onChange: (next) => updateField(field.id, (current) => ({ ...current, displayCondition: next })),
436
+ translate
437
+ }
438
+ )
439
+ ] }) : null
440
+ ] })
441
+ ] }, field.id);
442
+ }) }),
443
+ /* @__PURE__ */ jsx("button", { className: "form-engine-builder__add", type: "button", onClick: addField, children: translate("builder.addQuestion") })
444
+ ] });
445
+ }
446
+
447
+ // src/context.tsx
448
+ import {
449
+ assertValidFormSchema,
450
+ calculateFieldVisibility,
451
+ selectVisibleAnswers,
452
+ validateAnswers
453
+ } from "@form-engine-ts/core";
454
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
455
+ import { jsx as jsx2 } from "react/jsx-runtime";
456
+ var FormContext = createContext(null);
457
+ function issuesByField(issues) {
458
+ const result = {};
459
+ for (const issue of issues) result[issue.fieldId] ??= issue;
460
+ return result;
461
+ }
462
+ function FormProvider({
463
+ schema,
464
+ locale,
465
+ translator,
466
+ initialValues = {},
467
+ resetOnSuccess = false,
468
+ onSubmit,
469
+ children
470
+ }) {
471
+ const validSchema = useMemo(() => {
472
+ assertValidFormSchema(schema);
473
+ return schema;
474
+ }, [schema]);
475
+ const [values, setValues] = useState(() => ({ ...initialValues }));
476
+ const [errors, setErrors] = useState({});
477
+ const [submitStatus, setSubmitStatus] = useState("idle");
478
+ const [submitError, setSubmitError] = useState(null);
479
+ const visibility = useMemo(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
480
+ useEffect(() => {
481
+ const fieldIds = new Set(validSchema.fields.map((field) => field.id));
482
+ setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
483
+ }, [validSchema]);
484
+ const setValue = useCallback(
485
+ (fieldId, value) => {
486
+ setValues((current) => {
487
+ const next = { ...current, [fieldId]: value };
488
+ setErrors((currentErrors) => {
489
+ if (Object.keys(currentErrors).length === 0) return currentErrors;
490
+ const result = validateAnswers(validSchema, next);
491
+ return issuesByField(result.issues);
492
+ });
493
+ return next;
494
+ });
495
+ setSubmitStatus((current) => current === "success" || current === "error" ? "idle" : current);
496
+ setSubmitError(null);
497
+ },
498
+ [validSchema]
499
+ );
500
+ const reset = useCallback(() => {
501
+ setValues({ ...initialValues });
502
+ setErrors({});
503
+ setSubmitStatus("idle");
504
+ setSubmitError(null);
505
+ }, [initialValues]);
506
+ const submit = useCallback(async () => {
507
+ const validation = validateAnswers(validSchema, values);
508
+ if (!validation.valid) {
509
+ setErrors(issuesByField(validation.issues));
510
+ setSubmitStatus("error");
511
+ setSubmitError(null);
512
+ return false;
513
+ }
514
+ setErrors({});
515
+ setSubmitStatus("submitting");
516
+ setSubmitError(null);
517
+ try {
518
+ await onSubmit(selectVisibleAnswers(validSchema, values));
519
+ if (resetOnSuccess) setValues({ ...initialValues });
520
+ setSubmitStatus("success");
521
+ return true;
522
+ } catch (cause) {
523
+ setSubmitError(cause instanceof Error ? cause : new Error(String(cause)));
524
+ setSubmitStatus("error");
525
+ return false;
526
+ }
527
+ }, [initialValues, onSubmit, resetOnSuccess, validSchema, values]);
528
+ const translate = useCallback(
529
+ (key, params) => translator.translate(key, locale, params),
530
+ [locale, translator]
531
+ );
532
+ const contextValue = useMemo(
533
+ () => ({
534
+ schema: validSchema,
535
+ locale,
536
+ translator,
537
+ values,
538
+ visibility,
539
+ errors,
540
+ submitStatus,
541
+ submitError,
542
+ isSubmitting: submitStatus === "submitting",
543
+ setValue,
544
+ reset,
545
+ submit,
546
+ translate
547
+ }),
548
+ [
549
+ errors,
550
+ locale,
551
+ reset,
552
+ setValue,
553
+ submit,
554
+ submitError,
555
+ submitStatus,
556
+ translate,
557
+ translator,
558
+ validSchema,
559
+ values,
560
+ visibility
561
+ ]
562
+ );
563
+ return /* @__PURE__ */ jsx2(FormContext.Provider, { value: contextValue, children });
564
+ }
565
+ function useForm() {
566
+ const context = useContext(FormContext);
567
+ if (context === null) throw new Error("useForm must be called inside a FormProvider.");
568
+ return context;
569
+ }
570
+ function useField(fieldId) {
571
+ const form = useForm();
572
+ const field = form.schema.fields.find((item) => item.id === fieldId);
573
+ if (field === void 0) throw new Error(`Unknown form field: ${fieldId}`);
574
+ const setValue = useCallback((value) => form.setValue(fieldId, value), [fieldId, form]);
575
+ return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
576
+ }
577
+
578
+ // src/renderer.tsx
579
+ import {
580
+ validateAnswers as validateAnswers2
581
+ } from "@form-engine-ts/core";
582
+ import { useId } from "react";
583
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
584
+ function describedBy(field, error, helpId, errorId) {
585
+ const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
586
+ Boolean
587
+ );
588
+ return ids.length === 0 ? void 0 : ids.join(" ");
589
+ }
590
+ function RequiredMark({ required }) {
591
+ return required ? /* @__PURE__ */ jsxs2("span", { className: "fe-required", "aria-hidden": "true", children: [
592
+ " ",
593
+ "*"
594
+ ] }) : null;
595
+ }
596
+ function FieldMessage({ props }) {
597
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
598
+ props.field.description === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.helpId, className: "fe-help", children: props.field.description }),
599
+ props.error === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.errorId, className: "fe-error", children: props.translate(props.error.messageKey, props.error.params) })
600
+ ] });
601
+ }
602
+ function DefaultField(props) {
603
+ const { field, value, setValue, inputId, error, translate } = props;
604
+ const ariaProps = {
605
+ "aria-describedby": describedBy(field, error, props.helpId, props.errorId),
606
+ "aria-invalid": error === void 0 ? void 0 : true
607
+ };
608
+ if (field.type === "checkbox") {
609
+ return /* @__PURE__ */ jsxs2("div", { className: "fe-field fe-field--checkbox", "data-field-id": field.id, children: [
610
+ /* @__PURE__ */ jsxs2("label", { className: "fe-check-label", htmlFor: inputId, children: [
611
+ /* @__PURE__ */ jsx3(
612
+ "input",
613
+ {
614
+ ...ariaProps,
615
+ id: inputId,
616
+ name: field.id,
617
+ type: "checkbox",
618
+ checked: value === true,
619
+ onChange: (event) => setValue(event.currentTarget.checked)
620
+ }
621
+ ),
622
+ /* @__PURE__ */ jsxs2("span", { children: [
623
+ field.title,
624
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
625
+ ] })
626
+ ] }),
627
+ /* @__PURE__ */ jsx3(FieldMessage, { props })
628
+ ] });
629
+ }
630
+ if (field.type === "radio" || field.type === "multi-select") {
631
+ const selected = Array.isArray(value) ? value : [];
632
+ return /* @__PURE__ */ jsxs2("fieldset", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, ...ariaProps, children: [
633
+ /* @__PURE__ */ jsxs2("legend", { className: "fe-label", children: [
634
+ field.title,
635
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
636
+ ] }),
637
+ field.options.map((option, index) => {
638
+ const optionId = `${inputId}-${index}`;
639
+ const checked = field.type === "radio" ? value === option.id : selected.includes(option.id);
640
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-check-label", htmlFor: optionId, children: [
641
+ /* @__PURE__ */ jsx3(
642
+ "input",
643
+ {
644
+ id: optionId,
645
+ name: field.id,
646
+ type: field.type === "radio" ? "radio" : "checkbox",
647
+ value: option.id,
648
+ checked,
649
+ onChange: (event) => {
650
+ if (field.type === "radio") setValue(option.id);
651
+ else
652
+ setValue(
653
+ event.currentTarget.checked ? [...selected, option.id] : selected.filter((item) => item !== option.id)
654
+ );
655
+ }
656
+ }
657
+ ),
658
+ /* @__PURE__ */ jsx3("span", { children: option.label })
659
+ ] }, option.id);
660
+ }),
661
+ /* @__PURE__ */ jsx3(FieldMessage, { props })
662
+ ] });
663
+ }
664
+ if (field.type === "rating") {
665
+ const min = field.min ?? 1;
666
+ const max = field.max ?? 5;
667
+ return /* @__PURE__ */ jsxs2("fieldset", { className: "fe-field fe-field--rating", "data-field-id": field.id, ...ariaProps, children: [
668
+ /* @__PURE__ */ jsxs2("legend", { className: "fe-label", children: [
669
+ field.title,
670
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
671
+ ] }),
672
+ /* @__PURE__ */ jsx3("div", { className: "fe-rating-options", children: Array.from({ length: max - min + 1 }, (_, index) => min + index).map((rating) => {
673
+ const optionId = `${inputId}-${rating}`;
674
+ return /* @__PURE__ */ jsxs2("label", { className: "fe-rating-label", htmlFor: optionId, children: [
675
+ /* @__PURE__ */ jsx3(
676
+ "input",
677
+ {
678
+ id: optionId,
679
+ name: field.id,
680
+ type: "radio",
681
+ value: rating,
682
+ checked: value === rating,
683
+ onChange: () => setValue(rating)
684
+ }
685
+ ),
686
+ /* @__PURE__ */ jsx3("span", { children: rating })
687
+ ] }, rating);
688
+ }) }),
689
+ /* @__PURE__ */ jsx3(FieldMessage, { props })
690
+ ] });
691
+ }
692
+ const label = /* @__PURE__ */ jsxs2("label", { className: "fe-label", htmlFor: inputId, children: [
693
+ field.title,
694
+ /* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
695
+ ] });
696
+ let control;
697
+ if (field.type === "textarea") {
698
+ control = /* @__PURE__ */ jsx3(
699
+ "textarea",
700
+ {
701
+ ...ariaProps,
702
+ id: inputId,
703
+ name: field.id,
704
+ placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
705
+ value: typeof value === "string" ? value : "",
706
+ onChange: (event) => setValue(event.currentTarget.value)
707
+ }
708
+ );
709
+ } else if (field.type === "number") {
710
+ control = /* @__PURE__ */ jsx3(
711
+ "input",
712
+ {
713
+ ...ariaProps,
714
+ id: inputId,
715
+ name: field.id,
716
+ type: "number",
717
+ min: field.min,
718
+ max: field.max,
719
+ step: field.step,
720
+ placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
721
+ value: typeof value === "number" ? value : "",
722
+ onChange: (event) => setValue(event.currentTarget.value === "" ? void 0 : event.currentTarget.valueAsNumber)
723
+ }
724
+ );
725
+ } else if (field.type === "select") {
726
+ control = /* @__PURE__ */ jsxs2(
727
+ "select",
728
+ {
729
+ ...ariaProps,
730
+ id: inputId,
731
+ name: field.id,
732
+ value: typeof value === "string" ? value : "",
733
+ onChange: (event) => setValue(event.currentTarget.value || void 0),
734
+ children: [
735
+ /* @__PURE__ */ jsx3("option", { value: "", children: "\u2014" }),
736
+ field.options.map((option) => /* @__PURE__ */ jsx3("option", { value: option.id, children: option.label }, option.id))
737
+ ]
738
+ }
739
+ );
740
+ } else {
741
+ const placeholderKey = "placeholderKey" in field ? field.placeholderKey : void 0;
742
+ control = /* @__PURE__ */ jsx3(
743
+ "input",
744
+ {
745
+ ...ariaProps,
746
+ id: inputId,
747
+ name: field.id,
748
+ type: "text",
749
+ placeholder: placeholderKey === void 0 ? void 0 : translate(placeholderKey),
750
+ value: typeof value === "string" ? value : "",
751
+ onChange: (event) => setValue(event.currentTarget.value)
752
+ }
753
+ );
754
+ }
755
+ return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
756
+ label,
757
+ control,
758
+ /* @__PURE__ */ jsx3(FieldMessage, { props })
759
+ ] });
760
+ }
761
+ function FormRenderer({
762
+ components = {},
763
+ className = "",
764
+ successMessageKey,
765
+ errorMessageKey
766
+ }) {
767
+ const form = useForm();
768
+ const prefix = useId().replace(/:/g, "");
769
+ const handleSubmit = async (event) => {
770
+ event.preventDefault();
771
+ const formElement = event.currentTarget;
772
+ const validation = validateAnswers2(form.schema, form.values);
773
+ const firstInvalidFieldId = validation.issues[0]?.fieldId;
774
+ const valid = await form.submit();
775
+ if (!valid && firstInvalidFieldId !== void 0) {
776
+ queueMicrotask(() => {
777
+ const fieldContainer = [...formElement.querySelectorAll("[data-field-id]")].find(
778
+ (element) => element.dataset.fieldId === firstInvalidFieldId
779
+ );
780
+ fieldContainer?.querySelector("input, select, textarea")?.focus();
781
+ });
782
+ }
783
+ };
784
+ return /* @__PURE__ */ jsxs2("form", { className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
785
+ /* @__PURE__ */ jsxs2("header", { className: "fe-header", children: [
786
+ /* @__PURE__ */ jsx3("h1", { children: form.schema.title }),
787
+ form.schema.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { children: form.schema.description })
788
+ ] }),
789
+ /* @__PURE__ */ jsx3("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true).map((field) => {
790
+ const props = {
791
+ field,
792
+ value: form.values[field.id],
793
+ error: form.errors[field.id],
794
+ setValue: (value) => form.setValue(field.id, value),
795
+ translate: form.translate,
796
+ inputId: `${prefix}-${field.id}`,
797
+ errorId: `${prefix}-${field.id}-error`,
798
+ helpId: `${prefix}-${field.id}-help`
799
+ };
800
+ const Component = components[field.type];
801
+ return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
802
+ }) }),
803
+ /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") }),
804
+ /* @__PURE__ */ jsxs2("div", { className: "fe-status", "aria-live": "polite", children: [
805
+ form.submitStatus === "success" && successMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "status", children: form.translate(successMessageKey) }) : null,
806
+ form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
807
+ ] })
808
+ ] });
809
+ }
810
+ export {
811
+ FormBuilder,
812
+ FormProvider,
813
+ FormRenderer,
814
+ useField,
815
+ useForm
816
+ };