@fogpipe/forma-react 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ /**
2
+ * FormRenderer Component
3
+ *
4
+ * Renders a complete form from a Forma specification.
5
+ * Supports single-page and multi-page (wizard) forms.
6
+ */
7
+
8
+ import React, { forwardRef, useImperativeHandle, useRef, useMemo, useCallback } from "react";
9
+ import type { Forma, FieldDefinition, ValidationResult, JSONSchemaProperty } from "@fogpipe/forma-core";
10
+ import { useForma } from "./useForma.js";
11
+ import { FormaContext } from "./context.js";
12
+ import type { ComponentMap, LayoutProps, FieldWrapperProps, PageWrapperProps, BaseFieldProps, TextFieldProps, NumberFieldProps, SelectFieldProps, ArrayFieldProps, ArrayHelpers } from "./types.js";
13
+
14
+ /**
15
+ * Props for FormRenderer component
16
+ */
17
+ export interface FormRendererProps {
18
+ /** The Forma specification */
19
+ spec: Forma;
20
+ /** Initial form data */
21
+ initialData?: Record<string, unknown>;
22
+ /** Submit handler */
23
+ onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
24
+ /** Change handler */
25
+ onChange?: (data: Record<string, unknown>, computed?: Record<string, unknown>) => void;
26
+ /** Component map for rendering fields */
27
+ components: ComponentMap;
28
+ /** Custom layout component */
29
+ layout?: React.ComponentType<LayoutProps>;
30
+ /** Custom field wrapper component */
31
+ fieldWrapper?: React.ComponentType<FieldWrapperProps>;
32
+ /** Custom page wrapper component */
33
+ pageWrapper?: React.ComponentType<PageWrapperProps>;
34
+ /** When to validate */
35
+ validateOn?: "change" | "blur" | "submit";
36
+ /** Current page for controlled wizard */
37
+ page?: number;
38
+ }
39
+
40
+ /**
41
+ * Imperative handle for FormRenderer
42
+ */
43
+ export interface FormRendererHandle {
44
+ submitForm: () => Promise<void>;
45
+ resetForm: () => void;
46
+ validateForm: () => ValidationResult;
47
+ focusField: (path: string) => void;
48
+ focusFirstError: () => void;
49
+ getValues: () => Record<string, unknown>;
50
+ setValues: (values: Record<string, unknown>) => void;
51
+ isValid: boolean;
52
+ isDirty: boolean;
53
+ }
54
+
55
+ /**
56
+ * Default layout component
57
+ */
58
+ function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
59
+ return (
60
+ <form
61
+ onSubmit={(e) => {
62
+ e.preventDefault();
63
+ onSubmit();
64
+ }}
65
+ >
66
+ {children}
67
+ <button type="submit" disabled={isSubmitting}>
68
+ {isSubmitting ? "Submitting..." : "Submit"}
69
+ </button>
70
+ </form>
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Default field wrapper component with accessibility support
76
+ */
77
+ function DefaultFieldWrapper({ fieldPath, field, children, errors, required, visible }: FieldWrapperProps) {
78
+ if (!visible) return null;
79
+
80
+ const errorId = `${fieldPath}-error`;
81
+ const descriptionId = field.description ? `${fieldPath}-description` : undefined;
82
+ const hasErrors = errors.length > 0;
83
+
84
+ return (
85
+ <div className="field-wrapper" data-field-path={fieldPath}>
86
+ {field.label && (
87
+ <label htmlFor={fieldPath}>
88
+ {field.label}
89
+ {required && <span className="required" aria-hidden="true">*</span>}
90
+ {required && <span className="sr-only"> (required)</span>}
91
+ </label>
92
+ )}
93
+ {children}
94
+ {hasErrors && (
95
+ <div
96
+ id={errorId}
97
+ className="field-errors"
98
+ role="alert"
99
+ aria-live="polite"
100
+ >
101
+ {errors.map((error, i) => (
102
+ <span key={i} className="error">
103
+ {error.message}
104
+ </span>
105
+ ))}
106
+ </div>
107
+ )}
108
+ {field.description && (
109
+ <p id={descriptionId} className="field-description">
110
+ {field.description}
111
+ </p>
112
+ )}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Default page wrapper component
119
+ */
120
+ function DefaultPageWrapper({ title, description, children }: PageWrapperProps) {
121
+ return (
122
+ <div className="page-wrapper">
123
+ <h2>{title}</h2>
124
+ {description && <p>{description}</p>}
125
+ {children}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Extract numeric constraints from JSON Schema property
132
+ */
133
+ function getNumberConstraints(schema?: JSONSchemaProperty): { min?: number; max?: number; step?: number } {
134
+ if (!schema) return {};
135
+ if (schema.type !== "number" && schema.type !== "integer") return {};
136
+
137
+ return {
138
+ min: "minimum" in schema ? schema.minimum : undefined,
139
+ max: "maximum" in schema ? schema.maximum : undefined,
140
+ step: schema.type === "integer" ? 1 : undefined,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Create a default item for an array field based on item field definitions
146
+ */
147
+ function createDefaultItem(itemFields: Record<string, FieldDefinition>): Record<string, unknown> {
148
+ const item: Record<string, unknown> = {};
149
+ for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
150
+ if (fieldDef.type === "boolean") {
151
+ item[fieldName] = false;
152
+ } else if (fieldDef.type === "number" || fieldDef.type === "integer") {
153
+ item[fieldName] = null;
154
+ } else {
155
+ item[fieldName] = "";
156
+ }
157
+ }
158
+ return item;
159
+ }
160
+
161
+ /**
162
+ * FormRenderer component
163
+ */
164
+ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
165
+ function FormRenderer(props, ref) {
166
+ const {
167
+ spec,
168
+ initialData,
169
+ onSubmit,
170
+ onChange,
171
+ components,
172
+ layout: Layout = DefaultLayout,
173
+ fieldWrapper: FieldWrapper = DefaultFieldWrapper,
174
+ pageWrapper: PageWrapper = DefaultPageWrapper,
175
+ validateOn,
176
+ } = props;
177
+
178
+ const forma = useForma({
179
+ spec,
180
+ initialData,
181
+ onSubmit,
182
+ onChange,
183
+ validateOn,
184
+ });
185
+
186
+ const fieldRefs = useRef<Map<string, HTMLElement>>(new Map());
187
+
188
+ // Cache for array helper functions to prevent recreation on every render
189
+ const arrayHelpersCache = useRef<Map<string, {
190
+ push: (item?: unknown) => void;
191
+ insert: (index: number, item: unknown) => void;
192
+ remove: (index: number) => void;
193
+ move: (from: number, to: number) => void;
194
+ swap: (indexA: number, indexB: number) => void;
195
+ }>>(new Map());
196
+
197
+ // Focus a specific field by path
198
+ const focusField = useCallback((path: string) => {
199
+ const element = fieldRefs.current.get(path);
200
+ element?.focus();
201
+ }, []);
202
+
203
+ // Focus the first field with an error
204
+ const focusFirstError = useCallback(() => {
205
+ const firstError = forma.errors[0];
206
+ if (firstError) {
207
+ focusField(firstError.field);
208
+ }
209
+ }, [forma.errors, focusField]);
210
+
211
+ // Expose imperative handle
212
+ useImperativeHandle(
213
+ ref,
214
+ () => ({
215
+ submitForm: forma.submitForm,
216
+ resetForm: forma.resetForm,
217
+ validateForm: forma.validateForm,
218
+ focusField,
219
+ focusFirstError,
220
+ getValues: () => forma.data,
221
+ setValues: forma.setValues,
222
+ isValid: forma.isValid,
223
+ isDirty: forma.isDirty,
224
+ }),
225
+ [forma, focusField, focusFirstError]
226
+ );
227
+
228
+ // Determine which fields to render based on pages or fieldOrder
229
+ const fieldsToRender = useMemo(() => {
230
+ if (spec.pages && spec.pages.length > 0 && forma.wizard) {
231
+ // Wizard mode - render fields for the active page
232
+ const currentPage = forma.wizard.currentPage;
233
+ if (currentPage) {
234
+ return currentPage.fields;
235
+ }
236
+ // Fallback to first page
237
+ return spec.pages[0]?.fields ?? [];
238
+ }
239
+ // Single page mode - render all fields in order
240
+ return spec.fieldOrder;
241
+ }, [spec.pages, spec.fieldOrder, forma.wizard]);
242
+
243
+ // Render a single field (memoized)
244
+ const renderField = useCallback((fieldPath: string) => {
245
+ const fieldDef = spec.fields[fieldPath];
246
+ if (!fieldDef) return null;
247
+
248
+ const isVisible = forma.visibility[fieldPath] !== false;
249
+ if (!isVisible) return null;
250
+
251
+ // Infer field type
252
+ const fieldType = fieldDef.type || (fieldDef.itemFields ? "array" : "text");
253
+ const componentKey = fieldType as keyof ComponentMap;
254
+ const Component = components[componentKey] || components.fallback;
255
+
256
+ if (!Component) {
257
+ console.warn(`No component found for field type: ${fieldType}`);
258
+ return null;
259
+ }
260
+
261
+ const errors = forma.errors.filter((e) => e.field === fieldPath);
262
+ const touched = forma.touched[fieldPath] ?? false;
263
+ const required = forma.required[fieldPath] ?? false;
264
+ const disabled = forma.enabled[fieldPath] === false;
265
+
266
+ // Get schema property for additional constraints
267
+ const schemaProperty = spec.schema.properties[fieldPath];
268
+
269
+ // Base field props
270
+ const baseProps: BaseFieldProps = {
271
+ name: fieldPath,
272
+ field: fieldDef,
273
+ value: forma.data[fieldPath],
274
+ touched,
275
+ required,
276
+ disabled,
277
+ errors,
278
+ onChange: (value: unknown) => forma.setFieldValue(fieldPath, value),
279
+ onBlur: () => forma.setFieldTouched(fieldPath),
280
+ // Convenience properties
281
+ visible: true, // Always true since we already filtered for visibility
282
+ enabled: !disabled,
283
+ label: fieldDef.label ?? fieldPath,
284
+ description: fieldDef.description,
285
+ placeholder: fieldDef.placeholder,
286
+ };
287
+
288
+ // Build type-specific props
289
+ let fieldProps: BaseFieldProps | TextFieldProps | NumberFieldProps | SelectFieldProps | ArrayFieldProps = baseProps;
290
+
291
+ if (fieldType === "number" || fieldType === "integer") {
292
+ const constraints = getNumberConstraints(schemaProperty);
293
+ fieldProps = {
294
+ ...baseProps,
295
+ fieldType,
296
+ value: baseProps.value as number | null,
297
+ onChange: baseProps.onChange as (value: number | null) => void,
298
+ ...constraints,
299
+ } as NumberFieldProps;
300
+ } else if (fieldType === "select" || fieldType === "multiselect") {
301
+ fieldProps = {
302
+ ...baseProps,
303
+ fieldType,
304
+ value: baseProps.value as string | string[] | null,
305
+ onChange: baseProps.onChange as (value: string | string[] | null) => void,
306
+ options: fieldDef.options ?? [],
307
+ } as SelectFieldProps;
308
+ } else if (fieldType === "array" && fieldDef.itemFields) {
309
+ const arrayValue = (baseProps.value as unknown[] | undefined) ?? [];
310
+ const minItems = fieldDef.minItems ?? 0;
311
+ const maxItems = fieldDef.maxItems ?? Infinity;
312
+ const itemFieldDefs = fieldDef.itemFields;
313
+
314
+ // Get or create cached helper functions for this array field
315
+ // These functions read current values when called, not when created
316
+ if (!arrayHelpersCache.current.has(fieldPath)) {
317
+ arrayHelpersCache.current.set(fieldPath, {
318
+ push: (item?: unknown) => {
319
+ const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
320
+ const newItem = item ?? createDefaultItem(itemFieldDefs);
321
+ forma.setFieldValue(fieldPath, [...currentArray, newItem]);
322
+ },
323
+ insert: (index: number, item: unknown) => {
324
+ const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
325
+ const newArray = [...currentArray];
326
+ newArray.splice(index, 0, item);
327
+ forma.setFieldValue(fieldPath, newArray);
328
+ },
329
+ remove: (index: number) => {
330
+ const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
331
+ const newArray = [...currentArray];
332
+ newArray.splice(index, 1);
333
+ forma.setFieldValue(fieldPath, newArray);
334
+ },
335
+ move: (from: number, to: number) => {
336
+ const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
337
+ const newArray = [...currentArray];
338
+ const [item] = newArray.splice(from, 1);
339
+ newArray.splice(to, 0, item);
340
+ forma.setFieldValue(fieldPath, newArray);
341
+ },
342
+ swap: (indexA: number, indexB: number) => {
343
+ const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
344
+ const newArray = [...currentArray];
345
+ [newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]];
346
+ forma.setFieldValue(fieldPath, newArray);
347
+ },
348
+ });
349
+ }
350
+ const cachedHelpers = arrayHelpersCache.current.get(fieldPath)!;
351
+
352
+ const helpers: ArrayHelpers = {
353
+ items: arrayValue,
354
+ push: cachedHelpers.push,
355
+ insert: cachedHelpers.insert,
356
+ remove: cachedHelpers.remove,
357
+ move: cachedHelpers.move,
358
+ swap: cachedHelpers.swap,
359
+ getItemFieldProps: (index: number, fieldName: string) => {
360
+ const itemFieldDef = itemFieldDefs[fieldName];
361
+ const itemPath = `${fieldPath}[${index}].${fieldName}`;
362
+ const itemValue = (arrayValue[index] as Record<string, unknown>)?.[fieldName];
363
+ return {
364
+ name: itemPath,
365
+ value: itemValue,
366
+ type: itemFieldDef?.type ?? "text",
367
+ label: itemFieldDef?.label ?? fieldName,
368
+ description: itemFieldDef?.description,
369
+ placeholder: itemFieldDef?.placeholder,
370
+ visible: true,
371
+ enabled: !disabled,
372
+ required: itemFieldDef?.requiredWhen === "true",
373
+ touched: forma.touched[itemPath] ?? false,
374
+ errors: forma.errors.filter((e) => e.field === itemPath),
375
+ onChange: (value: unknown) => {
376
+ const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
377
+ const newArray = [...currentArray];
378
+ const item = (newArray[index] ?? {}) as Record<string, unknown>;
379
+ newArray[index] = { ...item, [fieldName]: value };
380
+ forma.setFieldValue(fieldPath, newArray);
381
+ },
382
+ onBlur: () => forma.setFieldTouched(itemPath),
383
+ itemIndex: index,
384
+ fieldName,
385
+ options: itemFieldDef?.options,
386
+ };
387
+ },
388
+ minItems,
389
+ maxItems,
390
+ canAdd: arrayValue.length < maxItems,
391
+ canRemove: arrayValue.length > minItems,
392
+ };
393
+ fieldProps = {
394
+ ...baseProps,
395
+ fieldType: "array",
396
+ value: arrayValue,
397
+ onChange: baseProps.onChange as (value: unknown[]) => void,
398
+ helpers,
399
+ itemFields: itemFieldDefs,
400
+ minItems,
401
+ maxItems,
402
+ } as ArrayFieldProps;
403
+ } else {
404
+ // Text-based fields
405
+ fieldProps = {
406
+ ...baseProps,
407
+ fieldType: fieldType as "text" | "email" | "password" | "url" | "textarea",
408
+ value: (baseProps.value as string) ?? "",
409
+ onChange: baseProps.onChange as (value: string) => void,
410
+ };
411
+ }
412
+
413
+ // Wrap props in { field, spec } structure for components
414
+ const componentProps = { field: fieldProps, spec };
415
+
416
+ return (
417
+ <FieldWrapper
418
+ key={fieldPath}
419
+ fieldPath={fieldPath}
420
+ field={fieldDef}
421
+ errors={errors}
422
+ touched={touched}
423
+ required={required}
424
+ visible={isVisible}
425
+ >
426
+ {React.createElement(Component as React.ComponentType<typeof componentProps>, componentProps)}
427
+ </FieldWrapper>
428
+ );
429
+ }, [spec, forma, components, FieldWrapper]);
430
+
431
+ // Render fields (memoized)
432
+ const renderedFields = useMemo(
433
+ () => fieldsToRender.map(renderField),
434
+ [fieldsToRender, renderField]
435
+ );
436
+
437
+ // Render with page wrapper if using pages
438
+ const content = useMemo(() => {
439
+ if (spec.pages && spec.pages.length > 0 && forma.wizard) {
440
+ const currentPage = forma.wizard.currentPage;
441
+ if (!currentPage) return null;
442
+
443
+ return (
444
+ <PageWrapper
445
+ title={currentPage.title}
446
+ description={currentPage.description}
447
+ pageIndex={forma.wizard.currentPageIndex}
448
+ totalPages={forma.wizard.pages.length}
449
+ >
450
+ {renderedFields}
451
+ </PageWrapper>
452
+ );
453
+ }
454
+
455
+ return <>{renderedFields}</>;
456
+ }, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
457
+
458
+ return (
459
+ <FormaContext.Provider value={forma}>
460
+ <Layout
461
+ onSubmit={forma.submitForm}
462
+ isSubmitting={forma.isSubmitting}
463
+ isValid={forma.isValid}
464
+ >
465
+ {content}
466
+ </Layout>
467
+ </FormaContext.Provider>
468
+ );
469
+ }
470
+ );