@fogpipe/forma-react 0.17.1 → 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.
Files changed (36) hide show
  1. package/README.md +82 -0
  2. package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
  3. package/dist/chunk-5K4QITFH.js +1276 -0
  4. package/dist/chunk-5K4QITFH.js.map +1 -0
  5. package/dist/defaults/index.d.ts +56 -0
  6. package/dist/defaults/index.js +895 -0
  7. package/dist/defaults/index.js.map +1 -0
  8. package/dist/defaults/styles/forma-defaults.css +696 -0
  9. package/dist/index.d.ts +7 -559
  10. package/dist/index.js +28 -1292
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/__tests__/defaults/components.test.tsx +818 -0
  14. package/src/__tests__/defaults/integration.test.tsx +494 -0
  15. package/src/__tests__/defaults/layout.test.tsx +298 -0
  16. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  17. package/src/defaults/componentMap.ts +45 -0
  18. package/src/defaults/components/ArrayField.tsx +183 -0
  19. package/src/defaults/components/BooleanInput.tsx +32 -0
  20. package/src/defaults/components/ComputedDisplay.tsx +26 -0
  21. package/src/defaults/components/DateInput.tsx +59 -0
  22. package/src/defaults/components/DisplayField.tsx +15 -0
  23. package/src/defaults/components/FallbackField.tsx +35 -0
  24. package/src/defaults/components/MatrixField.tsx +98 -0
  25. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  26. package/src/defaults/components/NumberInput.tsx +73 -0
  27. package/src/defaults/components/ObjectField.tsx +22 -0
  28. package/src/defaults/components/SelectInput.tsx +44 -0
  29. package/src/defaults/components/TextInput.tsx +48 -0
  30. package/src/defaults/components/TextareaInput.tsx +46 -0
  31. package/src/defaults/index.ts +33 -0
  32. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  33. package/src/defaults/layout/FormLayout.tsx +34 -0
  34. package/src/defaults/layout/PageWrapper.tsx +18 -0
  35. package/src/defaults/layout/WizardLayout.tsx +130 -0
  36. package/src/defaults/styles/forma-defaults.css +696 -0
@@ -0,0 +1,298 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ /**
3
+ * Tests for default layout components
4
+ */
5
+ import React from "react";
6
+ import { render, screen, fireEvent } from "@testing-library/react";
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import type { FieldError } from "@fogpipe/forma-core";
9
+ import type { FieldWrapperProps, PageWrapperProps } from "../../types.js";
10
+ import { FieldWrapper } from "../../defaults/layout/FieldWrapper.js";
11
+ import { FormLayout } from "../../defaults/layout/FormLayout.js";
12
+ import { PageWrapper } from "../../defaults/layout/PageWrapper.js";
13
+ import { FormaContext } from "../../context.js";
14
+ import type { UseFormaReturn } from "../../useForma.js";
15
+
16
+ function mockFormaContext(
17
+ overrides: Partial<UseFormaReturn> = {},
18
+ ): UseFormaReturn {
19
+ return {
20
+ data: {},
21
+ computed: {},
22
+ visibility: {},
23
+ required: {},
24
+ enabled: {},
25
+ readonly: {},
26
+ optionsVisibility: {},
27
+ touched: {},
28
+ errors: [],
29
+ isValid: true,
30
+ isSubmitting: false,
31
+ isSubmitted: false,
32
+ isDirty: false,
33
+ spec: {} as UseFormaReturn["spec"],
34
+ wizard: null,
35
+ setFieldValue: vi.fn(),
36
+ setFieldTouched: vi.fn(),
37
+ setValues: vi.fn(),
38
+ validateField: vi.fn(),
39
+ validateForm: vi.fn(),
40
+ submitForm: vi.fn(),
41
+ resetForm: vi.fn(),
42
+ getFieldProps: vi.fn(),
43
+ getSelectFieldProps: vi.fn(),
44
+ getArrayHelpers: vi.fn(),
45
+ on: vi.fn(),
46
+ ...overrides,
47
+ } as unknown as UseFormaReturn;
48
+ }
49
+
50
+ function renderWithContext(
51
+ ui: React.ReactElement,
52
+ contextOverrides: Partial<UseFormaReturn> = {},
53
+ ) {
54
+ const ctx = mockFormaContext(contextOverrides);
55
+ return render(
56
+ <FormaContext.Provider value={ctx}>{ui}</FormaContext.Provider>,
57
+ );
58
+ }
59
+
60
+ // ============================================================================
61
+ // FieldWrapper
62
+ // ============================================================================
63
+ describe("FieldWrapper", () => {
64
+ function makeProps(
65
+ overrides: Partial<FieldWrapperProps> = {},
66
+ ): FieldWrapperProps {
67
+ return {
68
+ fieldPath: "testField",
69
+ field: {
70
+ type: "text",
71
+ label: "Test Field",
72
+ } as FieldWrapperProps["field"],
73
+ children: <input type="text" />,
74
+ errors: [],
75
+ touched: false,
76
+ required: false,
77
+ showRequiredIndicator: false,
78
+ visible: true,
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ it("renders label with field name", () => {
84
+ renderWithContext(<FieldWrapper {...makeProps()} />);
85
+ expect(screen.getByText("Test Field")).toBeInTheDocument();
86
+ });
87
+
88
+ it("shows required indicator when showRequiredIndicator is true", () => {
89
+ renderWithContext(
90
+ <FieldWrapper {...makeProps({ showRequiredIndicator: true })} />,
91
+ );
92
+ expect(screen.getByText("*")).toBeInTheDocument();
93
+ });
94
+
95
+ it("hides required indicator when showRequiredIndicator is false", () => {
96
+ renderWithContext(
97
+ <FieldWrapper {...makeProps({ showRequiredIndicator: false })} />,
98
+ );
99
+ expect(screen.queryByText("*")).not.toBeInTheDocument();
100
+ });
101
+
102
+ it("renders description when provided", () => {
103
+ renderWithContext(
104
+ <FieldWrapper
105
+ {...makeProps({
106
+ field: {
107
+ type: "text",
108
+ label: "Test Field",
109
+ description: "Help text here",
110
+ } as FieldWrapperProps["field"],
111
+ })}
112
+ />,
113
+ );
114
+ expect(screen.getByText("Help text here")).toBeInTheDocument();
115
+ });
116
+
117
+ it("renders children", () => {
118
+ renderWithContext(
119
+ <FieldWrapper
120
+ {...makeProps({ children: <span>Child content</span> })}
121
+ />,
122
+ );
123
+ expect(screen.getByText("Child content")).toBeInTheDocument();
124
+ });
125
+
126
+ it("does NOT render errors when touched is false and not submitted", () => {
127
+ const errors: FieldError[] = [
128
+ { field: "testField", message: "Required", severity: "error" },
129
+ ];
130
+ renderWithContext(
131
+ <FieldWrapper {...makeProps({ errors, touched: false })} />,
132
+ );
133
+ expect(screen.queryByText("Required")).not.toBeInTheDocument();
134
+ });
135
+
136
+ it("renders errors when touched is true and errors exist", () => {
137
+ const errors: FieldError[] = [
138
+ { field: "testField", message: "Required", severity: "error" },
139
+ ];
140
+ renderWithContext(
141
+ <FieldWrapper {...makeProps({ errors, touched: true })} />,
142
+ );
143
+ expect(screen.getByText("Required")).toBeInTheDocument();
144
+ });
145
+
146
+ it("renders errors when isSubmitted is true even if not touched", () => {
147
+ const errors: FieldError[] = [
148
+ { field: "testField", message: "Required", severity: "error" },
149
+ ];
150
+ renderWithContext(
151
+ <FieldWrapper {...makeProps({ errors, touched: false })} />,
152
+ { isSubmitted: true },
153
+ );
154
+ expect(screen.getByText("Required")).toBeInTheDocument();
155
+ });
156
+
157
+ it("error container has role=alert", () => {
158
+ const errors: FieldError[] = [
159
+ { field: "testField", message: "Required", severity: "error" },
160
+ ];
161
+ renderWithContext(
162
+ <FieldWrapper {...makeProps({ errors, touched: true })} />,
163
+ );
164
+ expect(screen.getByRole("alert")).toBeInTheDocument();
165
+ });
166
+
167
+ it("returns null when visible is false", () => {
168
+ const { container } = renderWithContext(
169
+ <FieldWrapper {...makeProps({ visible: false })} />,
170
+ );
171
+ expect(container.firstChild).toBeNull();
172
+ });
173
+
174
+ it("applies forma-field--error class when has visible errors", () => {
175
+ const errors: FieldError[] = [
176
+ { field: "testField", message: "Required", severity: "error" },
177
+ ];
178
+ const { container } = renderWithContext(
179
+ <FieldWrapper {...makeProps({ errors, touched: true })} />,
180
+ );
181
+ expect(container.firstChild).toHaveClass("forma-field--error");
182
+ });
183
+
184
+ it("applies forma-field--required class when showRequiredIndicator", () => {
185
+ const { container } = renderWithContext(
186
+ <FieldWrapper {...makeProps({ showRequiredIndicator: true })} />,
187
+ );
188
+ expect(container.firstChild).toHaveClass("forma-field--required");
189
+ });
190
+ });
191
+
192
+ // ============================================================================
193
+ // FormLayout
194
+ // ============================================================================
195
+ describe("FormLayout", () => {
196
+ it("renders as form element", () => {
197
+ const { container } = render(
198
+ <FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
199
+ <div>Content</div>
200
+ </FormLayout>,
201
+ );
202
+ expect(container.querySelector("form")).toBeInTheDocument();
203
+ });
204
+
205
+ it("calls onSubmit on form submit", () => {
206
+ const onSubmit = vi.fn();
207
+ const { container } = render(
208
+ <FormLayout onSubmit={onSubmit} isSubmitting={false} isValid={true}>
209
+ <div>Content</div>
210
+ </FormLayout>,
211
+ );
212
+ fireEvent.submit(container.querySelector("form")!);
213
+ expect(onSubmit).toHaveBeenCalled();
214
+ });
215
+
216
+ it("renders submit button with Submit text", () => {
217
+ render(
218
+ <FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
219
+ <div>Content</div>
220
+ </FormLayout>,
221
+ );
222
+ expect(screen.getByRole("button")).toHaveTextContent("Submit");
223
+ });
224
+
225
+ it("shows Submitting... and disables button when isSubmitting", () => {
226
+ render(
227
+ <FormLayout onSubmit={vi.fn()} isSubmitting={true} isValid={true}>
228
+ <div>Content</div>
229
+ </FormLayout>,
230
+ );
231
+ const button = screen.getByRole("button");
232
+ expect(button).toHaveTextContent("Submitting...");
233
+ expect(button).toBeDisabled();
234
+ });
235
+
236
+ it("sets aria-busy on button when submitting", () => {
237
+ render(
238
+ <FormLayout onSubmit={vi.fn()} isSubmitting={true} isValid={true}>
239
+ <div>Content</div>
240
+ </FormLayout>,
241
+ );
242
+ expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
243
+ });
244
+
245
+ it("renders children inside form", () => {
246
+ render(
247
+ <FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
248
+ <div>Form content</div>
249
+ </FormLayout>,
250
+ );
251
+ expect(screen.getByText("Form content")).toBeInTheDocument();
252
+ });
253
+ });
254
+
255
+ // ============================================================================
256
+ // PageWrapper
257
+ // ============================================================================
258
+ describe("PageWrapper", () => {
259
+ function makePageProps(
260
+ overrides: Partial<PageWrapperProps> = {},
261
+ ): PageWrapperProps {
262
+ return {
263
+ title: "Page Title",
264
+ children: <div>Page content</div>,
265
+ pageIndex: 0,
266
+ totalPages: 3,
267
+ ...overrides,
268
+ };
269
+ }
270
+
271
+ it("renders title as h2", () => {
272
+ render(<PageWrapper {...makePageProps()} />);
273
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
274
+ "Page Title",
275
+ );
276
+ });
277
+
278
+ it("renders description when provided", () => {
279
+ render(
280
+ <PageWrapper
281
+ {...makePageProps({ description: "Some description" })}
282
+ />,
283
+ );
284
+ expect(screen.getByText("Some description")).toBeInTheDocument();
285
+ });
286
+
287
+ it("omits description element when not provided", () => {
288
+ const { container } = render(<PageWrapper {...makePageProps()} />);
289
+ expect(
290
+ container.querySelector(".forma-page__description"),
291
+ ).not.toBeInTheDocument();
292
+ });
293
+
294
+ it("renders children", () => {
295
+ render(<PageWrapper {...makePageProps()} />);
296
+ expect(screen.getByText("Page content")).toBeInTheDocument();
297
+ });
298
+ });
@@ -0,0 +1,43 @@
1
+ import React, { forwardRef } from "react";
2
+ import { FormRenderer } from "../FormRenderer.js";
3
+ import type { FormRendererProps, FormRendererHandle } from "../FormRenderer.js";
4
+ import {
5
+ defaultComponentMap,
6
+ defaultFieldWrapper,
7
+ defaultLayout,
8
+ defaultWizardLayout,
9
+ defaultPageWrapper,
10
+ } from "./componentMap.js";
11
+
12
+ export interface DefaultFormRendererProps
13
+ extends Omit<FormRendererProps, "components"> {
14
+ /** Component map (defaults to defaultComponentMap if not provided) */
15
+ components?: FormRendererProps["components"];
16
+ /** Use wizard layout for multi-page forms */
17
+ wizardLayout?: boolean;
18
+ }
19
+
20
+ export const DefaultFormRenderer = forwardRef<
21
+ FormRendererHandle,
22
+ DefaultFormRendererProps
23
+ >(function DefaultFormRenderer(props, ref) {
24
+ const {
25
+ components,
26
+ wizardLayout,
27
+ layout,
28
+ fieldWrapper,
29
+ pageWrapper,
30
+ ...rest
31
+ } = props;
32
+
33
+ return (
34
+ <FormRenderer
35
+ ref={ref}
36
+ components={components ?? defaultComponentMap}
37
+ fieldWrapper={fieldWrapper ?? defaultFieldWrapper}
38
+ layout={layout ?? (wizardLayout ? defaultWizardLayout : defaultLayout)}
39
+ pageWrapper={pageWrapper ?? defaultPageWrapper}
40
+ {...rest}
41
+ />
42
+ );
43
+ });
@@ -0,0 +1,45 @@
1
+ import type { ComponentMap } from "../types.js";
2
+ import { TextInput } from "./components/TextInput.js";
3
+ import { TextareaInput } from "./components/TextareaInput.js";
4
+ import { NumberInput, IntegerInput } from "./components/NumberInput.js";
5
+ import { BooleanInput } from "./components/BooleanInput.js";
6
+ import { DateInput, DateTimeInput } from "./components/DateInput.js";
7
+ import { SelectInput } from "./components/SelectInput.js";
8
+ import { MultiSelectInput } from "./components/MultiSelectInput.js";
9
+ import { ArrayField } from "./components/ArrayField.js";
10
+ import { ObjectField } from "./components/ObjectField.js";
11
+ import { ComputedDisplay } from "./components/ComputedDisplay.js";
12
+ import { DisplayField } from "./components/DisplayField.js";
13
+ import { MatrixField } from "./components/MatrixField.js";
14
+ import { FallbackField } from "./components/FallbackField.js";
15
+ import { FieldWrapper } from "./layout/FieldWrapper.js";
16
+ import { FormLayout } from "./layout/FormLayout.js";
17
+ import { WizardLayout } from "./layout/WizardLayout.js";
18
+ import { PageWrapper } from "./layout/PageWrapper.js";
19
+
20
+ export const defaultComponentMap: ComponentMap = {
21
+ text: TextInput,
22
+ email: TextInput,
23
+ phone: TextInput,
24
+ url: TextInput,
25
+ password: TextInput,
26
+ textarea: TextareaInput,
27
+ number: NumberInput,
28
+ integer: IntegerInput,
29
+ boolean: BooleanInput,
30
+ date: DateInput,
31
+ datetime: DateTimeInput,
32
+ select: SelectInput,
33
+ multiselect: MultiSelectInput,
34
+ array: ArrayField,
35
+ object: ObjectField,
36
+ computed: ComputedDisplay,
37
+ display: DisplayField,
38
+ matrix: MatrixField,
39
+ fallback: FallbackField,
40
+ };
41
+
42
+ export const defaultFieldWrapper = FieldWrapper;
43
+ export const defaultLayout = FormLayout;
44
+ export const defaultWizardLayout = WizardLayout;
45
+ export const defaultPageWrapper = PageWrapper;
@@ -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
+ }