@formbar/react 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +157 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/a11y.test.ts +114 -0
- package/src/__tests__/boundary-exports.test.ts +21 -0
- package/src/__tests__/selector.test.ts +14 -0
- package/src/__tests__/use-form.test.ts +24 -0
- package/src/a11y.ts +106 -0
- package/src/index.ts +34 -0
- package/src/use-field.ts +70 -0
- package/src/use-form-selector.ts +47 -0
- package/src/use-form.ts +82 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var core = require('@formbar/core');
|
|
5
|
+
|
|
6
|
+
// src/a11y.ts
|
|
7
|
+
var DEFAULT_FIELD_PREFIX = "field";
|
|
8
|
+
function fieldId(path, prefix = DEFAULT_FIELD_PREFIX) {
|
|
9
|
+
return `${prefix}-${path.replace(/[.[\]/]/g, "-").replace(/-+/g, "-").replace(/-$/, "")}`;
|
|
10
|
+
}
|
|
11
|
+
function descriptionId(path, prefix) {
|
|
12
|
+
return `${fieldId(path, prefix)}-description`;
|
|
13
|
+
}
|
|
14
|
+
function errorId(path, prefix) {
|
|
15
|
+
return `${fieldId(path, prefix)}-error`;
|
|
16
|
+
}
|
|
17
|
+
function getFieldProps(path, options) {
|
|
18
|
+
const id = fieldId(path);
|
|
19
|
+
const hasErrors = options?.issues?.some((i) => i.severity === "error") ?? false;
|
|
20
|
+
const describedBy = [];
|
|
21
|
+
if (options?.hasDescription) describedBy.push(descriptionId(path));
|
|
22
|
+
if (hasErrors) describedBy.push(errorId(path));
|
|
23
|
+
const props = {
|
|
24
|
+
id,
|
|
25
|
+
...hasErrors ? { "aria-invalid": true } : {},
|
|
26
|
+
...describedBy.length > 0 ? { "aria-describedby": describedBy.join(" ") } : {},
|
|
27
|
+
...options?.required ? { "aria-required": true } : {},
|
|
28
|
+
...hasErrors ? { "aria-errormessage": errorId(path) } : {}
|
|
29
|
+
};
|
|
30
|
+
return props;
|
|
31
|
+
}
|
|
32
|
+
function getLabelProps(path) {
|
|
33
|
+
return { htmlFor: fieldId(path) };
|
|
34
|
+
}
|
|
35
|
+
function getDescriptionProps(path) {
|
|
36
|
+
return { id: descriptionId(path) };
|
|
37
|
+
}
|
|
38
|
+
function getErrorProps(path) {
|
|
39
|
+
return { id: errorId(path), role: "alert" };
|
|
40
|
+
}
|
|
41
|
+
function findFirstErrorPath(issues) {
|
|
42
|
+
const firstError = issues.find((i) => i.severity === "error");
|
|
43
|
+
if (!firstError) return void 0;
|
|
44
|
+
return firstError.path.segments.join(".");
|
|
45
|
+
}
|
|
46
|
+
function focusFirstError(issues) {
|
|
47
|
+
const path = findFirstErrorPath(issues);
|
|
48
|
+
if (!path) return false;
|
|
49
|
+
if (typeof document === "undefined") return false;
|
|
50
|
+
const id = fieldId(path);
|
|
51
|
+
const element = document.getElementById(id);
|
|
52
|
+
if (element) {
|
|
53
|
+
element.focus();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
function useFormSelector(form, selector, equalityFn) {
|
|
59
|
+
const eqRef = react.useRef(equalityFn ?? Object.is);
|
|
60
|
+
eqRef.current = equalityFn ?? Object.is;
|
|
61
|
+
const selectorRef = react.useRef(selector);
|
|
62
|
+
selectorRef.current = selector;
|
|
63
|
+
const prevRef = react.useRef({
|
|
64
|
+
value: void 0,
|
|
65
|
+
initialized: false
|
|
66
|
+
});
|
|
67
|
+
const subscribe = react.useCallback((onStoreChange) => form.subscribe(onStoreChange), [form]);
|
|
68
|
+
const getSnapshot = react.useCallback(() => {
|
|
69
|
+
const next = selectorRef.current(form.getState());
|
|
70
|
+
if (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {
|
|
71
|
+
return prevRef.current.value;
|
|
72
|
+
}
|
|
73
|
+
prevRef.current = { value: next, initialized: true };
|
|
74
|
+
return next;
|
|
75
|
+
}, [form]);
|
|
76
|
+
return react.useSyncExternalStore(subscribe, getSnapshot);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/use-field.ts
|
|
80
|
+
function fieldSnapshotEqual(a, b) {
|
|
81
|
+
return a.value === b.value && a.meta === b.meta;
|
|
82
|
+
}
|
|
83
|
+
function useField(form, path, config) {
|
|
84
|
+
const configRef = react.useRef(config);
|
|
85
|
+
const stableConfig = react.useMemo(() => {
|
|
86
|
+
const prev = configRef.current;
|
|
87
|
+
if (prev === config) return prev;
|
|
88
|
+
if (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;
|
|
89
|
+
configRef.current = config;
|
|
90
|
+
return config;
|
|
91
|
+
}, [config]);
|
|
92
|
+
const field = react.useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);
|
|
93
|
+
useFormSelector(
|
|
94
|
+
form,
|
|
95
|
+
() => {
|
|
96
|
+
const state = form.getState();
|
|
97
|
+
const pathKey = field.path.segments.join(".");
|
|
98
|
+
const meta = state.fieldMeta[pathKey];
|
|
99
|
+
return { value: field.get(), meta };
|
|
100
|
+
},
|
|
101
|
+
fieldSnapshotEqual
|
|
102
|
+
);
|
|
103
|
+
return field;
|
|
104
|
+
}
|
|
105
|
+
function useForm(options) {
|
|
106
|
+
const autoFocus = options?.autoFocusOnError ?? true;
|
|
107
|
+
const formRef = react.useRef(null);
|
|
108
|
+
const disposeTimerRef = react.useRef(null);
|
|
109
|
+
if (formRef.current === null) {
|
|
110
|
+
formRef.current = core.createForm(options);
|
|
111
|
+
}
|
|
112
|
+
const form = formRef.current;
|
|
113
|
+
const subscribe = react.useRef((onStoreChange) => {
|
|
114
|
+
return form.subscribe(onStoreChange);
|
|
115
|
+
}).current;
|
|
116
|
+
react.useSyncExternalStore(subscribe, () => form.getState());
|
|
117
|
+
react.useEffect(() => {
|
|
118
|
+
if (disposeTimerRef.current !== null) {
|
|
119
|
+
clearTimeout(disposeTimerRef.current);
|
|
120
|
+
disposeTimerRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
return () => {
|
|
123
|
+
disposeTimerRef.current = setTimeout(() => {
|
|
124
|
+
formRef.current?.dispose();
|
|
125
|
+
}, 0);
|
|
126
|
+
};
|
|
127
|
+
}, []);
|
|
128
|
+
const wrappedApi = react.useMemo(() => {
|
|
129
|
+
if (!autoFocus) return form;
|
|
130
|
+
return {
|
|
131
|
+
...form,
|
|
132
|
+
submit: async (...args) => {
|
|
133
|
+
const result = await form.submit(...args);
|
|
134
|
+
if (!result.ok && result.fieldIssues?.length) {
|
|
135
|
+
focusFirstError(result.fieldIssues);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}, [form, autoFocus]);
|
|
141
|
+
return wrappedApi;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
exports.descriptionId = descriptionId;
|
|
145
|
+
exports.errorId = errorId;
|
|
146
|
+
exports.fieldId = fieldId;
|
|
147
|
+
exports.findFirstErrorPath = findFirstErrorPath;
|
|
148
|
+
exports.focusFirstError = focusFirstError;
|
|
149
|
+
exports.getDescriptionProps = getDescriptionProps;
|
|
150
|
+
exports.getErrorProps = getErrorProps;
|
|
151
|
+
exports.getFieldProps = getFieldProps;
|
|
152
|
+
exports.getLabelProps = getLabelProps;
|
|
153
|
+
exports.useField = useField;
|
|
154
|
+
exports.useForm = useForm;
|
|
155
|
+
exports.useFormSelector = useFormSelector;
|
|
156
|
+
//# sourceMappingURL=index.cjs.map
|
|
157
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ValidationIssue, FormApi, FieldConfig, FieldApi, CreateFormOptions, FormState } from '@formbar/core';
|
|
2
|
+
export { CreateFormOptions, DeepKeys, DeepValue, FieldApi, FieldConfig, FormAction, FormApi, FormDispatchResult, FormState, SubmitContext, SubmitResult, ValidationIssue, ValidatorFn, ValidatorInput } from '@formbar/core';
|
|
3
|
+
|
|
4
|
+
/** ARIA props for a form field */
|
|
5
|
+
interface FieldA11yProps {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly "aria-invalid"?: boolean;
|
|
8
|
+
readonly "aria-describedby"?: string;
|
|
9
|
+
readonly "aria-required"?: boolean;
|
|
10
|
+
readonly "aria-errormessage"?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Label props for semantic association */
|
|
13
|
+
interface LabelA11yProps {
|
|
14
|
+
readonly htmlFor: string;
|
|
15
|
+
}
|
|
16
|
+
/** Description/error message props */
|
|
17
|
+
interface DescriptionA11yProps {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly role?: "alert";
|
|
20
|
+
}
|
|
21
|
+
/** Generate a deterministic field ID from path */
|
|
22
|
+
declare function fieldId(path: string, prefix?: string): string;
|
|
23
|
+
/** Generate description element ID */
|
|
24
|
+
declare function descriptionId(path: string, prefix?: string): string;
|
|
25
|
+
/** Generate error element ID */
|
|
26
|
+
declare function errorId(path: string, prefix?: string): string;
|
|
27
|
+
/** Get ARIA props for a field input element */
|
|
28
|
+
declare function getFieldProps(path: string, options?: {
|
|
29
|
+
readonly issues?: readonly ValidationIssue[];
|
|
30
|
+
readonly required?: boolean;
|
|
31
|
+
readonly hasDescription?: boolean;
|
|
32
|
+
}): FieldA11yProps;
|
|
33
|
+
/** Get label props for semantic association */
|
|
34
|
+
declare function getLabelProps(path: string): LabelA11yProps;
|
|
35
|
+
/** Get description element props */
|
|
36
|
+
declare function getDescriptionProps(path: string): DescriptionA11yProps;
|
|
37
|
+
/** Get error message props */
|
|
38
|
+
declare function getErrorProps(path: string): DescriptionA11yProps;
|
|
39
|
+
/** Find the first field path with errors (for focus management) */
|
|
40
|
+
declare function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined;
|
|
41
|
+
/** Focus the first error field after submit (browser-only) */
|
|
42
|
+
declare function focusFirstError(issues: readonly ValidationIssue[]): boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* React hook that subscribes to a specific field with fine-grained re-rendering.
|
|
46
|
+
* Only re-renders when the field's value or metadata actually changes.
|
|
47
|
+
*
|
|
48
|
+
* @param form - The {@link FormApi} instance (from useForm or createForm).
|
|
49
|
+
* @param path - Dot-path to the field (e.g., `"user.email"`).
|
|
50
|
+
* @param config - Optional field configuration (label, validators, triggers).
|
|
51
|
+
* @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* function EmailField({ form }) {
|
|
56
|
+
* const field = useField(form, "email");
|
|
57
|
+
* return (
|
|
58
|
+
* <div>
|
|
59
|
+
* <input
|
|
60
|
+
* value={field.get() ?? ""}
|
|
61
|
+
* onChange={e => field.handleChange(e.target.value)}
|
|
62
|
+
* onBlur={() => field.handleBlur()}
|
|
63
|
+
* />
|
|
64
|
+
* {field.issues().map(i => <span key={i.code}>{i.message}</span>)}
|
|
65
|
+
* </div>
|
|
66
|
+
* );
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
declare function useField<TData, TUi, P extends string>(form: FormApi<TData, TUi>, path: P, config?: FieldConfig): FieldApi<TData, TUi, P>;
|
|
71
|
+
|
|
72
|
+
/** Options for useForm, extending core CreateFormOptions with React-specific behavior */
|
|
73
|
+
interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {
|
|
74
|
+
/** Auto-focus the first error field on submit failure (default: true) */
|
|
75
|
+
readonly autoFocusOnError?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* React hook that creates and manages a form instance with automatic cleanup.
|
|
79
|
+
* The form is created once on mount and disposed on unmount (StrictMode-safe).
|
|
80
|
+
*
|
|
81
|
+
* @param options - Form configuration (same as {@link createForm} options).
|
|
82
|
+
* @returns A stable {@link FormApi} reference that persists across re-renders.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```typescript
|
|
86
|
+
* function ContactForm() {
|
|
87
|
+
* const form = useForm({
|
|
88
|
+
* initialData: { name: "", email: "" },
|
|
89
|
+
* onSubmit: async ({ payload }) => {
|
|
90
|
+
* await saveContact(payload);
|
|
91
|
+
* return { ok: true, submitId: "1" };
|
|
92
|
+
* },
|
|
93
|
+
* });
|
|
94
|
+
*
|
|
95
|
+
* return <input value={form.field("name").get()} onChange={e => form.field("name").set(e.target.value)} />;
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
declare function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Subscribe to a derived value from form state with fine-grained reactivity.
|
|
103
|
+
* Only triggers re-render when the selected value changes (by equality function).
|
|
104
|
+
*
|
|
105
|
+
* @param form - The {@link FormApi} instance.
|
|
106
|
+
* @param selector - Function that extracts a value from the full form state.
|
|
107
|
+
* @param equalityFn - Optional equality comparator (defaults to `Object.is`).
|
|
108
|
+
* @returns The current selected value, updated reactively.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* const isValid = useFormSelector(form, (state) => state.issues.length === 0);
|
|
113
|
+
* const submitCount = useFormSelector(form, (state) => state.meta.submitted);
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare function useFormSelector<TData, TUi, T>(form: FormApi<TData, TUi>, selector: (state: FormState<TData, TUi>) => T, equalityFn?: (prev: T, next: T) => boolean): T;
|
|
117
|
+
|
|
118
|
+
export { type DescriptionA11yProps, type FieldA11yProps, type LabelA11yProps, type UseFormOptions, descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useField, useForm, useFormSelector };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ValidationIssue, FormApi, FieldConfig, FieldApi, CreateFormOptions, FormState } from '@formbar/core';
|
|
2
|
+
export { CreateFormOptions, DeepKeys, DeepValue, FieldApi, FieldConfig, FormAction, FormApi, FormDispatchResult, FormState, SubmitContext, SubmitResult, ValidationIssue, ValidatorFn, ValidatorInput } from '@formbar/core';
|
|
3
|
+
|
|
4
|
+
/** ARIA props for a form field */
|
|
5
|
+
interface FieldA11yProps {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly "aria-invalid"?: boolean;
|
|
8
|
+
readonly "aria-describedby"?: string;
|
|
9
|
+
readonly "aria-required"?: boolean;
|
|
10
|
+
readonly "aria-errormessage"?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Label props for semantic association */
|
|
13
|
+
interface LabelA11yProps {
|
|
14
|
+
readonly htmlFor: string;
|
|
15
|
+
}
|
|
16
|
+
/** Description/error message props */
|
|
17
|
+
interface DescriptionA11yProps {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly role?: "alert";
|
|
20
|
+
}
|
|
21
|
+
/** Generate a deterministic field ID from path */
|
|
22
|
+
declare function fieldId(path: string, prefix?: string): string;
|
|
23
|
+
/** Generate description element ID */
|
|
24
|
+
declare function descriptionId(path: string, prefix?: string): string;
|
|
25
|
+
/** Generate error element ID */
|
|
26
|
+
declare function errorId(path: string, prefix?: string): string;
|
|
27
|
+
/** Get ARIA props for a field input element */
|
|
28
|
+
declare function getFieldProps(path: string, options?: {
|
|
29
|
+
readonly issues?: readonly ValidationIssue[];
|
|
30
|
+
readonly required?: boolean;
|
|
31
|
+
readonly hasDescription?: boolean;
|
|
32
|
+
}): FieldA11yProps;
|
|
33
|
+
/** Get label props for semantic association */
|
|
34
|
+
declare function getLabelProps(path: string): LabelA11yProps;
|
|
35
|
+
/** Get description element props */
|
|
36
|
+
declare function getDescriptionProps(path: string): DescriptionA11yProps;
|
|
37
|
+
/** Get error message props */
|
|
38
|
+
declare function getErrorProps(path: string): DescriptionA11yProps;
|
|
39
|
+
/** Find the first field path with errors (for focus management) */
|
|
40
|
+
declare function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined;
|
|
41
|
+
/** Focus the first error field after submit (browser-only) */
|
|
42
|
+
declare function focusFirstError(issues: readonly ValidationIssue[]): boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* React hook that subscribes to a specific field with fine-grained re-rendering.
|
|
46
|
+
* Only re-renders when the field's value or metadata actually changes.
|
|
47
|
+
*
|
|
48
|
+
* @param form - The {@link FormApi} instance (from useForm or createForm).
|
|
49
|
+
* @param path - Dot-path to the field (e.g., `"user.email"`).
|
|
50
|
+
* @param config - Optional field configuration (label, validators, triggers).
|
|
51
|
+
* @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* function EmailField({ form }) {
|
|
56
|
+
* const field = useField(form, "email");
|
|
57
|
+
* return (
|
|
58
|
+
* <div>
|
|
59
|
+
* <input
|
|
60
|
+
* value={field.get() ?? ""}
|
|
61
|
+
* onChange={e => field.handleChange(e.target.value)}
|
|
62
|
+
* onBlur={() => field.handleBlur()}
|
|
63
|
+
* />
|
|
64
|
+
* {field.issues().map(i => <span key={i.code}>{i.message}</span>)}
|
|
65
|
+
* </div>
|
|
66
|
+
* );
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
declare function useField<TData, TUi, P extends string>(form: FormApi<TData, TUi>, path: P, config?: FieldConfig): FieldApi<TData, TUi, P>;
|
|
71
|
+
|
|
72
|
+
/** Options for useForm, extending core CreateFormOptions with React-specific behavior */
|
|
73
|
+
interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {
|
|
74
|
+
/** Auto-focus the first error field on submit failure (default: true) */
|
|
75
|
+
readonly autoFocusOnError?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* React hook that creates and manages a form instance with automatic cleanup.
|
|
79
|
+
* The form is created once on mount and disposed on unmount (StrictMode-safe).
|
|
80
|
+
*
|
|
81
|
+
* @param options - Form configuration (same as {@link createForm} options).
|
|
82
|
+
* @returns A stable {@link FormApi} reference that persists across re-renders.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```typescript
|
|
86
|
+
* function ContactForm() {
|
|
87
|
+
* const form = useForm({
|
|
88
|
+
* initialData: { name: "", email: "" },
|
|
89
|
+
* onSubmit: async ({ payload }) => {
|
|
90
|
+
* await saveContact(payload);
|
|
91
|
+
* return { ok: true, submitId: "1" };
|
|
92
|
+
* },
|
|
93
|
+
* });
|
|
94
|
+
*
|
|
95
|
+
* return <input value={form.field("name").get()} onChange={e => form.field("name").set(e.target.value)} />;
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
declare function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Subscribe to a derived value from form state with fine-grained reactivity.
|
|
103
|
+
* Only triggers re-render when the selected value changes (by equality function).
|
|
104
|
+
*
|
|
105
|
+
* @param form - The {@link FormApi} instance.
|
|
106
|
+
* @param selector - Function that extracts a value from the full form state.
|
|
107
|
+
* @param equalityFn - Optional equality comparator (defaults to `Object.is`).
|
|
108
|
+
* @returns The current selected value, updated reactively.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* const isValid = useFormSelector(form, (state) => state.issues.length === 0);
|
|
113
|
+
* const submitCount = useFormSelector(form, (state) => state.meta.submitted);
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare function useFormSelector<TData, TUi, T>(form: FormApi<TData, TUi>, selector: (state: FormState<TData, TUi>) => T, equalityFn?: (prev: T, next: T) => boolean): T;
|
|
117
|
+
|
|
118
|
+
export { type DescriptionA11yProps, type FieldA11yProps, type LabelA11yProps, type UseFormOptions, descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useField, useForm, useFormSelector };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useRef, useCallback, useSyncExternalStore, useMemo, useEffect } from 'react';
|
|
2
|
+
import { createForm } from '@formbar/core';
|
|
3
|
+
|
|
4
|
+
// src/a11y.ts
|
|
5
|
+
var DEFAULT_FIELD_PREFIX = "field";
|
|
6
|
+
function fieldId(path, prefix = DEFAULT_FIELD_PREFIX) {
|
|
7
|
+
return `${prefix}-${path.replace(/[.[\]/]/g, "-").replace(/-+/g, "-").replace(/-$/, "")}`;
|
|
8
|
+
}
|
|
9
|
+
function descriptionId(path, prefix) {
|
|
10
|
+
return `${fieldId(path, prefix)}-description`;
|
|
11
|
+
}
|
|
12
|
+
function errorId(path, prefix) {
|
|
13
|
+
return `${fieldId(path, prefix)}-error`;
|
|
14
|
+
}
|
|
15
|
+
function getFieldProps(path, options) {
|
|
16
|
+
const id = fieldId(path);
|
|
17
|
+
const hasErrors = options?.issues?.some((i) => i.severity === "error") ?? false;
|
|
18
|
+
const describedBy = [];
|
|
19
|
+
if (options?.hasDescription) describedBy.push(descriptionId(path));
|
|
20
|
+
if (hasErrors) describedBy.push(errorId(path));
|
|
21
|
+
const props = {
|
|
22
|
+
id,
|
|
23
|
+
...hasErrors ? { "aria-invalid": true } : {},
|
|
24
|
+
...describedBy.length > 0 ? { "aria-describedby": describedBy.join(" ") } : {},
|
|
25
|
+
...options?.required ? { "aria-required": true } : {},
|
|
26
|
+
...hasErrors ? { "aria-errormessage": errorId(path) } : {}
|
|
27
|
+
};
|
|
28
|
+
return props;
|
|
29
|
+
}
|
|
30
|
+
function getLabelProps(path) {
|
|
31
|
+
return { htmlFor: fieldId(path) };
|
|
32
|
+
}
|
|
33
|
+
function getDescriptionProps(path) {
|
|
34
|
+
return { id: descriptionId(path) };
|
|
35
|
+
}
|
|
36
|
+
function getErrorProps(path) {
|
|
37
|
+
return { id: errorId(path), role: "alert" };
|
|
38
|
+
}
|
|
39
|
+
function findFirstErrorPath(issues) {
|
|
40
|
+
const firstError = issues.find((i) => i.severity === "error");
|
|
41
|
+
if (!firstError) return void 0;
|
|
42
|
+
return firstError.path.segments.join(".");
|
|
43
|
+
}
|
|
44
|
+
function focusFirstError(issues) {
|
|
45
|
+
const path = findFirstErrorPath(issues);
|
|
46
|
+
if (!path) return false;
|
|
47
|
+
if (typeof document === "undefined") return false;
|
|
48
|
+
const id = fieldId(path);
|
|
49
|
+
const element = document.getElementById(id);
|
|
50
|
+
if (element) {
|
|
51
|
+
element.focus();
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
function useFormSelector(form, selector, equalityFn) {
|
|
57
|
+
const eqRef = useRef(equalityFn ?? Object.is);
|
|
58
|
+
eqRef.current = equalityFn ?? Object.is;
|
|
59
|
+
const selectorRef = useRef(selector);
|
|
60
|
+
selectorRef.current = selector;
|
|
61
|
+
const prevRef = useRef({
|
|
62
|
+
value: void 0,
|
|
63
|
+
initialized: false
|
|
64
|
+
});
|
|
65
|
+
const subscribe = useCallback((onStoreChange) => form.subscribe(onStoreChange), [form]);
|
|
66
|
+
const getSnapshot = useCallback(() => {
|
|
67
|
+
const next = selectorRef.current(form.getState());
|
|
68
|
+
if (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {
|
|
69
|
+
return prevRef.current.value;
|
|
70
|
+
}
|
|
71
|
+
prevRef.current = { value: next, initialized: true };
|
|
72
|
+
return next;
|
|
73
|
+
}, [form]);
|
|
74
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/use-field.ts
|
|
78
|
+
function fieldSnapshotEqual(a, b) {
|
|
79
|
+
return a.value === b.value && a.meta === b.meta;
|
|
80
|
+
}
|
|
81
|
+
function useField(form, path, config) {
|
|
82
|
+
const configRef = useRef(config);
|
|
83
|
+
const stableConfig = useMemo(() => {
|
|
84
|
+
const prev = configRef.current;
|
|
85
|
+
if (prev === config) return prev;
|
|
86
|
+
if (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;
|
|
87
|
+
configRef.current = config;
|
|
88
|
+
return config;
|
|
89
|
+
}, [config]);
|
|
90
|
+
const field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);
|
|
91
|
+
useFormSelector(
|
|
92
|
+
form,
|
|
93
|
+
() => {
|
|
94
|
+
const state = form.getState();
|
|
95
|
+
const pathKey = field.path.segments.join(".");
|
|
96
|
+
const meta = state.fieldMeta[pathKey];
|
|
97
|
+
return { value: field.get(), meta };
|
|
98
|
+
},
|
|
99
|
+
fieldSnapshotEqual
|
|
100
|
+
);
|
|
101
|
+
return field;
|
|
102
|
+
}
|
|
103
|
+
function useForm(options) {
|
|
104
|
+
const autoFocus = options?.autoFocusOnError ?? true;
|
|
105
|
+
const formRef = useRef(null);
|
|
106
|
+
const disposeTimerRef = useRef(null);
|
|
107
|
+
if (formRef.current === null) {
|
|
108
|
+
formRef.current = createForm(options);
|
|
109
|
+
}
|
|
110
|
+
const form = formRef.current;
|
|
111
|
+
const subscribe = useRef((onStoreChange) => {
|
|
112
|
+
return form.subscribe(onStoreChange);
|
|
113
|
+
}).current;
|
|
114
|
+
useSyncExternalStore(subscribe, () => form.getState());
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (disposeTimerRef.current !== null) {
|
|
117
|
+
clearTimeout(disposeTimerRef.current);
|
|
118
|
+
disposeTimerRef.current = null;
|
|
119
|
+
}
|
|
120
|
+
return () => {
|
|
121
|
+
disposeTimerRef.current = setTimeout(() => {
|
|
122
|
+
formRef.current?.dispose();
|
|
123
|
+
}, 0);
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
const wrappedApi = useMemo(() => {
|
|
127
|
+
if (!autoFocus) return form;
|
|
128
|
+
return {
|
|
129
|
+
...form,
|
|
130
|
+
submit: async (...args) => {
|
|
131
|
+
const result = await form.submit(...args);
|
|
132
|
+
if (!result.ok && result.fieldIssues?.length) {
|
|
133
|
+
focusFirstError(result.fieldIssues);
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}, [form, autoFocus]);
|
|
139
|
+
return wrappedApi;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useField, useForm, useFormSelector };
|
|
143
|
+
//# sourceMappingURL=index.js.map
|
|
144
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":";;;;AAEA,IAAM,oBAAA,GAAuB,OAAA;AAuBtB,SAAS,OAAA,CAAQ,IAAA,EAAc,MAAA,GAAiB,oBAAA,EAA8B;AACpF,EAAA,OAAO,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,IAAA,CAClB,QAAQ,UAAA,EAAY,GAAG,CAAA,CACvB,OAAA,CAAQ,OAAO,GAAG,CAAA,CAClB,OAAA,CAAQ,IAAA,EAAM,EAAE,CAAC,CAAA,CAAA;AACpB;AAGO,SAAS,aAAA,CAAc,MAAc,MAAA,EAAyB;AACpE,EAAA,OAAO,CAAA,EAAG,OAAA,CAAQ,IAAA,EAAM,MAAM,CAAC,CAAA,YAAA,CAAA;AAChC;AAGO,SAAS,OAAA,CAAQ,MAAc,MAAA,EAAyB;AAC9D,EAAA,OAAO,CAAA,EAAG,OAAA,CAAQ,IAAA,EAAM,MAAM,CAAC,CAAA,MAAA,CAAA;AAChC;AAGO,SAAS,aAAA,CACf,MACA,OAAA,EAKiB;AACjB,EAAA,MAAM,EAAA,GAAK,QAAQ,IAAI,CAAA;AACvB,EAAA,MAAM,SAAA,GAAY,SAAS,MAAA,EAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,QAAA,KAAa,OAAO,CAAA,IAAK,KAAA;AAE1E,EAAA,MAAM,cAAwB,EAAC;AAC/B,EAAA,IAAI,SAAS,cAAA,EAAgB,WAAA,CAAY,IAAA,CAAK,aAAA,CAAc,IAAI,CAAC,CAAA;AACjE,EAAA,IAAI,SAAA,EAAW,WAAA,CAAY,IAAA,CAAK,OAAA,CAAQ,IAAI,CAAC,CAAA;AAE7C,EAAA,MAAM,KAAA,GAAwB;AAAA,IAC7B,EAAA;AAAA,IACA,GAAI,SAAA,GAAY,EAAE,cAAA,EAAgB,IAAA,KAAkB,EAAC;AAAA,IACrD,GAAI,WAAA,CAAY,MAAA,GAAS,CAAA,GAAI,EAAE,kBAAA,EAAoB,WAAA,CAAY,IAAA,CAAK,GAAG,CAAA,EAAE,GAAI,EAAC;AAAA,IAC9E,GAAI,OAAA,EAAS,QAAA,GAAW,EAAE,eAAA,EAAiB,IAAA,KAAkB,EAAC;AAAA,IAC9D,GAAI,YAAY,EAAE,mBAAA,EAAqB,QAAQ,IAAI,CAAA,KAAM;AAAC,GAC3D;AAEA,EAAA,OAAO,KAAA;AACR;AAGO,SAAS,cAAc,IAAA,EAA8B;AAC3D,EAAA,OAAO,EAAE,OAAA,EAAS,OAAA,CAAQ,IAAI,CAAA,EAAE;AACjC;AAGO,SAAS,oBAAoB,IAAA,EAAoC;AACvE,EAAA,OAAO,EAAE,EAAA,EAAI,aAAA,CAAc,IAAI,CAAA,EAAE;AAClC;AAGO,SAAS,cAAc,IAAA,EAAoC;AACjE,EAAA,OAAO,EAAE,EAAA,EAAI,OAAA,CAAQ,IAAI,CAAA,EAAG,MAAM,OAAA,EAAQ;AAC3C;AAGO,SAAS,mBAAmB,MAAA,EAAwD;AAC1F,EAAA,MAAM,aAAa,MAAA,CAAO,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,aAAa,OAAO,CAAA;AAC5D,EAAA,IAAI,CAAC,YAAY,OAAO,MAAA;AACxB,EAAA,OAAO,UAAA,CAAW,IAAA,CAAK,QAAA,CAAS,IAAA,CAAK,GAAG,CAAA;AACzC;AAGO,SAAS,gBAAgB,MAAA,EAA6C;AAC5E,EAAA,MAAM,IAAA,GAAO,mBAAmB,MAAM,CAAA;AACtC,EAAA,IAAI,CAAC,MAAM,OAAO,KAAA;AAElB,EAAA,IAAI,OAAO,QAAA,KAAa,WAAA,EAAa,OAAO,KAAA;AAE5C,EAAA,MAAM,EAAA,GAAK,QAAQ,IAAI,CAAA;AACvB,EAAA,MAAM,OAAA,GAAU,QAAA,CAAS,cAAA,CAAe,EAAE,CAAA;AAC1C,EAAA,IAAI,OAAA,EAAS;AACZ,IAAA,OAAA,CAAQ,KAAA,EAAM;AACd,IAAA,OAAO,IAAA;AAAA,EACR;AACA,EAAA,OAAO,KAAA;AACR;ACvFO,SAAS,eAAA,CACf,IAAA,EACA,QAAA,EACA,UAAA,EACI;AACJ,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,UAAA,IAAc,MAAA,CAAO,EAAE,CAAA;AAC5C,EAAA,KAAA,CAAM,OAAA,GAAU,cAAc,MAAA,CAAO,EAAA;AAErC,EAAA,MAAM,WAAA,GAAc,OAAO,QAAQ,CAAA;AACnC,EAAA,WAAA,CAAY,OAAA,GAAU,QAAA;AAEtB,EAAA,MAAM,UAAU,MAAA,CAA6D;AAAA,IAC5E,KAAA,EAAO,MAAA;AAAA,IACP,WAAA,EAAa;AAAA,GACb,CAAA;AAED,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,aAAA,KAA8B,IAAA,CAAK,UAAU,aAAa,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA;AAElG,EAAA,MAAM,WAAA,GAAc,YAAY,MAAS;AACxC,IAAA,MAAM,IAAA,GAAO,WAAA,CAAY,OAAA,CAAQ,IAAA,CAAK,UAAU,CAAA;AAChD,IAAA,IAAI,OAAA,CAAQ,QAAQ,WAAA,IAAe,KAAA,CAAM,QAAQ,OAAA,CAAQ,OAAA,CAAQ,KAAA,EAAO,IAAI,CAAA,EAAG;AAC9E,MAAA,OAAO,QAAQ,OAAA,CAAQ,KAAA;AAAA,IACxB;AACA,IAAA,OAAA,CAAQ,OAAA,GAAU,EAAE,KAAA,EAAO,IAAA,EAAM,aAAa,IAAA,EAAK;AACnD,IAAA,OAAO,IAAA;AAAA,EACR,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA;AAET,EAAA,OAAO,oBAAA,CAAqB,WAAW,WAAW,CAAA;AACnD;;;ACrCA,SAAS,kBAAA,CAAmB,GAAkB,CAAA,EAA2B;AACxE,EAAA,OAAO,EAAE,KAAA,KAAU,CAAA,CAAE,KAAA,IAAS,CAAA,CAAE,SAAS,CAAA,CAAE,IAAA;AAC5C;AA4BO,SAAS,QAAA,CACf,IAAA,EACA,IAAA,EACA,MAAA,EAC0B;AAE1B,EAAA,MAAM,SAAA,GAAYA,OAAO,MAAM,CAAA;AAC/B,EAAA,MAAM,YAAA,GAAe,QAAQ,MAAM;AAClC,IAAA,MAAM,OAAO,SAAA,CAAU,OAAA;AACvB,IAAA,IAAI,IAAA,KAAS,QAAQ,OAAO,IAAA;AAC5B,IAAA,IAAI,IAAA,IAAQ,MAAA,IAAU,IAAA,CAAK,SAAA,CAAU,IAAI,MAAM,IAAA,CAAK,SAAA,CAAU,MAAM,CAAA,EAAG,OAAO,IAAA;AAC9E,IAAA,SAAA,CAAU,OAAA,GAAU,MAAA;AACpB,IAAA,OAAO,MAAA;AAAA,EACR,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,MAAM,IAAA,CAAK,YAAA,CAAa,IAAA,EAAM,YAAY,CAAA,EAAG,CAAC,IAAA,EAAM,IAAA,EAAM,YAAY,CAAC,CAAA;AAG7F,EAAA,eAAA;AAAA,IACC,IAAA;AAAA,IACA,MAAM;AACL,MAAA,MAAM,KAAA,GAAQ,KAAK,QAAA,EAAS;AAC5B,MAAA,MAAM,OAAA,GAAU,KAAA,CAAM,IAAA,CAAK,QAAA,CAAS,KAAK,GAAG,CAAA;AAC5C,MAAA,MAAM,IAAA,GAAQ,KAAA,CAAM,SAAA,CAAuD,OAAO,CAAA;AAClF,MAAA,OAAO,EAAE,KAAA,EAAO,KAAA,CAAM,GAAA,IAAO,IAAA,EAAK;AAAA,IACnC,CAAA;AAAA,IACA;AAAA,GACD;AAEA,EAAA,OAAO,KAAA;AACR;ACpCO,SAAS,QAAoB,OAAA,EAA2D;AAC9F,EAAA,MAAM,SAAA,GAAY,SAAS,gBAAA,IAAoB,IAAA;AAC/C,EAAA,MAAM,OAAA,GAAUA,OAAmC,IAAI,CAAA;AACvD,EAAA,MAAM,eAAA,GAAkBA,OAA6C,IAAI,CAAA;AAEzE,EAAA,IAAI,OAAA,CAAQ,YAAY,IAAA,EAAM;AAC7B,IAAA,OAAA,CAAQ,OAAA,GAAU,WAAuB,OAAO,CAAA;AAAA,EACjD;AAEA,EAAA,MAAM,OAAO,OAAA,CAAQ,OAAA;AAGrB,EAAA,MAAM,SAAA,GAAYA,MAAAA,CAAO,CAAC,aAAA,KAA8B;AACvD,IAAA,OAAO,IAAA,CAAK,UAAU,aAAa,CAAA;AAAA,EACpC,CAAC,CAAA,CAAE,OAAA;AAEH,EAAAC,oBAAAA,CAAqB,SAAA,EAAW,MAAM,IAAA,CAAK,UAAU,CAAA;AAGrD,EAAA,SAAA,CAAU,MAAM;AACf,IAAA,IAAI,eAAA,CAAgB,YAAY,IAAA,EAAM;AACrC,MAAA,YAAA,CAAa,gBAAgB,OAAO,CAAA;AACpC,MAAA,eAAA,CAAgB,OAAA,GAAU,IAAA;AAAA,IAC3B;AACA,IAAA,OAAO,MAAM;AACZ,MAAA,eAAA,CAAgB,OAAA,GAAU,WAAW,MAAM;AAC1C,QAAA,OAAA,CAAQ,SAAS,OAAA,EAAQ;AAAA,MAC1B,GAAG,CAAC,CAAA;AAAA,IACL,CAAA;AAAA,EACD,CAAA,EAAG,EAAE,CAAA;AAGL,EAAA,MAAM,UAAA,GAAaC,QAAQ,MAA2B;AACrD,IAAA,IAAI,CAAC,WAAW,OAAO,IAAA;AAEvB,IAAA,OAAO;AAAA,MACN,GAAG,IAAA;AAAA,MACH,MAAA,EAAQ,UAAU,IAAA,KAA2E;AAC5F,QAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,MAAA,CAAO,GAAG,IAAI,CAAA;AACxC,QAAA,IAAI,CAAC,MAAA,CAAO,EAAA,IAAM,MAAA,CAAO,aAAa,MAAA,EAAQ;AAC7C,UAAA,eAAA,CAAgB,OAAO,WAAW,CAAA;AAAA,QACnC;AACA,QAAA,OAAO,MAAA;AAAA,MACR;AAAA,KACD;AAAA,EACD,CAAA,EAAG,CAAC,IAAA,EAAM,SAAS,CAAC,CAAA;AAEpB,EAAA,OAAO,UAAA;AACR","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"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@formbar/react",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "React hooks and a11y utilities for @formbar/core",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc --noEmit",
|
|
24
|
+
"build:dist": "tsup",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@formbar/core": "workspace:*"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=18.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^19.0.0",
|
|
35
|
+
"react": "^19.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
descriptionId,
|
|
4
|
+
errorId,
|
|
5
|
+
fieldId,
|
|
6
|
+
findFirstErrorPath,
|
|
7
|
+
getDescriptionProps,
|
|
8
|
+
getErrorProps,
|
|
9
|
+
getFieldProps,
|
|
10
|
+
getLabelProps,
|
|
11
|
+
} from "../index.js";
|
|
12
|
+
|
|
13
|
+
describe("a11y helpers", () => {
|
|
14
|
+
test("fieldId generates deterministic ID from path", () => {
|
|
15
|
+
expect(fieldId("name")).toBe("field-name");
|
|
16
|
+
expect(fieldId("address.city")).toBe("field-address-city");
|
|
17
|
+
expect(fieldId("items[0].name")).toBe("field-items-0-name");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("descriptionId derives from fieldId", () => {
|
|
21
|
+
expect(descriptionId("name")).toBe("field-name-description");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("errorId derives from fieldId", () => {
|
|
25
|
+
expect(errorId("name")).toBe("field-name-error");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("getFieldProps without issues", () => {
|
|
29
|
+
const props = getFieldProps("name");
|
|
30
|
+
expect(props.id).toBe("field-name");
|
|
31
|
+
expect(props["aria-invalid"]).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("getFieldProps with error issues sets aria-invalid", () => {
|
|
35
|
+
const issues = [
|
|
36
|
+
{
|
|
37
|
+
code: "required",
|
|
38
|
+
message: "Required",
|
|
39
|
+
severity: "error" as const,
|
|
40
|
+
stage: "draft",
|
|
41
|
+
path: { namespace: "data" as const, segments: ["name"] },
|
|
42
|
+
source: { origin: "function-validator" as const, validatorId: "test" },
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
const props = getFieldProps("name", { issues });
|
|
46
|
+
expect(props["aria-invalid"]).toBe(true);
|
|
47
|
+
expect(props["aria-describedby"]).toBe("field-name-error");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("getFieldProps with description and errors", () => {
|
|
51
|
+
const issues = [
|
|
52
|
+
{
|
|
53
|
+
code: "required",
|
|
54
|
+
message: "Required",
|
|
55
|
+
severity: "error" as const,
|
|
56
|
+
stage: "draft",
|
|
57
|
+
path: { namespace: "data" as const, segments: ["name"] },
|
|
58
|
+
source: { origin: "function-validator" as const, validatorId: "test" },
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
const props = getFieldProps("name", { issues, hasDescription: true });
|
|
62
|
+
expect(props["aria-describedby"]).toBe("field-name-description field-name-error");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("getFieldProps with required", () => {
|
|
66
|
+
const props = getFieldProps("name", { required: true });
|
|
67
|
+
expect(props["aria-required"]).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("getLabelProps generates htmlFor", () => {
|
|
71
|
+
expect(getLabelProps("name").htmlFor).toBe("field-name");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("getDescriptionProps generates id", () => {
|
|
75
|
+
expect(getDescriptionProps("name").id).toBe("field-name-description");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("getErrorProps generates id with alert role", () => {
|
|
79
|
+
const props = getErrorProps("name");
|
|
80
|
+
expect(props.id).toBe("field-name-error");
|
|
81
|
+
expect(props.role).toBe("alert");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("findFirstErrorPath returns first error path", () => {
|
|
85
|
+
const issues = [
|
|
86
|
+
{
|
|
87
|
+
code: "required",
|
|
88
|
+
message: "Required",
|
|
89
|
+
severity: "warning" as const,
|
|
90
|
+
stage: "draft",
|
|
91
|
+
path: { namespace: "data" as const, segments: ["email"] },
|
|
92
|
+
source: { origin: "function-validator" as const, validatorId: "test" },
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
code: "required",
|
|
96
|
+
message: "Required",
|
|
97
|
+
severity: "error" as const,
|
|
98
|
+
stage: "draft",
|
|
99
|
+
path: { namespace: "data" as const, segments: ["name"] },
|
|
100
|
+
source: { origin: "function-validator" as const, validatorId: "test" },
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
expect(findFirstErrorPath(issues)).toBe("name");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("findFirstErrorPath returns undefined when no errors", () => {
|
|
107
|
+
expect(findFirstErrorPath([])).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("focusFirstError returns false without document", async () => {
|
|
111
|
+
const { focusFirstError } = await import("../a11y.js");
|
|
112
|
+
expect(focusFirstError([])).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("@formbar/react public API surface", () => {
|
|
4
|
+
it("exports expected symbols from main entry", async () => {
|
|
5
|
+
const mod = await import("../index.js");
|
|
6
|
+
const exports = Object.keys(mod).sort();
|
|
7
|
+
|
|
8
|
+
expect(exports).toEqual(
|
|
9
|
+
expect.arrayContaining(["useForm", "useField", "useFormSelector", "getFieldProps", "getLabelProps"]),
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("does not export schema-driven symbols (moved to @formbar/react-schema)", async () => {
|
|
14
|
+
const mod = await import("../index.js");
|
|
15
|
+
const exports = Object.keys(mod);
|
|
16
|
+
|
|
17
|
+
expect(exports).not.toContain("useSchemaForm");
|
|
18
|
+
expect(exports).not.toContain("RendererRegistry");
|
|
19
|
+
expect(exports).not.toContain("renderLayoutTree");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { useField, useFormSelector } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("useFormSelector", () => {
|
|
5
|
+
test("is exported as a function", () => {
|
|
6
|
+
expect(typeof useFormSelector).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("useField", () => {
|
|
11
|
+
test("is exported as a function", () => {
|
|
12
|
+
expect(typeof useField).toBe("function");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { type UseFormOptions, useForm } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("useForm", () => {
|
|
5
|
+
test("is exported as a function", () => {
|
|
6
|
+
expect(typeof useForm).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("accepts an optional options parameter", () => {
|
|
10
|
+
expect(useForm.length).toBe(1);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("UseFormOptions type is exported", () => {
|
|
14
|
+
// Type-level check: UseFormOptions should be importable
|
|
15
|
+
const opts: UseFormOptions = { autoFocusOnError: false };
|
|
16
|
+
expect(opts.autoFocusOnError).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("UseFormOptions defaults autoFocusOnError to true", () => {
|
|
20
|
+
const opts: UseFormOptions = {};
|
|
21
|
+
expect(opts.autoFocusOnError).toBeUndefined();
|
|
22
|
+
// The hook defaults undefined to true internally
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/a11y.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ValidationIssue } from "@formbar/core";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_FIELD_PREFIX = "field";
|
|
4
|
+
|
|
5
|
+
/** ARIA props for a form field */
|
|
6
|
+
export interface FieldA11yProps {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly "aria-invalid"?: boolean;
|
|
9
|
+
readonly "aria-describedby"?: string;
|
|
10
|
+
readonly "aria-required"?: boolean;
|
|
11
|
+
readonly "aria-errormessage"?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Label props for semantic association */
|
|
15
|
+
export interface LabelA11yProps {
|
|
16
|
+
readonly htmlFor: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Description/error message props */
|
|
20
|
+
export interface DescriptionA11yProps {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly role?: "alert";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Generate a deterministic field ID from path */
|
|
26
|
+
export function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {
|
|
27
|
+
return `${prefix}-${path
|
|
28
|
+
.replace(/[.[\]/]/g, "-")
|
|
29
|
+
.replace(/-+/g, "-")
|
|
30
|
+
.replace(/-$/, "")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Generate description element ID */
|
|
34
|
+
export function descriptionId(path: string, prefix?: string): string {
|
|
35
|
+
return `${fieldId(path, prefix)}-description`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Generate error element ID */
|
|
39
|
+
export function errorId(path: string, prefix?: string): string {
|
|
40
|
+
return `${fieldId(path, prefix)}-error`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Get ARIA props for a field input element */
|
|
44
|
+
export function getFieldProps(
|
|
45
|
+
path: string,
|
|
46
|
+
options?: {
|
|
47
|
+
readonly issues?: readonly ValidationIssue[];
|
|
48
|
+
readonly required?: boolean;
|
|
49
|
+
readonly hasDescription?: boolean;
|
|
50
|
+
},
|
|
51
|
+
): FieldA11yProps {
|
|
52
|
+
const id = fieldId(path);
|
|
53
|
+
const hasErrors = options?.issues?.some((i) => i.severity === "error") ?? false;
|
|
54
|
+
|
|
55
|
+
const describedBy: string[] = [];
|
|
56
|
+
if (options?.hasDescription) describedBy.push(descriptionId(path));
|
|
57
|
+
if (hasErrors) describedBy.push(errorId(path));
|
|
58
|
+
|
|
59
|
+
const props: FieldA11yProps = {
|
|
60
|
+
id,
|
|
61
|
+
...(hasErrors ? { "aria-invalid": true as const } : {}),
|
|
62
|
+
...(describedBy.length > 0 ? { "aria-describedby": describedBy.join(" ") } : {}),
|
|
63
|
+
...(options?.required ? { "aria-required": true as const } : {}),
|
|
64
|
+
...(hasErrors ? { "aria-errormessage": errorId(path) } : {}),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return props;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Get label props for semantic association */
|
|
71
|
+
export function getLabelProps(path: string): LabelA11yProps {
|
|
72
|
+
return { htmlFor: fieldId(path) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Get description element props */
|
|
76
|
+
export function getDescriptionProps(path: string): DescriptionA11yProps {
|
|
77
|
+
return { id: descriptionId(path) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Get error message props */
|
|
81
|
+
export function getErrorProps(path: string): DescriptionA11yProps {
|
|
82
|
+
return { id: errorId(path), role: "alert" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Find the first field path with errors (for focus management) */
|
|
86
|
+
export function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {
|
|
87
|
+
const firstError = issues.find((i) => i.severity === "error");
|
|
88
|
+
if (!firstError) return undefined;
|
|
89
|
+
return firstError.path.segments.join(".");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Focus the first error field after submit (browser-only) */
|
|
93
|
+
export function focusFirstError(issues: readonly ValidationIssue[]): boolean {
|
|
94
|
+
const path = findFirstErrorPath(issues);
|
|
95
|
+
if (!path) return false;
|
|
96
|
+
|
|
97
|
+
if (typeof document === "undefined") return false;
|
|
98
|
+
|
|
99
|
+
const id = fieldId(path);
|
|
100
|
+
const element = document.getElementById(id);
|
|
101
|
+
if (element) {
|
|
102
|
+
element.focus();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Re-export core types that React consumers need
|
|
2
|
+
export type {
|
|
3
|
+
CreateFormOptions,
|
|
4
|
+
DeepKeys,
|
|
5
|
+
DeepValue,
|
|
6
|
+
FieldApi,
|
|
7
|
+
FieldConfig,
|
|
8
|
+
FormAction,
|
|
9
|
+
FormApi,
|
|
10
|
+
FormDispatchResult,
|
|
11
|
+
FormState,
|
|
12
|
+
SubmitContext,
|
|
13
|
+
SubmitResult,
|
|
14
|
+
ValidationIssue,
|
|
15
|
+
ValidatorFn,
|
|
16
|
+
ValidatorInput,
|
|
17
|
+
} from "@formbar/core";
|
|
18
|
+
export {
|
|
19
|
+
type DescriptionA11yProps,
|
|
20
|
+
descriptionId,
|
|
21
|
+
errorId,
|
|
22
|
+
type FieldA11yProps,
|
|
23
|
+
fieldId,
|
|
24
|
+
findFirstErrorPath,
|
|
25
|
+
focusFirstError,
|
|
26
|
+
getDescriptionProps,
|
|
27
|
+
getErrorProps,
|
|
28
|
+
getFieldProps,
|
|
29
|
+
getLabelProps,
|
|
30
|
+
type LabelA11yProps,
|
|
31
|
+
} from "./a11y.js";
|
|
32
|
+
export { useField } from "./use-field.js";
|
|
33
|
+
export { type UseFormOptions, useForm } from "./use-form.js";
|
|
34
|
+
export { useFormSelector } from "./use-form-selector.js";
|
package/src/use-field.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from "@formbar/core";
|
|
2
|
+
import { useMemo, useRef } from "react";
|
|
3
|
+
import { useFormSelector } from "./use-form-selector.js";
|
|
4
|
+
|
|
5
|
+
interface FieldSnapshot {
|
|
6
|
+
readonly value: unknown;
|
|
7
|
+
readonly meta: FieldMetaEntry | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {
|
|
11
|
+
return a.value === b.value && a.meta === b.meta;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* React hook that subscribes to a specific field with fine-grained re-rendering.
|
|
16
|
+
* Only re-renders when the field's value or metadata actually changes.
|
|
17
|
+
*
|
|
18
|
+
* @param form - The {@link FormApi} instance (from useForm or createForm).
|
|
19
|
+
* @param path - Dot-path to the field (e.g., `"user.email"`).
|
|
20
|
+
* @param config - Optional field configuration (label, validators, triggers).
|
|
21
|
+
* @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* function EmailField({ form }) {
|
|
26
|
+
* const field = useField(form, "email");
|
|
27
|
+
* return (
|
|
28
|
+
* <div>
|
|
29
|
+
* <input
|
|
30
|
+
* value={field.get() ?? ""}
|
|
31
|
+
* onChange={e => field.handleChange(e.target.value)}
|
|
32
|
+
* onBlur={() => field.handleBlur()}
|
|
33
|
+
* />
|
|
34
|
+
* {field.issues().map(i => <span key={i.code}>{i.message}</span>)}
|
|
35
|
+
* </div>
|
|
36
|
+
* );
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function useField<TData, TUi, P extends string>(
|
|
41
|
+
form: FormApi<TData, TUi>,
|
|
42
|
+
path: P,
|
|
43
|
+
config?: FieldConfig,
|
|
44
|
+
): FieldApi<TData, TUi, P> {
|
|
45
|
+
// Stabilize config reference — inline objects create new references every render
|
|
46
|
+
const configRef = useRef(config);
|
|
47
|
+
const stableConfig = useMemo(() => {
|
|
48
|
+
const prev = configRef.current;
|
|
49
|
+
if (prev === config) return prev;
|
|
50
|
+
if (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;
|
|
51
|
+
configRef.current = config;
|
|
52
|
+
return config;
|
|
53
|
+
}, [config]);
|
|
54
|
+
|
|
55
|
+
const field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);
|
|
56
|
+
|
|
57
|
+
// Subscribe to field value and touched state to trigger re-renders
|
|
58
|
+
useFormSelector(
|
|
59
|
+
form,
|
|
60
|
+
() => {
|
|
61
|
+
const state = form.getState();
|
|
62
|
+
const pathKey = field.path.segments.join(".");
|
|
63
|
+
const meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];
|
|
64
|
+
return { value: field.get(), meta } as FieldSnapshot;
|
|
65
|
+
},
|
|
66
|
+
fieldSnapshotEqual,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return field as FieldApi<TData, TUi, P>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { FormApi, FormState } from "@formbar/core";
|
|
2
|
+
import { useCallback, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe to a derived value from form state with fine-grained reactivity.
|
|
6
|
+
* Only triggers re-render when the selected value changes (by equality function).
|
|
7
|
+
*
|
|
8
|
+
* @param form - The {@link FormApi} instance.
|
|
9
|
+
* @param selector - Function that extracts a value from the full form state.
|
|
10
|
+
* @param equalityFn - Optional equality comparator (defaults to `Object.is`).
|
|
11
|
+
* @returns The current selected value, updated reactively.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const isValid = useFormSelector(form, (state) => state.issues.length === 0);
|
|
16
|
+
* const submitCount = useFormSelector(form, (state) => state.meta.submitted);
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function useFormSelector<TData, TUi, T>(
|
|
20
|
+
form: FormApi<TData, TUi>,
|
|
21
|
+
selector: (state: FormState<TData, TUi>) => T,
|
|
22
|
+
equalityFn?: (prev: T, next: T) => boolean,
|
|
23
|
+
): T {
|
|
24
|
+
const eqRef = useRef(equalityFn ?? Object.is);
|
|
25
|
+
eqRef.current = equalityFn ?? Object.is;
|
|
26
|
+
|
|
27
|
+
const selectorRef = useRef(selector);
|
|
28
|
+
selectorRef.current = selector;
|
|
29
|
+
|
|
30
|
+
const prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({
|
|
31
|
+
value: undefined as T,
|
|
32
|
+
initialized: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);
|
|
36
|
+
|
|
37
|
+
const getSnapshot = useCallback((): T => {
|
|
38
|
+
const next = selectorRef.current(form.getState());
|
|
39
|
+
if (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {
|
|
40
|
+
return prevRef.current.value;
|
|
41
|
+
}
|
|
42
|
+
prevRef.current = { value: next, initialized: true };
|
|
43
|
+
return next;
|
|
44
|
+
}, [form]);
|
|
45
|
+
|
|
46
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
47
|
+
}
|
package/src/use-form.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { CreateFormOptions, FormApi, SubmitResult } from "@formbar/core";
|
|
2
|
+
import { createForm } from "@formbar/core";
|
|
3
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
4
|
+
import { focusFirstError } from "./a11y.js";
|
|
5
|
+
|
|
6
|
+
/** Options for useForm, extending core CreateFormOptions with React-specific behavior */
|
|
7
|
+
export interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {
|
|
8
|
+
/** Auto-focus the first error field on submit failure (default: true) */
|
|
9
|
+
readonly autoFocusOnError?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* React hook that creates and manages a form instance with automatic cleanup.
|
|
14
|
+
* The form is created once on mount and disposed on unmount (StrictMode-safe).
|
|
15
|
+
*
|
|
16
|
+
* @param options - Form configuration (same as {@link createForm} options).
|
|
17
|
+
* @returns A stable {@link FormApi} reference that persists across re-renders.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* function ContactForm() {
|
|
22
|
+
* const form = useForm({
|
|
23
|
+
* initialData: { name: "", email: "" },
|
|
24
|
+
* onSubmit: async ({ payload }) => {
|
|
25
|
+
* await saveContact(payload);
|
|
26
|
+
* return { ok: true, submitId: "1" };
|
|
27
|
+
* },
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* return <input value={form.field("name").get()} onChange={e => form.field("name").set(e.target.value)} />;
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {
|
|
35
|
+
const autoFocus = options?.autoFocusOnError ?? true;
|
|
36
|
+
const formRef = useRef<FormApi<TData, TUi> | null>(null);
|
|
37
|
+
const disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
38
|
+
|
|
39
|
+
if (formRef.current === null) {
|
|
40
|
+
formRef.current = createForm<TData, TUi>(options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const form = formRef.current;
|
|
44
|
+
|
|
45
|
+
// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature
|
|
46
|
+
const subscribe = useRef((onStoreChange: () => void) => {
|
|
47
|
+
return form.subscribe(onStoreChange);
|
|
48
|
+
}).current;
|
|
49
|
+
|
|
50
|
+
useSyncExternalStore(subscribe, () => form.getState());
|
|
51
|
+
|
|
52
|
+
// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (disposeTimerRef.current !== null) {
|
|
55
|
+
clearTimeout(disposeTimerRef.current);
|
|
56
|
+
disposeTimerRef.current = null;
|
|
57
|
+
}
|
|
58
|
+
return () => {
|
|
59
|
+
disposeTimerRef.current = setTimeout(() => {
|
|
60
|
+
formRef.current?.dispose();
|
|
61
|
+
}, 0);
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Wrap the form API to auto-focus on submit errors (ADR §12)
|
|
66
|
+
const wrappedApi = useMemo((): FormApi<TData, TUi> => {
|
|
67
|
+
if (!autoFocus) return form;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...form,
|
|
71
|
+
submit: async (...args: Parameters<FormApi<TData, TUi>["submit"]>): Promise<SubmitResult> => {
|
|
72
|
+
const result = await form.submit(...args);
|
|
73
|
+
if (!result.ok && result.fieldIssues?.length) {
|
|
74
|
+
focusFirstError(result.fieldIssues);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}, [form, autoFocus]);
|
|
80
|
+
|
|
81
|
+
return wrappedApi;
|
|
82
|
+
}
|