@fogpipe/forma-react 0.12.0 → 0.14.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 CHANGED
@@ -25,22 +25,28 @@ npm install @fogpipe/forma-core @fogpipe/forma-react
25
25
  Components receive `{ field, spec }` props. The `field` object contains all field state and handlers:
26
26
 
27
27
  ```tsx
28
- import type { ComponentMap, TextComponentProps, BooleanComponentProps } from '@fogpipe/forma-react';
28
+ import type {
29
+ ComponentMap,
30
+ TextComponentProps,
31
+ BooleanComponentProps,
32
+ } from "@fogpipe/forma-react";
29
33
 
30
34
  const TextInput = ({ field }: TextComponentProps) => (
31
35
  <div>
32
36
  <input
33
37
  type="text"
34
- value={field.value || ''}
38
+ value={field.value || ""}
35
39
  onChange={(e) => field.onChange(e.target.value)}
36
40
  onBlur={field.onBlur}
37
41
  placeholder={field.placeholder}
38
- aria-invalid={field['aria-invalid']}
39
- aria-describedby={field['aria-describedby']}
40
- aria-required={field['aria-required']}
42
+ aria-invalid={field["aria-invalid"]}
43
+ aria-describedby={field["aria-describedby"]}
44
+ aria-required={field["aria-required"]}
41
45
  />
42
46
  {field.errors.map((e, i) => (
43
- <span key={i} className="error">{e.message}</span>
47
+ <span key={i} className="error">
48
+ {e.message}
49
+ </span>
44
50
  ))}
45
51
  </div>
46
52
  );
@@ -67,21 +73,21 @@ const components: ComponentMap = {
67
73
  ### 2. Render the Form
68
74
 
69
75
  ```tsx
70
- import { FormRenderer } from '@fogpipe/forma-react';
71
- import type { Forma } from '@fogpipe/forma-core';
76
+ import { FormRenderer } from "@fogpipe/forma-react";
77
+ import type { Forma } from "@fogpipe/forma-core";
72
78
 
73
79
  const myForm: Forma = {
74
80
  meta: { title: "Contact Us" },
75
81
  fields: [
76
82
  { id: "name", type: "text", label: "Name", required: true },
77
83
  { id: "email", type: "email", label: "Email", required: true },
78
- { id: "subscribe", type: "boolean", label: "Subscribe to newsletter" }
79
- ]
84
+ { id: "subscribe", type: "boolean", label: "Subscribe to newsletter" },
85
+ ],
80
86
  };
81
87
 
82
88
  function App() {
83
89
  const handleSubmit = (data: Record<string, unknown>) => {
84
- console.log('Submitted:', data);
90
+ console.log("Submitted:", data);
85
91
  };
86
92
 
87
93
  return (
@@ -99,7 +105,7 @@ function App() {
99
105
  For custom rendering, use the `useForma` hook directly:
100
106
 
101
107
  ```tsx
102
- import { useForma } from '@fogpipe/forma-react';
108
+ import { useForma } from "@fogpipe/forma-react";
103
109
 
104
110
  function CustomForm({ spec }: { spec: Forma }) {
105
111
  const {
@@ -114,19 +120,24 @@ function CustomForm({ spec }: { spec: Forma }) {
114
120
  submitForm,
115
121
  } = useForma({
116
122
  spec,
117
- onSubmit: (data) => console.log(data)
123
+ onSubmit: (data) => console.log(data),
118
124
  });
119
125
 
120
126
  return (
121
- <form onSubmit={(e) => { e.preventDefault(); submitForm(); }}>
122
- {spec.fields.map(field => {
127
+ <form
128
+ onSubmit={(e) => {
129
+ e.preventDefault();
130
+ submitForm();
131
+ }}
132
+ >
133
+ {spec.fields.map((field) => {
123
134
  if (!visibility[field.id]) return null;
124
135
 
125
136
  return (
126
137
  <div key={field.id}>
127
138
  <label>{field.label}</label>
128
139
  <input
129
- value={String(data[field.id] || '')}
140
+ value={String(data[field.id] || "")}
130
141
  onChange={(e) => setFieldValue(field.id, e.target.value)}
131
142
  onBlur={() => setFieldTouched(field.id)}
132
143
  />
@@ -154,11 +165,13 @@ function WizardForm({ spec }: { spec: Forma }) {
154
165
  return (
155
166
  <div>
156
167
  {/* Page indicator */}
157
- <div>Page {wizard.currentPageIndex + 1} of {wizard.pages.length}</div>
168
+ <div>
169
+ Page {wizard.currentPageIndex + 1} of {wizard.pages.length}
170
+ </div>
158
171
 
159
172
  {/* Current page fields */}
160
- {wizard.currentPage?.fields.map(fieldId => {
161
- const field = spec.fields.find(f => f.id === fieldId);
173
+ {wizard.currentPage?.fields.map((fieldId) => {
174
+ const field = spec.fields.find((f) => f.id === fieldId);
162
175
  if (!field) return null;
163
176
  // Render field...
164
177
  })}
@@ -180,20 +193,83 @@ function WizardForm({ spec }: { spec: Forma }) {
180
193
  }
181
194
  ```
182
195
 
196
+ ## Event System
197
+
198
+ The event system lets you observe form lifecycle events for side effects like analytics, data injection, and external state sync. Events do not trigger React re-renders.
199
+
200
+ ### Available Events
201
+
202
+ | Event | Description |
203
+ | -------------- | ------------------------------------------------------- |
204
+ | `fieldChanged` | Fires after a field value changes |
205
+ | `preSubmit` | Fires before validation; `data` is mutable |
206
+ | `postSubmit` | Fires after submission (success, error, or invalid) |
207
+ | `pageChanged` | Fires when wizard page changes |
208
+ | `formReset` | Fires after `resetForm()` completes |
209
+
210
+ ### Declarative Registration
211
+
212
+ Pass listeners via the `on` option — callbacks are stable (ref-based):
213
+
214
+ ```tsx
215
+ const forma = useForma({
216
+ spec,
217
+ onSubmit: handleSubmit,
218
+ on: {
219
+ fieldChanged: (event) => {
220
+ analytics.track("field_changed", { path: event.path, source: event.source });
221
+ },
222
+ preSubmit: async (event) => {
223
+ event.data.token = await getCSRFToken();
224
+ },
225
+ postSubmit: (event) => {
226
+ if (event.success) router.push("/thank-you");
227
+ },
228
+ },
229
+ });
230
+ ```
231
+
232
+ ### Imperative Registration
233
+
234
+ Register listeners dynamically — returns an unsubscribe function:
235
+
236
+ ```tsx
237
+ const forma = useForma({ spec, onSubmit: handleSubmit });
238
+
239
+ useEffect(() => {
240
+ const unsubscribe = forma.on("fieldChanged", (event) => {
241
+ console.log(`${event.path}: ${event.previousValue} → ${event.value}`);
242
+ });
243
+ return unsubscribe;
244
+ }, [forma]);
245
+ ```
246
+
247
+ ### Event Payloads
248
+
249
+ **`fieldChanged`**: `{ path, value, previousValue, source }` where `source` is `'user' | 'reset' | 'setValues'`
250
+
251
+ **`preSubmit`**: `{ data, computed }` — mutate `data` to inject fields before validation
252
+
253
+ **`postSubmit`**: `{ data, success, error?, validationErrors? }`
254
+
255
+ **`pageChanged`**: `{ fromIndex, toIndex, page }`
256
+
257
+ **`formReset`**: `{}` (no payload)
258
+
183
259
  ## API Reference
184
260
 
185
261
  ### FormRenderer Props
186
262
 
187
- | Prop | Type | Description |
188
- |------|------|-------------|
189
- | `spec` | `Forma` | The Forma specification |
190
- | `components` | `ComponentMap` | Map of field types to components |
191
- | `initialData` | `Record<string, unknown>` | Initial form values |
192
- | `onSubmit` | `(data) => void` | Submit handler |
193
- | `onChange` | `(data, computed) => void` | Change handler |
194
- | `layout` | `React.ComponentType<LayoutProps>` | Custom layout |
195
- | `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Custom field wrapper |
196
- | `validateOn` | `"change" \| "blur" \| "submit"` | Validation timing |
263
+ | Prop | Type | Description |
264
+ | -------------- | ---------------------------------------- | -------------------------------- |
265
+ | `spec` | `Forma` | The Forma specification |
266
+ | `components` | `ComponentMap` | Map of field types to components |
267
+ | `initialData` | `Record<string, unknown>` | Initial form values |
268
+ | `onSubmit` | `(data) => void` | Submit handler |
269
+ | `onChange` | `(data, computed) => void` | Change handler |
270
+ | `layout` | `React.ComponentType<LayoutProps>` | Custom layout |
271
+ | `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Custom field wrapper |
272
+ | `validateOn` | `"change" \| "blur" \| "submit"` | Validation timing |
197
273
 
198
274
  ### FormRenderer Ref
199
275
 
@@ -211,55 +287,57 @@ formRef.current?.setValues({ name: "John" });
211
287
 
212
288
  ### useForma Return Value
213
289
 
214
- | Property | Type | Description |
215
- |----------|------|-------------|
216
- | `data` | `Record<string, unknown>` | Current form values |
217
- | `computed` | `Record<string, unknown>` | Computed field values |
218
- | `visibility` | `Record<string, boolean>` | Field visibility map |
219
- | `required` | `Record<string, boolean>` | Field required state |
220
- | `enabled` | `Record<string, boolean>` | Field enabled state |
221
- | `errors` | `FieldError[]` | Validation errors |
222
- | `isValid` | `boolean` | Form validity |
223
- | `isSubmitting` | `boolean` | Submission in progress |
224
- | `isDirty` | `boolean` | Any field modified |
225
- | `wizard` | `WizardHelpers \| null` | Wizard navigation |
290
+ | Property | Type | Description |
291
+ | -------------- | ------------------------- | ---------------------- |
292
+ | `data` | `Record<string, unknown>` | Current form values |
293
+ | `computed` | `Record<string, unknown>` | Computed field values |
294
+ | `visibility` | `Record<string, boolean>` | Field visibility map |
295
+ | `required` | `Record<string, boolean>` | Field required state |
296
+ | `enabled` | `Record<string, boolean>` | Field enabled state |
297
+ | `errors` | `FieldError[]` | Validation errors |
298
+ | `isValid` | `boolean` | Form validity |
299
+ | `isSubmitting` | `boolean` | Submission in progress |
300
+ | `isDirty` | `boolean` | Any field modified |
301
+ | `wizard` | `WizardHelpers \| null` | Wizard navigation |
226
302
 
227
303
  ### useForma Methods
228
304
 
229
- | Method | Description |
230
- |--------|-------------|
231
- | `setFieldValue(path, value)` | Set field value |
232
- | `setFieldTouched(path, touched?)` | Mark field as touched |
233
- | `setValues(values)` | Set multiple values |
234
- | `validateField(path)` | Validate single field |
235
- | `validateForm()` | Validate entire form |
236
- | `submitForm()` | Submit the form |
237
- | `resetForm()` | Reset to initial values |
305
+ | Method | Description |
306
+ | --------------------------------- | ------------------------------------ |
307
+ | `setFieldValue(path, value)` | Set field value |
308
+ | `setFieldTouched(path, touched?)` | Mark field as touched |
309
+ | `setValues(values)` | Set multiple values |
310
+ | `validateField(path)` | Validate single field |
311
+ | `validateForm()` | Validate entire form |
312
+ | `submitForm()` | Submit the form |
313
+ | `resetForm()` | Reset to initial values |
314
+ | `on(event, listener)` | Register event listener; returns unsubscribe |
238
315
 
239
316
  ### useForma Options
240
317
 
241
- | Option | Type | Default | Description |
242
- |--------|------|---------|-------------|
243
- | `spec` | `Forma` | required | The Forma specification |
244
- | `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
245
- | `onSubmit` | `(data) => void` | - | Submit handler |
246
- | `onChange` | `(data, computed) => void` | - | Change handler |
247
- | `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
248
- | `referenceData` | `Record<string, unknown>` | - | Additional reference data |
249
- | `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
318
+ | Option | Type | Default | Description |
319
+ | ---------------------- | -------------------------------- | -------- | ------------------------- |
320
+ | `spec` | `Forma` | required | The Forma specification |
321
+ | `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
322
+ | `onSubmit` | `(data) => void` | - | Submit handler |
323
+ | `onChange` | `(data, computed) => void` | - | Change handler |
324
+ | `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
325
+ | `referenceData` | `Record<string, unknown>` | - | Additional reference data |
326
+ | `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
327
+ | `on` | `FormaEvents` | - | Declarative event listeners |
250
328
 
251
329
  ## Error Boundary
252
330
 
253
331
  Wrap forms with `FormaErrorBoundary` to catch render errors gracefully:
254
332
 
255
333
  ```tsx
256
- import { FormRenderer, FormaErrorBoundary } from '@fogpipe/forma-react';
334
+ import { FormRenderer, FormaErrorBoundary } from "@fogpipe/forma-react";
257
335
 
258
336
  function App() {
259
337
  return (
260
338
  <FormaErrorBoundary
261
339
  fallback={<div>Something went wrong with the form</div>}
262
- onError={(error) => console.error('Form error:', error)}
340
+ onError={(error) => console.error("Form error:", error)}
263
341
  >
264
342
  <FormRenderer spec={myForm} components={components} />
265
343
  </FormaErrorBoundary>
@@ -268,6 +346,7 @@ function App() {
268
346
  ```
269
347
 
270
348
  The error boundary supports:
349
+
271
350
  - Custom fallback UI (static or function)
272
351
  - `onError` callback for logging
273
352
  - `resetKey` prop to reset error state
package/dist/index.d.ts CHANGED
@@ -4,6 +4,84 @@ import * as React$1 from 'react';
4
4
  import React__default from 'react';
5
5
  import * as react_jsx_runtime from 'react/jsx-runtime';
6
6
 
7
+ /**
8
+ * Event system for forma-react
9
+ *
10
+ * Lightweight event emitter for form lifecycle events.
11
+ * Events are for side effects (analytics, data injection, external state sync)
12
+ * — they do not trigger React re-renders.
13
+ */
14
+
15
+ /**
16
+ * Map of all forma event names to their payload types.
17
+ */
18
+ interface FormaEventMap {
19
+ /**
20
+ * Fires after a field value changes via user input, setFieldValue, setValues, or resetForm.
21
+ * Does NOT fire for computed/calculated field changes or on initial mount.
22
+ */
23
+ fieldChanged: {
24
+ /** Field path (e.g., "age" or "medications[0].dosage") */
25
+ path: string;
26
+ /** New value */
27
+ value: unknown;
28
+ /** Value before the change */
29
+ previousValue: unknown;
30
+ /** What triggered the change */
31
+ source: "user" | "reset" | "setValues";
32
+ };
33
+ /**
34
+ * Fires at the start of submitForm(), before validation.
35
+ * The `data` object is mutable — consumers can inject extra fields.
36
+ * Async handlers are awaited before proceeding to validation.
37
+ */
38
+ preSubmit: {
39
+ /** Mutable form data — add/modify fields here */
40
+ data: Record<string, unknown>;
41
+ /** Read-only snapshot of computed values */
42
+ computed: Record<string, unknown>;
43
+ };
44
+ /**
45
+ * Fires after onSubmit resolves/rejects or after validation failure.
46
+ */
47
+ postSubmit: {
48
+ /** The submitted data (reflects any preSubmit mutations) */
49
+ data: Record<string, unknown>;
50
+ /** Whether submission succeeded */
51
+ success: boolean;
52
+ /** Present when onSubmit threw an error */
53
+ error?: Error;
54
+ /** Present when validation failed (onSubmit was never called) */
55
+ validationErrors?: FieldError[];
56
+ };
57
+ /**
58
+ * Fires when the wizard page changes via nextPage, previousPage, or goToPage.
59
+ * Does NOT fire on initial render or automatic page clamping.
60
+ */
61
+ pageChanged: {
62
+ /** Previous page index */
63
+ fromIndex: number;
64
+ /** New page index */
65
+ toIndex: number;
66
+ /** The new current page */
67
+ page: PageState;
68
+ };
69
+ /**
70
+ * Fires after resetForm() completes and state is back to initial values.
71
+ */
72
+ formReset: Record<string, never>;
73
+ }
74
+ /**
75
+ * Listener function type for a specific event.
76
+ */
77
+ type FormaEventListener<K extends keyof FormaEventMap> = (event: FormaEventMap[K]) => void | Promise<void>;
78
+ /**
79
+ * Declarative event listener map for useForma `on` option.
80
+ */
81
+ type FormaEvents = Partial<{
82
+ [K in keyof FormaEventMap]: FormaEventListener<K>;
83
+ }>;
84
+
7
85
  /**
8
86
  * Type definitions for forma-react components
9
87
  */
@@ -499,6 +577,12 @@ interface UseFormaOptions {
499
577
  * Set to 0 (default) for immediate validation.
500
578
  */
501
579
  validationDebounceMs?: number;
580
+ /**
581
+ * Declarative event listeners for form lifecycle events.
582
+ * Listeners are stable for the lifetime of the hook — the latest
583
+ * callback is always invoked via refs, without causing dependency changes.
584
+ */
585
+ on?: FormaEvents;
502
586
  }
503
587
  /**
504
588
  * Page state for multi-page forms
@@ -575,6 +659,11 @@ interface UseFormaReturn {
575
659
  submitForm: () => Promise<void>;
576
660
  /** Reset the form */
577
661
  resetForm: () => void;
662
+ /**
663
+ * Register an imperative event listener. Returns an unsubscribe function.
664
+ * Multiple listeners per event are supported; they fire in registration order.
665
+ */
666
+ on: <K extends keyof FormaEventMap>(event: K, listener: (payload: FormaEventMap[K]) => void | Promise<void>) => () => void;
578
667
  /** Get props for any field */
579
668
  getFieldProps: (path: string) => GetFieldPropsResult;
580
669
  /** Get props for select field (includes options) */
@@ -722,4 +811,4 @@ declare const FormaContext: React$1.Context<UseFormaReturn | null>;
722
811
  */
723
812
  declare function useFormaContext(): UseFormaReturn;
724
813
 
725
- export { type ArrayComponentProps, type ArrayFieldProps, type ArrayHelpers, type ArrayItemFieldProps, type ArrayItemFieldPropsResult, type BaseFieldProps, type BooleanComponentProps, type BooleanFieldProps, type ComponentMap, type ComputedComponentProps, type ComputedFieldProps, type DateComponentProps, type DateFieldProps, type DateTimeComponentProps, type DateTimeFieldProps, type DisplayComponentProps, type DisplayFieldProps, type FieldComponentProps, type FieldProps, FieldRenderer, type FieldRendererProps, type FieldWrapperProps, FormRenderer, type FormRendererHandle, type FormRendererProps, type UseFormaReturn as FormState, FormaContext, FormaErrorBoundary, type FormaErrorBoundaryProps, type GetArrayHelpersResult, type GetFieldPropsResult, type GetSelectFieldPropsResult, type IntegerComponentProps, type IntegerFieldProps, type LayoutProps, type LegacyFieldProps, type MultiSelectComponentProps, type MultiSelectFieldProps, type NumberComponentProps, type NumberFieldProps, type ObjectComponentProps, type ObjectFieldProps, type PageState, type PageWrapperProps, type SelectComponentProps, type SelectFieldProps, type SelectionFieldProps, type TextComponentProps, type TextFieldProps, type UseFormaOptions, type UseFormaReturn, type WizardHelpers, useForma, useFormaContext };
814
+ export { type ArrayComponentProps, type ArrayFieldProps, type ArrayHelpers, type ArrayItemFieldProps, type ArrayItemFieldPropsResult, type BaseFieldProps, type BooleanComponentProps, type BooleanFieldProps, type ComponentMap, type ComputedComponentProps, type ComputedFieldProps, type DateComponentProps, type DateFieldProps, type DateTimeComponentProps, type DateTimeFieldProps, type DisplayComponentProps, type DisplayFieldProps, type FieldComponentProps, type FieldProps, FieldRenderer, type FieldRendererProps, type FieldWrapperProps, FormRenderer, type FormRendererHandle, type FormRendererProps, type UseFormaReturn as FormState, FormaContext, FormaErrorBoundary, type FormaErrorBoundaryProps, type FormaEventListener, type FormaEventMap, type FormaEvents, type GetArrayHelpersResult, type GetFieldPropsResult, type GetSelectFieldPropsResult, type IntegerComponentProps, type IntegerFieldProps, type LayoutProps, type LegacyFieldProps, type MultiSelectComponentProps, type MultiSelectFieldProps, type NumberComponentProps, type NumberFieldProps, type ObjectComponentProps, type ObjectFieldProps, type PageState, type PageWrapperProps, type SelectComponentProps, type SelectFieldProps, type SelectionFieldProps, type TextComponentProps, type TextFieldProps, type UseFormaOptions, type UseFormaReturn, type WizardHelpers, useForma, useFormaContext };