@formbar/react 0.3.0 → 0.4.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/README.md +47 -1
- package/dist/index.cjs +20 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
- package/src/__tests__/core-form-options.test.ts +69 -0
- package/src/__tests__/use-expression-props.test.ts +198 -0
- package/src/core-form-options.ts +16 -0
- package/src/index.ts +1 -0
- package/src/use-expression-props.ts +8 -0
- package/src/use-form.ts +2 -1
package/README.md
CHANGED
|
@@ -2,7 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
React hooks and accessibility helpers for forms created with `@formbar/core`.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Reactive ordinary expression props (#90)
|
|
6
|
+
|
|
7
|
+
`useExpressionProps(service, definitions)` observes **all** expression props,
|
|
8
|
+
not only values/visibility. It returns `{ values, setters, diagnostics }`.
|
|
9
|
+
Direct `mode: "write"` refs have authorized setters; derived/read expressions do
|
|
10
|
+
not. Services use one immutable Kuery expression profile and host namespaces. Keep
|
|
11
|
+
definitions stable.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { useExpressionProps } from "@formbar/react";
|
|
15
|
+
import type { ExpressionService, PropDefinitions } from "@formbar/expressions";
|
|
16
|
+
|
|
17
|
+
const quantity = { kind: "ref", ref: { namespace: "data", segments: ["quantity"] } } as const;
|
|
18
|
+
const props: PropDefinitions = {
|
|
19
|
+
value: { mode: "write", expression: quantity },
|
|
20
|
+
disabled: { mode: "read", expression: {
|
|
21
|
+
kind: "op", op: "lte", args: [quantity, { kind: "literal", value: 0 }],
|
|
22
|
+
} },
|
|
23
|
+
total: { mode: "read", expression: {
|
|
24
|
+
kind: "op", op: "mul", args: [quantity, {
|
|
25
|
+
kind: "ref", ref: { namespace: "data", segments: ["unitPrice"] },
|
|
26
|
+
}],
|
|
27
|
+
} },
|
|
28
|
+
};
|
|
29
|
+
export function Quantity({ service }: { service: ExpressionService }) {
|
|
30
|
+
const { values, setters } = useExpressionProps(service, props);
|
|
31
|
+
return <>
|
|
32
|
+
<input aria-label="Quantity" type="number" value={String(values.value ?? "")}
|
|
33
|
+
onChange={event => setters.value?.(Number(event.currentTarget.value))} />
|
|
34
|
+
<output>{String(values.total ?? "")}</output>
|
|
35
|
+
<button type="button" disabled={Boolean(values.disabled)}>Buy</button>
|
|
36
|
+
</>;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The host constructs/disposes the service outside render and registers core using
|
|
41
|
+
`createCoreExpressionNamespaces(form)`. No provider dependency is imposed on this
|
|
42
|
+
hook. `useSyncExternalStore` handles StrictMode/unmount/rebinding; resource-free
|
|
43
|
+
observation construction does not leak on abandoned renders. Replacement releases
|
|
44
|
+
old subscriptions and invalidates retained binding setters. Use the neutral
|
|
45
|
+
`forwardExpressionProp` helper with a host type guard for typed custom-widget
|
|
46
|
+
forwarding rather than unchecked casts. This is not a full declarative renderer.
|
|
47
|
+
|
|
48
|
+
## Package installation
|
|
6
49
|
|
|
7
50
|
```bash
|
|
8
51
|
bun add @formbar/react @formbar/core react
|
|
@@ -54,3 +97,6 @@ export function ContactForm() {
|
|
|
54
97
|
|
|
55
98
|
- Depends on `@formbar/core`.
|
|
56
99
|
- Peer dependency: `react >=18.0.0`.
|
|
100
|
+
|
|
101
|
+
`autoFocusOnError` is handled by `useForm` and is not forwarded to core. Other options are forwarded unchanged, so
|
|
102
|
+
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) => {
|
|
@@ -140,6 +154,10 @@ function useForm(options) {
|
|
|
140
154
|
}, [form, autoFocus]);
|
|
141
155
|
return wrappedApi;
|
|
142
156
|
}
|
|
157
|
+
function useExpressionProps(service, definitions) {
|
|
158
|
+
const binding = react.useMemo(() => service.resolveProps(definitions), [service, definitions]);
|
|
159
|
+
return react.useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);
|
|
160
|
+
}
|
|
143
161
|
|
|
144
162
|
exports.descriptionId = descriptionId;
|
|
145
163
|
exports.errorId = errorId;
|
|
@@ -150,6 +168,7 @@ exports.getDescriptionProps = getDescriptionProps;
|
|
|
150
168
|
exports.getErrorProps = getErrorProps;
|
|
151
169
|
exports.getFieldProps = getFieldProps;
|
|
152
170
|
exports.getLabelProps = getLabelProps;
|
|
171
|
+
exports.useExpressionProps = useExpressionProps;
|
|
153
172
|
exports.useField = useField;
|
|
154
173
|
exports.useForm = useForm;
|
|
155
174
|
exports.useFormSelector = useFormSelector;
|
package/dist/index.cjs.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","useCallback","useSyncExternalStore","useMemo","createForm","useEffect"],"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,GAAQA,YAAA,CAAO,UAAA,IAAc,MAAA,CAAO,EAAE,CAAA;AAC5C,EAAA,KAAA,CAAM,OAAA,GAAU,cAAc,MAAA,CAAO,EAAA;AAErC,EAAA,MAAM,WAAA,GAAcA,aAAO,QAAQ,CAAA;AACnC,EAAA,WAAA,CAAY,OAAA,GAAU,QAAA;AAEtB,EAAA,MAAM,UAAUA,YAAA,CAA6D;AAAA,IAC5E,KAAA,EAAO,MAAA;AAAA,IACP,WAAA,EAAa;AAAA,GACb,CAAA;AAED,EAAA,MAAM,SAAA,GAAYC,iBAAA,CAAY,CAAC,aAAA,KAA8B,IAAA,CAAK,UAAU,aAAa,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA;AAElG,EAAA,MAAM,WAAA,GAAcA,kBAAY,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,OAAOC,0BAAA,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,GAAYF,aAAO,MAAM,CAAA;AAC/B,EAAA,MAAM,YAAA,GAAeG,cAAQ,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,GAAQA,aAAA,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,GAAUH,aAAmC,IAAI,CAAA;AACvD,EAAA,MAAM,eAAA,GAAkBA,aAA6C,IAAI,CAAA;AAEzE,EAAA,IAAI,OAAA,CAAQ,YAAY,IAAA,EAAM;AAC7B,IAAA,OAAA,CAAQ,OAAA,GAAUI,gBAAuB,OAAO,CAAA;AAAA,EACjD;AAEA,EAAA,MAAM,OAAO,OAAA,CAAQ,OAAA;AAGrB,EAAA,MAAM,SAAA,GAAYJ,YAAAA,CAAO,CAAC,aAAA,KAA8B;AACvD,IAAA,OAAO,IAAA,CAAK,UAAU,aAAa,CAAA;AAAA,EACpC,CAAC,CAAA,CAAE,OAAA;AAEH,EAAAE,0BAAAA,CAAqB,SAAA,EAAW,MAAM,IAAA,CAAK,UAAU,CAAA;AAGrD,EAAAG,eAAA,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,GAAaF,cAAQ,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.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","../src/use-expression-props.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","import type { ExpressionService, PropDefinitions, ResolvedProps } from \"@formbar/expressions\";\nimport { useMemo, useSyncExternalStore } from \"react\";\n\n/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */\nexport function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {\n\tconst binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);\n\treturn useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ValidationIssue, FormApi, FieldConfig, FieldApi, CreateFormOptions, FormState } from '@formbar/core';
|
|
2
2
|
export { CreateFormOptions, DeepKeys, DeepValue, FieldApi, FieldConfig, FormAction, FormApi, FormDispatchResult, FormState, SubmitContext, SubmitResult, ValidationIssue, ValidatorFn, ValidatorInput } from '@formbar/core';
|
|
3
|
+
import { ExpressionService, PropDefinitions, ResolvedProps } from '@formbar/expressions';
|
|
3
4
|
|
|
4
5
|
/** ARIA props for a form field */
|
|
5
6
|
interface FieldA11yProps {
|
|
@@ -115,4 +116,7 @@ declare function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): Form
|
|
|
115
116
|
*/
|
|
116
117
|
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
|
|
|
118
|
-
|
|
119
|
+
/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */
|
|
120
|
+
declare function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps;
|
|
121
|
+
|
|
122
|
+
export { type DescriptionA11yProps, type FieldA11yProps, type LabelA11yProps, type UseFormOptions, descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useExpressionProps, useField, useForm, useFormSelector };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ValidationIssue, FormApi, FieldConfig, FieldApi, CreateFormOptions, FormState } from '@formbar/core';
|
|
2
2
|
export { CreateFormOptions, DeepKeys, DeepValue, FieldApi, FieldConfig, FormAction, FormApi, FormDispatchResult, FormState, SubmitContext, SubmitResult, ValidationIssue, ValidatorFn, ValidatorInput } from '@formbar/core';
|
|
3
|
+
import { ExpressionService, PropDefinitions, ResolvedProps } from '@formbar/expressions';
|
|
3
4
|
|
|
4
5
|
/** ARIA props for a form field */
|
|
5
6
|
interface FieldA11yProps {
|
|
@@ -115,4 +116,7 @@ declare function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): Form
|
|
|
115
116
|
*/
|
|
116
117
|
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
|
|
|
118
|
-
|
|
119
|
+
/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */
|
|
120
|
+
declare function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps;
|
|
121
|
+
|
|
122
|
+
export { type DescriptionA11yProps, type FieldA11yProps, type LabelA11yProps, type UseFormOptions, descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useExpressionProps, useField, useForm, useFormSelector };
|
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) => {
|
|
@@ -138,7 +152,11 @@ function useForm(options) {
|
|
|
138
152
|
}, [form, autoFocus]);
|
|
139
153
|
return wrappedApi;
|
|
140
154
|
}
|
|
155
|
+
function useExpressionProps(service, definitions) {
|
|
156
|
+
const binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);
|
|
157
|
+
return useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);
|
|
158
|
+
}
|
|
141
159
|
|
|
142
|
-
export { descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useField, useForm, useFormSelector };
|
|
160
|
+
export { descriptionId, errorId, fieldId, findFirstErrorPath, focusFirstError, getDescriptionProps, getErrorProps, getFieldProps, getLabelProps, useExpressionProps, useField, useForm, useFormSelector };
|
|
143
161
|
//# sourceMappingURL=index.js.map
|
|
144
162
|
//# sourceMappingURL=index.js.map
|
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","../src/use-expression-props.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","import type { ExpressionService, PropDefinitions, ResolvedProps } from \"@formbar/expressions\";\nimport { useMemo, useSyncExternalStore } from \"react\";\n\n/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */\nexport function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {\n\tconst binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);\n\treturn useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formbar/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React hooks and a11y utilities for @formbar/core",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,15 +24,19 @@
|
|
|
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/
|
|
30
|
+
"@formbar/expressions": "^0.4.0",
|
|
31
|
+
"@formbar/core": "^0.4.0"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
34
|
"react": ">=18.0.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
37
|
+
"@types/react-dom": "^19.0.0",
|
|
38
|
+
"react-dom": "^19.0.0",
|
|
39
|
+
"jsdom": "^26.0.0",
|
|
36
40
|
"@types/react": "^19.0.0",
|
|
37
41
|
"react": "^19.0.0"
|
|
38
42
|
}
|
|
@@ -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,198 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createCoreExpressionNamespaces, createForm } from "@formbar/core";
|
|
3
|
+
import { createExpressionService, failure } from "@formbar/expressions";
|
|
4
|
+
import type { ExpressionService, PropDefinitions, ResolvedProps } from "@formbar/expressions";
|
|
5
|
+
import { StrictMode, act, createElement } from "react";
|
|
6
|
+
import type { FormEvent } from "react";
|
|
7
|
+
import { createRoot } from "react-dom/client";
|
|
8
|
+
import type { Root } from "react-dom/client";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
import { literal, namespace, op, ref } from "../../../../test/expression-fixtures.js";
|
|
11
|
+
import { useExpressionProps } from "../index.js";
|
|
12
|
+
|
|
13
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
14
|
+
let root: Root;
|
|
15
|
+
let container: HTMLDivElement;
|
|
16
|
+
let latest: ResolvedProps;
|
|
17
|
+
|
|
18
|
+
function Widget({ service, definitions }: { service: ExpressionService; definitions: PropDefinitions }) {
|
|
19
|
+
latest = useExpressionProps(service, definitions);
|
|
20
|
+
const { values, setters } = latest;
|
|
21
|
+
return createElement(
|
|
22
|
+
"section",
|
|
23
|
+
{ "data-label": values.custom },
|
|
24
|
+
createElement("input", {
|
|
25
|
+
value: String(values.value ?? ""),
|
|
26
|
+
onInput: (event: FormEvent<HTMLInputElement>) => setters.value?.(Number(event.currentTarget.value)),
|
|
27
|
+
}),
|
|
28
|
+
createElement("output", null, String(values.total ?? "")),
|
|
29
|
+
createElement("button", { type: "button", disabled: Boolean(values.disabled) }, "Buy"),
|
|
30
|
+
createElement("span", null, String(values.title ?? "")),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const definitions: PropDefinitions = {
|
|
35
|
+
value: { mode: "write", expression: ref("quantity") },
|
|
36
|
+
total: {
|
|
37
|
+
mode: "read",
|
|
38
|
+
expression: op("add", op("mul", ref("quantity"), ref("unitPrice")), op("sub", ref("fee", "pricing"), literal(1))),
|
|
39
|
+
},
|
|
40
|
+
disabled: { mode: "read", expression: op("or", ref("locked", "ui"), op("lte", ref("quantity"), literal(0))) },
|
|
41
|
+
custom: { mode: "read", expression: ref("label", "pricing") },
|
|
42
|
+
title: { mode: "literal", value: "Order" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const render = (service: ExpressionService, props = definitions) =>
|
|
46
|
+
act(() => root.render(createElement(StrictMode, null, createElement(Widget, { service, definitions: props }))));
|
|
47
|
+
const input = () => container.querySelector("input") as HTMLInputElement;
|
|
48
|
+
const total = () => container.querySelector("output")?.textContent;
|
|
49
|
+
const edit = (value: string) =>
|
|
50
|
+
act(() => {
|
|
51
|
+
input().value = value;
|
|
52
|
+
input().dispatchEvent(new Event("input", { bubbles: true }));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
container = document.createElement("div");
|
|
57
|
+
document.body.append(container);
|
|
58
|
+
root = createRoot(container);
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
container.remove();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("mounted expression props", () => {
|
|
66
|
+
it("clears actual DOM values and unmounts when disposal observers and provider cleanup throw", () => {
|
|
67
|
+
const form = createForm({ initialData: { quantity: 2, unitPrice: 10 }, initialUiState: { locked: false } });
|
|
68
|
+
form.onDispose(() => {
|
|
69
|
+
throw new Error("SECRET disposal observer");
|
|
70
|
+
});
|
|
71
|
+
const external = namespace({ fee: 3, label: "Secret" });
|
|
72
|
+
const service = createExpressionService({
|
|
73
|
+
namespaces: {
|
|
74
|
+
...createCoreExpressionNamespaces(form),
|
|
75
|
+
pricing: {
|
|
76
|
+
...external.provider,
|
|
77
|
+
subscribe(listener) {
|
|
78
|
+
const stop = external.provider.subscribe(listener);
|
|
79
|
+
return () => {
|
|
80
|
+
stop();
|
|
81
|
+
throw new Error("SECRET cleanup");
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
render(service);
|
|
88
|
+
expect(input().value).toBe("2");
|
|
89
|
+
expect(total()).toBe("22");
|
|
90
|
+
const retained = latest.setters.value;
|
|
91
|
+
act(() => form.dispose());
|
|
92
|
+
expect(input().value).toBe("");
|
|
93
|
+
expect(total()).toBe("");
|
|
94
|
+
expect(retained(7)).toEqual(failure("disposed"));
|
|
95
|
+
expect(form.getDisposalDiagnostics()).toEqual([{ code: "adapter" }]);
|
|
96
|
+
act(() => root.render(null));
|
|
97
|
+
expect(external.listeners.size).toBe(0);
|
|
98
|
+
expect(service.getLifecycleDiagnostics()).toEqual([{ code: "adapter" }]);
|
|
99
|
+
service.dispose();
|
|
100
|
+
form.dispose();
|
|
101
|
+
});
|
|
102
|
+
it("reacts to core edits and native input with arithmetic, disabled and ordinary custom props", () => {
|
|
103
|
+
const form = createForm({ initialData: { quantity: 2, unitPrice: 10 }, initialUiState: { locked: false } });
|
|
104
|
+
const external = namespace({ fee: 3, label: "Standard" });
|
|
105
|
+
const service = createExpressionService({
|
|
106
|
+
namespaces: { ...createCoreExpressionNamespaces(form), pricing: external.provider },
|
|
107
|
+
});
|
|
108
|
+
render(service);
|
|
109
|
+
expect(input().value).toBe("2");
|
|
110
|
+
expect(total()).toBe("22");
|
|
111
|
+
expect(container.querySelector("section")?.dataset.label).toBe("Standard");
|
|
112
|
+
expect(external.listeners.size).toBe(1);
|
|
113
|
+
act(() => {
|
|
114
|
+
form.setValue("unitPrice", 12);
|
|
115
|
+
});
|
|
116
|
+
expect(total()).toBe("26");
|
|
117
|
+
edit("3");
|
|
118
|
+
expect(form.getState().data.quantity).toBe(3);
|
|
119
|
+
expect(total()).toBe("38");
|
|
120
|
+
expect(latest.setters.total).toBeUndefined();
|
|
121
|
+
expect(Object.hasOwn(form.getState().data, "total")).toBe(false);
|
|
122
|
+
act(() => {
|
|
123
|
+
form.dispatch({ type: "set-value", path: "$ui.locked", value: true });
|
|
124
|
+
});
|
|
125
|
+
expect(container.querySelector("button")?.disabled).toBe(true);
|
|
126
|
+
act(() => external.replace({ fee: 5, label: "Express" }));
|
|
127
|
+
expect(total()).toBe("40");
|
|
128
|
+
expect(container.querySelector("section")?.dataset.label).toBe("Express");
|
|
129
|
+
act(() => service.dispose());
|
|
130
|
+
expect(total()).toBe("");
|
|
131
|
+
expect(external.listeners.size).toBe(0);
|
|
132
|
+
form.dispose();
|
|
133
|
+
});
|
|
134
|
+
it("rejects denied/stale writes, clears revoked values, and cleans StrictMode/unmount subscriptions", () => {
|
|
135
|
+
let allowed = true;
|
|
136
|
+
const form = createForm({ initialData: { quantity: 2, unitPrice: 10 }, initialUiState: { locked: false } });
|
|
137
|
+
const external = namespace({ fee: 3, label: "Secret" });
|
|
138
|
+
const service = createExpressionService({
|
|
139
|
+
namespaces: { ...createCoreExpressionNamespaces(form), pricing: external.provider },
|
|
140
|
+
authorize: () => allowed,
|
|
141
|
+
});
|
|
142
|
+
render(service);
|
|
143
|
+
const retained = latest.setters.value;
|
|
144
|
+
act(() => {
|
|
145
|
+
allowed = false;
|
|
146
|
+
service.invalidateAuthorization();
|
|
147
|
+
});
|
|
148
|
+
expect(input().value).toBe("");
|
|
149
|
+
expect(container.querySelector("section")?.dataset.label).toBeUndefined();
|
|
150
|
+
expect(retained(7)).toEqual(failure("denied"));
|
|
151
|
+
edit("8");
|
|
152
|
+
expect(form.getState().data.quantity).toBe(2);
|
|
153
|
+
act(() => {
|
|
154
|
+
allowed = true;
|
|
155
|
+
service.invalidateAuthorization();
|
|
156
|
+
});
|
|
157
|
+
expect(retained(9)).toEqual(failure("stale"));
|
|
158
|
+
const afterGrant = latest.setters.value;
|
|
159
|
+
act(() => root.render(null));
|
|
160
|
+
expect(external.listeners.size).toBe(0);
|
|
161
|
+
expect(afterGrant(9)).toEqual(failure("stale"));
|
|
162
|
+
service.dispose();
|
|
163
|
+
form.dispose();
|
|
164
|
+
});
|
|
165
|
+
it("rebinds definitions and service without render-created observer leaks or former-target writes", () => {
|
|
166
|
+
const external = namespace({ fee: 3, label: "First" });
|
|
167
|
+
const first = createForm({ initialData: { quantity: 2, unitPrice: 10 }, initialUiState: { locked: false } });
|
|
168
|
+
const second = createForm({ initialData: { quantity: 8, unitPrice: 10 }, initialUiState: { locked: false } });
|
|
169
|
+
const service = createExpressionService({
|
|
170
|
+
namespaces: { ...createCoreExpressionNamespaces(first), pricing: external.provider },
|
|
171
|
+
});
|
|
172
|
+
const replacement = createExpressionService({
|
|
173
|
+
namespaces: { ...createCoreExpressionNamespaces(second), pricing: external.provider },
|
|
174
|
+
});
|
|
175
|
+
render(service);
|
|
176
|
+
const old = latest.setters.value;
|
|
177
|
+
render(service, { ...definitions, value: { mode: "write", expression: ref("unitPrice") } });
|
|
178
|
+
expect(old(9)).toEqual(failure("stale"));
|
|
179
|
+
expect(input().value).toBe("10");
|
|
180
|
+
const rebound = latest.setters.value;
|
|
181
|
+
render(replacement);
|
|
182
|
+
expect(rebound(9)).toEqual(failure("stale"));
|
|
183
|
+
expect(input().value).toBe("8");
|
|
184
|
+
expect(external.listeners.size).toBe(1);
|
|
185
|
+
act(() => {
|
|
186
|
+
first.setValue("quantity", 99);
|
|
187
|
+
});
|
|
188
|
+
expect(input().value).toBe("8");
|
|
189
|
+
edit("7");
|
|
190
|
+
expect(second.getState().data.quantity).toBe(7);
|
|
191
|
+
act(() => root.render(null));
|
|
192
|
+
expect(external.listeners.size).toBe(0);
|
|
193
|
+
service.dispose();
|
|
194
|
+
replacement.dispose();
|
|
195
|
+
first.dispose();
|
|
196
|
+
second.dispose();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -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/index.ts
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ExpressionService, PropDefinitions, ResolvedProps } from "@formbar/expressions";
|
|
2
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */
|
|
5
|
+
export function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {
|
|
6
|
+
const binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);
|
|
7
|
+
return useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);
|
|
8
|
+
}
|
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;
|