@cleverbrush/react-form 0.0.0-beta-20260410073748
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 +624 -0
- package/dist/FormStore.d.ts +17 -0
- package/dist/components.d.ts +92 -0
- package/dist/contexts.d.ts +22 -0
- package/dist/debounce.d.ts +7 -0
- package/dist/helpers.d.ts +38 -0
- package/dist/hooks.d.ts +36 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +132 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
# @cleverbrush/react-form
|
|
2
|
+
|
|
3
|
+
[](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
|
|
4
|
+
[](../../LICENSE)
|
|
5
|
+
<!-- coverage-badge-start -->
|
|
6
|
+

|
|
7
|
+
<!-- coverage-badge-end -->
|
|
8
|
+
|
|
9
|
+
A headless, schema-driven form system for React based on `@cleverbrush/schema`. Uses PropertyDescriptors for type-safe field binding, supports global UI renderer configuration via a provider, and is completely UI-agnostic — works with plain HTML, MUI, Ant Design, or any component library.
|
|
10
|
+
|
|
11
|
+
## Why @cleverbrush/react-form?
|
|
12
|
+
|
|
13
|
+
**The problem:** Every popular React form library — React Hook Form, Formik, React Final Form — requires you to reference fields by **string names**: `register("email")`, `<Field name="address.city" />`. The moment you pass a field name as a string, you lose TypeScript's type safety. Rename a property in your data model and the compiler stays silent — your form just silently breaks at runtime. The larger your codebase, the more of these invisible string references you accumulate, and the more fragile every refactor becomes.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
// React Hook Form — field names are plain strings
|
|
17
|
+
const { register } = useForm<User>();
|
|
18
|
+
<input {...register("name")} /> // ← no compiler error if "name" is renamed
|
|
19
|
+
<input {...register("emial")} /> // ← typo: silently fails at runtime
|
|
20
|
+
|
|
21
|
+
// Formik — same problem
|
|
22
|
+
<Field name="address.city" /> // ← rename "city" → "town" and nothing warns you
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**The solution:** `@cleverbrush/react-form` binds fields via **PropertyDescriptor selectors** — actual TypeScript expressions like `(t) => t.address.city` — instead of strings. The compiler knows the exact shape of your schema, so a renamed or mistyped property is a **compile-time error**, not a runtime surprise. On top of that, the schema **IS** the validation, the type definition, **AND** the form field configuration. One source of truth.
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// @cleverbrush/react-form — fully type-safe selectors
|
|
29
|
+
<Field forProperty={(t) => t.name} form={form} /> // ✓ checked at compile time
|
|
30
|
+
<Field forProperty={(t) => t.address.city} form={form} /> // ✓ rename "city" → compiler error
|
|
31
|
+
<Field forProperty={(t) => t.emial} form={form} /> // ✗ compile error: "emial" doesn't exist
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**What makes it different:**
|
|
35
|
+
|
|
36
|
+
| Feature | @cleverbrush/react-form | React Hook Form | Formik | React Final Form |
|
|
37
|
+
| --- | --- | --- | --- | --- |
|
|
38
|
+
| Schema-driven validation | ✓ built-in | ~ via resolver | ~ via plugin | ✗ |
|
|
39
|
+
| Single source of truth (types + validation) | ✓ | ✗ | ✗ | ✗ |
|
|
40
|
+
| Type-safe field selectors | ✓ | ~ | ✗ | ✗ |
|
|
41
|
+
| Headless / UI-agnostic | ✓ | ✓ | ~ | ✓ |
|
|
42
|
+
| Global renderer system | ✓ | ✗ | ✗ | ✗ |
|
|
43
|
+
| Auto-field rendering by type + variant | ✓ | ✗ | ✗ | ✗ |
|
|
44
|
+
| Nested objects | ✓ | ✓ | ✓ | ✓ |
|
|
45
|
+
| Async validation | ✓ | ✓ | ✓ | ✓ |
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install @cleverbrush/react-form
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Peer dependencies:** `react >=18`, `@cleverbrush/schema ^2.0.0`
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { object, string, number } from '@cleverbrush/schema';
|
|
59
|
+
import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form';
|
|
60
|
+
|
|
61
|
+
// 1. Define schema — reuse across forms, API validation, mapping, etc.
|
|
62
|
+
const ContactSchema = object({
|
|
63
|
+
name: string().required('Name is required').minLength(2, 'Name must be at least 2 characters'),
|
|
64
|
+
email: string().required('Email is required'),
|
|
65
|
+
age: number().required('Age is required').min(18, 'Must be at least 18')
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// 2. Define renderers once per app — maps schema types to UI components
|
|
69
|
+
const renderers = {
|
|
70
|
+
string: ({ value, onChange, onBlur, error, touched }) => (
|
|
71
|
+
<div>
|
|
72
|
+
<input
|
|
73
|
+
type="text"
|
|
74
|
+
value={value ?? ''}
|
|
75
|
+
onChange={(e) => onChange(e.target.value)}
|
|
76
|
+
onBlur={onBlur}
|
|
77
|
+
/>
|
|
78
|
+
{touched && error && <span className="error">{error}</span>}
|
|
79
|
+
</div>
|
|
80
|
+
),
|
|
81
|
+
number: ({ value, onChange, onBlur, error, touched }) => (
|
|
82
|
+
<div>
|
|
83
|
+
<input
|
|
84
|
+
type="number"
|
|
85
|
+
value={value ?? ''}
|
|
86
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
87
|
+
onBlur={onBlur}
|
|
88
|
+
/>
|
|
89
|
+
{touched && error && <span className="error">{error}</span>}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// 3. Each form component only picks which fields to show — no boilerplate
|
|
95
|
+
function ContactForm() {
|
|
96
|
+
const form = useSchemaForm(ContactSchema);
|
|
97
|
+
|
|
98
|
+
const handleSubmit = async () => {
|
|
99
|
+
const result = await form.submit();
|
|
100
|
+
if (result.valid) {
|
|
101
|
+
console.log('Submitted:', result.object);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div>
|
|
107
|
+
<Field forProperty={(t) => t.name} form={form} />
|
|
108
|
+
<Field forProperty={(t) => t.email} form={form} />
|
|
109
|
+
<Field forProperty={(t) => t.age} form={form} />
|
|
110
|
+
<button onClick={handleSubmit}>Submit</button>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 4. Wrap once at the app root — all forms below share the renderers
|
|
116
|
+
function App() {
|
|
117
|
+
return (
|
|
118
|
+
<FormSystemProvider renderers={renderers}>
|
|
119
|
+
<ContactForm />
|
|
120
|
+
</FormSystemProvider>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## How It Works — Step by Step
|
|
126
|
+
|
|
127
|
+
1. **Define a schema** using `@cleverbrush/schema` — this is your single source of truth for types, validation rules, and field metadata
|
|
128
|
+
2. **Register renderers** via `FormSystemProvider` — plain functions that map schema types (`"string"`, `"number"`, `"boolean"`) to your UI components (plain HTML, MUI, Ant Design, etc.)
|
|
129
|
+
3. **Create a form instance** via `useSchemaForm(schema)` — returns state management, validation, submit/reset lifecycle
|
|
130
|
+
4. **Render fields** via `<Field forProperty={(t) => t.name} form={form} />` — the component looks up the registered renderer for the field's schema type
|
|
131
|
+
5. **Submit** — `form.submit()` runs the schema's full validation and returns a typed result
|
|
132
|
+
|
|
133
|
+
## Core Concepts
|
|
134
|
+
|
|
135
|
+
| Part | Responsibility | When to Use |
|
|
136
|
+
|------|---------------|-------------|
|
|
137
|
+
| **FormSystemProvider** | Global renderer registry via React Context | Once at the app root |
|
|
138
|
+
| **useSchemaForm** | Per-schema form instance (state, validation, lifecycle) | In every component that needs a form |
|
|
139
|
+
| **useField** | Descriptor-based field binding (value, dirty, touched, error) | When you want fine-grained control |
|
|
140
|
+
| **Field** | UI-agnostic component that resolves renderers by schema type | For most form fields — quick and declarative |
|
|
141
|
+
|
|
142
|
+
## Registering Renderers
|
|
143
|
+
|
|
144
|
+
Renderers are plain functions that receive field state and return React nodes. Define a renderer map keyed by schema type (`string`, `number`, `boolean`, etc.):
|
|
145
|
+
|
|
146
|
+
### Plain HTML
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
import { FieldRenderProps } from '@cleverbrush/react-form';
|
|
150
|
+
|
|
151
|
+
const htmlRenderers = {
|
|
152
|
+
string: ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
|
|
153
|
+
<div>
|
|
154
|
+
{label && <label>{label}</label>}
|
|
155
|
+
<input
|
|
156
|
+
type="text"
|
|
157
|
+
name={name}
|
|
158
|
+
value={value ?? ''}
|
|
159
|
+
onChange={(e) => onChange(e.target.value)}
|
|
160
|
+
onBlur={onBlur}
|
|
161
|
+
{...fieldProps}
|
|
162
|
+
/>
|
|
163
|
+
{touched && error && <span className="error">{error}</span>}
|
|
164
|
+
</div>
|
|
165
|
+
),
|
|
166
|
+
number: ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
|
|
167
|
+
<div>
|
|
168
|
+
{label && <label>{label}</label>}
|
|
169
|
+
<input
|
|
170
|
+
type="number"
|
|
171
|
+
name={name}
|
|
172
|
+
value={value ?? ''}
|
|
173
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
174
|
+
onBlur={onBlur}
|
|
175
|
+
{...fieldProps}
|
|
176
|
+
/>
|
|
177
|
+
{touched && error && <span className="error">{error}</span>}
|
|
178
|
+
</div>
|
|
179
|
+
),
|
|
180
|
+
boolean: ({ value, onChange, label }: FieldRenderProps) => (
|
|
181
|
+
<label>
|
|
182
|
+
<input
|
|
183
|
+
type="checkbox"
|
|
184
|
+
checked={value ?? false}
|
|
185
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
186
|
+
/>
|
|
187
|
+
{label}
|
|
188
|
+
</label>
|
|
189
|
+
)
|
|
190
|
+
};
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Variant Renderers
|
|
194
|
+
|
|
195
|
+
You can register renderers for specific variants using a `"type:variant"` key.
|
|
196
|
+
When `<Field variant="password" />` is rendered on a `string` field, the
|
|
197
|
+
registry is checked for `"string:password"` first, then falls back to `"string"`:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
const renderers = {
|
|
201
|
+
// Default string renderer
|
|
202
|
+
string: ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
|
|
203
|
+
<div>
|
|
204
|
+
{label && <label>{label}</label>}
|
|
205
|
+
<input type="text" name={name} value={value ?? ''}
|
|
206
|
+
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
|
|
207
|
+
{...fieldProps} />
|
|
208
|
+
{touched && error && <span className="error">{error}</span>}
|
|
209
|
+
</div>
|
|
210
|
+
),
|
|
211
|
+
// Password variant — rendered when <Field variant="password" /> is used on a string field
|
|
212
|
+
'string:password': ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
|
|
213
|
+
<div>
|
|
214
|
+
{label && <label>{label}</label>}
|
|
215
|
+
<input type="password" name={name} value={value ?? ''}
|
|
216
|
+
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
|
|
217
|
+
{...fieldProps} />
|
|
218
|
+
{touched && error && <span className="error">{error}</span>}
|
|
219
|
+
</div>
|
|
220
|
+
),
|
|
221
|
+
// Textarea variant
|
|
222
|
+
'string:textarea': ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
|
|
223
|
+
<div>
|
|
224
|
+
{label && <label>{label}</label>}
|
|
225
|
+
<textarea name={name} value={value ?? ''}
|
|
226
|
+
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
|
|
227
|
+
{...(fieldProps as any)} />
|
|
228
|
+
{touched && error && <span className="error">{error}</span>}
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
};
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### MUI (Material UI)
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { TextField, Checkbox } from '@mui/material';
|
|
238
|
+
|
|
239
|
+
const muiRenderers = {
|
|
240
|
+
string: ({ value, onChange, onBlur, error, touched }: FieldRenderProps) => (
|
|
241
|
+
<TextField
|
|
242
|
+
value={value ?? ''}
|
|
243
|
+
onChange={(e) => onChange(e.target.value)}
|
|
244
|
+
onBlur={onBlur}
|
|
245
|
+
error={touched && !!error}
|
|
246
|
+
helperText={touched ? error : undefined}
|
|
247
|
+
/>
|
|
248
|
+
),
|
|
249
|
+
number: ({ value, onChange, onBlur, error, touched }: FieldRenderProps) => (
|
|
250
|
+
<TextField
|
|
251
|
+
type="number"
|
|
252
|
+
value={value ?? ''}
|
|
253
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
254
|
+
onBlur={onBlur}
|
|
255
|
+
error={touched && !!error}
|
|
256
|
+
helperText={touched ? error : undefined}
|
|
257
|
+
/>
|
|
258
|
+
),
|
|
259
|
+
boolean: ({ value, onChange }: FieldRenderProps) => (
|
|
260
|
+
<Checkbox
|
|
261
|
+
checked={value ?? false}
|
|
262
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
263
|
+
/>
|
|
264
|
+
)
|
|
265
|
+
};
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## FormSystemProvider
|
|
269
|
+
|
|
270
|
+
Register renderers once at the top of your app. All `<Field>` components below resolve renderers by schema type automatically:
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
import { FormSystemProvider } from '@cleverbrush/react-form';
|
|
274
|
+
|
|
275
|
+
// Public website with plain HTML inputs
|
|
276
|
+
<FormSystemProvider renderers={htmlRenderers}>
|
|
277
|
+
<PublicApp />
|
|
278
|
+
</FormSystemProvider>
|
|
279
|
+
|
|
280
|
+
// Admin panel using MUI
|
|
281
|
+
<FormSystemProvider renderers={muiRenderers}>
|
|
282
|
+
<AdminApp />
|
|
283
|
+
</FormSystemProvider>
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Nesting
|
|
287
|
+
|
|
288
|
+
Inner providers override/extend outer providers:
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
<FormSystemProvider renderers={muiRenderers}>
|
|
292
|
+
<MainApp />
|
|
293
|
+
{/* Override just the string renderer in this section */}
|
|
294
|
+
<FormSystemProvider renderers={{ string: customStringRenderer }}>
|
|
295
|
+
<SpecialSection />
|
|
296
|
+
</FormSystemProvider>
|
|
297
|
+
</FormSystemProvider>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## useSchemaForm
|
|
301
|
+
|
|
302
|
+
Creates a form instance bound to a schema. Returns field binding and form lifecycle methods:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
const form = useSchemaForm(UserSchema, {
|
|
306
|
+
createMissingStructure: true, // default: true — auto-create parent objects when setting nested values
|
|
307
|
+
validateOnMount: false, // default: false — set to true to show errors immediately on mount
|
|
308
|
+
validationDebounceMs: 300 // optional — debounce onChange validation (ms); validate()/submit() are always immediate
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Returned API
|
|
313
|
+
|
|
314
|
+
| Method | Description |
|
|
315
|
+
|--------|-------------|
|
|
316
|
+
| `form.useField(forProperty)` | Bind a field by PropertyDescriptor selector |
|
|
317
|
+
| `form.submit()` | Validate and return `ValidationResult` (includes `result.object` on success) |
|
|
318
|
+
| `form.validate()` | Run validation, propagate errors to fields |
|
|
319
|
+
| `form.reset(values?)` | Reset all fields; optionally set new initial values |
|
|
320
|
+
| `form.getValue()` | Get current form values as plain object |
|
|
321
|
+
| `form.setValue(values)` | Merge values into form state |
|
|
322
|
+
|
|
323
|
+
## useField
|
|
324
|
+
|
|
325
|
+
Binds a single field via PropertyDescriptor selector. Can be used via `form.useField()` or the context-based standalone `useField()`:
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
// Via form instance
|
|
329
|
+
const name = form.useField((t) => t.name);
|
|
330
|
+
const city = form.useField((t) => t.address.city);
|
|
331
|
+
|
|
332
|
+
// Or via context (inside a FormProvider)
|
|
333
|
+
const name = useField((t) => t.name);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Returned State & API
|
|
337
|
+
|
|
338
|
+
| Property | Type | Description |
|
|
339
|
+
|----------|------|-------------|
|
|
340
|
+
| `value` | `T \| undefined` | Current field value |
|
|
341
|
+
| `initialValue` | `T \| undefined` | Value at form init / last reset |
|
|
342
|
+
| `dirty` | `boolean` | `true` if value differs from initialValue |
|
|
343
|
+
| `touched` | `boolean` | `true` after `onBlur` has been called |
|
|
344
|
+
| `error` | `string \| undefined` | Validation error message from schema |
|
|
345
|
+
| `validating` | `boolean` | `true` during async validation |
|
|
346
|
+
| `onChange(value)` | `(T) => void` | Update field value |
|
|
347
|
+
| `onBlur()` | `() => void` | Mark field as touched |
|
|
348
|
+
| `setValue(value)` | `(T) => void` | Alias for `onChange` |
|
|
349
|
+
| `schema` | `SchemaBuilder` | The field's schema builder |
|
|
350
|
+
|
|
351
|
+
## Field Component
|
|
352
|
+
|
|
353
|
+
Resolves the renderer from the `FormSystemProvider` registry by schema type (and optional variant), or uses an explicit `renderer` prop:
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
// Auto-resolved from FormSystemProvider (string schema → string renderer)
|
|
357
|
+
<Field forProperty={(t) => t.name} form={form} />
|
|
358
|
+
|
|
359
|
+
// Variant-based resolution: looks up "string:password", falls back to "string"
|
|
360
|
+
<Field forProperty={(t) => t.password} form={form} variant="password" />
|
|
361
|
+
|
|
362
|
+
// With label, name, and extra props for the renderer
|
|
363
|
+
<Field
|
|
364
|
+
forProperty={(t) => t.email}
|
|
365
|
+
form={form}
|
|
366
|
+
label="Email address"
|
|
367
|
+
name="email"
|
|
368
|
+
fieldProps={{ placeholder: 'you@example.com', autoComplete: 'email' }}
|
|
369
|
+
/>
|
|
370
|
+
|
|
371
|
+
// Explicit renderer override
|
|
372
|
+
<Field forProperty={(t) => t.name} form={form} renderer={customRenderer} />
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Props
|
|
376
|
+
|
|
377
|
+
| Prop | Type | Description |
|
|
378
|
+
|------|------|-------------|
|
|
379
|
+
| `forProperty` | `(tree) => PropertyDescriptor` | PropertyDescriptor selector for the field |
|
|
380
|
+
| `form` | `SchemaFormInstance` | Form instance from `useSchemaForm` |
|
|
381
|
+
| `renderer?` | `FieldRenderer` | Optional explicit renderer (overrides provider) |
|
|
382
|
+
| `variant?` | `string` | Variant hint for renderer resolution and forwarded to the renderer |
|
|
383
|
+
| `label?` | `string` | Visible label text forwarded to the renderer |
|
|
384
|
+
| `name?` | `string` | HTML `name` attribute forwarded to the renderer |
|
|
385
|
+
| `fieldProps?` | `Record<string, unknown>` | Extra renderer-specific props (e.g. `placeholder`, `autoComplete`) |
|
|
386
|
+
|
|
387
|
+
## Headless Usage (without Field component)
|
|
388
|
+
|
|
389
|
+
For full control over rendering, use `form.useField()` directly:
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
function UserForm() {
|
|
393
|
+
const form = useSchemaForm(UserSchema);
|
|
394
|
+
const name = form.useField((t) => t.name);
|
|
395
|
+
const email = form.useField((t) => t.email);
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<>
|
|
399
|
+
<input
|
|
400
|
+
value={name.value ?? ''}
|
|
401
|
+
onChange={(e) => name.onChange(e.target.value)}
|
|
402
|
+
onBlur={name.onBlur}
|
|
403
|
+
/>
|
|
404
|
+
{name.touched && name.error && <span>{name.error}</span>}
|
|
405
|
+
|
|
406
|
+
<input
|
|
407
|
+
value={email.value ?? ''}
|
|
408
|
+
onChange={(e) => email.onChange(e.target.value)}
|
|
409
|
+
onBlur={email.onBlur}
|
|
410
|
+
/>
|
|
411
|
+
<button onClick={() => form.submit()}>Submit</button>
|
|
412
|
+
</>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## FormProvider
|
|
418
|
+
|
|
419
|
+
Context bridge that allows standalone `useField()` usage outside of `form.useField()`:
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
import { FormProvider, useField } from '@cleverbrush/react-form';
|
|
423
|
+
|
|
424
|
+
function NameInput() {
|
|
425
|
+
const name = useField((t) => t.name);
|
|
426
|
+
return <input value={name.value ?? ''} onChange={(e) => name.onChange(e.target.value)} />;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function UserForm() {
|
|
430
|
+
const form = useSchemaForm(UserSchema);
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<FormProvider form={form}>
|
|
434
|
+
<NameInput />
|
|
435
|
+
</FormProvider>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Nested Object Schemas
|
|
441
|
+
|
|
442
|
+
PropertyDescriptor selectors support nested paths:
|
|
443
|
+
|
|
444
|
+
```tsx
|
|
445
|
+
const UserSchema = object({
|
|
446
|
+
name: string(),
|
|
447
|
+
address: object({
|
|
448
|
+
city: string(),
|
|
449
|
+
zip: number()
|
|
450
|
+
})
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
function UserForm() {
|
|
454
|
+
const form = useSchemaForm(UserSchema);
|
|
455
|
+
|
|
456
|
+
return (
|
|
457
|
+
<>
|
|
458
|
+
<Field forProperty={(t) => t.name} form={form} />
|
|
459
|
+
<Field forProperty={(t) => t.address.city} form={form} />
|
|
460
|
+
<Field forProperty={(t) => t.address.zip} form={form} />
|
|
461
|
+
</>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## Validation
|
|
467
|
+
|
|
468
|
+
Validation uses `@cleverbrush/schema` validators. Errors are automatically propagated to the corresponding field state:
|
|
469
|
+
|
|
470
|
+
```tsx
|
|
471
|
+
const SignupSchema = object({
|
|
472
|
+
username: string().addValidator(async (val) => {
|
|
473
|
+
if (val.length < 3) {
|
|
474
|
+
return {
|
|
475
|
+
valid: false,
|
|
476
|
+
errors: [{ message: 'Username must be at least 3 characters' }]
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return { valid: true };
|
|
480
|
+
}),
|
|
481
|
+
email: string()
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
function SignupForm() {
|
|
485
|
+
const form = useSchemaForm(SignupSchema);
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<FormSystemProvider renderers={htmlRenderers}>
|
|
489
|
+
<Field forProperty={(t) => t.username} form={form} />
|
|
490
|
+
<Field forProperty={(t) => t.email} form={form} />
|
|
491
|
+
<button onClick={async () => {
|
|
492
|
+
const result = await form.submit();
|
|
493
|
+
if (result.valid) {
|
|
494
|
+
console.log('Success:', result.object);
|
|
495
|
+
}
|
|
496
|
+
}}>Submit</button>
|
|
497
|
+
</FormSystemProvider>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### How validation works
|
|
503
|
+
|
|
504
|
+
1. `form.validate()` or `form.submit()` runs the schema's full validation
|
|
505
|
+
2. Per-property errors are resolved via `getErrorsFor()` using PropertyDescriptors
|
|
506
|
+
3. Each field's `error` state is updated automatically
|
|
507
|
+
4. Renderers receive the `error` string and `touched` boolean to decide how/when to display errors
|
|
508
|
+
|
|
509
|
+
## End-to-End Example
|
|
510
|
+
|
|
511
|
+
Here's a complete, realistic example showing all pieces together — a registration form with nested address, custom validation, and MUI renderers:
|
|
512
|
+
|
|
513
|
+
```tsx
|
|
514
|
+
import { object, string, number } from '@cleverbrush/schema';
|
|
515
|
+
import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form';
|
|
516
|
+
import { TextField } from '@mui/material';
|
|
517
|
+
|
|
518
|
+
// Schema — single source of truth for types, validation, and form fields
|
|
519
|
+
const RegistrationSchema = object({
|
|
520
|
+
name: string().required('Name is required').minLength(2, 'Too short'),
|
|
521
|
+
email: string().required('Email is required').matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email'),
|
|
522
|
+
age: number().required('Age is required').min(18, 'Must be 18+'),
|
|
523
|
+
address: object({
|
|
524
|
+
city: string().required('City is required'),
|
|
525
|
+
zip: string().required('ZIP is required').minLength(5, 'Invalid ZIP')
|
|
526
|
+
})
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Renderers — define once, reuse everywhere
|
|
530
|
+
const renderers = {
|
|
531
|
+
string: ({ value, onChange, onBlur, error, touched }) => (
|
|
532
|
+
<TextField
|
|
533
|
+
value={value ?? ''}
|
|
534
|
+
onChange={(e) => onChange(e.target.value)}
|
|
535
|
+
onBlur={onBlur}
|
|
536
|
+
error={touched && !!error}
|
|
537
|
+
helperText={touched ? error : undefined}
|
|
538
|
+
fullWidth
|
|
539
|
+
margin="normal"
|
|
540
|
+
/>
|
|
541
|
+
),
|
|
542
|
+
number: ({ value, onChange, onBlur, error, touched }) => (
|
|
543
|
+
<TextField
|
|
544
|
+
type="number"
|
|
545
|
+
value={value ?? ''}
|
|
546
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
547
|
+
onBlur={onBlur}
|
|
548
|
+
error={touched && !!error}
|
|
549
|
+
helperText={touched ? error : undefined}
|
|
550
|
+
fullWidth
|
|
551
|
+
margin="normal"
|
|
552
|
+
/>
|
|
553
|
+
)
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// Form component — just declare which fields to show
|
|
557
|
+
function RegistrationForm() {
|
|
558
|
+
const form = useSchemaForm(RegistrationSchema);
|
|
559
|
+
|
|
560
|
+
return (
|
|
561
|
+
<div>
|
|
562
|
+
<Field forProperty={(t) => t.name} form={form} />
|
|
563
|
+
<Field forProperty={(t) => t.email} form={form} />
|
|
564
|
+
<Field forProperty={(t) => t.age} form={form} />
|
|
565
|
+
<Field forProperty={(t) => t.address.city} form={form} />
|
|
566
|
+
<Field forProperty={(t) => t.address.zip} form={form} />
|
|
567
|
+
<button onClick={async () => {
|
|
568
|
+
const result = await form.submit();
|
|
569
|
+
if (result.valid) {
|
|
570
|
+
console.log('Registered:', result.object);
|
|
571
|
+
}
|
|
572
|
+
}}>Register</button>
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// App — wrap with provider
|
|
578
|
+
function App() {
|
|
579
|
+
return (
|
|
580
|
+
<FormSystemProvider renderers={renderers}>
|
|
581
|
+
<RegistrationForm />
|
|
582
|
+
</FormSystemProvider>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
## API Reference
|
|
588
|
+
|
|
589
|
+
### Exports
|
|
590
|
+
|
|
591
|
+
| Export | Type | Description |
|
|
592
|
+
|--------|------|-------------|
|
|
593
|
+
| `FormSystemProvider` | Component | Global renderer registry provider |
|
|
594
|
+
| `FormProvider` | Component | Form context bridge for standalone `useField` |
|
|
595
|
+
| `Field` | Component | Auto-rendered field by schema type |
|
|
596
|
+
| `useSchemaForm` | Hook | Create a form instance from schema |
|
|
597
|
+
| `useField` | Hook | Context-based field binding (use inside `FormProvider`) |
|
|
598
|
+
| `useFormSystem` | Hook | Access `FormSystemProvider` config |
|
|
599
|
+
|
|
600
|
+
### Types
|
|
601
|
+
|
|
602
|
+
| Type | Description |
|
|
603
|
+
|------|-------------|
|
|
604
|
+
| `FieldRenderer` | `(props: FieldRenderProps) => ReactNode` |
|
|
605
|
+
| `FieldRenderProps` | Props passed to renderers: `value`, `initialValue`, `dirty`, `touched`, `error`, `validating`, `onChange`, `onBlur`, `setValue`, `schema`, `variant?`, `label?`, `name?`, `fieldProps?` |
|
|
606
|
+
| `FormSystemConfig` | `{ renderers?: Record<string, FieldRenderer> }` — keys can be `"type"` or `"type:variant"` |
|
|
607
|
+
| `FieldState` | `{ value, initialValue, dirty, touched, error, validating }` |
|
|
608
|
+
| `UseFieldResult` | `FieldState & { onChange, onBlur, setValue, schema }` |
|
|
609
|
+
| `UseSchemaFormOptions` | `{ createMissingStructure?: boolean; validateOnMount?: boolean; validationDebounceMs?: number }` |
|
|
610
|
+
| `SchemaFormInstance` | Return type of `useSchemaForm` |
|
|
611
|
+
| `FormSystemProviderProps` | Props for `FormSystemProvider` |
|
|
612
|
+
| `FormProviderProps` | Props for `FormProvider` |
|
|
613
|
+
| `FieldProps` | Props for `Field` |
|
|
614
|
+
|
|
615
|
+
## Code Quality
|
|
616
|
+
|
|
617
|
+
- **Linting:** [Biome](https://biomejs.dev/) — enforced on every PR via CI
|
|
618
|
+
- **Type checking:** TypeScript strict mode — field selectors and form state are fully typed end-to-end
|
|
619
|
+
- **Unit tests:** [Vitest](https://vitest.dev/) + [Testing Library](https://testing-library.com/) — covering form state management, validation, async validators, field rendering, and provider configuration
|
|
620
|
+
- **CI:** Every pull request must pass lint + build + test before merge — see [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)
|
|
621
|
+
|
|
622
|
+
## License
|
|
623
|
+
|
|
624
|
+
BSD-3-Clause
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FieldState } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Internal form store — manages field state and notification.
|
|
4
|
+
* Does not depend on React; used by hooks for state management.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createFormStore(initialValues: any): {
|
|
7
|
+
getFieldState: (path: string) => FieldState;
|
|
8
|
+
updateFieldState: (path: string, patch: Partial<FieldState>) => void;
|
|
9
|
+
subscribe: (path: string, listener: () => void) => () => void;
|
|
10
|
+
subscribeGlobal: (listener: () => void) => () => void;
|
|
11
|
+
getValues: () => any;
|
|
12
|
+
setValues: (newValues: any) => void;
|
|
13
|
+
resetAll: (newInitialValues?: any) => void;
|
|
14
|
+
notifyAll: () => void;
|
|
15
|
+
getAllFieldPaths: () => string[];
|
|
16
|
+
};
|
|
17
|
+
export type FormStore = ReturnType<typeof createFormStore>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { InferType, ObjectSchemaBuilder, PropertyDescriptor, PropertyDescriptorTree, SchemaBuilder } from '@cleverbrush/schema';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
import type { SchemaFormInstance } from './hooks.js';
|
|
4
|
+
import type { FieldRenderer, FormSystemConfig, UseFieldResult } from './types.js';
|
|
5
|
+
export type FormSystemProviderProps = {
|
|
6
|
+
/**
|
|
7
|
+
* Renderer registry mapping schema types to renderer functions.
|
|
8
|
+
* Example: `{ string: (props) => <input .../>, number: (props) => <input type="number" .../> }`
|
|
9
|
+
*/
|
|
10
|
+
renderers?: Record<string, FieldRenderer>;
|
|
11
|
+
/**
|
|
12
|
+
* Full configuration object (for future extensibility).
|
|
13
|
+
* If both `renderers` and `config.renderers` are provided, `renderers` takes precedence.
|
|
14
|
+
*/
|
|
15
|
+
config?: FormSystemConfig;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Provides global renderer configuration via React Context.
|
|
20
|
+
* Supports nesting — inner provider overrides outer.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <FormSystemProvider renderers={htmlRenderers}>
|
|
25
|
+
* <App />
|
|
26
|
+
* </FormSystemProvider>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function FormSystemProvider({ renderers, config, children }: FormSystemProviderProps): React.ReactNode;
|
|
30
|
+
export type FormProviderProps<TSchema extends ObjectSchemaBuilder<any, any, any>> = {
|
|
31
|
+
form: SchemaFormInstance<TSchema>;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Provides form context to children so they can use the context-based useField.
|
|
36
|
+
*/
|
|
37
|
+
export declare function FormProvider<TSchema extends ObjectSchemaBuilder<any, any, any>>({ form, children }: FormProviderProps<TSchema>): React.ReactNode;
|
|
38
|
+
/**
|
|
39
|
+
* Context-based useField — can be used inside a FormProvider.
|
|
40
|
+
* For best IntelliSense, prefer `form.useField()` which infers field types from the schema.
|
|
41
|
+
* When using this context-based version, specify the schema type explicitly:
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* const name = useField<typeof MySchema>((t) => t.name);
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function useField<TSchema extends ObjectSchemaBuilder<any, any, any>, TPropertySchema extends SchemaBuilder<any, any, any> = SchemaBuilder<any, any, any>>(forProperty: (tree: PropertyDescriptorTree<TSchema, TSchema>) => PropertyDescriptor<TSchema, TPropertySchema, any>): UseFieldResult<InferType<TPropertySchema>>;
|
|
49
|
+
/**
|
|
50
|
+
* Hook to access FormSystem configuration.
|
|
51
|
+
*/
|
|
52
|
+
export declare function useFormSystem(): FormSystemConfig;
|
|
53
|
+
export type FieldProps<TSchema extends ObjectSchemaBuilder<any, any, any>> = {
|
|
54
|
+
forProperty: (tree: PropertyDescriptorTree<TSchema, TSchema>) => PropertyDescriptor<TSchema, any, any>;
|
|
55
|
+
form: SchemaFormInstance<TSchema>;
|
|
56
|
+
renderer?: FieldRenderer;
|
|
57
|
+
/**
|
|
58
|
+
* Rendering variant hint. Participates in renderer resolution:
|
|
59
|
+
* the registry is checked for `"type:variant"` (e.g. `"string:password"`)
|
|
60
|
+
* before falling back to the base `"type"` key.
|
|
61
|
+
* Also forwarded to the renderer via `FieldRenderProps.variant`.
|
|
62
|
+
*/
|
|
63
|
+
variant?: string;
|
|
64
|
+
/** Visible label text forwarded to the renderer via `FieldRenderProps.label`. */
|
|
65
|
+
label?: string;
|
|
66
|
+
/** HTML `name` attribute forwarded to the renderer via `FieldRenderProps.name`. */
|
|
67
|
+
name?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Bag of extra renderer-specific props forwarded to the renderer via
|
|
70
|
+
* `FieldRenderProps.fieldProps` (e.g. `placeholder`, `autoComplete`).
|
|
71
|
+
*/
|
|
72
|
+
fieldProps?: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* UI-agnostic Field component.
|
|
76
|
+
*
|
|
77
|
+
* Resolves a renderer from:
|
|
78
|
+
* 1. Explicit `renderer` prop
|
|
79
|
+
* 2. FormSystemProvider registry — checked for `"type:variant"` first,
|
|
80
|
+
* then for the base `"type"`
|
|
81
|
+
*
|
|
82
|
+
* All rendering hints (`variant`, `label`, `name`, `fieldProps`) are
|
|
83
|
+
* forwarded to the resolved renderer via `FieldRenderProps`.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* <Field forProperty={(t) => t.password} form={form}
|
|
88
|
+
* variant="password" label="Password"
|
|
89
|
+
* fieldProps={{ placeholder: "Enter password", autoComplete: "current-password" }} />
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export declare function Field<TSchema extends ObjectSchemaBuilder<any, any, any>>({ forProperty, form, renderer, variant, label, name, fieldProps }: FieldProps<TSchema>): React.ReactNode;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ObjectSchemaBuilder, PropertyDescriptorInner, PropertyDescriptorTree } from '@cleverbrush/schema';
|
|
2
|
+
import type { FormStore } from './FormStore.js';
|
|
3
|
+
import type { FormSystemConfig, UseSchemaFormOptions } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Context for global form system configuration (renderers, etc).
|
|
6
|
+
*/
|
|
7
|
+
export declare const FormSystemContext: import("react").Context<FormSystemConfig | null>;
|
|
8
|
+
/**
|
|
9
|
+
* Internal form context value — used to bridge useSchemaForm store
|
|
10
|
+
* to the context-based useField and Field component.
|
|
11
|
+
*/
|
|
12
|
+
export type FormContextValue = {
|
|
13
|
+
store: FormStore;
|
|
14
|
+
descriptorTree: PropertyDescriptorTree<any, any>;
|
|
15
|
+
schema: ObjectSchemaBuilder<any, any, any>;
|
|
16
|
+
options: UseSchemaFormOptions;
|
|
17
|
+
pathMap: Map<PropertyDescriptorInner<any, any, any>, string>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Context for per-form data — allows useField to work via context.
|
|
21
|
+
*/
|
|
22
|
+
export declare const FormContext: import("react").Context<FormContextValue | null>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A debounce function ensures that a function is not called too frequently.
|
|
3
|
+
* It only allows the function to be executed after a specified delay has passed since the last call.
|
|
4
|
+
* @param func The function to debounce.
|
|
5
|
+
* @param wait The delay in ms before the function is called.
|
|
6
|
+
*/
|
|
7
|
+
export declare function debounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PropertyDescriptorInner, PropertyDescriptorTree, SchemaBuilder } from '@cleverbrush/schema';
|
|
2
|
+
import { ObjectSchemaBuilder } from '@cleverbrush/schema';
|
|
3
|
+
/**
|
|
4
|
+
* Builds a map from PropertyDescriptorInner identity → dot-separated path
|
|
5
|
+
* by traversing the descriptor tree recursively.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildDescriptorPathMap(tree: PropertyDescriptorTree<any, any>, schema: ObjectSchemaBuilder<any, any, any>): Map<PropertyDescriptorInner<any, any, any>, string>;
|
|
8
|
+
/**
|
|
9
|
+
* Looks up the path for a given descriptor from the pre-built map.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getDescriptorPath(inner: PropertyDescriptorInner<any, any, any>, pathMap: Map<PropertyDescriptorInner<any, any, any>, string>): string;
|
|
12
|
+
/**
|
|
13
|
+
* Returns the schema type string (e.g. "string", "number", "object").
|
|
14
|
+
*/
|
|
15
|
+
export declare function getSchemaType(schema: SchemaBuilder<any, any, any>): string;
|
|
16
|
+
/**
|
|
17
|
+
* Builds a selector function from a dot-separated path string.
|
|
18
|
+
* The selector traverses the PropertyDescriptorTree by property access.
|
|
19
|
+
* Example: "customer.address.city" → (tree) => tree.customer.address.city
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildSelectorFromPath(path: string): (tree: any) => any;
|
|
22
|
+
/**
|
|
23
|
+
* Checks whether a validation error path matches a field's expected error path.
|
|
24
|
+
* Matches exact path, path with nested suffix, or path with validator suffix.
|
|
25
|
+
* Example: isErrorPathMatch("$.user.name", "$.user.name") → true
|
|
26
|
+
* isErrorPathMatch("$.user.name($validators[0])", "$.user.name") → true
|
|
27
|
+
*/
|
|
28
|
+
export declare function isErrorPathMatch(errPath: string, fieldErrorPath: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Ensures all nested object structures exist in the values object
|
|
31
|
+
* by traversing the schema and creating empty objects where needed.
|
|
32
|
+
* This prevents ObjectSchemaBuilder.validate() from throwing when
|
|
33
|
+
* nested object properties are undefined.
|
|
34
|
+
*
|
|
35
|
+
* Example: ensureNestedStructure({}, OrderSchema)
|
|
36
|
+
* → { customer: { address: {} } }
|
|
37
|
+
*/
|
|
38
|
+
export declare function ensureNestedStructure(values: any, schema: ObjectSchemaBuilder<any, any, any>): any;
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InferType, PropertyDescriptor, PropertyDescriptorTree, SchemaBuilder, ValidationResult } from '@cleverbrush/schema';
|
|
2
|
+
import { ObjectSchemaBuilder } from '@cleverbrush/schema';
|
|
3
|
+
import type { FormContextValue } from './contexts.js';
|
|
4
|
+
import type { FieldRenderer, FormSystemConfig, UseFieldResult, UseSchemaFormOptions } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Return type for useSchemaForm — fully typed for IntelliSense.
|
|
7
|
+
* The `useField` method infers the field value type from the schema via PropertyDescriptor.
|
|
8
|
+
*/
|
|
9
|
+
export type SchemaFormInstance<TSchema extends ObjectSchemaBuilder<any, any, any>> = {
|
|
10
|
+
useField: <TPropertySchema extends SchemaBuilder<any, any, any>>(forProperty: (tree: PropertyDescriptorTree<TSchema, TSchema>) => PropertyDescriptor<TSchema, TPropertySchema, any>) => UseFieldResult<InferType<TPropertySchema>>;
|
|
11
|
+
submit: () => Promise<ValidationResult<InferType<TSchema>>>;
|
|
12
|
+
validate: () => Promise<ValidationResult<InferType<TSchema>>>;
|
|
13
|
+
reset: (values?: Partial<InferType<TSchema>>) => void;
|
|
14
|
+
getValue: () => InferType<TSchema>;
|
|
15
|
+
setValue: (values: Partial<InferType<TSchema>>) => void;
|
|
16
|
+
/** @internal — Used by FormProvider and Field to access internal context */
|
|
17
|
+
_getFormContext: () => FormContextValue;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Hook that binds a schema to a form instance.
|
|
21
|
+
* Provides field binding API, form-level validation, submit, reset.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useSchemaForm<TSchema extends ObjectSchemaBuilder<any, any, any>>(schema: TSchema, options?: UseSchemaFormOptions): SchemaFormInstance<TSchema>;
|
|
24
|
+
/**
|
|
25
|
+
* Internal useField implementation, requires FormContextValue.
|
|
26
|
+
* Returns UseFieldResult with untyped values — callers should cast to the proper generic type.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useFieldFromContext(formContext: FormContextValue, forProperty: (tree: any) => any, triggerValidation?: (markTouched: boolean) => Promise<any>): UseFieldResult;
|
|
29
|
+
/**
|
|
30
|
+
* Resolves a renderer from the FormSystem config based on schema type and optional variant.
|
|
31
|
+
*
|
|
32
|
+
* When `variant` is provided the registry is checked for `"type:variant"` first
|
|
33
|
+
* (e.g. `"string:password"`). If no match is found it falls back to the base
|
|
34
|
+
* `"type"` key (e.g. `"string"`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveRenderer(config: FormSystemConfig | null, schema: SchemaBuilder<any, any, any>, variant?: string): FieldRenderer | undefined;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { FieldProps, FormProviderProps, FormSystemProviderProps } from './components.js';
|
|
2
|
+
export { Field, FormProvider, FormSystemProvider, useField, useFormSystem } from './components.js';
|
|
3
|
+
export type { SchemaFormInstance } from './hooks.js';
|
|
4
|
+
export { useSchemaForm } from './hooks.js';
|
|
5
|
+
export type { FieldRenderer, FieldRenderProps, FieldState, FormSystemConfig, UseFieldResult, UseSchemaFormOptions } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{useContext as B,useMemo as ce}from"react";import{createContext as W}from"react";var b=W(null),E=W(null);import{ObjectSchemaBuilder as H,SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR as Y}from"@cleverbrush/schema";function U(t,s){let r=new Map,e=s.introspect();if(!e.properties)return r;for(let n of Object.keys(e.properties)){let i=t[n];if(!i||!(Y in i))continue;let f=i[Y];r.set(f,n);let c=e.properties[n];if(c instanceof H){let l=U(i,c);for(let[a,p]of l)r.set(a,`${n}.${p}`)}}return r}function G(t,s){return s.get(t)??""}function M(t){return t.introspect()?.type??"unknown"}function z(t){let s=t.split(".");return r=>{let e=r;for(let n of s){if(e==null)return;e=e[n]}return e}}function A(t,s){let r=s.introspect();if(!r.properties)return t??{};let e=t!=null?{...t}:{};for(let n of Object.keys(r.properties)){let i=r.properties[n];i instanceof H&&(e[n]=A(e[n],i))}return e}import{ObjectSchemaBuilder as oe,SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR as se}from"@cleverbrush/schema";import{useCallback as y,useEffect as Q,useMemo as K,useRef as h,useState as ae}from"react";function q(t,s){let r=null;return(...e)=>{r!==null&&clearTimeout(r),r=setTimeout(()=>{t(...e)},s)}}function J(t){let s=t!=null?{...t}:{},r=new Map,e=new Map,n=new Set;function i(o){return r.has(o)||r.set(o,{value:void 0,initialValue:void 0,dirty:!1,touched:!1,error:void 0,validating:!1}),r.get(o)}function f(o){return i(o)}function c(o,u){let g={...i(o),...u};r.set(o,g),l(o)}function l(o){let u=e.get(o);if(u)for(let d of u)d()}function a(){for(let[,o]of e)for(let u of o)u();for(let o of n)o()}function p(o,u){return e.has(o)||e.set(o,new Set),e.get(o).add(u),()=>{e.get(o)?.delete(u)}}function F(o){return n.add(o),()=>{n.delete(o)}}function R(){return s}function T(o){s=o!=null?{...o}:{}}function S(o){s=o!=null?{...o}:{},r.clear(),a()}function P(){return Array.from(r.keys())}return{getFieldState:f,updateFieldState:c,subscribe:p,subscribeGlobal:F,getValues:R,setValues:T,resetAll:S,notifyAll:a,getAllFieldPaths:P}}function ie(t,s){let r={createMissingStructure:!0,...s},e=h(null);e.current||(e.current=J({}));let n=e.current,i=h(null);i.current||(i.current=oe.getPropertiesFor(t));let f=i.current,c=h(null);c.current||(c.current=U(f,t));let l=c.current,a=h(t),p=h(r);p.current=r;let F=K(()=>({store:n,descriptorTree:f,schema:a.current,options:p.current,pathMap:l}),[n,f,l]),R=h(F);R.current=F;let T=h(0),S=y(async m=>{let I=++T.current,j=n.getValues(),ee=A(j,a.current),D;try{D=await a.current.validateAsync(ee,{doNotStopOnFirstError:!0})}catch{return{valid:!1}}if(I!==T.current)return D;let re=n.getAllFieldPaths();for(let w of re){let v={error:void 0};m&&(v.touched=!0),n.updateFieldState(w,v)}let $=D,te=new Set;if(typeof $.getErrorsFor=="function"){let w=$.getErrorsFor;for(let[,v]of l)try{let ne=z(v),O=w(ne);if(O&&Array.isArray(O.errors)&&O.errors.length>0){let L={error:O.errors[0]};m&&(L.touched=!0),n.updateFieldState(v,L),te.add(v)}}catch{}}return D},[n,l]),P=y(async()=>S(!0),[S]),o=y(async()=>P(),[P]),u=y(m=>{let I=m??{};n.resetAll(I)},[n]),d=y(()=>n.getValues(),[n]),g=y(m=>{let j={...n.getValues(),...m};n.setValues(j),n.notifyAll()},[n]),x=y(()=>R.current,[]),V=h(r.validateOnMount);Q(()=>{V.current&&S(!0)},[]);let C=h(null);r.validationDebounceMs!=null&&r.validationDebounceMs>0&&!C.current&&(C.current=q(m=>{S(m)},r.validationDebounceMs));let N=y(m=>C.current?(C.current(m),Promise.resolve(void 0)):S(m),[S]),_=y(m=>k(R.current,m,N),[N]);return K(()=>({useField:_,submit:o,validate:P,reset:u,getValue:d,setValue:g,_getFormContext:x}),[_,o,P,u,d,g,x])}function k(t,s,r){let{store:e,descriptorTree:n,options:i,pathMap:f}=t,l=s(n)[se],a=G(l,f),p=l.getSchema(),F=h(null);if(F.current!==a){let u=e.getValues(),{success:d,value:g}=l.getValue(u),x=e.getFieldState(a);if(x.initialValue===void 0&&!x.touched){let V=d?g:void 0;e.updateFieldState(a,{value:V,initialValue:V,dirty:!1})}F.current=a}let[,R]=ae(0);Q(()=>e.subscribe(a,()=>{R(d=>d+1)}),[e,a]);let T=y(u=>{let d=e.getValues();l.setValue(d,u,{createMissingStructure:i.createMissingStructure!==!1}),e.setValues(d);let g=e.getFieldState(a);e.updateFieldState(a,{value:u,dirty:u!==g.initialValue}),r&&r(!1)},[e,l,a,i,r]),S=y(()=>{e.updateFieldState(a,{touched:!0})},[e,a]),P=y(u=>{T(u)},[T]),o=e.getFieldState(a);return{value:o.value,initialValue:o.initialValue,dirty:o.dirty,touched:o.touched,error:o.error,validating:o.validating,onChange:T,onBlur:S,setValue:P,schema:p}}function X(t,s,r){if(!t?.renderers)return;let e=M(s);if(r){let n=t.renderers[`${e}:${r}`];if(n)return n}return t.renderers[e]}import{jsx as Z}from"react/jsx-runtime";function ue({renderers:t,config:s,children:r}){let e=B(b),n=ce(()=>{let i={...s,renderers:{...s?.renderers,...t}};return e?{...e,...i,renderers:{...e.renderers,...i.renderers}}:i},[e,s,t]);return Z(b.Provider,{value:n,children:r})}function le({form:t,children:s}){let r=t._getFormContext();return Z(E.Provider,{value:r,children:s})}function me(t){let s=B(E);if(!s)throw new Error("useField must be used within a FormProvider. Wrap your component tree with <FormProvider form={form}>.");return k(s,t)}function pe(){let t=B(b);if(!t)throw new Error("useFormSystem must be used within a FormSystemProvider");return t}function de({forProperty:t,form:s,renderer:r,variant:e,label:n,name:i,fieldProps:f}){let c=s.useField(t),l=B(b),a=r??X(l,c.schema,e);if(!a){let p=M(c.schema),F=e?`"${p}:${e}" or "${p}"`:`"${p}"`;throw new Error(`No renderer found for schema type ${F}. Provide a renderer prop or configure one in FormSystemProvider.`)}return a({value:c.value,initialValue:c.initialValue,dirty:c.dirty,touched:c.touched,error:c.error,validating:c.validating,onChange:c.onChange,onBlur:c.onBlur,setValue:c.setValue,schema:c.schema,variant:e,label:n,name:i,fieldProps:f})}export{de as Field,le as FormProvider,ue as FormSystemProvider,me as useField,pe as useFormSystem,ie as useSchemaForm};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/components.tsx","../src/contexts.ts","../src/helpers.ts","../src/hooks.ts","../src/debounce.ts","../src/FormStore.ts"],"sourcesContent":["import type {\n InferType,\n ObjectSchemaBuilder,\n PropertyDescriptor,\n PropertyDescriptorTree,\n SchemaBuilder\n} from '@cleverbrush/schema';\nimport type React from 'react';\nimport { useContext, useMemo } from 'react';\nimport { FormContext, FormSystemContext } from './contexts.js';\nimport { getSchemaType } from './helpers.js';\nimport type { SchemaFormInstance } from './hooks.js';\nimport { resolveRenderer, useFieldFromContext } from './hooks.js';\nimport type {\n FieldRenderer,\n FormSystemConfig,\n UseFieldResult\n} from './types.js';\n\n// ─── FormSystemProvider ──────────────────────────────────────────────────────\n\nexport type FormSystemProviderProps = {\n /**\n * Renderer registry mapping schema types to renderer functions.\n * Example: `{ string: (props) => <input .../>, number: (props) => <input type=\"number\" .../> }`\n */\n renderers?: Record<string, FieldRenderer>;\n /**\n * Full configuration object (for future extensibility).\n * If both `renderers` and `config.renderers` are provided, `renderers` takes precedence.\n */\n config?: FormSystemConfig;\n children: React.ReactNode;\n};\n\n/**\n * Provides global renderer configuration via React Context.\n * Supports nesting — inner provider overrides outer.\n *\n * @example\n * ```tsx\n * <FormSystemProvider renderers={htmlRenderers}>\n * <App />\n * </FormSystemProvider>\n * ```\n */\nexport function FormSystemProvider({\n renderers,\n config,\n children\n}: FormSystemProviderProps): React.ReactNode {\n const parentConfig = useContext(FormSystemContext);\n\n const resolvedConfig: FormSystemConfig = useMemo(() => {\n const current: FormSystemConfig = {\n ...config,\n renderers: {\n ...config?.renderers,\n ...renderers\n }\n };\n if (!parentConfig) return current;\n return {\n ...parentConfig,\n ...current,\n renderers: {\n ...parentConfig.renderers,\n ...current.renderers\n }\n };\n }, [parentConfig, config, renderers]);\n\n return (\n <FormSystemContext.Provider value={resolvedConfig}>\n {children}\n </FormSystemContext.Provider>\n );\n}\n\n// ─── FormProvider ────────────────────────────────────────────────────────────\n\nexport type FormProviderProps<\n TSchema extends ObjectSchemaBuilder<any, any, any>\n> = {\n form: SchemaFormInstance<TSchema>;\n children: React.ReactNode;\n};\n\n/**\n * Provides form context to children so they can use the context-based useField.\n */\nexport function FormProvider<\n TSchema extends ObjectSchemaBuilder<any, any, any>\n>({ form, children }: FormProviderProps<TSchema>): React.ReactNode {\n const formContext = form._getFormContext();\n return (\n <FormContext.Provider value={formContext}>\n {children}\n </FormContext.Provider>\n );\n}\n\n// ─── Context-based useField ──────────────────────────────────────────────────\n\n/**\n * Context-based useField — can be used inside a FormProvider.\n * For best IntelliSense, prefer `form.useField()` which infers field types from the schema.\n * When using this context-based version, specify the schema type explicitly:\n *\n * @example\n * ```tsx\n * const name = useField<typeof MySchema>((t) => t.name);\n * ```\n */\nexport function useField<\n TSchema extends ObjectSchemaBuilder<any, any, any>,\n TPropertySchema extends SchemaBuilder<any, any, any> = SchemaBuilder<\n any,\n any,\n any\n >\n>(\n forProperty: (\n tree: PropertyDescriptorTree<TSchema, TSchema>\n ) => PropertyDescriptor<TSchema, TPropertySchema, any>\n): UseFieldResult<InferType<TPropertySchema>> {\n const formContext = useContext(FormContext);\n if (!formContext) {\n throw new Error(\n 'useField must be used within a FormProvider. ' +\n 'Wrap your component tree with <FormProvider form={form}>.'\n );\n }\n return useFieldFromContext(formContext, forProperty) as UseFieldResult<\n InferType<TPropertySchema>\n >;\n}\n\n// ─── useFormSystem ───────────────────────────────────────────────────────────\n\n/**\n * Hook to access FormSystem configuration.\n */\nexport function useFormSystem(): FormSystemConfig {\n const config = useContext(FormSystemContext);\n if (!config) {\n throw new Error(\n 'useFormSystem must be used within a FormSystemProvider'\n );\n }\n return config;\n}\n\n// ─── Field Component ─────────────────────────────────────────────────────────\n\nexport type FieldProps<TSchema extends ObjectSchemaBuilder<any, any, any>> = {\n forProperty: (\n tree: PropertyDescriptorTree<TSchema, TSchema>\n ) => PropertyDescriptor<TSchema, any, any>;\n form: SchemaFormInstance<TSchema>;\n renderer?: FieldRenderer;\n /**\n * Rendering variant hint. Participates in renderer resolution:\n * the registry is checked for `\"type:variant\"` (e.g. `\"string:password\"`)\n * before falling back to the base `\"type\"` key.\n * Also forwarded to the renderer via `FieldRenderProps.variant`.\n */\n variant?: string;\n /** Visible label text forwarded to the renderer via `FieldRenderProps.label`. */\n label?: string;\n /** HTML `name` attribute forwarded to the renderer via `FieldRenderProps.name`. */\n name?: string;\n /**\n * Bag of extra renderer-specific props forwarded to the renderer via\n * `FieldRenderProps.fieldProps` (e.g. `placeholder`, `autoComplete`).\n */\n fieldProps?: Record<string, unknown>;\n};\n\n/**\n * UI-agnostic Field component.\n *\n * Resolves a renderer from:\n * 1. Explicit `renderer` prop\n * 2. FormSystemProvider registry — checked for `\"type:variant\"` first,\n * then for the base `\"type\"`\n *\n * All rendering hints (`variant`, `label`, `name`, `fieldProps`) are\n * forwarded to the resolved renderer via `FieldRenderProps`.\n *\n * @example\n * ```tsx\n * <Field forProperty={(t) => t.password} form={form}\n * variant=\"password\" label=\"Password\"\n * fieldProps={{ placeholder: \"Enter password\", autoComplete: \"current-password\" }} />\n * ```\n */\nexport function Field<TSchema extends ObjectSchemaBuilder<any, any, any>>({\n forProperty,\n form,\n renderer,\n variant,\n label,\n name,\n fieldProps\n}: FieldProps<TSchema>): React.ReactNode {\n const fieldResult = form.useField(forProperty);\n const systemConfig = useContext(FormSystemContext);\n\n const resolvedRenderer =\n renderer ?? resolveRenderer(systemConfig, fieldResult.schema, variant);\n\n if (!resolvedRenderer) {\n const schemaType = getSchemaType(fieldResult.schema);\n const tried = variant\n ? `\"${schemaType}:${variant}\" or \"${schemaType}\"`\n : `\"${schemaType}\"`;\n throw new Error(\n `No renderer found for schema type ${tried}. ` +\n 'Provide a renderer prop or configure one in FormSystemProvider.'\n );\n }\n\n return resolvedRenderer({\n value: fieldResult.value,\n initialValue: fieldResult.initialValue,\n dirty: fieldResult.dirty,\n touched: fieldResult.touched,\n error: fieldResult.error,\n validating: fieldResult.validating,\n onChange: fieldResult.onChange,\n onBlur: fieldResult.onBlur,\n setValue: fieldResult.setValue,\n schema: fieldResult.schema,\n variant,\n label,\n name,\n fieldProps\n });\n}\n","import type {\n ObjectSchemaBuilder,\n PropertyDescriptorInner,\n PropertyDescriptorTree\n} from '@cleverbrush/schema';\nimport { createContext } from 'react';\nimport type { FormStore } from './FormStore.js';\nimport type { FormSystemConfig, UseSchemaFormOptions } from './types.js';\n\n/**\n * Context for global form system configuration (renderers, etc).\n */\nexport const FormSystemContext = createContext<FormSystemConfig | null>(null);\n\n/**\n * Internal form context value — used to bridge useSchemaForm store\n * to the context-based useField and Field component.\n */\nexport type FormContextValue = {\n store: FormStore;\n descriptorTree: PropertyDescriptorTree<any, any>;\n schema: ObjectSchemaBuilder<any, any, any>;\n options: UseSchemaFormOptions;\n pathMap: Map<PropertyDescriptorInner<any, any, any>, string>;\n};\n\n/**\n * Context for per-form data — allows useField to work via context.\n */\nexport const FormContext = createContext<FormContextValue | null>(null);\n","import type {\n PropertyDescriptorInner,\n PropertyDescriptorTree,\n SchemaBuilder\n} from '@cleverbrush/schema';\nimport {\n ObjectSchemaBuilder,\n SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR\n} from '@cleverbrush/schema';\n\n/**\n * Builds a map from PropertyDescriptorInner identity → dot-separated path\n * by traversing the descriptor tree recursively.\n */\nexport function buildDescriptorPathMap(\n tree: PropertyDescriptorTree<any, any>,\n schema: ObjectSchemaBuilder<any, any, any>\n): Map<PropertyDescriptorInner<any, any, any>, string> {\n const map = new Map<PropertyDescriptorInner<any, any, any>, string>();\n const introspected = schema.introspect();\n if (!introspected.properties) return map;\n\n for (const propName of Object.keys(introspected.properties)) {\n const propDescriptor = (tree as any)[propName];\n if (\n !propDescriptor ||\n !(SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR in propDescriptor)\n ) {\n continue;\n }\n const inner = propDescriptor[\n SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR\n ] as PropertyDescriptorInner<any, any, any>;\n map.set(inner, propName);\n\n // Recurse into nested objects\n const propSchema = introspected.properties[propName];\n if (propSchema instanceof ObjectSchemaBuilder) {\n const childMap = buildDescriptorPathMap(propDescriptor, propSchema);\n for (const [childInner, childPath] of childMap) {\n map.set(childInner, `${propName}.${childPath}`);\n }\n }\n }\n\n return map;\n}\n\n/**\n * Looks up the path for a given descriptor from the pre-built map.\n */\nexport function getDescriptorPath(\n inner: PropertyDescriptorInner<any, any, any>,\n pathMap: Map<PropertyDescriptorInner<any, any, any>, string>\n): string {\n return pathMap.get(inner) ?? '';\n}\n\n/**\n * Returns the schema type string (e.g. \"string\", \"number\", \"object\").\n */\nexport function getSchemaType(schema: SchemaBuilder<any, any, any>): string {\n const introspected = schema.introspect();\n return introspected?.type ?? 'unknown';\n}\n\n/**\n * Builds a selector function from a dot-separated path string.\n * The selector traverses the PropertyDescriptorTree by property access.\n * Example: \"customer.address.city\" → (tree) => tree.customer.address.city\n */\nexport function buildSelectorFromPath(path: string): (tree: any) => any {\n const parts = path.split('.');\n return (tree: any) => {\n let current = tree;\n for (const part of parts) {\n if (current == null) return undefined;\n current = current[part];\n }\n return current;\n };\n}\n\n/**\n * Checks whether a validation error path matches a field's expected error path.\n * Matches exact path, path with nested suffix, or path with validator suffix.\n * Example: isErrorPathMatch(\"$.user.name\", \"$.user.name\") → true\n * isErrorPathMatch(\"$.user.name($validators[0])\", \"$.user.name\") → true\n */\nexport function isErrorPathMatch(\n errPath: string,\n fieldErrorPath: string\n): boolean {\n return (\n errPath === fieldErrorPath ||\n errPath.startsWith(fieldErrorPath + '.') ||\n errPath.startsWith(fieldErrorPath + '(')\n );\n}\n\n/**\n * Ensures all nested object structures exist in the values object\n * by traversing the schema and creating empty objects where needed.\n * This prevents ObjectSchemaBuilder.validate() from throwing when\n * nested object properties are undefined.\n *\n * Example: ensureNestedStructure({}, OrderSchema)\n * → { customer: { address: {} } }\n */\nexport function ensureNestedStructure(\n values: any,\n schema: ObjectSchemaBuilder<any, any, any>\n): any {\n const introspected = schema.introspect();\n if (!introspected.properties) return values != null ? values : {};\n\n const result = values != null ? { ...values } : {};\n for (const key of Object.keys(introspected.properties)) {\n const propSchema = introspected.properties[key];\n if (propSchema instanceof ObjectSchemaBuilder) {\n result[key] = ensureNestedStructure(result[key], propSchema);\n }\n }\n return result;\n}\n","import type {\n InferType,\n PropertyDescriptor,\n PropertyDescriptorInner,\n PropertyDescriptorTree,\n SchemaBuilder,\n ValidationResult\n} from '@cleverbrush/schema';\nimport {\n ObjectSchemaBuilder,\n SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR\n} from '@cleverbrush/schema';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { FormContextValue } from './contexts.js';\nimport { debounce } from './debounce.js';\nimport type { FormStore } from './FormStore.js';\nimport { createFormStore } from './FormStore.js';\nimport {\n buildDescriptorPathMap,\n buildSelectorFromPath,\n ensureNestedStructure,\n getDescriptorPath,\n getSchemaType\n} from './helpers.js';\nimport type {\n FieldRenderer,\n FormSystemConfig,\n UseFieldResult,\n UseSchemaFormOptions\n} from './types.js';\n\n// ─── SchemaFormInstance ──────────────────────────────────────────────────────\n\n/**\n * Return type for useSchemaForm — fully typed for IntelliSense.\n * The `useField` method infers the field value type from the schema via PropertyDescriptor.\n */\nexport type SchemaFormInstance<\n TSchema extends ObjectSchemaBuilder<any, any, any>\n> = {\n useField: <TPropertySchema extends SchemaBuilder<any, any, any>>(\n forProperty: (\n tree: PropertyDescriptorTree<TSchema, TSchema>\n ) => PropertyDescriptor<TSchema, TPropertySchema, any>\n ) => UseFieldResult<InferType<TPropertySchema>>;\n submit: () => Promise<ValidationResult<InferType<TSchema>>>;\n validate: () => Promise<ValidationResult<InferType<TSchema>>>;\n reset: (values?: Partial<InferType<TSchema>>) => void;\n getValue: () => InferType<TSchema>;\n setValue: (values: Partial<InferType<TSchema>>) => void;\n /** @internal — Used by FormProvider and Field to access internal context */\n _getFormContext: () => FormContextValue;\n};\n\n// ─── useSchemaForm ───────────────────────────────────────────────────────────\n\n/**\n * Hook that binds a schema to a form instance.\n * Provides field binding API, form-level validation, submit, reset.\n */\nexport function useSchemaForm<\n TSchema extends ObjectSchemaBuilder<any, any, any>\n>(\n schema: TSchema,\n options?: UseSchemaFormOptions\n): SchemaFormInstance<TSchema> {\n const resolvedOptions: UseSchemaFormOptions = {\n createMissingStructure: true,\n ...options\n };\n\n const storeRef = useRef<FormStore | null>(null);\n if (!storeRef.current) {\n storeRef.current = createFormStore({});\n }\n const store = storeRef.current;\n\n const descriptorTreeRef = useRef<PropertyDescriptorTree<\n TSchema,\n TSchema\n > | null>(null);\n if (!descriptorTreeRef.current) {\n descriptorTreeRef.current = ObjectSchemaBuilder.getPropertiesFor(\n schema\n ) as PropertyDescriptorTree<TSchema, TSchema>;\n }\n const descriptorTree = descriptorTreeRef.current;\n\n const pathMapRef = useRef<Map<\n PropertyDescriptorInner<any, any, any>,\n string\n > | null>(null);\n if (!pathMapRef.current) {\n pathMapRef.current = buildDescriptorPathMap(descriptorTree, schema);\n }\n const pathMap = pathMapRef.current;\n\n const schemaRef = useRef(schema);\n const optionsRef = useRef(resolvedOptions);\n optionsRef.current = resolvedOptions;\n\n const formContextValue = useMemo<FormContextValue>(\n () => ({\n store,\n descriptorTree,\n schema: schemaRef.current,\n options: optionsRef.current,\n pathMap\n }),\n [store, descriptorTree, pathMap]\n );\n\n const formContextRef = useRef(formContextValue);\n formContextRef.current = formContextValue;\n\n // Generation counter to discard stale validation results from concurrent runs\n const validationGenRef = useRef(0);\n\n /**\n * Runs full schema validation using getErrorsFor to extract per-field errors.\n * Optionally marks all fields as touched (used by submit/explicit validate).\n */\n const runValidation = useCallback(\n async (\n markTouched: boolean\n ): Promise<ValidationResult<InferType<TSchema>>> => {\n const gen = ++validationGenRef.current;\n const values = store.getValues();\n // Ensure all nested object structures exist to prevent\n // ObjectSchemaBuilder.validateAsync() from throwing on undefined nested objects\n const safeValues = ensureNestedStructure(values, schemaRef.current);\n let result: ValidationResult<InferType<TSchema>>;\n try {\n result = (await schemaRef.current.validateAsync(safeValues, {\n doNotStopOnFirstError: true\n })) as ValidationResult<InferType<TSchema>>;\n } catch {\n // If validation itself throws, treat as invalid\n return { valid: false } as ValidationResult<InferType<TSchema>>;\n }\n\n // Discard results if a newer validation has started since this one began\n if (gen !== validationGenRef.current) {\n return result as ValidationResult<InferType<TSchema>>;\n }\n\n // Clear all existing field errors\n const allPaths = store.getAllFieldPaths();\n for (const p of allPaths) {\n const patch: Partial<{\n error: string | undefined;\n touched: boolean;\n }> = { error: undefined };\n if (markTouched) {\n patch.touched = true;\n }\n store.updateFieldState(p, patch);\n }\n\n // Use getErrorsFor to extract per-field errors via tree selectors\n const resultWithErrors = result as ValidationResult<\n InferType<TSchema>\n > & {\n getErrorsFor?: (selector: (t: any) => any) => {\n errors: ReadonlyArray<string>;\n isValid: boolean;\n };\n errors?: ReadonlyArray<{ message: string; path?: string }>;\n };\n\n // Build a map of errors found via getErrorsFor so we can detect gaps\n const fieldsWithErrors = new Set<string>();\n\n if (typeof resultWithErrors.getErrorsFor === 'function') {\n const getErrorsFor = resultWithErrors.getErrorsFor;\n\n // Extract per-field errors by building selectors from field paths\n for (const [, path] of pathMap) {\n try {\n const selector = buildSelectorFromPath(path);\n const fieldResult = getErrorsFor(selector);\n if (\n fieldResult &&\n Array.isArray(fieldResult.errors) &&\n fieldResult.errors.length > 0\n ) {\n const errorMessage = fieldResult.errors[0];\n const patch: Partial<{\n error: string | undefined;\n touched: boolean;\n }> = { error: errorMessage };\n if (markTouched) {\n patch.touched = true;\n }\n store.updateFieldState(path, patch);\n fieldsWithErrors.add(path);\n }\n } catch {\n // If getErrorsFor fails for this path, skip\n }\n }\n }\n\n return result as ValidationResult<InferType<TSchema>>;\n },\n [store, pathMap]\n );\n\n const validate = useCallback(async (): Promise<\n ValidationResult<InferType<TSchema>>\n > => {\n return runValidation(true);\n }, [runValidation]);\n\n const submit = useCallback(async (): Promise<\n ValidationResult<InferType<TSchema>>\n > => {\n return validate();\n }, [validate]);\n\n const reset = useCallback(\n (values?: Partial<InferType<TSchema>>) => {\n const newValues = values ?? {};\n store.resetAll(newValues);\n },\n [store]\n );\n\n const getValue = useCallback((): InferType<TSchema> => {\n return store.getValues();\n }, [store]);\n\n const setValueFn = useCallback(\n (values: Partial<InferType<TSchema>>) => {\n const currentValues = store.getValues();\n const merged = { ...currentValues, ...values };\n store.setValues(merged);\n store.notifyAll();\n },\n [store]\n );\n\n const _getFormContext = useCallback(() => formContextRef.current, []);\n\n // Validate on mount when requested — runs once after first render\n const validateOnMountRef = useRef(resolvedOptions.validateOnMount);\n // biome-ignore lint/correctness/useExhaustiveDependencies: We only want to check validateOnMount on the initial mount, ignoring changes to it after that\n useEffect(() => {\n if (validateOnMountRef.current) {\n runValidation(true);\n }\n }, []);\n\n // Create a debounced version of runValidation for onChange triggers.\n // validate(), submit(), and validateOnMount always use runValidation directly.\n const debouncedValidationRef = useRef<\n ((markTouched: boolean) => void) | null\n >(null);\n if (\n resolvedOptions.validationDebounceMs != null &&\n resolvedOptions.validationDebounceMs > 0 &&\n !debouncedValidationRef.current\n ) {\n debouncedValidationRef.current = debounce((markTouched: boolean) => {\n runValidation(markTouched);\n }, resolvedOptions.validationDebounceMs);\n }\n\n const triggerValidation = useCallback(\n (markTouched: boolean) => {\n if (debouncedValidationRef.current) {\n debouncedValidationRef.current(markTouched);\n return Promise.resolve(\n undefined as unknown as ValidationResult<InferType<TSchema>>\n );\n }\n return runValidation(markTouched);\n },\n [runValidation]\n );\n\n const useFieldHook = useCallback(\n <TPropertySchema extends SchemaBuilder<any, any, any>>(\n forProperty: (\n tree: PropertyDescriptorTree<TSchema, TSchema>\n ) => PropertyDescriptor<TSchema, TPropertySchema, any>\n ): UseFieldResult<InferType<TPropertySchema>> => {\n return useFieldFromContext(\n formContextRef.current,\n forProperty,\n triggerValidation\n ) as UseFieldResult<InferType<TPropertySchema>>;\n },\n [triggerValidation]\n );\n\n return useMemo(\n () => ({\n useField: useFieldHook,\n submit,\n validate,\n reset,\n getValue,\n setValue: setValueFn,\n _getFormContext\n }),\n [\n useFieldHook,\n submit,\n validate,\n reset,\n getValue,\n setValueFn,\n _getFormContext\n ]\n );\n}\n\n// ─── useField (from context) ─────────────────────────────────────────────────\n\n/**\n * Internal useField implementation, requires FormContextValue.\n * Returns UseFieldResult with untyped values — callers should cast to the proper generic type.\n */\nexport function useFieldFromContext(\n formContext: FormContextValue,\n forProperty: (tree: any) => any,\n triggerValidation?: (markTouched: boolean) => Promise<any>\n): UseFieldResult {\n const { store, descriptorTree, options, pathMap } = formContext;\n\n const descriptor = forProperty(descriptorTree as any);\n const inner = descriptor[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR];\n const path = getDescriptorPath(inner, pathMap);\n const fieldSchema = inner.getSchema();\n\n // Initialize field state from current values on first access\n const initializedRef = useRef<string | null>(null);\n\n if (initializedRef.current !== path) {\n const values = store.getValues();\n const { success, value } = inner.getValue(values);\n const currentState = store.getFieldState(path);\n if (currentState.initialValue === undefined && !currentState.touched) {\n const resolvedValue = success ? value : undefined;\n store.updateFieldState(path, {\n value: resolvedValue,\n initialValue: resolvedValue,\n dirty: false\n });\n }\n initializedRef.current = path;\n }\n\n const [, setRenderTick] = useState(0);\n\n // Subscribe to field changes with proper cleanup on unmount/path change\n useEffect(() => {\n const unsub = store.subscribe(path, () => {\n setRenderTick(c => c + 1);\n });\n return unsub;\n }, [store, path]);\n\n const onChange = useCallback(\n (value: any) => {\n const values = store.getValues();\n inner.setValue(values, value, {\n createMissingStructure: options.createMissingStructure !== false\n });\n store.setValues(values);\n const currentState = store.getFieldState(path);\n store.updateFieldState(path, {\n value,\n dirty: value !== currentState.initialValue\n });\n // Run validation on every field change (without marking all fields touched)\n if (triggerValidation) {\n triggerValidation(false);\n }\n },\n [store, inner, path, options, triggerValidation]\n );\n\n const onBlur = useCallback(() => {\n store.updateFieldState(path, { touched: true });\n }, [store, path]);\n\n const setValue = useCallback(\n (value: any) => {\n onChange(value);\n },\n [onChange]\n );\n\n const fieldState = store.getFieldState(path);\n\n return {\n value: fieldState.value,\n initialValue: fieldState.initialValue,\n dirty: fieldState.dirty,\n touched: fieldState.touched,\n error: fieldState.error,\n validating: fieldState.validating,\n onChange,\n onBlur,\n setValue,\n schema: fieldSchema\n };\n}\n\n/**\n * Resolves a renderer from the FormSystem config based on schema type and optional variant.\n *\n * When `variant` is provided the registry is checked for `\"type:variant\"` first\n * (e.g. `\"string:password\"`). If no match is found it falls back to the base\n * `\"type\"` key (e.g. `\"string\"`).\n */\nexport function resolveRenderer(\n config: FormSystemConfig | null,\n schema: SchemaBuilder<any, any, any>,\n variant?: string\n): FieldRenderer | undefined {\n if (!config?.renderers) return undefined;\n const type = getSchemaType(schema);\n if (variant) {\n const variantRenderer = config.renderers[`${type}:${variant}`];\n if (variantRenderer) return variantRenderer;\n }\n return config.renderers[type];\n}\n","/**\n * A debounce function ensures that a function is not called too frequently.\n * It only allows the function to be executed after a specified delay has passed since the last call.\n * @param func The function to debounce.\n * @param wait The delay in ms before the function is called.\n */\nexport function debounce<T extends (...args: any[]) => void>(\n func: T,\n wait: number\n): (...args: Parameters<T>) => void {\n let timeout: ReturnType<typeof setTimeout> | null = null;\n\n return (...args: Parameters<T>) => {\n if (timeout !== null) {\n clearTimeout(timeout);\n }\n timeout = setTimeout(() => {\n func(...args);\n }, wait);\n };\n}\n","import type { FieldState } from './types.js';\n\n/**\n * Internal form store — manages field state and notification.\n * Does not depend on React; used by hooks for state management.\n */\nexport function createFormStore(initialValues: any) {\n let values: any = initialValues != null ? { ...initialValues } : {};\n const fieldStates = new Map<string, FieldState>();\n const listeners = new Map<string, Set<() => void>>();\n const globalListeners = new Set<() => void>();\n\n function ensureFieldState(path: string): FieldState {\n if (!fieldStates.has(path)) {\n fieldStates.set(path, {\n value: undefined,\n initialValue: undefined,\n dirty: false,\n touched: false,\n error: undefined,\n validating: false\n });\n }\n return fieldStates.get(path)!;\n }\n\n function getFieldState(path: string): FieldState {\n return ensureFieldState(path);\n }\n\n function updateFieldState(path: string, patch: Partial<FieldState>) {\n const current = ensureFieldState(path);\n const updated = { ...current, ...patch };\n fieldStates.set(path, updated);\n notifyPath(path);\n }\n\n function notifyPath(path: string) {\n const pathListeners = listeners.get(path);\n if (pathListeners) {\n for (const listener of pathListeners) {\n listener();\n }\n }\n }\n\n function notifyAll() {\n for (const [, pathListeners] of listeners) {\n for (const listener of pathListeners) {\n listener();\n }\n }\n for (const listener of globalListeners) {\n listener();\n }\n }\n\n function subscribe(path: string, listener: () => void): () => void {\n if (!listeners.has(path)) {\n listeners.set(path, new Set());\n }\n listeners.get(path)!.add(listener);\n return () => {\n listeners.get(path)?.delete(listener);\n };\n }\n\n function subscribeGlobal(listener: () => void): () => void {\n globalListeners.add(listener);\n return () => {\n globalListeners.delete(listener);\n };\n }\n\n function getValues(): any {\n return values;\n }\n\n function setValues(newValues: any) {\n values = newValues != null ? { ...newValues } : {};\n }\n\n function resetAll(newInitialValues?: any) {\n values = newInitialValues != null ? { ...newInitialValues } : {};\n fieldStates.clear();\n notifyAll();\n }\n\n function getAllFieldPaths(): string[] {\n return Array.from(fieldStates.keys());\n }\n\n return {\n getFieldState,\n updateFieldState,\n subscribe,\n subscribeGlobal,\n getValues,\n setValues,\n resetAll,\n notifyAll,\n getAllFieldPaths\n };\n}\n\nexport type FormStore = ReturnType<typeof createFormStore>;\n"],"mappings":"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","names":["useContext","useMemo","createContext","FormSystemContext","FormContext","ObjectSchemaBuilder","SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR","buildDescriptorPathMap","tree","schema","map","introspected","propName","propDescriptor","inner","propSchema","childMap","childInner","childPath","getDescriptorPath","pathMap","getSchemaType","buildSelectorFromPath","path","parts","current","part","ensureNestedStructure","values","schema","introspected","result","key","propSchema","ObjectSchemaBuilder","ObjectSchemaBuilder","SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR","useCallback","useEffect","useMemo","useRef","useState","debounce","func","wait","timeout","args","createFormStore","initialValues","values","fieldStates","listeners","globalListeners","ensureFieldState","path","getFieldState","updateFieldState","patch","updated","notifyPath","pathListeners","listener","notifyAll","subscribe","subscribeGlobal","getValues","setValues","newValues","resetAll","newInitialValues","getAllFieldPaths","useSchemaForm","schema","options","resolvedOptions","storeRef","useRef","createFormStore","store","descriptorTreeRef","ObjectSchemaBuilder","descriptorTree","pathMapRef","buildDescriptorPathMap","pathMap","schemaRef","optionsRef","formContextValue","useMemo","formContextRef","validationGenRef","runValidation","useCallback","markTouched","gen","values","safeValues","ensureNestedStructure","result","allPaths","p","patch","resultWithErrors","fieldsWithErrors","getErrorsFor","path","selector","buildSelectorFromPath","fieldResult","validate","submit","reset","newValues","getValue","setValueFn","merged","_getFormContext","validateOnMountRef","useEffect","debouncedValidationRef","debounce","triggerValidation","useFieldHook","forProperty","useFieldFromContext","formContext","inner","SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR","getDescriptorPath","fieldSchema","initializedRef","success","value","currentState","resolvedValue","setRenderTick","useState","c","onChange","onBlur","setValue","fieldState","resolveRenderer","config","variant","type","getSchemaType","variantRenderer","jsx","FormSystemProvider","renderers","config","children","parentConfig","useContext","FormSystemContext","resolvedConfig","useMemo","current","FormProvider","form","formContext","FormContext","useField","forProperty","useFieldFromContext","useFormSystem","Field","renderer","variant","label","name","fieldProps","fieldResult","systemConfig","resolvedRenderer","resolveRenderer","schemaType","getSchemaType","tried"]}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SchemaBuilder } from '@cleverbrush/schema';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* A renderer function that receives field state and returns a React node.
|
|
5
|
+
*/
|
|
6
|
+
export type FieldRenderer = (props: FieldRenderProps) => ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* Props passed to a field renderer.
|
|
9
|
+
*/
|
|
10
|
+
export type FieldRenderProps = {
|
|
11
|
+
value: any;
|
|
12
|
+
initialValue: any;
|
|
13
|
+
dirty: boolean;
|
|
14
|
+
touched: boolean;
|
|
15
|
+
error: string | undefined;
|
|
16
|
+
validating: boolean;
|
|
17
|
+
onChange: (value: any) => void;
|
|
18
|
+
onBlur: () => void;
|
|
19
|
+
setValue: (value: any) => void;
|
|
20
|
+
schema: SchemaBuilder<any, any, any>;
|
|
21
|
+
/**
|
|
22
|
+
* Rendering variant hint passed from the `Field` component.
|
|
23
|
+
* Used by renderers to select a sub-variant of the base schema type
|
|
24
|
+
* (e.g. `"password"` for a string field rendered as a password input).
|
|
25
|
+
*
|
|
26
|
+
* Also participates in renderer resolution: when set, the renderer
|
|
27
|
+
* registry is first checked for `"type:variant"` (e.g. `"string:password"`)
|
|
28
|
+
* before falling back to the base `"type"` key.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <Field forProperty={(t) => t.secret} form={form} variant="password" />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
variant?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Visible label text forwarded from the `Field` component.
|
|
38
|
+
* Renderers can use this to render a `<label>` element.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <Field forProperty={(t) => t.name} form={form} label="Full name" />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
label?: string;
|
|
46
|
+
/**
|
|
47
|
+
* HTML `name` attribute forwarded from the `Field` component.
|
|
48
|
+
* Renderers can apply this to the underlying input for `FormData` submission.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* <Field forProperty={(t) => t.email} form={form} name="email" />
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
name?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Bag of extra renderer-specific props forwarded from the `Field` component.
|
|
58
|
+
* Useful for passing HTML attributes (`placeholder`, `autoComplete`, `type`)
|
|
59
|
+
* or UI-library-specific options without extending `FieldRenderProps` itself.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* <Field
|
|
64
|
+
* forProperty={(t) => t.email}
|
|
65
|
+
* form={form}
|
|
66
|
+
* fieldProps={{ placeholder: "you@example.com", autoComplete: "email" }}
|
|
67
|
+
* />
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
fieldProps?: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Configuration for FormSystemProvider.
|
|
74
|
+
*/
|
|
75
|
+
export type FormSystemConfig = {
|
|
76
|
+
renderers?: Record<string, FieldRenderer>;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Field state tracked per-field.
|
|
80
|
+
*/
|
|
81
|
+
export type FieldState = {
|
|
82
|
+
value: any;
|
|
83
|
+
initialValue: any;
|
|
84
|
+
dirty: boolean;
|
|
85
|
+
touched: boolean;
|
|
86
|
+
error: string | undefined;
|
|
87
|
+
validating: boolean;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Return type for useField — strongly typed with the inferred property value type.
|
|
91
|
+
* When the value type cannot be inferred, falls back to `any`.
|
|
92
|
+
*/
|
|
93
|
+
export type UseFieldResult<T = any> = {
|
|
94
|
+
value: T | undefined;
|
|
95
|
+
initialValue: T | undefined;
|
|
96
|
+
dirty: boolean;
|
|
97
|
+
touched: boolean;
|
|
98
|
+
error: string | undefined;
|
|
99
|
+
validating: boolean;
|
|
100
|
+
onChange: (value: T) => void;
|
|
101
|
+
onBlur: () => void;
|
|
102
|
+
setValue: (value: T) => void;
|
|
103
|
+
schema: SchemaBuilder<any, any, any>;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Options for useSchemaForm.
|
|
107
|
+
*/
|
|
108
|
+
export type UseSchemaFormOptions = {
|
|
109
|
+
createMissingStructure?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* When `true`, runs full schema validation on mount and marks all fields
|
|
112
|
+
* as touched so that error messages (e.g. for required fields) are visible
|
|
113
|
+
* immediately without waiting for user interaction.
|
|
114
|
+
*
|
|
115
|
+
* @default false
|
|
116
|
+
*/
|
|
117
|
+
validateOnMount?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Debounce delay in milliseconds for onChange-triggered validation.
|
|
120
|
+
* When set, rapid field changes only trigger one validation run after
|
|
121
|
+
* the user stops typing for the specified duration.
|
|
122
|
+
*
|
|
123
|
+
* Explicit calls to `form.validate()`, `form.submit()`, and
|
|
124
|
+
* `validateOnMount` are **not** debounced — they always run immediately.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```tsx
|
|
128
|
+
* const form = useSchemaForm(Schema, { validationDebounceMs: 300 });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
validationDebounceMs?: number;
|
|
132
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Andrew Zolotukhin <andrew_zol@cleverbrush.com>",
|
|
3
|
+
"bugs": {
|
|
4
|
+
"url": "https://github.com/cleverbrush/framework/issues",
|
|
5
|
+
"email": "andrew_zol@cleverbrush.com"
|
|
6
|
+
},
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@cleverbrush/schema": "0.0.0-beta-20260410073748"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/react": "^19.0.0",
|
|
12
|
+
"react": "^19.0.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"react": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"description": "Headless, schema-driven form system for React based on @cleverbrush/schema",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://docs.cleverbrush.com/modules/_cleverbrush_react_form.html",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"react forms",
|
|
24
|
+
"schema validation",
|
|
25
|
+
"form system",
|
|
26
|
+
"cleverbrush"
|
|
27
|
+
],
|
|
28
|
+
"license": "BSD 3-Clause",
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"name": "@cleverbrush/react-form",
|
|
38
|
+
"readme": "https://github.com/cleverbrush/framework/tree/master/libs/react-form#readme",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "github:cleverbrush/framework"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"watch": "tsc --build --watch",
|
|
45
|
+
"build": "tsup && tsc --project tsconfig.build.json --emitDeclarationOnly",
|
|
46
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
47
|
+
},
|
|
48
|
+
"type": "module",
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"version": "0.0.0-beta-20260410073748"
|
|
51
|
+
}
|