@fogpipe/forma-react 0.11.2 → 0.12.0-alpha.2

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.
@@ -5,11 +5,36 @@
5
5
  * Supports single-page and multi-page (wizard) forms.
6
6
  */
7
7
 
8
- import React, { forwardRef, useImperativeHandle, useRef, useMemo, useCallback } from "react";
9
- import type { Forma, FieldDefinition, ValidationResult, JSONSchemaProperty, SelectOption } from "@fogpipe/forma-core";
8
+ import React, {
9
+ forwardRef,
10
+ useImperativeHandle,
11
+ useRef,
12
+ useMemo,
13
+ useCallback,
14
+ } from "react";
15
+ import type {
16
+ Forma,
17
+ FieldDefinition,
18
+ ValidationResult,
19
+ JSONSchemaProperty,
20
+ SelectOption,
21
+ } from "@fogpipe/forma-core";
22
+ import { isAdornableField, isSelectionField } from "@fogpipe/forma-core";
10
23
  import { useForma } from "./useForma.js";
11
24
  import { FormaContext } from "./context.js";
12
- import type { ComponentMap, LayoutProps, FieldWrapperProps, PageWrapperProps, BaseFieldProps, TextFieldProps, NumberFieldProps, SelectFieldProps, ArrayFieldProps, ArrayHelpers } from "./types.js";
25
+ import type {
26
+ ComponentMap,
27
+ LayoutProps,
28
+ FieldWrapperProps,
29
+ PageWrapperProps,
30
+ BaseFieldProps,
31
+ TextFieldProps,
32
+ NumberFieldProps,
33
+ SelectFieldProps,
34
+ ArrayFieldProps,
35
+ ArrayHelpers,
36
+ DisplayFieldProps,
37
+ } from "./types.js";
13
38
 
14
39
  /**
15
40
  * Props for FormRenderer component
@@ -22,7 +47,10 @@ export interface FormRendererProps {
22
47
  /** Submit handler */
23
48
  onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
24
49
  /** Change handler */
25
- onChange?: (data: Record<string, unknown>, computed?: Record<string, unknown>) => void;
50
+ onChange?: (
51
+ data: Record<string, unknown>,
52
+ computed?: Record<string, unknown>,
53
+ ) => void;
26
54
  /** Component map for rendering fields */
27
55
  components: ComponentMap;
28
56
  /** Custom layout component */
@@ -74,11 +102,20 @@ function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
74
102
  /**
75
103
  * Default field wrapper component with accessibility support
76
104
  */
77
- function DefaultFieldWrapper({ fieldPath, field, children, errors, showRequiredIndicator, visible }: FieldWrapperProps) {
105
+ function DefaultFieldWrapper({
106
+ fieldPath,
107
+ field,
108
+ children,
109
+ errors,
110
+ showRequiredIndicator,
111
+ visible,
112
+ }: FieldWrapperProps) {
78
113
  if (!visible) return null;
79
114
 
80
115
  const errorId = `${fieldPath}-error`;
81
- const descriptionId = field.description ? `${fieldPath}-description` : undefined;
116
+ const descriptionId = field.description
117
+ ? `${fieldPath}-description`
118
+ : undefined;
82
119
  const hasErrors = errors.length > 0;
83
120
 
84
121
  return (
@@ -86,8 +123,14 @@ function DefaultFieldWrapper({ fieldPath, field, children, errors, showRequiredI
86
123
  {field.label && (
87
124
  <label htmlFor={fieldPath}>
88
125
  {field.label}
89
- {showRequiredIndicator && <span className="required" aria-hidden="true">*</span>}
90
- {showRequiredIndicator && <span className="sr-only"> (required)</span>}
126
+ {showRequiredIndicator && (
127
+ <span className="required" aria-hidden="true">
128
+ *
129
+ </span>
130
+ )}
131
+ {showRequiredIndicator && (
132
+ <span className="sr-only"> (required)</span>
133
+ )}
91
134
  </label>
92
135
  )}
93
136
  {children}
@@ -117,7 +160,11 @@ function DefaultFieldWrapper({ fieldPath, field, children, errors, showRequiredI
117
160
  /**
118
161
  * Default page wrapper component
119
162
  */
120
- function DefaultPageWrapper({ title, description, children }: PageWrapperProps) {
163
+ function DefaultPageWrapper({
164
+ title,
165
+ description,
166
+ children,
167
+ }: PageWrapperProps) {
121
168
  return (
122
169
  <div className="page-wrapper">
123
170
  <h2>{title}</h2>
@@ -130,13 +177,23 @@ function DefaultPageWrapper({ title, description, children }: PageWrapperProps)
130
177
  /**
131
178
  * Extract numeric constraints from JSON Schema property
132
179
  */
133
- function getNumberConstraints(schema?: JSONSchemaProperty): { min?: number; max?: number; step?: number } {
180
+ function getNumberConstraints(schema?: JSONSchemaProperty): {
181
+ min?: number;
182
+ max?: number;
183
+ step?: number;
184
+ } {
134
185
  if (!schema) return {};
135
186
  if (schema.type !== "number" && schema.type !== "integer") return {};
136
187
 
137
188
  // Extract min/max from schema
138
- const min = "minimum" in schema && typeof schema.minimum === "number" ? schema.minimum : undefined;
139
- const max = "maximum" in schema && typeof schema.maximum === "number" ? schema.maximum : undefined;
189
+ const min =
190
+ "minimum" in schema && typeof schema.minimum === "number"
191
+ ? schema.minimum
192
+ : undefined;
193
+ const max =
194
+ "maximum" in schema && typeof schema.maximum === "number"
195
+ ? schema.maximum
196
+ : undefined;
140
197
 
141
198
  // Use multipleOf for step if defined, otherwise default to 1 for integers
142
199
  let step: number | undefined;
@@ -152,7 +209,9 @@ function getNumberConstraints(schema?: JSONSchemaProperty): { min?: number; max?
152
209
  /**
153
210
  * Create a default item for an array field based on item field definitions
154
211
  */
155
- function createDefaultItem(itemFields: Record<string, FieldDefinition>): Record<string, unknown> {
212
+ function createDefaultItem(
213
+ itemFields: Record<string, FieldDefinition>,
214
+ ): Record<string, unknown> {
156
215
  const item: Record<string, unknown> = {};
157
216
  for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
158
217
  if (fieldDef.type === "boolean") {
@@ -221,9 +280,27 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
221
280
  isValid: forma.isValid,
222
281
  isDirty: forma.isDirty,
223
282
  }),
224
- [forma, focusField, focusFirstError]
283
+ [forma, focusField, focusFirstError],
225
284
  );
226
285
 
286
+ // Destructure only the values renderField needs from forma.
287
+ // This prevents renderField from recreating when unrelated state changes
288
+ // (isSubmitting, isDirty, wizard page navigation, etc.)
289
+ const {
290
+ data: formaData,
291
+ computed: formaComputed,
292
+ visibility: formaVisibility,
293
+ required: formaRequired,
294
+ enabled: formaEnabled,
295
+ readonly: formaReadonly,
296
+ optionsVisibility: formaOptionsVisibility,
297
+ touched: formaTouched,
298
+ errors: formaErrors,
299
+ setFieldValue,
300
+ setFieldTouched,
301
+ getArrayHelpers,
302
+ } = forma;
303
+
227
304
  // Determine which fields to render based on pages or fieldOrder
228
305
  const fieldsToRender = useMemo(() => {
229
306
  if (spec.pages && spec.pages.length > 0 && forma.wizard) {
@@ -240,161 +317,248 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
240
317
  }, [spec.pages, spec.fieldOrder, forma.wizard]);
241
318
 
242
319
  // Render a single field (memoized)
243
- const renderField = useCallback((fieldPath: string) => {
244
- const fieldDef = spec.fields[fieldPath];
245
- if (!fieldDef) return null;
246
-
247
- const isVisible = forma.visibility[fieldPath] !== false;
248
- if (!isVisible) return null;
320
+ const renderField = useCallback(
321
+ (fieldPath: string) => {
322
+ const fieldDef = spec.fields[fieldPath];
323
+ if (!fieldDef) return null;
324
+
325
+ const isVisible = formaVisibility[fieldPath] !== false;
326
+ if (!isVisible) {
327
+ return <div key={fieldPath} data-field-path={fieldPath} hidden />;
328
+ }
249
329
 
250
- // Infer field type
251
- const fieldType = fieldDef.type || (fieldDef.itemFields ? "array" : "text");
252
- const componentKey = fieldType as keyof ComponentMap;
253
- const Component = components[componentKey] || components.fallback;
330
+ // Get field type (type is required on all field definitions)
331
+ const fieldType = fieldDef.type;
332
+ const componentKey = fieldType as keyof ComponentMap;
333
+ const Component = components[componentKey] || components.fallback;
254
334
 
255
- if (!Component) {
256
- console.warn(`No component found for field type: ${fieldType}`);
257
- return null;
258
- }
335
+ if (!Component) {
336
+ console.warn(`No component found for field type: ${fieldType}`);
337
+ return null;
338
+ }
259
339
 
260
- const errors = forma.errors.filter((e) => e.field === fieldPath);
261
- const touched = forma.touched[fieldPath] ?? false;
262
- const required = forma.required[fieldPath] ?? false;
263
- const disabled = forma.enabled[fieldPath] === false;
264
-
265
- // Get schema property for additional constraints
266
- const schemaProperty = spec.schema.properties[fieldPath];
267
-
268
- // Boolean fields: hide asterisk unless they have validation rules (consent pattern)
269
- // - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
270
- // - Consent checkbox ("I accept terms"): has validation rule → show asterisk
271
- const isBooleanField = schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
272
- const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
273
- const showRequiredIndicator = required && (!isBooleanField || hasValidationRules);
274
-
275
- // Base field props
276
- const baseProps: BaseFieldProps = {
277
- name: fieldPath,
278
- field: fieldDef,
279
- value: forma.data[fieldPath],
280
- touched,
281
- required,
282
- disabled,
283
- errors,
284
- onChange: (value: unknown) => forma.setFieldValue(fieldPath, value),
285
- onBlur: () => forma.setFieldTouched(fieldPath),
286
- // Convenience properties
287
- visible: true, // Always true since we already filtered for visibility
288
- enabled: !disabled,
289
- label: fieldDef.label ?? fieldPath,
290
- description: fieldDef.description,
291
- placeholder: fieldDef.placeholder,
292
- };
293
-
294
- // Build type-specific props
295
- let fieldProps: BaseFieldProps | TextFieldProps | NumberFieldProps | SelectFieldProps | ArrayFieldProps = baseProps;
296
-
297
- if (fieldType === "number" || fieldType === "integer") {
298
- const constraints = getNumberConstraints(schemaProperty);
299
- fieldProps = {
300
- ...baseProps,
301
- fieldType,
302
- value: baseProps.value as number | null,
303
- onChange: baseProps.onChange as (value: number | null) => void,
304
- ...constraints,
305
- } as NumberFieldProps;
306
- } else if (fieldType === "select" || fieldType === "multiselect") {
307
- fieldProps = {
308
- ...baseProps,
309
- fieldType,
310
- value: baseProps.value as string | string[] | null,
311
- onChange: baseProps.onChange as (value: string | string[] | null) => void,
312
- options: forma.optionsVisibility[fieldPath] ?? fieldDef.options ?? [],
313
- } as SelectFieldProps;
314
- } else if (fieldType === "array" && fieldDef.itemFields) {
315
- const arrayValue = Array.isArray(baseProps.value) ? baseProps.value : [];
316
- const minItems = fieldDef.minItems ?? 0;
317
- const maxItems = fieldDef.maxItems ?? Infinity;
318
- const itemFieldDefs = fieldDef.itemFields;
319
-
320
- // Get helpers from useForma - these are fresh on each render, avoiding stale closures
321
- const baseHelpers = forma.getArrayHelpers(fieldPath);
322
-
323
- // Wrap push to add default item creation when called without arguments
324
- const pushWithDefault = (item?: unknown): void => {
325
- const newItem = item ?? createDefaultItem(itemFieldDefs);
326
- baseHelpers.push(newItem);
340
+ const errors = formaErrors.filter((e) => e.field === fieldPath);
341
+ const touched = formaTouched[fieldPath] ?? false;
342
+ const required = formaRequired[fieldPath] ?? false;
343
+ const disabled = formaEnabled[fieldPath] === false;
344
+
345
+ // Get schema property for additional constraints
346
+ const schemaProperty = spec.schema.properties[fieldPath];
347
+
348
+ // Boolean fields: hide asterisk unless they have validation rules (consent pattern)
349
+ // - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
350
+ // - Consent checkbox ("I accept terms"): has validation rule → show asterisk
351
+ const isBooleanField =
352
+ schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
353
+ const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
354
+ const showRequiredIndicator =
355
+ required && (!isBooleanField || hasValidationRules);
356
+
357
+ // Base field props
358
+ const isReadonly = formaReadonly[fieldPath] ?? false;
359
+ const baseProps: BaseFieldProps = {
360
+ name: fieldPath,
361
+ field: fieldDef,
362
+ value: formaData[fieldPath],
363
+ touched,
364
+ required,
365
+ disabled,
366
+ errors,
367
+ onChange: (value: unknown) => setFieldValue(fieldPath, value),
368
+ onBlur: () => setFieldTouched(fieldPath),
369
+ // Convenience properties
370
+ visible: true, // Always true since we already filtered for visibility
371
+ enabled: !disabled,
372
+ readonly: isReadonly,
373
+ label: fieldDef.label ?? fieldPath,
374
+ description: fieldDef.description,
375
+ placeholder: fieldDef.placeholder,
376
+ // Adorner properties (only for adornable field types)
377
+ ...(isAdornableField(fieldDef) && {
378
+ prefix: fieldDef.prefix,
379
+ suffix: fieldDef.suffix,
380
+ }),
381
+ // Presentation variant
382
+ variant: fieldDef.variant,
383
+ variantConfig: fieldDef.variantConfig,
327
384
  };
328
385
 
329
- // Extend getItemFieldProps to include additional metadata (itemIndex, fieldName, options)
330
- const getItemFieldPropsExtended = (index: number, fieldName: string) => {
331
- const baseProps = baseHelpers.getItemFieldProps(index, fieldName);
332
- const itemFieldDef = itemFieldDefs[fieldName];
333
- const itemPath = `${fieldPath}[${index}].${fieldName}`;
334
- return {
386
+ // Build type-specific props
387
+ let fieldProps:
388
+ | BaseFieldProps
389
+ | TextFieldProps
390
+ | NumberFieldProps
391
+ | SelectFieldProps
392
+ | ArrayFieldProps
393
+ | DisplayFieldProps = baseProps;
394
+
395
+ if (fieldType === "number" || fieldType === "integer") {
396
+ const constraints = getNumberConstraints(schemaProperty);
397
+ fieldProps = {
335
398
  ...baseProps,
336
- itemIndex: index,
337
- fieldName,
338
- options: (forma.optionsVisibility[itemPath] as SelectOption[] | undefined) ?? itemFieldDef?.options,
399
+ fieldType,
400
+ value: baseProps.value as number | null,
401
+ onChange: baseProps.onChange as (value: number | null) => void,
402
+ ...constraints,
403
+ } as NumberFieldProps;
404
+ } else if (fieldType === "select" || fieldType === "multiselect") {
405
+ const selectOptions = isSelectionField(fieldDef)
406
+ ? fieldDef.options
407
+ : [];
408
+ fieldProps = {
409
+ ...baseProps,
410
+ fieldType,
411
+ value: baseProps.value as string | string[] | null,
412
+ onChange: baseProps.onChange as (
413
+ value: string | string[] | null,
414
+ ) => void,
415
+ options: formaOptionsVisibility[fieldPath] ?? selectOptions ?? [],
416
+ } as SelectFieldProps;
417
+ } else if (
418
+ fieldType === "array" &&
419
+ fieldDef.type === "array" &&
420
+ fieldDef.itemFields
421
+ ) {
422
+ const arrayValue = Array.isArray(baseProps.value)
423
+ ? baseProps.value
424
+ : [];
425
+ const minItems = fieldDef.minItems ?? 0;
426
+ const maxItems = fieldDef.maxItems ?? Infinity;
427
+ const itemFieldDefs = fieldDef.itemFields;
428
+
429
+ // Get helpers from useForma - these are fresh on each render, avoiding stale closures
430
+ const baseHelpers = getArrayHelpers(fieldPath);
431
+
432
+ // Wrap push to add default item creation when called without arguments
433
+ const pushWithDefault = (item?: unknown): void => {
434
+ const newItem = item ?? createDefaultItem(itemFieldDefs);
435
+ baseHelpers.push(newItem);
339
436
  };
340
- };
341
437
 
342
- const helpers: ArrayHelpers = {
343
- items: arrayValue,
344
- push: pushWithDefault,
345
- insert: baseHelpers.insert,
346
- remove: baseHelpers.remove,
347
- move: baseHelpers.move,
348
- swap: baseHelpers.swap,
349
- getItemFieldProps: getItemFieldPropsExtended,
350
- minItems,
351
- maxItems,
352
- canAdd: arrayValue.length < maxItems,
353
- canRemove: arrayValue.length > minItems,
354
- };
355
- fieldProps = {
356
- ...baseProps,
357
- fieldType: "array",
358
- value: arrayValue,
359
- onChange: baseProps.onChange as (value: unknown[]) => void,
360
- helpers,
361
- itemFields: itemFieldDefs,
362
- minItems,
363
- maxItems,
364
- } as ArrayFieldProps;
365
- } else {
366
- // Text-based fields
367
- fieldProps = {
368
- ...baseProps,
369
- fieldType: fieldType as "text" | "email" | "password" | "url" | "textarea",
370
- value: (baseProps.value as string) ?? "",
371
- onChange: baseProps.onChange as (value: string) => void,
372
- };
373
- }
438
+ // Extend getItemFieldProps to include additional metadata (itemIndex, fieldName, options)
439
+ const getItemFieldPropsExtended = (
440
+ index: number,
441
+ fieldName: string,
442
+ ) => {
443
+ const baseProps = baseHelpers.getItemFieldProps(index, fieldName);
444
+ const itemFieldDef = itemFieldDefs[fieldName];
445
+ const itemPath = `${fieldPath}[${index}].${fieldName}`;
446
+ return {
447
+ ...baseProps,
448
+ itemIndex: index,
449
+ fieldName,
450
+ options:
451
+ (formaOptionsVisibility[itemPath] as
452
+ | SelectOption[]
453
+ | undefined) ??
454
+ (itemFieldDef && isSelectionField(itemFieldDef)
455
+ ? itemFieldDef.options
456
+ : undefined),
457
+ };
458
+ };
374
459
 
375
- // Wrap props in { field, spec } structure for components
376
- const componentProps = { field: fieldProps, spec };
377
-
378
- return (
379
- <FieldWrapper
380
- key={fieldPath}
381
- fieldPath={fieldPath}
382
- field={fieldDef}
383
- errors={errors}
384
- touched={touched}
385
- required={required}
386
- showRequiredIndicator={showRequiredIndicator}
387
- visible={isVisible}
388
- >
389
- {React.createElement(Component as React.ComponentType<typeof componentProps>, componentProps)}
390
- </FieldWrapper>
391
- );
392
- }, [spec, forma, components, FieldWrapper]);
460
+ const helpers: ArrayHelpers = {
461
+ items: arrayValue,
462
+ push: pushWithDefault,
463
+ insert: baseHelpers.insert,
464
+ remove: baseHelpers.remove,
465
+ move: baseHelpers.move,
466
+ swap: baseHelpers.swap,
467
+ getItemFieldProps: getItemFieldPropsExtended,
468
+ minItems,
469
+ maxItems,
470
+ canAdd: arrayValue.length < maxItems,
471
+ canRemove: arrayValue.length > minItems,
472
+ };
473
+ fieldProps = {
474
+ ...baseProps,
475
+ fieldType: "array",
476
+ value: arrayValue,
477
+ onChange: baseProps.onChange as (value: unknown[]) => void,
478
+ helpers,
479
+ itemFields: itemFieldDefs,
480
+ minItems,
481
+ maxItems,
482
+ } as ArrayFieldProps;
483
+ } else if (fieldType === "display" && fieldDef.type === "display") {
484
+ // Display fields (read-only presentation content)
485
+ // Resolve source value if the display field has a source property
486
+ const sourceValue = fieldDef.source
487
+ ? (formaData[fieldDef.source] ?? formaComputed[fieldDef.source])
488
+ : undefined;
489
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
490
+ const {
491
+ onChange: _onChange,
492
+ value: _value,
493
+ ...displayBaseProps
494
+ } = baseProps;
495
+ fieldProps = {
496
+ ...displayBaseProps,
497
+ fieldType: "display",
498
+ content: fieldDef.content,
499
+ sourceValue,
500
+ format: fieldDef.format,
501
+ } as DisplayFieldProps;
502
+ } else {
503
+ // Text-based fields
504
+ fieldProps = {
505
+ ...baseProps,
506
+ fieldType: fieldType as
507
+ | "text"
508
+ | "email"
509
+ | "password"
510
+ | "url"
511
+ | "textarea",
512
+ value: (baseProps.value as string) ?? "",
513
+ onChange: baseProps.onChange as (value: string) => void,
514
+ };
515
+ }
516
+
517
+ // Wrap props in { field, spec } structure for components
518
+ const componentProps = { field: fieldProps, spec };
519
+
520
+ return (
521
+ <div key={fieldPath} data-field-path={fieldPath}>
522
+ <FieldWrapper
523
+ fieldPath={fieldPath}
524
+ field={fieldDef}
525
+ errors={errors}
526
+ touched={touched}
527
+ required={required}
528
+ showRequiredIndicator={showRequiredIndicator}
529
+ visible={isVisible}
530
+ >
531
+ {React.createElement(
532
+ Component as React.ComponentType<typeof componentProps>,
533
+ componentProps,
534
+ )}
535
+ </FieldWrapper>
536
+ </div>
537
+ );
538
+ },
539
+ [
540
+ spec,
541
+ components,
542
+ FieldWrapper,
543
+ formaData,
544
+ formaComputed,
545
+ formaVisibility,
546
+ formaRequired,
547
+ formaEnabled,
548
+ formaReadonly,
549
+ formaOptionsVisibility,
550
+ formaTouched,
551
+ formaErrors,
552
+ setFieldValue,
553
+ setFieldTouched,
554
+ getArrayHelpers,
555
+ ],
556
+ );
393
557
 
394
558
  // Render fields (memoized)
395
559
  const renderedFields = useMemo(
396
560
  () => fieldsToRender.map(renderField),
397
- [fieldsToRender, renderField]
561
+ [fieldsToRender, renderField],
398
562
  );
399
563
 
400
564
  // Render with page wrapper if using pages
@@ -429,5 +593,5 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
429
593
  </Layout>
430
594
  </FormaContext.Provider>
431
595
  );
432
- }
596
+ },
433
597
  );