@fogpipe/forma-react 0.17.0 → 0.18.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 +111 -26
- package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
- package/dist/chunk-5K4QITFH.js +1276 -0
- package/dist/chunk-5K4QITFH.js.map +1 -0
- package/dist/defaults/index.d.ts +56 -0
- package/dist/defaults/index.js +895 -0
- package/dist/defaults/index.js.map +1 -0
- package/dist/defaults/styles/forma-defaults.css +696 -0
- package/dist/index.d.ts +13 -549
- package/dist/index.js +34 -1273
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/FieldRenderer.tsx +12 -4
- package/src/FormRenderer.tsx +26 -9
- package/src/__tests__/FieldRenderer.test.tsx +5 -1
- package/src/__tests__/FormRenderer.test.tsx +146 -0
- package/src/__tests__/canProceed.test.ts +243 -0
- package/src/__tests__/defaults/components.test.tsx +818 -0
- package/src/__tests__/defaults/integration.test.tsx +494 -0
- package/src/__tests__/defaults/layout.test.tsx +298 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/defaults/DefaultFormRenderer.tsx +43 -0
- package/src/defaults/componentMap.ts +45 -0
- package/src/defaults/components/ArrayField.tsx +183 -0
- package/src/defaults/components/BooleanInput.tsx +32 -0
- package/src/defaults/components/ComputedDisplay.tsx +26 -0
- package/src/defaults/components/DateInput.tsx +59 -0
- package/src/defaults/components/DisplayField.tsx +15 -0
- package/src/defaults/components/FallbackField.tsx +35 -0
- package/src/defaults/components/MatrixField.tsx +98 -0
- package/src/defaults/components/MultiSelectInput.tsx +51 -0
- package/src/defaults/components/NumberInput.tsx +73 -0
- package/src/defaults/components/ObjectField.tsx +22 -0
- package/src/defaults/components/SelectInput.tsx +44 -0
- package/src/defaults/components/TextInput.tsx +48 -0
- package/src/defaults/components/TextareaInput.tsx +46 -0
- package/src/defaults/index.ts +33 -0
- package/src/defaults/layout/FieldWrapper.tsx +83 -0
- package/src/defaults/layout/FormLayout.tsx +34 -0
- package/src/defaults/layout/PageWrapper.tsx +18 -0
- package/src/defaults/layout/WizardLayout.tsx +130 -0
- package/src/defaults/styles/forma-defaults.css +696 -0
- package/src/events.ts +4 -1
- package/src/types.ts +16 -4
- package/src/useForma.ts +48 -34
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import type { ArrayComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function ArrayField({ field }: ArrayComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
|
|
7
|
+
// Stable keys via ref-based sequential counter
|
|
8
|
+
const itemKeysRef = useRef<string[]>([]);
|
|
9
|
+
const nextKeyRef = useRef(0);
|
|
10
|
+
|
|
11
|
+
const currentLength = field.helpers.items.length;
|
|
12
|
+
const keysLength = itemKeysRef.current.length;
|
|
13
|
+
|
|
14
|
+
if (currentLength > keysLength) {
|
|
15
|
+
for (let i = keysLength; i < currentLength; i++) {
|
|
16
|
+
itemKeysRef.current.push(`item-${nextKeyRef.current++}`);
|
|
17
|
+
}
|
|
18
|
+
} else if (currentLength < keysLength) {
|
|
19
|
+
itemKeysRef.current.length = currentLength;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fieldOrder =
|
|
23
|
+
field.itemFieldOrder ?? Object.keys(field.itemFields);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="forma-array"
|
|
28
|
+
aria-invalid={hasErrors || undefined}
|
|
29
|
+
>
|
|
30
|
+
{field.helpers.items.length === 0 && (
|
|
31
|
+
<p className="forma-array__empty">No items</p>
|
|
32
|
+
)}
|
|
33
|
+
{field.helpers.items.map((_, index) => (
|
|
34
|
+
<div
|
|
35
|
+
key={itemKeysRef.current[index]}
|
|
36
|
+
className="forma-array__item"
|
|
37
|
+
>
|
|
38
|
+
<div className="forma-array__item-fields">
|
|
39
|
+
{fieldOrder.map((fieldName) => {
|
|
40
|
+
const itemProps = field.helpers.getItemFieldProps(
|
|
41
|
+
index,
|
|
42
|
+
fieldName,
|
|
43
|
+
);
|
|
44
|
+
return (
|
|
45
|
+
<div key={fieldName} className="forma-field">
|
|
46
|
+
<label
|
|
47
|
+
htmlFor={itemProps.name}
|
|
48
|
+
className="forma-label"
|
|
49
|
+
>
|
|
50
|
+
{itemProps.label}
|
|
51
|
+
</label>
|
|
52
|
+
{renderItemField(itemProps)}
|
|
53
|
+
{itemProps.errors.length > 0 && itemProps.touched && (
|
|
54
|
+
<div className="forma-field__errors" role="alert">
|
|
55
|
+
{itemProps.errors.map((err, i) => (
|
|
56
|
+
<span key={i} className="forma-field__error">
|
|
57
|
+
{err.message}
|
|
58
|
+
</span>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="forma-button forma-button--danger forma-array__remove"
|
|
69
|
+
onClick={() => field.helpers.remove(index)}
|
|
70
|
+
disabled={!field.helpers.canRemove || field.disabled}
|
|
71
|
+
>
|
|
72
|
+
Remove
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
))}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
className="forma-button forma-button--secondary forma-array__add"
|
|
79
|
+
onClick={() => field.helpers.push()}
|
|
80
|
+
disabled={!field.helpers.canAdd || field.disabled}
|
|
81
|
+
>
|
|
82
|
+
+ Add Item
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderItemField(
|
|
89
|
+
itemProps: ReturnType<
|
|
90
|
+
ArrayComponentProps["field"]["helpers"]["getItemFieldProps"]
|
|
91
|
+
>,
|
|
92
|
+
) {
|
|
93
|
+
const type = itemProps.type;
|
|
94
|
+
|
|
95
|
+
if (type === "select" && itemProps.options) {
|
|
96
|
+
return (
|
|
97
|
+
<select
|
|
98
|
+
id={itemProps.name}
|
|
99
|
+
className="forma-select"
|
|
100
|
+
value={String(itemProps.value ?? "")}
|
|
101
|
+
onChange={(e) => {
|
|
102
|
+
const value = e.target.value;
|
|
103
|
+
if (!value) {
|
|
104
|
+
itemProps.onChange(null);
|
|
105
|
+
} else {
|
|
106
|
+
const option = itemProps.options?.find(
|
|
107
|
+
(opt) => String(opt.value) === value,
|
|
108
|
+
);
|
|
109
|
+
itemProps.onChange(option ? option.value : value);
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
onBlur={itemProps.onBlur}
|
|
113
|
+
disabled={!itemProps.enabled}
|
|
114
|
+
>
|
|
115
|
+
<option value="">Select...</option>
|
|
116
|
+
{itemProps.options.map((opt) => (
|
|
117
|
+
<option key={String(opt.value)} value={String(opt.value)}>
|
|
118
|
+
{opt.label}
|
|
119
|
+
</option>
|
|
120
|
+
))}
|
|
121
|
+
</select>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (type === "number" || type === "integer") {
|
|
126
|
+
return (
|
|
127
|
+
<input
|
|
128
|
+
id={itemProps.name}
|
|
129
|
+
type="number"
|
|
130
|
+
className="forma-input"
|
|
131
|
+
value={itemProps.value != null ? String(itemProps.value) : ""}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
const val = e.target.value;
|
|
134
|
+
if (val === "") {
|
|
135
|
+
itemProps.onChange(null);
|
|
136
|
+
} else {
|
|
137
|
+
const num =
|
|
138
|
+
type === "integer"
|
|
139
|
+
? parseInt(val, 10)
|
|
140
|
+
: parseFloat(val);
|
|
141
|
+
itemProps.onChange(isNaN(num) ? null : num);
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
onBlur={itemProps.onBlur}
|
|
145
|
+
disabled={!itemProps.enabled}
|
|
146
|
+
step={type === "integer" ? 1 : "any"}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (type === "boolean") {
|
|
152
|
+
return (
|
|
153
|
+
<div className="forma-checkbox">
|
|
154
|
+
<input
|
|
155
|
+
id={itemProps.name}
|
|
156
|
+
type="checkbox"
|
|
157
|
+
className="forma-checkbox__input"
|
|
158
|
+
checked={Boolean(itemProps.value)}
|
|
159
|
+
onChange={(e) => itemProps.onChange(e.target.checked)}
|
|
160
|
+
onBlur={itemProps.onBlur}
|
|
161
|
+
disabled={!itemProps.enabled}
|
|
162
|
+
/>
|
|
163
|
+
<label htmlFor={itemProps.name} className="forma-checkbox__label">
|
|
164
|
+
{itemProps.label}
|
|
165
|
+
</label>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Default: text input
|
|
171
|
+
return (
|
|
172
|
+
<input
|
|
173
|
+
id={itemProps.name}
|
|
174
|
+
type="text"
|
|
175
|
+
className="forma-input"
|
|
176
|
+
value={String(itemProps.value ?? "")}
|
|
177
|
+
onChange={(e) => itemProps.onChange(e.target.value)}
|
|
178
|
+
onBlur={itemProps.onBlur}
|
|
179
|
+
disabled={!itemProps.enabled}
|
|
180
|
+
placeholder={itemProps.placeholder}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { BooleanComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function BooleanInput({ field }: BooleanComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="forma-checkbox">
|
|
15
|
+
<input
|
|
16
|
+
id={field.name}
|
|
17
|
+
name={field.name}
|
|
18
|
+
type="checkbox"
|
|
19
|
+
className="forma-checkbox__input"
|
|
20
|
+
checked={field.value ?? false}
|
|
21
|
+
onChange={(e) => field.onChange(e.target.checked)}
|
|
22
|
+
onBlur={field.onBlur}
|
|
23
|
+
disabled={field.disabled}
|
|
24
|
+
aria-invalid={hasErrors || undefined}
|
|
25
|
+
aria-describedby={describedBy || undefined}
|
|
26
|
+
/>
|
|
27
|
+
<label htmlFor={field.name} className="forma-checkbox__label">
|
|
28
|
+
{field.label}
|
|
29
|
+
</label>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ComputedComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function ComputedDisplay({ field }: ComputedComponentProps) {
|
|
5
|
+
let displayValue: string;
|
|
6
|
+
if (field.value === null || field.value === undefined) {
|
|
7
|
+
displayValue = "\u2014";
|
|
8
|
+
} else if (typeof field.value === "object") {
|
|
9
|
+
try {
|
|
10
|
+
displayValue = JSON.stringify(field.value);
|
|
11
|
+
} catch {
|
|
12
|
+
displayValue = String(field.value);
|
|
13
|
+
}
|
|
14
|
+
} else {
|
|
15
|
+
displayValue = String(field.value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<output
|
|
20
|
+
id={field.name}
|
|
21
|
+
className="forma-computed"
|
|
22
|
+
>
|
|
23
|
+
{displayValue}
|
|
24
|
+
</output>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { DateComponentProps, DateTimeComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function DateInput({ field }: DateComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<input
|
|
15
|
+
id={field.name}
|
|
16
|
+
name={field.name}
|
|
17
|
+
type="date"
|
|
18
|
+
className="forma-input forma-input--date"
|
|
19
|
+
value={field.value ?? ""}
|
|
20
|
+
onChange={(e) => field.onChange(e.target.value || null)}
|
|
21
|
+
onBlur={field.onBlur}
|
|
22
|
+
disabled={field.disabled}
|
|
23
|
+
readOnly={field.readonly}
|
|
24
|
+
aria-invalid={hasErrors || undefined}
|
|
25
|
+
aria-required={field.required || undefined}
|
|
26
|
+
aria-describedby={describedBy || undefined}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DateTimeInput({ field }: DateTimeComponentProps) {
|
|
32
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
33
|
+
const describedBy = [
|
|
34
|
+
field.description ? `${field.name}-description` : null,
|
|
35
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
36
|
+
]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join(" ");
|
|
39
|
+
|
|
40
|
+
// datetime-local expects "YYYY-MM-DDTHH:mm" format
|
|
41
|
+
const inputValue = field.value ?? "";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<input
|
|
45
|
+
id={field.name}
|
|
46
|
+
name={field.name}
|
|
47
|
+
type="datetime-local"
|
|
48
|
+
className="forma-input forma-input--datetime"
|
|
49
|
+
value={inputValue}
|
|
50
|
+
onChange={(e) => field.onChange(e.target.value || null)}
|
|
51
|
+
onBlur={field.onBlur}
|
|
52
|
+
disabled={field.disabled}
|
|
53
|
+
readOnly={field.readonly}
|
|
54
|
+
aria-invalid={hasErrors || undefined}
|
|
55
|
+
aria-required={field.required || undefined}
|
|
56
|
+
aria-describedby={describedBy || undefined}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { DisplayComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function DisplayField({ field }: DisplayComponentProps) {
|
|
5
|
+
const content =
|
|
6
|
+
field.sourceValue !== undefined
|
|
7
|
+
? String(field.sourceValue)
|
|
8
|
+
: field.content;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="forma-display">
|
|
12
|
+
{content && <p className="forma-display__content">{content}</p>}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { FieldComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
declare const process: { env: { NODE_ENV?: string } } | undefined;
|
|
5
|
+
|
|
6
|
+
export function FallbackField({ field }: FieldComponentProps) {
|
|
7
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
8
|
+
const isDev =
|
|
9
|
+
typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="forma-fallback">
|
|
13
|
+
{isDev && (
|
|
14
|
+
<p className="forma-fallback__warning">
|
|
15
|
+
Unknown field type: "{field.field.type}"
|
|
16
|
+
</p>
|
|
17
|
+
)}
|
|
18
|
+
<input
|
|
19
|
+
id={field.name}
|
|
20
|
+
name={field.name}
|
|
21
|
+
type="text"
|
|
22
|
+
className="forma-input"
|
|
23
|
+
value={String(field.value ?? "")}
|
|
24
|
+
onChange={(e) => {
|
|
25
|
+
if ("onChange" in field && typeof field.onChange === "function") {
|
|
26
|
+
(field.onChange as (value: string) => void)(e.target.value);
|
|
27
|
+
}
|
|
28
|
+
}}
|
|
29
|
+
onBlur={field.onBlur}
|
|
30
|
+
disabled={field.disabled}
|
|
31
|
+
aria-invalid={hasErrors || undefined}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MatrixComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function MatrixField({ field }: MatrixComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const visibleRows = field.rows.filter((r) => r.visible);
|
|
7
|
+
const currentValue = field.value ?? {};
|
|
8
|
+
|
|
9
|
+
const handleChange = (
|
|
10
|
+
rowId: string,
|
|
11
|
+
colValue: string | number,
|
|
12
|
+
) => {
|
|
13
|
+
if (field.disabled) return;
|
|
14
|
+
|
|
15
|
+
const next = { ...currentValue };
|
|
16
|
+
if (field.multiSelect) {
|
|
17
|
+
const current = (currentValue[rowId] ?? []) as string[];
|
|
18
|
+
const colStr = String(colValue);
|
|
19
|
+
const exists = current.includes(colStr);
|
|
20
|
+
next[rowId] = exists
|
|
21
|
+
? current.filter((v) => v !== colStr)
|
|
22
|
+
: [...current, colStr];
|
|
23
|
+
} else {
|
|
24
|
+
next[rowId] = colValue;
|
|
25
|
+
}
|
|
26
|
+
field.onChange(next as Record<string, string | number | string[] | number[]>);
|
|
27
|
+
field.onBlur();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isChecked = (
|
|
31
|
+
rowId: string,
|
|
32
|
+
colValue: string | number,
|
|
33
|
+
): boolean => {
|
|
34
|
+
const rowValue = currentValue[rowId];
|
|
35
|
+
if (rowValue === undefined || rowValue === null) return false;
|
|
36
|
+
if (Array.isArray(rowValue)) {
|
|
37
|
+
return (rowValue as (string | number)[]).includes(colValue);
|
|
38
|
+
}
|
|
39
|
+
return rowValue === colValue;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="forma-matrix" aria-invalid={hasErrors || undefined}>
|
|
44
|
+
<table className="forma-matrix__table" role="grid">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th scope="col" className="forma-matrix__corner" />
|
|
48
|
+
{field.columns.map((col) => (
|
|
49
|
+
<th
|
|
50
|
+
key={String(col.value)}
|
|
51
|
+
scope="col"
|
|
52
|
+
className="forma-matrix__col-header"
|
|
53
|
+
>
|
|
54
|
+
{col.label}
|
|
55
|
+
</th>
|
|
56
|
+
))}
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{visibleRows.map((row) => (
|
|
61
|
+
<tr key={row.id} className="forma-matrix__row">
|
|
62
|
+
<th scope="row" className="forma-matrix__row-header">
|
|
63
|
+
{row.label}
|
|
64
|
+
</th>
|
|
65
|
+
{field.columns.map((col) => {
|
|
66
|
+
const cellId = `${field.name}-${row.id}-${col.value}`;
|
|
67
|
+
return (
|
|
68
|
+
<td key={String(col.value)} className="forma-matrix__cell">
|
|
69
|
+
<input
|
|
70
|
+
id={cellId}
|
|
71
|
+
type={field.multiSelect ? "checkbox" : "radio"}
|
|
72
|
+
name={
|
|
73
|
+
field.multiSelect
|
|
74
|
+
? cellId
|
|
75
|
+
: `${field.name}-${row.id}`
|
|
76
|
+
}
|
|
77
|
+
className={
|
|
78
|
+
field.multiSelect
|
|
79
|
+
? "forma-checkbox__input"
|
|
80
|
+
: "forma-radio__input"
|
|
81
|
+
}
|
|
82
|
+
checked={isChecked(row.id, col.value)}
|
|
83
|
+
onChange={() => handleChange(row.id, col.value)}
|
|
84
|
+
disabled={field.disabled}
|
|
85
|
+
/>
|
|
86
|
+
<label htmlFor={cellId} className="forma-sr-only">
|
|
87
|
+
{row.label}: {col.label}
|
|
88
|
+
</label>
|
|
89
|
+
</td>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</tr>
|
|
93
|
+
))}
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MultiSelectComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function MultiSelectInput({ field }: MultiSelectComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
const selected = field.value ?? [];
|
|
14
|
+
|
|
15
|
+
const handleToggle = (optionValue: string) => {
|
|
16
|
+
if (selected.includes(optionValue)) {
|
|
17
|
+
field.onChange(selected.filter((v) => v !== optionValue));
|
|
18
|
+
} else {
|
|
19
|
+
field.onChange([...selected, optionValue]);
|
|
20
|
+
}
|
|
21
|
+
field.onBlur();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<fieldset
|
|
26
|
+
className="forma-multiselect"
|
|
27
|
+
aria-describedby={describedBy || undefined}
|
|
28
|
+
aria-invalid={hasErrors || undefined}
|
|
29
|
+
>
|
|
30
|
+
<legend className="forma-sr-only">{field.label}</legend>
|
|
31
|
+
{field.options.map((opt) => {
|
|
32
|
+
const optId = `${field.name}-${opt.value}`;
|
|
33
|
+
return (
|
|
34
|
+
<div key={String(opt.value)} className="forma-multiselect__option">
|
|
35
|
+
<input
|
|
36
|
+
id={optId}
|
|
37
|
+
type="checkbox"
|
|
38
|
+
className="forma-checkbox__input"
|
|
39
|
+
checked={selected.includes(String(opt.value))}
|
|
40
|
+
onChange={() => handleToggle(String(opt.value))}
|
|
41
|
+
disabled={field.disabled}
|
|
42
|
+
/>
|
|
43
|
+
<label htmlFor={optId} className="forma-checkbox__label">
|
|
44
|
+
{opt.label}
|
|
45
|
+
</label>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</fieldset>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { NumberComponentProps, IntegerComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
function NumberInputBase({
|
|
5
|
+
field,
|
|
6
|
+
parseValue,
|
|
7
|
+
}: {
|
|
8
|
+
field: NumberComponentProps["field"] | IntegerComponentProps["field"];
|
|
9
|
+
parseValue: (val: string) => number;
|
|
10
|
+
}) {
|
|
11
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
12
|
+
const describedBy = [
|
|
13
|
+
field.description ? `${field.name}-description` : null,
|
|
14
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
15
|
+
]
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join(" ");
|
|
18
|
+
|
|
19
|
+
const input = (
|
|
20
|
+
<input
|
|
21
|
+
id={field.name}
|
|
22
|
+
name={field.name}
|
|
23
|
+
type="number"
|
|
24
|
+
className={`forma-input forma-input--${field.fieldType}`}
|
|
25
|
+
value={field.value != null ? String(field.value) : ""}
|
|
26
|
+
onChange={(e) => {
|
|
27
|
+
const val = e.target.value;
|
|
28
|
+
if (val === "") {
|
|
29
|
+
field.onChange(null);
|
|
30
|
+
} else {
|
|
31
|
+
const num = parseValue(val);
|
|
32
|
+
field.onChange(isNaN(num) ? null : num);
|
|
33
|
+
}
|
|
34
|
+
}}
|
|
35
|
+
onBlur={field.onBlur}
|
|
36
|
+
disabled={field.disabled}
|
|
37
|
+
readOnly={field.readonly}
|
|
38
|
+
placeholder={field.placeholder}
|
|
39
|
+
min={field.min}
|
|
40
|
+
max={field.max}
|
|
41
|
+
step={field.step ?? (field.fieldType === "integer" ? 1 : "any")}
|
|
42
|
+
aria-invalid={hasErrors || undefined}
|
|
43
|
+
aria-required={field.required || undefined}
|
|
44
|
+
aria-describedby={describedBy || undefined}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (field.prefix || field.suffix) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="forma-input-adorner">
|
|
51
|
+
{field.prefix && (
|
|
52
|
+
<span className="forma-input-adorner__prefix">{field.prefix}</span>
|
|
53
|
+
)}
|
|
54
|
+
{input}
|
|
55
|
+
{field.suffix && (
|
|
56
|
+
<span className="forma-input-adorner__suffix">{field.suffix}</span>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return input;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function NumberInput({ field }: NumberComponentProps) {
|
|
66
|
+
return <NumberInputBase field={field} parseValue={parseFloat} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function IntegerInput({ field }: IntegerComponentProps) {
|
|
70
|
+
return (
|
|
71
|
+
<NumberInputBase field={field} parseValue={(v) => parseInt(v, 10)} />
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ObjectComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function ObjectField({ field }: ObjectComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<fieldset
|
|
9
|
+
className="forma-object"
|
|
10
|
+
aria-invalid={hasErrors || undefined}
|
|
11
|
+
>
|
|
12
|
+
<legend className="forma-object__legend">{field.label}</legend>
|
|
13
|
+
{field.description && (
|
|
14
|
+
<p className="forma-object__description">{field.description}</p>
|
|
15
|
+
)}
|
|
16
|
+
<div className="forma-object__fields">
|
|
17
|
+
{/* Object child fields are rendered by FormRenderer, not here.
|
|
18
|
+
The object component is a visual container only. */}
|
|
19
|
+
</div>
|
|
20
|
+
</fieldset>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { SelectComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function SelectInput({ field }: SelectComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<select
|
|
15
|
+
id={field.name}
|
|
16
|
+
name={field.name}
|
|
17
|
+
className="forma-select"
|
|
18
|
+
value={field.value !== null ? String(field.value) : ""}
|
|
19
|
+
onChange={(e) => {
|
|
20
|
+
const value = e.target.value;
|
|
21
|
+
if (!value) {
|
|
22
|
+
field.onChange(null);
|
|
23
|
+
} else {
|
|
24
|
+
// Preserve string value
|
|
25
|
+
field.onChange(value);
|
|
26
|
+
}
|
|
27
|
+
}}
|
|
28
|
+
onBlur={field.onBlur}
|
|
29
|
+
disabled={field.disabled}
|
|
30
|
+
aria-invalid={hasErrors || undefined}
|
|
31
|
+
aria-required={field.required || undefined}
|
|
32
|
+
aria-describedby={describedBy || undefined}
|
|
33
|
+
>
|
|
34
|
+
{(!field.required || field.value === null) && (
|
|
35
|
+
<option value="">{field.placeholder ?? "Select..."}</option>
|
|
36
|
+
)}
|
|
37
|
+
{field.options.map((opt) => (
|
|
38
|
+
<option key={String(opt.value)} value={String(opt.value)}>
|
|
39
|
+
{opt.label}
|
|
40
|
+
</option>
|
|
41
|
+
))}
|
|
42
|
+
</select>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { TextComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function TextInput({ field }: TextComponentProps) {
|
|
5
|
+
const inputType = field.fieldType === "phone" ? "tel" : field.fieldType;
|
|
6
|
+
|
|
7
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
8
|
+
const describedBy = [
|
|
9
|
+
field.description ? `${field.name}-description` : null,
|
|
10
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
11
|
+
]
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join(" ");
|
|
14
|
+
|
|
15
|
+
const input = (
|
|
16
|
+
<input
|
|
17
|
+
id={field.name}
|
|
18
|
+
name={field.name}
|
|
19
|
+
type={inputType}
|
|
20
|
+
className="forma-input"
|
|
21
|
+
value={field.value}
|
|
22
|
+
onChange={(e) => field.onChange(e.target.value)}
|
|
23
|
+
onBlur={field.onBlur}
|
|
24
|
+
disabled={field.disabled}
|
|
25
|
+
readOnly={field.readonly}
|
|
26
|
+
placeholder={field.placeholder}
|
|
27
|
+
aria-invalid={hasErrors || undefined}
|
|
28
|
+
aria-required={field.required || undefined}
|
|
29
|
+
aria-describedby={describedBy || undefined}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (field.prefix || field.suffix) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="forma-input-adorner">
|
|
36
|
+
{field.prefix && (
|
|
37
|
+
<span className="forma-input-adorner__prefix">{field.prefix}</span>
|
|
38
|
+
)}
|
|
39
|
+
{input}
|
|
40
|
+
{field.suffix && (
|
|
41
|
+
<span className="forma-input-adorner__suffix">{field.suffix}</span>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return input;
|
|
48
|
+
}
|