@fogpipe/forma-react 0.12.0-alpha.1 → 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.
package/src/useForma.ts CHANGED
@@ -5,10 +5,26 @@
5
5
  * This is a placeholder - the full implementation will be migrated from formidable.
6
6
  */
7
7
 
8
- import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
9
- import type { Forma, FieldError, ValidationResult, SelectOption } from "@fogpipe/forma-core";
8
+ import {
9
+ useCallback,
10
+ useEffect,
11
+ useMemo,
12
+ useReducer,
13
+ useRef,
14
+ useState,
15
+ } from "react";
16
+ import type {
17
+ Forma,
18
+ FieldError,
19
+ ValidationResult,
20
+ SelectOption,
21
+ } from "@fogpipe/forma-core";
10
22
  import { isAdornableField } from "@fogpipe/forma-core";
11
- import type { GetFieldPropsResult, GetSelectFieldPropsResult, GetArrayHelpersResult } from "./types.js";
23
+ import type {
24
+ GetFieldPropsResult,
25
+ GetSelectFieldPropsResult,
26
+ GetArrayHelpersResult,
27
+ } from "./types.js";
12
28
  import {
13
29
  getVisibility,
14
30
  getRequired,
@@ -32,7 +48,10 @@ export interface UseFormaOptions {
32
48
  /** Submit handler */
33
49
  onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
34
50
  /** Change handler */
35
- onChange?: (data: Record<string, unknown>, computed?: Record<string, unknown>) => void;
51
+ onChange?: (
52
+ data: Record<string, unknown>,
53
+ computed?: Record<string, unknown>,
54
+ ) => void;
36
55
  /** When to validate: on change, blur, or submit only */
37
56
  validateOn?: "change" | "blur" | "submit";
38
57
  /** Additional reference data to merge with spec.referenceData */
@@ -222,7 +241,15 @@ function getDefaultBooleanValues(spec: Forma): Record<string, boolean> {
222
241
  * Main Forma hook
223
242
  */
224
243
  export function useForma(options: UseFormaOptions): UseFormaReturn {
225
- const { spec: inputSpec, initialData = {}, onSubmit, onChange, validateOn = "blur", referenceData, validationDebounceMs = 0 } = options;
244
+ const {
245
+ spec: inputSpec,
246
+ initialData = {},
247
+ onSubmit,
248
+ onChange,
249
+ validateOn = "blur",
250
+ referenceData,
251
+ validationDebounceMs = 0,
252
+ } = options;
226
253
 
227
254
  // Merge referenceData from options with spec.referenceData
228
255
  const spec = useMemo((): Forma => {
@@ -255,47 +282,48 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
255
282
  // Calculate computed values
256
283
  const computed = useMemo(
257
284
  () => calculate(state.data, spec),
258
- [state.data, spec]
285
+ [state.data, spec],
259
286
  );
260
287
 
261
288
  // Calculate visibility
262
289
  const visibility = useMemo(
263
290
  () => getVisibility(state.data, spec, { computed }),
264
- [state.data, spec, computed]
291
+ [state.data, spec, computed],
265
292
  );
266
293
 
267
294
  // Calculate required state
268
295
  const required = useMemo(
269
296
  () => getRequired(state.data, spec, { computed }),
270
- [state.data, spec, computed]
297
+ [state.data, spec, computed],
271
298
  );
272
299
 
273
300
  // Calculate enabled state
274
301
  const enabled = useMemo(
275
302
  () => getEnabled(state.data, spec, { computed }),
276
- [state.data, spec, computed]
303
+ [state.data, spec, computed],
277
304
  );
278
305
 
279
306
  // Calculate readonly state
280
307
  const readonly = useMemo(
281
308
  () => getReadonly(state.data, spec, { computed }),
282
- [state.data, spec, computed]
309
+ [state.data, spec, computed],
283
310
  );
284
311
 
285
312
  // Calculate visible options for all select/multiselect fields (memoized)
286
313
  const optionsVisibility = useMemo(
287
314
  () => getOptionsVisibility(state.data, spec, { computed }),
288
- [state.data, spec, computed]
315
+ [state.data, spec, computed],
289
316
  );
290
317
 
291
318
  // Validate form - compute immediate result
292
319
  const immediateValidation = useMemo(
293
320
  () => validate(state.data, spec, { computed, onlyVisible: true }),
294
- [state.data, spec, computed]
321
+ [state.data, spec, computed],
295
322
  );
296
323
 
297
324
  // Debounced validation state (only used when validationDebounceMs > 0)
298
- const [debouncedValidation, setDebouncedValidation] = useState<ValidationResult>(immediateValidation);
325
+ const [debouncedValidation, setDebouncedValidation] =
326
+ useState<ValidationResult>(immediateValidation);
299
327
 
300
328
  // Apply debouncing if configured
301
329
  useEffect(() => {
@@ -314,7 +342,8 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
314
342
  }, [immediateValidation, validationDebounceMs]);
315
343
 
316
344
  // Use debounced validation for display, but immediate for submit
317
- const validation = validationDebounceMs > 0 ? debouncedValidation : immediateValidation;
345
+ const validation =
346
+ validationDebounceMs > 0 ? debouncedValidation : immediateValidation;
318
347
 
319
348
  // isDirty is tracked via reducer state for O(1) performance
320
349
 
@@ -328,42 +357,52 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
328
357
  }, [state.data, computed, onChange]);
329
358
 
330
359
  // Helper function to set value at nested path
331
- const setNestedValue = useCallback((path: string, value: unknown): void => {
332
- // Handle array index notation: "items[0].name" -> nested structure
333
- const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
334
-
335
- if (parts.length === 1) {
336
- // Simple path - just set directly
337
- dispatch({ type: "SET_FIELD_VALUE", field: path, value });
338
- return;
339
- }
360
+ const setNestedValue = useCallback(
361
+ (path: string, value: unknown): void => {
362
+ // Handle array index notation: "items[0].name" -> nested structure
363
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
364
+
365
+ if (parts.length === 1) {
366
+ // Simple path - just set directly
367
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
368
+ return;
369
+ }
340
370
 
341
- // Build nested object for complex paths
342
- const buildNestedObject = (data: Record<string, unknown>, pathParts: string[], val: unknown): Record<string, unknown> => {
343
- const result = { ...data };
344
- let current: Record<string, unknown> = result;
345
-
346
- for (let i = 0; i < pathParts.length - 1; i++) {
347
- const part = pathParts[i];
348
- const nextPart = pathParts[i + 1];
349
- const isNextArrayIndex = /^\d+$/.test(nextPart);
350
-
351
- if (current[part] === undefined) {
352
- current[part] = isNextArrayIndex ? [] : {};
353
- } else if (Array.isArray(current[part])) {
354
- current[part] = [...(current[part] as unknown[])];
355
- } else {
356
- current[part] = { ...(current[part] as Record<string, unknown>) };
371
+ // Build nested object for complex paths
372
+ const buildNestedObject = (
373
+ data: Record<string, unknown>,
374
+ pathParts: string[],
375
+ val: unknown,
376
+ ): Record<string, unknown> => {
377
+ const result = { ...data };
378
+ let current: Record<string, unknown> = result;
379
+
380
+ for (let i = 0; i < pathParts.length - 1; i++) {
381
+ const part = pathParts[i];
382
+ const nextPart = pathParts[i + 1];
383
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
384
+
385
+ if (current[part] === undefined) {
386
+ current[part] = isNextArrayIndex ? [] : {};
387
+ } else if (Array.isArray(current[part])) {
388
+ current[part] = [...(current[part] as unknown[])];
389
+ } else {
390
+ current[part] = { ...(current[part] as Record<string, unknown>) };
391
+ }
392
+ current = current[part] as Record<string, unknown>;
357
393
  }
358
- current = current[part] as Record<string, unknown>;
359
- }
360
394
 
361
- current[pathParts[pathParts.length - 1]] = val;
362
- return result;
363
- };
395
+ current[pathParts[pathParts.length - 1]] = val;
396
+ return result;
397
+ };
364
398
 
365
- dispatch({ type: "SET_VALUES", values: buildNestedObject(state.data, parts, value) });
366
- }, [state.data]);
399
+ dispatch({
400
+ type: "SET_VALUES",
401
+ values: buildNestedObject(state.data, parts, value),
402
+ });
403
+ },
404
+ [state.data],
405
+ );
367
406
 
368
407
  // Actions
369
408
  const setFieldValue = useCallback(
@@ -373,7 +412,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
373
412
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
374
413
  }
375
414
  },
376
- [validateOn, setNestedValue]
415
+ [validateOn, setNestedValue],
377
416
  );
378
417
 
379
418
  const setFieldTouched = useCallback((path: string, touched = true) => {
@@ -388,7 +427,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
388
427
  (path: string): FieldError[] => {
389
428
  return validation.errors.filter((e) => e.field === path);
390
429
  },
391
- [validation]
430
+ [validation],
392
431
  );
393
432
 
394
433
  const validateForm = useCallback((): ValidationResult => {
@@ -432,7 +471,10 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
432
471
 
433
472
  // Clamp currentPage to valid range (handles case where current page becomes hidden)
434
473
  const maxPageIndex = Math.max(0, visiblePages.length - 1);
435
- const clampedPageIndex = Math.min(Math.max(0, state.currentPage), maxPageIndex);
474
+ const clampedPageIndex = Math.min(
475
+ Math.max(0, state.currentPage),
476
+ maxPageIndex,
477
+ );
436
478
 
437
479
  // Auto-correct page index if it's out of bounds
438
480
  if (clampedPageIndex !== state.currentPage && visiblePages.length > 0) {
@@ -470,12 +512,13 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
470
512
  // Get errors only for visible fields on the current page
471
513
  const pageErrors = validation.errors.filter((e) => {
472
514
  // Check if field is on current page (including array items like "items[0].name")
473
- const isOnCurrentPage = currentPage.fields.includes(e.field) ||
474
- currentPage.fields.some(f => e.field.startsWith(`${f}[`));
515
+ const isOnCurrentPage =
516
+ currentPage.fields.includes(e.field) ||
517
+ currentPage.fields.some((f) => e.field.startsWith(`${f}[`));
475
518
  // Only count errors for visible fields
476
519
  const isVisible = visibility[e.field] !== false;
477
520
  // Only count actual errors, not warnings
478
- const isError = e.severity === 'error';
521
+ const isError = e.severity === "error";
479
522
  return isOnCurrentPage && isVisible && isError;
480
523
  });
481
524
  return pageErrors.length === 0;
@@ -491,7 +534,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
491
534
  validateCurrentPage: () => {
492
535
  if (!currentPage) return true;
493
536
  const pageErrors = validation.errors.filter((e) =>
494
- currentPage.fields.includes(e.field)
537
+ currentPage.fields.includes(e.field),
495
538
  );
496
539
  return pageErrors.length === 0;
497
540
  },
@@ -502,7 +545,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
502
545
  // Uses stateDataRef to always access current state, avoiding stale closure issues
503
546
  const getValueAtPath = useCallback((path: string): unknown => {
504
547
  // Handle array index notation: "items[0].name" -> ["items", "0", "name"]
505
- const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
548
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
506
549
  let value: unknown = stateDataRef.current;
507
550
  for (const part of parts) {
508
551
  if (value === null || value === undefined) return undefined;
@@ -515,7 +558,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
515
558
  // Uses stateDataRef to always access current state, avoiding stale closure issues
516
559
  const setValueAtPath = useCallback((path: string, value: unknown): void => {
517
560
  // For nested paths, we need to build the nested structure
518
- const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
561
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
519
562
  if (parts.length === 1) {
520
563
  dispatch({ type: "SET_FIELD_VALUE", field: path, value });
521
564
  return;
@@ -545,7 +588,9 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
545
588
  }, []); // No dependencies - uses ref for current state
546
589
 
547
590
  // Memoized onChange/onBlur handlers for fields
548
- const fieldHandlers = useRef<Map<string, { onChange: (value: unknown) => void; onBlur: () => void }>>(new Map());
591
+ const fieldHandlers = useRef<
592
+ Map<string, { onChange: (value: unknown) => void; onBlur: () => void }>
593
+ >(new Map());
549
594
 
550
595
  // Clean up stale field handlers when spec changes to prevent memory leaks
551
596
  useEffect(() => {
@@ -563,221 +608,309 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
563
608
  }
564
609
  // Remove handlers for fields that no longer exist
565
610
  for (const key of fieldHandlers.current.keys()) {
566
- const baseField = key.split('[')[0];
611
+ const baseField = key.split("[")[0];
567
612
  if (!validFields.has(key) && !validFields.has(baseField)) {
568
613
  fieldHandlers.current.delete(key);
569
614
  }
570
615
  }
571
616
  }, [spec]);
572
617
 
573
- const getFieldHandlers = useCallback((path: string) => {
574
- if (!fieldHandlers.current.has(path)) {
575
- fieldHandlers.current.set(path, {
576
- onChange: (value: unknown) => setValueAtPath(path, value),
577
- onBlur: () => setFieldTouched(path),
578
- });
579
- }
580
- return fieldHandlers.current.get(path)!;
581
- }, [setValueAtPath, setFieldTouched]);
618
+ const getFieldHandlers = useCallback(
619
+ (path: string) => {
620
+ if (!fieldHandlers.current.has(path)) {
621
+ fieldHandlers.current.set(path, {
622
+ onChange: (value: unknown) => setValueAtPath(path, value),
623
+ onBlur: () => setFieldTouched(path),
624
+ });
625
+ }
626
+ return fieldHandlers.current.get(path)!;
627
+ },
628
+ [setValueAtPath, setFieldTouched],
629
+ );
582
630
 
583
631
  // Get field props for any field
584
- const getFieldProps = useCallback((path: string): GetFieldPropsResult => {
585
- const fieldDef = spec.fields[path];
586
- const handlers = getFieldHandlers(path);
587
-
588
- // Determine field type from definition or infer from schema
589
- let fieldType = fieldDef?.type || "text";
590
- if (!fieldType || fieldType === "computed") {
591
- const schemaProperty = spec.schema.properties[path];
592
- if (schemaProperty) {
593
- if (schemaProperty.type === "number") fieldType = "number";
594
- else if (schemaProperty.type === "integer") fieldType = "integer";
595
- else if (schemaProperty.type === "boolean") fieldType = "boolean";
596
- else if (schemaProperty.type === "array") fieldType = "array";
597
- else if (schemaProperty.type === "object") fieldType = "object";
598
- else if ("enum" in schemaProperty && schemaProperty.enum) fieldType = "select";
599
- else if ("format" in schemaProperty) {
600
- if (schemaProperty.format === "date") fieldType = "date";
601
- else if (schemaProperty.format === "date-time") fieldType = "datetime";
602
- else if (schemaProperty.format === "email") fieldType = "email";
603
- else if (schemaProperty.format === "uri") fieldType = "url";
632
+ const getFieldProps = useCallback(
633
+ (path: string): GetFieldPropsResult => {
634
+ const fieldDef = spec.fields[path];
635
+ const handlers = getFieldHandlers(path);
636
+
637
+ // Determine field type from definition or infer from schema
638
+ let fieldType = fieldDef?.type || "text";
639
+ if (!fieldType || fieldType === "computed") {
640
+ const schemaProperty = spec.schema.properties[path];
641
+ if (schemaProperty) {
642
+ if (schemaProperty.type === "number") fieldType = "number";
643
+ else if (schemaProperty.type === "integer") fieldType = "integer";
644
+ else if (schemaProperty.type === "boolean") fieldType = "boolean";
645
+ else if (schemaProperty.type === "array") fieldType = "array";
646
+ else if (schemaProperty.type === "object") fieldType = "object";
647
+ else if ("enum" in schemaProperty && schemaProperty.enum)
648
+ fieldType = "select";
649
+ else if ("format" in schemaProperty) {
650
+ if (schemaProperty.format === "date") fieldType = "date";
651
+ else if (schemaProperty.format === "date-time")
652
+ fieldType = "datetime";
653
+ else if (schemaProperty.format === "email") fieldType = "email";
654
+ else if (schemaProperty.format === "uri") fieldType = "url";
655
+ }
604
656
  }
605
657
  }
606
- }
607
658
 
608
- const fieldErrors = validation.errors.filter((e) => e.field === path);
609
- const isTouched = state.touched[path] ?? false;
610
- const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
611
- const displayedErrors = showErrors ? fieldErrors : [];
612
- const hasErrors = displayedErrors.length > 0;
613
- const isRequired = required[path] ?? false;
614
-
615
- // Boolean fields: hide asterisk unless they have validation rules (consent pattern)
616
- // - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
617
- // - Consent checkbox ("I accept terms"): has validation rule → show asterisk
618
- const schemaProperty = spec.schema.properties[path];
619
- const isBooleanField = schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
620
- const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
621
- const showRequiredIndicator = isRequired && (!isBooleanField || hasValidationRules);
622
-
623
- // Pass through adorner props for adornable field types
624
- const adornerProps = fieldDef && isAdornableField(fieldDef)
625
- ? { prefix: fieldDef.prefix, suffix: fieldDef.suffix }
626
- : {};
659
+ const fieldErrors = validation.errors.filter((e) => e.field === path);
660
+ const isTouched = state.touched[path] ?? false;
661
+ const showErrors =
662
+ validateOn === "change" ||
663
+ (validateOn === "blur" && isTouched) ||
664
+ state.isSubmitted;
665
+ const displayedErrors = showErrors ? fieldErrors : [];
666
+ const hasErrors = displayedErrors.length > 0;
667
+ const isRequired = required[path] ?? false;
668
+
669
+ // Boolean fields: hide asterisk unless they have validation rules (consent pattern)
670
+ // - Binary question ("Do you smoke?"): no validation false is valid → hide asterisk
671
+ // - Consent checkbox ("I accept terms"): has validation rule → show asterisk
672
+ const schemaProperty = spec.schema.properties[path];
673
+ const isBooleanField =
674
+ schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
675
+ const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
676
+ const showRequiredIndicator =
677
+ isRequired && (!isBooleanField || hasValidationRules);
678
+
679
+ // Pass through adorner props for adornable field types
680
+ const adornerProps =
681
+ fieldDef && isAdornableField(fieldDef)
682
+ ? { prefix: fieldDef.prefix, suffix: fieldDef.suffix }
683
+ : {};
627
684
 
628
- return {
629
- name: path,
630
- value: getValueAtPath(path),
631
- type: fieldType,
632
- label: fieldDef?.label || path.charAt(0).toUpperCase() + path.slice(1),
633
- description: fieldDef?.description,
634
- placeholder: fieldDef?.placeholder,
635
- visible: visibility[path] !== false,
636
- enabled: enabled[path] !== false,
637
- readonly: readonly[path] ?? false,
638
- required: isRequired,
639
- showRequiredIndicator,
640
- touched: isTouched,
641
- errors: displayedErrors,
642
- onChange: handlers.onChange,
643
- onBlur: handlers.onBlur,
644
- // ARIA accessibility attributes
645
- "aria-invalid": hasErrors || undefined,
646
- "aria-describedby": hasErrors ? `${path}-error` : undefined,
647
- "aria-required": isRequired || undefined,
648
- // Adorner props (only for adornable field types)
649
- ...adornerProps,
650
- // Presentation variant
651
- variant: fieldDef?.variant,
652
- variantConfig: fieldDef?.variantConfig,
653
- };
654
- }, [spec, state.touched, state.isSubmitted, visibility, enabled, readonly, required, validation.errors, validateOn, getValueAtPath, getFieldHandlers]);
685
+ return {
686
+ name: path,
687
+ value: getValueAtPath(path),
688
+ type: fieldType,
689
+ label: fieldDef?.label || path.charAt(0).toUpperCase() + path.slice(1),
690
+ description: fieldDef?.description,
691
+ placeholder: fieldDef?.placeholder,
692
+ visible: visibility[path] !== false,
693
+ enabled: enabled[path] !== false,
694
+ readonly: readonly[path] ?? false,
695
+ required: isRequired,
696
+ showRequiredIndicator,
697
+ touched: isTouched,
698
+ errors: displayedErrors,
699
+ onChange: handlers.onChange,
700
+ onBlur: handlers.onBlur,
701
+ // ARIA accessibility attributes
702
+ "aria-invalid": hasErrors || undefined,
703
+ "aria-describedby": hasErrors ? `${path}-error` : undefined,
704
+ "aria-required": isRequired || undefined,
705
+ // Adorner props (only for adornable field types)
706
+ ...adornerProps,
707
+ // Presentation variant
708
+ variant: fieldDef?.variant,
709
+ variantConfig: fieldDef?.variantConfig,
710
+ };
711
+ },
712
+ [
713
+ spec,
714
+ state.touched,
715
+ state.isSubmitted,
716
+ visibility,
717
+ enabled,
718
+ readonly,
719
+ required,
720
+ validation.errors,
721
+ validateOn,
722
+ getValueAtPath,
723
+ getFieldHandlers,
724
+ ],
725
+ );
655
726
 
656
727
  // Get select field props - uses pre-computed optionsVisibility map
657
- const getSelectFieldProps = useCallback((path: string): GetSelectFieldPropsResult => {
658
- const baseProps = getFieldProps(path);
659
-
660
- // Look up pre-computed visible options from memoized map
661
- const visibleOptions = optionsVisibility[path] ?? [];
662
-
663
- return {
664
- ...baseProps,
665
- options: visibleOptions as SelectOption[],
666
- };
667
- }, [getFieldProps, optionsVisibility]);
668
-
669
- // Get array helpers
670
- const getArrayHelpers = useCallback((path: string): GetArrayHelpersResult => {
671
- const fieldDef = spec.fields[path];
672
- const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
673
- const arrayDef = fieldDef?.type === "array" ? fieldDef : undefined;
674
- const minItems = arrayDef?.minItems ?? 0;
675
- const maxItems = arrayDef?.maxItems ?? Infinity;
676
-
677
- const canAdd = currentValue.length < maxItems;
678
- const canRemove = currentValue.length > minItems;
679
-
680
- const getItemFieldProps = (index: number, fieldName: string): GetFieldPropsResult => {
681
- const itemPath = `${path}[${index}].${fieldName}`;
682
- const itemFieldDef = arrayDef?.itemFields?.[fieldName];
683
- const handlers = getFieldHandlers(itemPath);
684
-
685
- // Get item value
686
- const item = (currentValue[index] as Record<string, unknown>) ?? {};
687
- const itemValue = item[fieldName];
688
-
689
- const fieldErrors = validation.errors.filter((e) => e.field === itemPath);
690
- const isTouched = state.touched[itemPath] ?? false;
691
- const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
728
+ const getSelectFieldProps = useCallback(
729
+ (path: string): GetSelectFieldPropsResult => {
730
+ const baseProps = getFieldProps(path);
692
731
 
693
732
  // Look up pre-computed visible options from memoized map
694
- const visibleOptions = optionsVisibility[itemPath] as SelectOption[] | undefined;
733
+ const visibleOptions = optionsVisibility[path] ?? [];
695
734
 
696
735
  return {
697
- name: itemPath,
698
- value: itemValue,
699
- type: itemFieldDef?.type || "text",
700
- label: itemFieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
701
- description: itemFieldDef?.description,
702
- placeholder: itemFieldDef?.placeholder,
703
- visible: true,
704
- enabled: enabled[path] !== false,
705
- readonly: readonly[itemPath] ?? false,
706
- required: false, // TODO: Evaluate item field required
707
- showRequiredIndicator: false, // Item fields don't show required indicator
708
- touched: isTouched,
709
- errors: showErrors ? fieldErrors : [],
710
- onChange: handlers.onChange,
711
- onBlur: handlers.onBlur,
712
- options: visibleOptions,
736
+ ...baseProps,
737
+ options: visibleOptions as SelectOption[],
713
738
  };
714
- };
739
+ },
740
+ [getFieldProps, optionsVisibility],
741
+ );
715
742
 
716
- return {
717
- items: currentValue,
718
- push: (item: unknown) => {
719
- if (canAdd) {
720
- setValueAtPath(path, [...currentValue, item]);
721
- }
722
- },
723
- remove: (index: number) => {
724
- if (canRemove) {
743
+ // Get array helpers
744
+ const getArrayHelpers = useCallback(
745
+ (path: string): GetArrayHelpersResult => {
746
+ const fieldDef = spec.fields[path];
747
+ const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
748
+ const arrayDef = fieldDef?.type === "array" ? fieldDef : undefined;
749
+ const minItems = arrayDef?.minItems ?? 0;
750
+ const maxItems = arrayDef?.maxItems ?? Infinity;
751
+
752
+ const canAdd = currentValue.length < maxItems;
753
+ const canRemove = currentValue.length > minItems;
754
+
755
+ const getItemFieldProps = (
756
+ index: number,
757
+ fieldName: string,
758
+ ): GetFieldPropsResult => {
759
+ const itemPath = `${path}[${index}].${fieldName}`;
760
+ const itemFieldDef = arrayDef?.itemFields?.[fieldName];
761
+ const handlers = getFieldHandlers(itemPath);
762
+
763
+ // Get item value
764
+ const item = (currentValue[index] as Record<string, unknown>) ?? {};
765
+ const itemValue = item[fieldName];
766
+
767
+ const fieldErrors = validation.errors.filter(
768
+ (e) => e.field === itemPath,
769
+ );
770
+ const isTouched = state.touched[itemPath] ?? false;
771
+ const showErrors =
772
+ validateOn === "change" ||
773
+ (validateOn === "blur" && isTouched) ||
774
+ state.isSubmitted;
775
+
776
+ // Look up pre-computed visible options from memoized map
777
+ const visibleOptions = optionsVisibility[itemPath] as
778
+ | SelectOption[]
779
+ | undefined;
780
+
781
+ return {
782
+ name: itemPath,
783
+ value: itemValue,
784
+ type: itemFieldDef?.type || "text",
785
+ label:
786
+ itemFieldDef?.label ||
787
+ fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
788
+ description: itemFieldDef?.description,
789
+ placeholder: itemFieldDef?.placeholder,
790
+ visible: true,
791
+ enabled: enabled[path] !== false,
792
+ readonly: readonly[itemPath] ?? false,
793
+ required: false, // TODO: Evaluate item field required
794
+ showRequiredIndicator: false, // Item fields don't show required indicator
795
+ touched: isTouched,
796
+ errors: showErrors ? fieldErrors : [],
797
+ onChange: handlers.onChange,
798
+ onBlur: handlers.onBlur,
799
+ options: visibleOptions,
800
+ };
801
+ };
802
+
803
+ return {
804
+ items: currentValue,
805
+ push: (item: unknown) => {
806
+ if (canAdd) {
807
+ setValueAtPath(path, [...currentValue, item]);
808
+ }
809
+ },
810
+ remove: (index: number) => {
811
+ if (canRemove) {
812
+ const newArray = [...currentValue];
813
+ newArray.splice(index, 1);
814
+ setValueAtPath(path, newArray);
815
+ }
816
+ },
817
+ move: (from: number, to: number) => {
725
818
  const newArray = [...currentValue];
726
- newArray.splice(index, 1);
819
+ const [item] = newArray.splice(from, 1);
820
+ newArray.splice(to, 0, item);
727
821
  setValueAtPath(path, newArray);
728
- }
729
- },
730
- move: (from: number, to: number) => {
731
- const newArray = [...currentValue];
732
- const [item] = newArray.splice(from, 1);
733
- newArray.splice(to, 0, item);
734
- setValueAtPath(path, newArray);
735
- },
736
- swap: (indexA: number, indexB: number) => {
737
- const newArray = [...currentValue];
738
- [newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]];
739
- setValueAtPath(path, newArray);
740
- },
741
- insert: (index: number, item: unknown) => {
742
- if (canAdd) {
822
+ },
823
+ swap: (indexA: number, indexB: number) => {
743
824
  const newArray = [...currentValue];
744
- newArray.splice(index, 0, item);
825
+ [newArray[indexA], newArray[indexB]] = [
826
+ newArray[indexB],
827
+ newArray[indexA],
828
+ ];
745
829
  setValueAtPath(path, newArray);
746
- }
747
- },
748
- getItemFieldProps,
749
- minItems,
750
- maxItems,
751
- canAdd,
752
- canRemove,
753
- };
754
- }, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, readonly, state.touched, state.isSubmitted, validation.errors, validateOn, optionsVisibility]);
755
-
756
- return {
757
- data: state.data,
758
- computed,
759
- visibility,
760
- required,
761
- enabled,
762
- readonly,
763
- optionsVisibility,
764
- touched: state.touched,
765
- errors: validation.errors,
766
- isValid: validation.valid,
767
- isSubmitting: state.isSubmitting,
768
- isSubmitted: state.isSubmitted,
769
- isDirty: state.isDirty,
770
- spec,
771
- wizard,
772
- setFieldValue,
773
- setFieldTouched,
774
- setValues,
775
- validateField,
776
- validateForm,
777
- submitForm,
778
- resetForm,
779
- getFieldProps,
780
- getSelectFieldProps,
781
- getArrayHelpers,
782
- };
830
+ },
831
+ insert: (index: number, item: unknown) => {
832
+ if (canAdd) {
833
+ const newArray = [...currentValue];
834
+ newArray.splice(index, 0, item);
835
+ setValueAtPath(path, newArray);
836
+ }
837
+ },
838
+ getItemFieldProps,
839
+ minItems,
840
+ maxItems,
841
+ canAdd,
842
+ canRemove,
843
+ };
844
+ },
845
+ [
846
+ spec.fields,
847
+ getValueAtPath,
848
+ setValueAtPath,
849
+ getFieldHandlers,
850
+ enabled,
851
+ readonly,
852
+ state.touched,
853
+ state.isSubmitted,
854
+ validation.errors,
855
+ validateOn,
856
+ optionsVisibility,
857
+ ],
858
+ );
859
+
860
+ return useMemo(
861
+ (): UseFormaReturn => ({
862
+ data: state.data,
863
+ computed,
864
+ visibility,
865
+ required,
866
+ enabled,
867
+ readonly,
868
+ optionsVisibility,
869
+ touched: state.touched,
870
+ errors: validation.errors,
871
+ isValid: validation.valid,
872
+ isSubmitting: state.isSubmitting,
873
+ isSubmitted: state.isSubmitted,
874
+ isDirty: state.isDirty,
875
+ spec,
876
+ wizard,
877
+ setFieldValue,
878
+ setFieldTouched,
879
+ setValues,
880
+ validateField,
881
+ validateForm,
882
+ submitForm,
883
+ resetForm,
884
+ getFieldProps,
885
+ getSelectFieldProps,
886
+ getArrayHelpers,
887
+ }),
888
+ [
889
+ state.data,
890
+ state.touched,
891
+ state.isSubmitting,
892
+ state.isSubmitted,
893
+ state.isDirty,
894
+ computed,
895
+ visibility,
896
+ required,
897
+ enabled,
898
+ readonly,
899
+ optionsVisibility,
900
+ validation.errors,
901
+ validation.valid,
902
+ spec,
903
+ wizard,
904
+ setFieldValue,
905
+ setFieldTouched,
906
+ setValues,
907
+ validateField,
908
+ validateForm,
909
+ submitForm,
910
+ resetForm,
911
+ getFieldProps,
912
+ getSelectFieldProps,
913
+ getArrayHelpers,
914
+ ],
915
+ );
783
916
  }