@formbar/react 0.3.0 → 0.3.1

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
@@ -54,3 +54,6 @@ export function ContactForm() {
54
54
 
55
55
  - Depends on `@formbar/core`.
56
56
  - Peer dependency: `react >=18.0.0`.
57
+
58
+ `autoFocusOnError` is handled by `useForm` and is not forwarded to core. Other options are forwarded unchanged, so
59
+ core's development-only unknown-option diagnostics also apply to `useForm` without duplicate React-option warnings.
package/dist/index.cjs CHANGED
@@ -102,12 +102,26 @@ function useField(form, path, config) {
102
102
  );
103
103
  return field;
104
104
  }
105
+
106
+ // src/core-form-options.ts
107
+ function getCoreFormOptions(options) {
108
+ if (options === void 0) return void 0;
109
+ try {
110
+ if (!Object.hasOwn(options, "autoFocusOnError")) return options;
111
+ const { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);
112
+ return Object.create(Object.getPrototypeOf(options), coreDescriptors);
113
+ } catch {
114
+ return options;
115
+ }
116
+ }
117
+
118
+ // src/use-form.ts
105
119
  function useForm(options) {
106
120
  const autoFocus = options?.autoFocusOnError ?? true;
107
121
  const formRef = react.useRef(null);
108
122
  const disposeTimerRef = react.useRef(null);
109
123
  if (formRef.current === null) {
110
- formRef.current = core.createForm(options);
124
+ formRef.current = core.createForm(getCoreFormOptions(options));
111
125
  }
112
126
  const form = formRef.current;
113
127
  const subscribe = react.useRef((onStoreChange) => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/use-form.ts"],"names":["useRef","useCallback","useSyncExternalStore","useMemo","createForm","useEffect"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(options);\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\n\tuseSyncExternalStore(subscribe, () => form.getState());\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n"]}
1
+ {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/core-form-options.ts","../src/use-form.ts"],"names":["useRef","useCallback","useSyncExternalStore","useMemo","createForm","useEffect"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions } from \"@formbar/core\";\nimport type { UseFormOptions } from \"./use-form.js\";\n\nexport function getCoreFormOptions<TData, TUi>(\n\toptions: UseFormOptions<TData, TUi> | undefined,\n): CreateFormOptions<TData, TUi> | undefined {\n\tif (options === undefined) return undefined;\n\ttry {\n\t\tif (!Object.hasOwn(options, \"autoFocusOnError\")) return options;\n\t\tconst { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);\n\t\treturn Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;\n\t} catch {\n\t\t// Option diagnostics must not make useForm less resilient than createForm.\n\t\treturn options;\n\t}\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\nimport { getCoreFormOptions } from \"./core-form-options.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(getCoreFormOptions(options));\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\n\tuseSyncExternalStore(subscribe, () => form.getState());\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n"]}
package/dist/index.js CHANGED
@@ -100,12 +100,26 @@ function useField(form, path, config) {
100
100
  );
101
101
  return field;
102
102
  }
103
+
104
+ // src/core-form-options.ts
105
+ function getCoreFormOptions(options) {
106
+ if (options === void 0) return void 0;
107
+ try {
108
+ if (!Object.hasOwn(options, "autoFocusOnError")) return options;
109
+ const { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);
110
+ return Object.create(Object.getPrototypeOf(options), coreDescriptors);
111
+ } catch {
112
+ return options;
113
+ }
114
+ }
115
+
116
+ // src/use-form.ts
103
117
  function useForm(options) {
104
118
  const autoFocus = options?.autoFocusOnError ?? true;
105
119
  const formRef = useRef(null);
106
120
  const disposeTimerRef = useRef(null);
107
121
  if (formRef.current === null) {
108
- formRef.current = createForm(options);
122
+ formRef.current = createForm(getCoreFormOptions(options));
109
123
  }
110
124
  const form = formRef.current;
111
125
  const subscribe = useRef((onStoreChange) => {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/use-form.ts"],"names":["useRef","useSyncExternalStore","useMemo"],"mappings":"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","file":"index.js","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(options);\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\n\tuseSyncExternalStore(subscribe, () => form.getState());\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n"]}
1
+ {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/core-form-options.ts","../src/use-form.ts"],"names":["useRef","useSyncExternalStore","useMemo"],"mappings":"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","file":"index.js","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions } from \"@formbar/core\";\nimport type { UseFormOptions } from \"./use-form.js\";\n\nexport function getCoreFormOptions<TData, TUi>(\n\toptions: UseFormOptions<TData, TUi> | undefined,\n): CreateFormOptions<TData, TUi> | undefined {\n\tif (options === undefined) return undefined;\n\ttry {\n\t\tif (!Object.hasOwn(options, \"autoFocusOnError\")) return options;\n\t\tconst { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);\n\t\treturn Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;\n\t} catch {\n\t\t// Option diagnostics must not make useForm less resilient than createForm.\n\t\treturn options;\n\t}\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\nimport { getCoreFormOptions } from \"./core-form-options.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(getCoreFormOptions(options));\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\n\tuseSyncExternalStore(subscribe, () => form.getState());\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formbar/react",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "React hooks and a11y utilities for @formbar/core",
6
6
  "license": "MIT",
@@ -24,10 +24,10 @@
24
24
  "scripts": {
25
25
  "build": "tsc --noEmit",
26
26
  "build:dist": "tsup",
27
- "test": "vitest run"
27
+ "test": "vitest run --root ../.. packages/react/src/__tests__"
28
28
  },
29
29
  "dependencies": {
30
- "@formbar/core": "^0.3.0"
30
+ "@formbar/core": "^0.3.1"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18.0.0"
@@ -0,0 +1,69 @@
1
+ import { createForm } from "@formbar/core";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import { getCoreFormOptions } from "../core-form-options.js";
4
+
5
+ type TrapName = "getOwnPropertyDescriptor" | "ownKeys" | "getPrototypeOf";
6
+
7
+ function createHostileOptions(trapName: TrapName) {
8
+ return new Proxy(
9
+ { autoFocusOnError: false, initialData: { name: "Ada" } },
10
+ {
11
+ [trapName]: () => {
12
+ throw new Error(`${trapName} blocked`);
13
+ },
14
+ },
15
+ );
16
+ }
17
+
18
+ describe("getCoreFormOptions", () => {
19
+ test("returns the same reference when autoFocusOnError is absent or inherited", () => {
20
+ const options = { initialData: { name: "Ada" }, unknownOption: true };
21
+ const inherited = Object.create({ autoFocusOnError: false }) as typeof options;
22
+
23
+ expect(getCoreFormOptions(options)).toBe(options);
24
+ expect(getCoreFormOptions(inherited)).toBe(inherited);
25
+ expect(getCoreFormOptions(undefined)).toBeUndefined();
26
+ });
27
+
28
+ test("removes an own autoFocusOnError without mutating or dropping other keys", () => {
29
+ const symbolKey = Symbol("unknown");
30
+ const options = {
31
+ autoFocusOnError: false,
32
+ initialData: { name: "Ada" },
33
+ unknownOption: "preserved",
34
+ [symbolKey]: "also preserved",
35
+ };
36
+
37
+ const coreOptions = getCoreFormOptions(options) as typeof options;
38
+
39
+ expect(coreOptions).not.toBe(options);
40
+ expect(Object.hasOwn(coreOptions, "autoFocusOnError")).toBe(false);
41
+ expect(coreOptions.initialData).toBe(options.initialData);
42
+ expect(coreOptions.unknownOption).toBe("preserved");
43
+ expect(coreOptions[symbolKey]).toBe("also preserved");
44
+ expect(options.autoFocusOnError).toBe(false);
45
+ });
46
+
47
+ test.each<TrapName>(["getOwnPropertyDescriptor", "ownKeys", "getPrototypeOf"])(
48
+ "forwards the original options when the %s trap throws",
49
+ (trapName) => {
50
+ const options = createHostileOptions(trapName);
51
+
52
+ expect(getCoreFormOptions(options)).toBe(options);
53
+ },
54
+ );
55
+
56
+ test.each<TrapName>(["getOwnPropertyDescriptor", "ownKeys", "getPrototypeOf"])(
57
+ "does not block form creation when the %s trap throws",
58
+ (trapName) => {
59
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
60
+ const options = createHostileOptions(trapName);
61
+
62
+ try {
63
+ expect(() => createForm(getCoreFormOptions(options))).not.toThrow();
64
+ } finally {
65
+ warn.mockRestore();
66
+ }
67
+ },
68
+ );
69
+ });
@@ -0,0 +1,16 @@
1
+ import type { CreateFormOptions } from "@formbar/core";
2
+ import type { UseFormOptions } from "./use-form.js";
3
+
4
+ export function getCoreFormOptions<TData, TUi>(
5
+ options: UseFormOptions<TData, TUi> | undefined,
6
+ ): CreateFormOptions<TData, TUi> | undefined {
7
+ if (options === undefined) return undefined;
8
+ try {
9
+ if (!Object.hasOwn(options, "autoFocusOnError")) return options;
10
+ const { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);
11
+ return Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;
12
+ } catch {
13
+ // Option diagnostics must not make useForm less resilient than createForm.
14
+ return options;
15
+ }
16
+ }
package/src/use-form.ts CHANGED
@@ -2,6 +2,7 @@ import type { CreateFormOptions, FormApi, SubmitResult } from "@formbar/core";
2
2
  import { createForm } from "@formbar/core";
3
3
  import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
4
4
  import { focusFirstError } from "./a11y.js";
5
+ import { getCoreFormOptions } from "./core-form-options.js";
5
6
 
6
7
  /** Options for useForm, extending core CreateFormOptions with React-specific behavior */
7
8
  export interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {
@@ -37,7 +38,7 @@ export function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormA
37
38
  const disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38
39
 
39
40
  if (formRef.current === null) {
40
- formRef.current = createForm<TData, TUi>(options);
41
+ formRef.current = createForm<TData, TUi>(getCoreFormOptions(options));
41
42
  }
42
43
 
43
44
  const form = formRef.current;