@formos/react 0.1.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 +499 -0
- package/dist/index.d.ts +413 -0
- package/dist/index.js +396 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# @formos/react
|
|
2
|
+
|
|
3
|
+
React bindings for the Formos form engine.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
`@formos/react` provides **React hooks and components** to integrate the headless `@formos/kernel` with React applications. It bridges the gap between the framework-agnostic form engine and React's component model.
|
|
8
|
+
|
|
9
|
+
## Responsibilities
|
|
10
|
+
|
|
11
|
+
This package is responsible for:
|
|
12
|
+
|
|
13
|
+
- **React Context Management**: Providing form engine instance via React context
|
|
14
|
+
- **Hook API**: Exposing form functionality through idiomatic React hooks
|
|
15
|
+
- **State Synchronization**: Keeping React components in sync with form state
|
|
16
|
+
- **SSR Compatibility**: Safe server-side rendering support
|
|
17
|
+
- **Performance Optimization**: Minimizing unnecessary re-renders
|
|
18
|
+
|
|
19
|
+
## What This Package Does NOT Do
|
|
20
|
+
|
|
21
|
+
❌ **NO UI Components**
|
|
22
|
+
- Does not provide input components, buttons, or form elements
|
|
23
|
+
- Does not include styling or CSS
|
|
24
|
+
- Does not use shadcn/ui or any UI library
|
|
25
|
+
- Headless - bring your own UI
|
|
26
|
+
|
|
27
|
+
❌ **NO Form Logic**
|
|
28
|
+
- Does not contain validation logic (use kernel adapters)
|
|
29
|
+
- Does not define schemas (use `@formos/schema`)
|
|
30
|
+
- Does not execute effects or validators
|
|
31
|
+
- Pure React bindings only
|
|
32
|
+
|
|
33
|
+
❌ **NO Styling or Theming**
|
|
34
|
+
- No CSS or style objects
|
|
35
|
+
- No theme providers
|
|
36
|
+
- Style your components however you want
|
|
37
|
+
|
|
38
|
+
**Think of it as:** React hooks for the form engine, not a complete form library.
|
|
39
|
+
|
|
40
|
+
## Stability Guarantee
|
|
41
|
+
|
|
42
|
+
### Hooks API is STABLE ✅
|
|
43
|
+
|
|
44
|
+
The public hooks and components are committed to backward compatibility:
|
|
45
|
+
- Hook signatures won't change
|
|
46
|
+
- Return types remain consistent
|
|
47
|
+
- Context API is stable
|
|
48
|
+
|
|
49
|
+
This enables:
|
|
50
|
+
- **Safe updates**: Upgrade without breaking UI
|
|
51
|
+
- **Custom UI flexibility**: Build any UI on these hooks
|
|
52
|
+
- **Long-term reliability**: Code written today works tomorrow
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pnpm add @formos/react @formos/kernel @formos/schema react
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Basic Usage
|
|
61
|
+
|
|
62
|
+
### 1. Setup FormProvider
|
|
63
|
+
|
|
64
|
+
Wrap your application with `FormProvider`:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { FormProvider } from '@formos/react';
|
|
68
|
+
import { normalizeSchema, type FormSchemaV1 } from '@formos/schema';
|
|
69
|
+
|
|
70
|
+
const schema: FormSchemaV1 = {
|
|
71
|
+
version: 'v1',
|
|
72
|
+
fields: [
|
|
73
|
+
{
|
|
74
|
+
name: 'email',
|
|
75
|
+
type: 'email',
|
|
76
|
+
required: true
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'password',
|
|
80
|
+
type: 'password',
|
|
81
|
+
required: true
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const normalized = normalizeSchema(schema);
|
|
87
|
+
|
|
88
|
+
function App() {
|
|
89
|
+
return (
|
|
90
|
+
<FormProvider
|
|
91
|
+
schema={normalized}
|
|
92
|
+
validators={{
|
|
93
|
+
required: (value) => value ? null : 'Required',
|
|
94
|
+
email: (value) => {
|
|
95
|
+
const regex = /\S+@\S+\.\S+/;
|
|
96
|
+
return regex.test(String(value)) ? null : 'Invalid email';
|
|
97
|
+
}
|
|
98
|
+
}}
|
|
99
|
+
onSubmit={async (values) => {
|
|
100
|
+
console.log('Form submitted:', values);
|
|
101
|
+
await fetch('/api/submit', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
body: JSON.stringify(values)
|
|
104
|
+
});
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<LoginForm />
|
|
108
|
+
</FormProvider>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 2. Use Form Hooks
|
|
114
|
+
|
|
115
|
+
#### useForm - Form-level operations
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
import { useForm } from '@formos/react';
|
|
119
|
+
|
|
120
|
+
function LoginForm() {
|
|
121
|
+
const { submit, reset, isValid, isDirty } = useForm();
|
|
122
|
+
|
|
123
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
const success = await submit();
|
|
126
|
+
|
|
127
|
+
if (success) {
|
|
128
|
+
console.log('Login successful');
|
|
129
|
+
} else {
|
|
130
|
+
console.log('Validation failed');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<form onSubmit={handleSubmit}>
|
|
136
|
+
<EmailField />
|
|
137
|
+
<PasswordField />
|
|
138
|
+
|
|
139
|
+
<button type="submit" disabled={!isValid() || !isDirty()}>
|
|
140
|
+
Login
|
|
141
|
+
</button>
|
|
142
|
+
<button type="button" onClick={reset}>
|
|
143
|
+
Reset
|
|
144
|
+
</button>
|
|
145
|
+
</form>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### useField - Field-level state and actions
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { useField } from '@formos/react';
|
|
154
|
+
|
|
155
|
+
function EmailField() {
|
|
156
|
+
const {
|
|
157
|
+
value,
|
|
158
|
+
error,
|
|
159
|
+
touched,
|
|
160
|
+
dirty,
|
|
161
|
+
setValue,
|
|
162
|
+
markTouched
|
|
163
|
+
} = useField('email', {
|
|
164
|
+
validateOnChange: true,
|
|
165
|
+
validateOnBlur: true
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div>
|
|
170
|
+
<label htmlFor="email">Email</label>
|
|
171
|
+
<input
|
|
172
|
+
id="email"
|
|
173
|
+
type="email"
|
|
174
|
+
value={String(value || '')}
|
|
175
|
+
onChange={(e) => setValue(e.target.value)}
|
|
176
|
+
onBlur={() => markTouched()}
|
|
177
|
+
className={touched && error ? 'error' : ''}
|
|
178
|
+
/>
|
|
179
|
+
{touched && error && (
|
|
180
|
+
<span className="error-message">{error}</span>
|
|
181
|
+
)}
|
|
182
|
+
{dirty && <span className="dirty-indicator">*</span>}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### useFormState - Observe form state
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
import { useFormState } from '@formos/react';
|
|
192
|
+
|
|
193
|
+
function SubmitButton() {
|
|
194
|
+
const { isValid, isDirty, isSubmitting } = useFormState();
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<button
|
|
198
|
+
type="submit"
|
|
199
|
+
disabled={!isValid || !isDirty || isSubmitting}
|
|
200
|
+
>
|
|
201
|
+
{isSubmitting ? 'Submitting...' : 'Submit Form'}
|
|
202
|
+
</button>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ErrorSummary() {
|
|
207
|
+
const { errors } = useFormState();
|
|
208
|
+
const errorList = Object.entries(errors).filter(([_, error]) => error);
|
|
209
|
+
|
|
210
|
+
if (errorList.length === 0) return null;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="error-summary">
|
|
214
|
+
<h3>Please fix the following errors:</h3>
|
|
215
|
+
<ul>
|
|
216
|
+
{errorList.map(([field, error]) => (
|
|
217
|
+
<li key={field}>{field}: {error}</li>
|
|
218
|
+
))}
|
|
219
|
+
</ul>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Multi-Step Forms
|
|
226
|
+
|
|
227
|
+
Use `useStep` for multi-step form navigation:
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
import { useStep } from '@formos/react';
|
|
231
|
+
|
|
232
|
+
const multiStepSchema: FormSchemaV1 = {
|
|
233
|
+
version: 'v1',
|
|
234
|
+
fields: [
|
|
235
|
+
{ name: 'firstName', type: 'text' },
|
|
236
|
+
{ name: 'lastName', type: 'text' },
|
|
237
|
+
{ name: 'email', type: 'email' },
|
|
238
|
+
{ name: 'phone', type: 'tel' }
|
|
239
|
+
],
|
|
240
|
+
steps: [
|
|
241
|
+
{ id: 'personal', title: 'Personal Info', fields: ['firstName', 'lastName'] },
|
|
242
|
+
{ id: 'contact', title: 'Contact', fields: ['email', 'phone'] }
|
|
243
|
+
]
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
function StepNavigator() {
|
|
247
|
+
const {
|
|
248
|
+
currentStep,
|
|
249
|
+
totalSteps,
|
|
250
|
+
nextStep,
|
|
251
|
+
prevStep,
|
|
252
|
+
canGoToStep
|
|
253
|
+
} = useStep();
|
|
254
|
+
|
|
255
|
+
const handleNext = async () => {
|
|
256
|
+
const moved = await nextStep();
|
|
257
|
+
if (!moved) {
|
|
258
|
+
alert('Please fix errors before proceeding');
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div>
|
|
264
|
+
<div className="step-indicator">
|
|
265
|
+
Step {currentStep + 1} of {totalSteps}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div className="step-content">
|
|
269
|
+
{currentStep === 0 ? <PersonalInfoStep /> : <ContactStep />}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="step-navigation">
|
|
273
|
+
<button
|
|
274
|
+
onClick={prevStep}
|
|
275
|
+
disabled={currentStep === 0}
|
|
276
|
+
>
|
|
277
|
+
Previous
|
|
278
|
+
</button>
|
|
279
|
+
|
|
280
|
+
<button onClick={handleNext}>
|
|
281
|
+
{currentStep === totalSteps - 1 ? 'Submit' : 'Next'}
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Advanced Patterns
|
|
290
|
+
|
|
291
|
+
### Custom Field Components
|
|
292
|
+
|
|
293
|
+
Create reusable field components:
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
interface TextFieldProps {
|
|
297
|
+
name: string;
|
|
298
|
+
label: string;
|
|
299
|
+
type?: 'text' | 'email' | 'password' | 'tel';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function TextField({ name, label, type = 'text' }: TextFieldProps) {
|
|
303
|
+
const { value, error, touched, setValue, markTouched } = useField(name);
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div className="field">
|
|
307
|
+
<label htmlFor={name}>{label}</label>
|
|
308
|
+
<input
|
|
309
|
+
id={name}
|
|
310
|
+
type={type}
|
|
311
|
+
value={String(value || '')}
|
|
312
|
+
onChange={(e) => setValue(e.target.value)}
|
|
313
|
+
onBlur={() => markTouched()}
|
|
314
|
+
aria-invalid={touched && !!error}
|
|
315
|
+
aria-describedby={error ? `${name}-error` : undefined}
|
|
316
|
+
/>
|
|
317
|
+
{touched && error && (
|
|
318
|
+
<span id={`${name}-error`} className="error" role="alert">
|
|
319
|
+
{error}
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Usage
|
|
327
|
+
<TextField name="email" label="Email Address" type="email" />
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Conditional Fields
|
|
331
|
+
|
|
332
|
+
Show/hide fields based on conditions:
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
import { isFieldVisible } from '@formos/kernel';
|
|
336
|
+
|
|
337
|
+
function ConditionalField({ fieldName }: { fieldName: string }) {
|
|
338
|
+
const { schema, engine } = useForm();
|
|
339
|
+
const field = schema.fieldMap?.get(fieldName);
|
|
340
|
+
|
|
341
|
+
if (!field || !isFieldVisible(field, (name) => engine.getValue(name))) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return <TextField name={fieldName} label={field.label || fieldName} />;
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Form-level Validation
|
|
350
|
+
|
|
351
|
+
Trigger validation at form level:
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
function ValidateAllButton() {
|
|
355
|
+
const { validate, getErrors } = useForm();
|
|
356
|
+
|
|
357
|
+
const handleValidate = async () => {
|
|
358
|
+
const isValid = await validate('manual');
|
|
359
|
+
|
|
360
|
+
if (isValid) {
|
|
361
|
+
alert('Form is valid!');
|
|
362
|
+
} else {
|
|
363
|
+
const errors = getErrors();
|
|
364
|
+
console.log('Validation errors:', errors);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<button type="button" onClick={handleValidate}>
|
|
370
|
+
Validate All Fields
|
|
371
|
+
</button>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## SSR (Server-Side Rendering)
|
|
377
|
+
|
|
378
|
+
The package is SSR-safe. Use it with Next.js, Remix, or other SSR frameworks:
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
// app/page.tsx (Next.js App Router)
|
|
382
|
+
'use client';
|
|
383
|
+
|
|
384
|
+
import { FormProvider, useField } from '@formos/react';
|
|
385
|
+
import { normalizeSchema } from '@formos/schema';
|
|
386
|
+
|
|
387
|
+
const schema = normalizeSchema({
|
|
388
|
+
version: 'v1',
|
|
389
|
+
fields: [{ name: 'email', type: 'email' }]
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
export default function ContactForm() {
|
|
393
|
+
return (
|
|
394
|
+
<FormProvider schema={schema} validators={{/* ... */}}>
|
|
395
|
+
<EmailField />
|
|
396
|
+
</FormProvider>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Note:** FormProvider must be used in a Client Component (`'use client'`) because it manages React state.
|
|
402
|
+
|
|
403
|
+
## Performance Optimization
|
|
404
|
+
|
|
405
|
+
### Minimize Re-renders
|
|
406
|
+
|
|
407
|
+
The hooks use React context with a version counter system. Components only re-render when:
|
|
408
|
+
1. Their specific field value changes (for `useField`)
|
|
409
|
+
2. Form state changes (for `useFormState`)
|
|
410
|
+
3. Step changes (for `useStep`)
|
|
411
|
+
|
|
412
|
+
### Memoization
|
|
413
|
+
|
|
414
|
+
Form engine instance is memoized and only recreated if schema or validators change:
|
|
415
|
+
|
|
416
|
+
```tsx
|
|
417
|
+
// Engine is stable across re-renders
|
|
418
|
+
const engine = useMemo(() => createFormEngine(schema, options), [schema, options]);
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Selective Subscriptions
|
|
422
|
+
|
|
423
|
+
Only subscribe to the state you need:
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
// ❌ Bad - subscribes to all form state
|
|
427
|
+
function SubmitButton() {
|
|
428
|
+
const { values, errors, isDirty } = useFormState();
|
|
429
|
+
return <button disabled={!isDirty}>Submit</button>;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ✅ Good - only checks dirty state when needed
|
|
433
|
+
function SubmitButton() {
|
|
434
|
+
const { isDirty } = useForm();
|
|
435
|
+
return <button disabled={!isDirty()}>Submit</button>;
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## API Reference
|
|
440
|
+
|
|
441
|
+
### Components
|
|
442
|
+
|
|
443
|
+
- **`FormProvider`**: Context provider for form engine
|
|
444
|
+
|
|
445
|
+
### Hooks
|
|
446
|
+
|
|
447
|
+
- **`useForm()`**: Form-level operations and engine access
|
|
448
|
+
- **`useField(name, options?)`**: Field-level state and actions
|
|
449
|
+
- **`useFormState()`**: Reactive form state observation
|
|
450
|
+
- **`useStep()`**: Multi-step navigation (multi-step forms only)
|
|
451
|
+
|
|
452
|
+
### Types
|
|
453
|
+
|
|
454
|
+
- **`FormProviderProps`**: Props for FormProvider
|
|
455
|
+
- **`UseFieldOptions`**: Options for useField
|
|
456
|
+
- **`UseFieldResult`**: Return type of useField
|
|
457
|
+
- **`UseFormStateResult`**: Return type of useFormState
|
|
458
|
+
- **`UseStepResult`**: Return type of useStep
|
|
459
|
+
|
|
460
|
+
## TypeScript Support
|
|
461
|
+
|
|
462
|
+
Full TypeScript support with strict typing:
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
import type { UseFieldResult, UseFormStateResult } from '@formos/react';
|
|
466
|
+
|
|
467
|
+
// Field result is fully typed
|
|
468
|
+
const field: UseFieldResult = useField('email');
|
|
469
|
+
|
|
470
|
+
// Form state is fully typed
|
|
471
|
+
const state: UseFormStateResult = useFormState();
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Package Structure
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
@formos/react/
|
|
478
|
+
├── src/
|
|
479
|
+
│ ├── index.ts # Public API exports
|
|
480
|
+
│ ├── FormContext.tsx # React context
|
|
481
|
+
│ ├── FormProvider.tsx # Provider component
|
|
482
|
+
│ ├── useForm.ts # Form hook
|
|
483
|
+
│ ├── useField.ts # Field hook
|
|
484
|
+
│ ├── useFormState.ts # State observation hook
|
|
485
|
+
│ ├── useStep.ts # Multi-step hook
|
|
486
|
+
│ └── types.ts # Type definitions
|
|
487
|
+
├── package.json
|
|
488
|
+
├── tsconfig.json
|
|
489
|
+
├── tsup.config.ts
|
|
490
|
+
└── README.md
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Contributing
|
|
494
|
+
|
|
495
|
+
This package is part of the Formos monorepo. See the root README for contribution guidelines.
|
|
496
|
+
|
|
497
|
+
## License
|
|
498
|
+
|
|
499
|
+
MIT
|