@fogpipe/forma-react 0.6.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 +277 -0
- package/dist/index.d.ts +668 -0
- package/dist/index.js +1039 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/ErrorBoundary.tsx +115 -0
- package/src/FieldRenderer.tsx +258 -0
- package/src/FormRenderer.tsx +470 -0
- package/src/__tests__/FormRenderer.test.tsx +803 -0
- package/src/__tests__/test-utils.tsx +297 -0
- package/src/__tests__/useForma.test.ts +1103 -0
- package/src/context.ts +23 -0
- package/src/index.ts +91 -0
- package/src/types.ts +482 -0
- package/src/useForma.ts +681 -0
package/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# @fogpipe/forma-react
|
|
2
|
+
|
|
3
|
+
Headless React form renderer for Forma specifications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @fogpipe/forma-core @fogpipe/forma-react
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Headless Architecture** - Bring your own UI components
|
|
14
|
+
- **useForma Hook** - Complete form state management
|
|
15
|
+
- **FormRenderer Component** - Declarative form rendering
|
|
16
|
+
- **Multi-Page Support** - Built-in wizard navigation
|
|
17
|
+
- **Type Safety** - Full TypeScript support
|
|
18
|
+
- **Accessibility** - Built-in ARIA attribute support
|
|
19
|
+
- **Error Boundary** - Graceful error handling for form components
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Define Your Components
|
|
24
|
+
|
|
25
|
+
Components receive `{ field, spec }` props. The `field` object contains all field state and handlers:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import type { ComponentMap, TextComponentProps, BooleanComponentProps } from '@fogpipe/forma-react';
|
|
29
|
+
|
|
30
|
+
const TextInput = ({ field }: TextComponentProps) => (
|
|
31
|
+
<div>
|
|
32
|
+
<input
|
|
33
|
+
type="text"
|
|
34
|
+
value={field.value || ''}
|
|
35
|
+
onChange={(e) => field.onChange(e.target.value)}
|
|
36
|
+
onBlur={field.onBlur}
|
|
37
|
+
placeholder={field.placeholder}
|
|
38
|
+
aria-invalid={field['aria-invalid']}
|
|
39
|
+
aria-describedby={field['aria-describedby']}
|
|
40
|
+
aria-required={field['aria-required']}
|
|
41
|
+
/>
|
|
42
|
+
{field.errors.map((e, i) => (
|
|
43
|
+
<span key={i} className="error">{e.message}</span>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const Checkbox = ({ field }: BooleanComponentProps) => (
|
|
49
|
+
<label>
|
|
50
|
+
<input
|
|
51
|
+
type="checkbox"
|
|
52
|
+
checked={field.value || false}
|
|
53
|
+
onChange={(e) => field.onChange(e.target.checked)}
|
|
54
|
+
/>
|
|
55
|
+
{field.label}
|
|
56
|
+
</label>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const components: ComponentMap = {
|
|
60
|
+
text: TextInput,
|
|
61
|
+
email: TextInput,
|
|
62
|
+
boolean: Checkbox,
|
|
63
|
+
// ... more components
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Render the Form
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { FormRenderer } from '@fogpipe/forma-react';
|
|
71
|
+
import type { Forma } from '@fogpipe/forma-core';
|
|
72
|
+
|
|
73
|
+
const myForm: Forma = {
|
|
74
|
+
meta: { title: "Contact Us" },
|
|
75
|
+
fields: [
|
|
76
|
+
{ id: "name", type: "text", label: "Name", required: true },
|
|
77
|
+
{ id: "email", type: "email", label: "Email", required: true },
|
|
78
|
+
{ id: "subscribe", type: "boolean", label: "Subscribe to newsletter" }
|
|
79
|
+
]
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function App() {
|
|
83
|
+
const handleSubmit = (data: Record<string, unknown>) => {
|
|
84
|
+
console.log('Submitted:', data);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<FormRenderer
|
|
89
|
+
spec={myForm}
|
|
90
|
+
components={components}
|
|
91
|
+
onSubmit={handleSubmit}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## useForma Hook
|
|
98
|
+
|
|
99
|
+
For custom rendering, use the `useForma` hook directly:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { useForma } from '@fogpipe/forma-react';
|
|
103
|
+
|
|
104
|
+
function CustomForm({ spec }: { spec: Forma }) {
|
|
105
|
+
const {
|
|
106
|
+
data,
|
|
107
|
+
errors,
|
|
108
|
+
visibility,
|
|
109
|
+
required,
|
|
110
|
+
isValid,
|
|
111
|
+
isSubmitting,
|
|
112
|
+
setFieldValue,
|
|
113
|
+
setFieldTouched,
|
|
114
|
+
submitForm,
|
|
115
|
+
} = useForma({
|
|
116
|
+
spec,
|
|
117
|
+
onSubmit: (data) => console.log(data)
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<form onSubmit={(e) => { e.preventDefault(); submitForm(); }}>
|
|
122
|
+
{spec.fields.map(field => {
|
|
123
|
+
if (!visibility[field.id]) return null;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div key={field.id}>
|
|
127
|
+
<label>{field.label}</label>
|
|
128
|
+
<input
|
|
129
|
+
value={String(data[field.id] || '')}
|
|
130
|
+
onChange={(e) => setFieldValue(field.id, e.target.value)}
|
|
131
|
+
onBlur={() => setFieldTouched(field.id)}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
|
|
137
|
+
<button type="submit" disabled={isSubmitting || !isValid}>
|
|
138
|
+
Submit
|
|
139
|
+
</button>
|
|
140
|
+
</form>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Multi-Page Forms (Wizard)
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
function WizardForm({ spec }: { spec: Forma }) {
|
|
149
|
+
const forma = useForma({ spec, onSubmit: handleSubmit });
|
|
150
|
+
const { wizard } = forma;
|
|
151
|
+
|
|
152
|
+
if (!wizard) return <div>Not a wizard form</div>;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div>
|
|
156
|
+
{/* Page indicator */}
|
|
157
|
+
<div>Page {wizard.currentPageIndex + 1} of {wizard.pages.length}</div>
|
|
158
|
+
|
|
159
|
+
{/* Current page fields */}
|
|
160
|
+
{wizard.currentPage?.fields.map(fieldId => {
|
|
161
|
+
const field = spec.fields.find(f => f.id === fieldId);
|
|
162
|
+
if (!field) return null;
|
|
163
|
+
// Render field...
|
|
164
|
+
})}
|
|
165
|
+
|
|
166
|
+
{/* Navigation */}
|
|
167
|
+
<button onClick={wizard.previousPage} disabled={!wizard.hasPreviousPage}>
|
|
168
|
+
Previous
|
|
169
|
+
</button>
|
|
170
|
+
|
|
171
|
+
{wizard.isLastPage ? (
|
|
172
|
+
<button onClick={forma.submitForm}>Submit</button>
|
|
173
|
+
) : (
|
|
174
|
+
<button onClick={wizard.nextPage} disabled={!wizard.canProceed}>
|
|
175
|
+
Next
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## API Reference
|
|
184
|
+
|
|
185
|
+
### FormRenderer Props
|
|
186
|
+
|
|
187
|
+
| Prop | Type | Description |
|
|
188
|
+
|------|------|-------------|
|
|
189
|
+
| `spec` | `Forma` | The Forma specification |
|
|
190
|
+
| `components` | `ComponentMap` | Map of field types to components |
|
|
191
|
+
| `initialData` | `Record<string, unknown>` | Initial form values |
|
|
192
|
+
| `onSubmit` | `(data) => void` | Submit handler |
|
|
193
|
+
| `onChange` | `(data, computed) => void` | Change handler |
|
|
194
|
+
| `layout` | `React.ComponentType<LayoutProps>` | Custom layout |
|
|
195
|
+
| `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Custom field wrapper |
|
|
196
|
+
| `validateOn` | `"change" \| "blur" \| "submit"` | Validation timing |
|
|
197
|
+
|
|
198
|
+
### FormRenderer Ref
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
const formRef = useRef<FormRendererHandle>(null);
|
|
202
|
+
|
|
203
|
+
// Imperative methods
|
|
204
|
+
formRef.current?.submitForm();
|
|
205
|
+
formRef.current?.resetForm();
|
|
206
|
+
formRef.current?.validateForm();
|
|
207
|
+
formRef.current?.focusFirstError();
|
|
208
|
+
formRef.current?.getValues();
|
|
209
|
+
formRef.current?.setValues({ name: "John" });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### useForma Return Value
|
|
213
|
+
|
|
214
|
+
| Property | Type | Description |
|
|
215
|
+
|----------|------|-------------|
|
|
216
|
+
| `data` | `Record<string, unknown>` | Current form values |
|
|
217
|
+
| `computed` | `Record<string, unknown>` | Computed field values |
|
|
218
|
+
| `visibility` | `Record<string, boolean>` | Field visibility map |
|
|
219
|
+
| `required` | `Record<string, boolean>` | Field required state |
|
|
220
|
+
| `enabled` | `Record<string, boolean>` | Field enabled state |
|
|
221
|
+
| `errors` | `FieldError[]` | Validation errors |
|
|
222
|
+
| `isValid` | `boolean` | Form validity |
|
|
223
|
+
| `isSubmitting` | `boolean` | Submission in progress |
|
|
224
|
+
| `isDirty` | `boolean` | Any field modified |
|
|
225
|
+
| `wizard` | `WizardHelpers \| null` | Wizard navigation |
|
|
226
|
+
|
|
227
|
+
### useForma Methods
|
|
228
|
+
|
|
229
|
+
| Method | Description |
|
|
230
|
+
|--------|-------------|
|
|
231
|
+
| `setFieldValue(path, value)` | Set field value |
|
|
232
|
+
| `setFieldTouched(path, touched?)` | Mark field as touched |
|
|
233
|
+
| `setValues(values)` | Set multiple values |
|
|
234
|
+
| `validateField(path)` | Validate single field |
|
|
235
|
+
| `validateForm()` | Validate entire form |
|
|
236
|
+
| `submitForm()` | Submit the form |
|
|
237
|
+
| `resetForm()` | Reset to initial values |
|
|
238
|
+
|
|
239
|
+
### useForma Options
|
|
240
|
+
|
|
241
|
+
| Option | Type | Default | Description |
|
|
242
|
+
|--------|------|---------|-------------|
|
|
243
|
+
| `spec` | `Forma` | required | The Forma specification |
|
|
244
|
+
| `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
|
|
245
|
+
| `onSubmit` | `(data) => void` | - | Submit handler |
|
|
246
|
+
| `onChange` | `(data, computed) => void` | - | Change handler |
|
|
247
|
+
| `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
|
|
248
|
+
| `referenceData` | `Record<string, unknown>` | - | Additional reference data |
|
|
249
|
+
| `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
|
|
250
|
+
|
|
251
|
+
## Error Boundary
|
|
252
|
+
|
|
253
|
+
Wrap forms with `FormaErrorBoundary` to catch render errors gracefully:
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import { FormRenderer, FormaErrorBoundary } from '@fogpipe/forma-react';
|
|
257
|
+
|
|
258
|
+
function App() {
|
|
259
|
+
return (
|
|
260
|
+
<FormaErrorBoundary
|
|
261
|
+
fallback={<div>Something went wrong with the form</div>}
|
|
262
|
+
onError={(error) => console.error('Form error:', error)}
|
|
263
|
+
>
|
|
264
|
+
<FormRenderer spec={myForm} components={components} />
|
|
265
|
+
</FormaErrorBoundary>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The error boundary supports:
|
|
271
|
+
- Custom fallback UI (static or function)
|
|
272
|
+
- `onError` callback for logging
|
|
273
|
+
- `resetKey` prop to reset error state
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
MIT
|