oxform-react 0.1.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.
@@ -0,0 +1,3 @@
1
+ export { useField } from '#use-field';
2
+ export type { UseFieldReturn } from '#use-field';
3
+ export { useForm } from '#use-form';
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "oxform-react",
3
+ "version": "0.1.0",
4
+ "description": "Oxform is a lean and flexible framework-agnostic form library.",
5
+ "author": "Frantss <frantss.bongiovanni@gmail.com>",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "imports": {
11
+ "#*": [
12
+ "./src/*.ts",
13
+ "./src/**/*.ts"
14
+ ]
15
+ },
16
+ "exports": {
17
+ ".": "./dist/index.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "scripts": {
23
+ "build": "tsdown --config-loader unrun",
24
+ "test": "vitest",
25
+ "lint:types": "tsc --noEmit"
26
+ },
27
+ "dependencies": {
28
+ "@standard-schema/spec": "catalog:oxform",
29
+ "@tanstack/react-store": "^0.8.0",
30
+ "oxform-core": "workspace:*"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19.2.7",
34
+ "@types/react-dom": "^19.2.3",
35
+ "@vitejs/plugin-react": "^5.1.2",
36
+ "react": "19.2.3",
37
+ "react-dom": "19.2.3",
38
+ "tsdown": "catalog:oxform",
39
+ "typescript": "catalog:oxform",
40
+ "vitest": "catalog:oxform",
41
+ "vitest-browser-react": "^2.0.2",
42
+ "zod": "^4.2.1"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
46
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
47
+ }
48
+ }
package/readme.md ADDED
@@ -0,0 +1,12 @@
1
+ # Oxform React
2
+
3
+ This package contains the React integration for Oxform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install oxform-react
9
+ yarn add oxform-react
10
+ pnpm add oxform-react
11
+ bun add oxform-react
12
+ ```
@@ -0,0 +1,59 @@
1
+ // reference: https://stevekinney.com/courses/react-performance/performance-testing-strategy
2
+
3
+ import { Profiler, type ProfilerOnRenderCallback } from 'react';
4
+ import { expect } from 'vitest';
5
+ import { render } from 'vitest-browser-react';
6
+
7
+ interface RenderMetrics {
8
+ renderTime: number;
9
+ commitTime: number;
10
+ actualDuration: number;
11
+ baseDuration: number;
12
+ startTime: number;
13
+ }
14
+
15
+ export function measureRenderPerformance(component: React.ReactElement, testName: string): Promise<RenderMetrics> {
16
+ return new Promise(resolve => {
17
+ let metrics: RenderMetrics;
18
+
19
+ const onRender: ProfilerOnRenderCallback = (_, phase, actualDuration, baseDuration, startTime, commitTime) => {
20
+ if (phase === 'mount' || phase === 'update') {
21
+ metrics = {
22
+ renderTime: actualDuration,
23
+ commitTime,
24
+ actualDuration,
25
+ baseDuration,
26
+ startTime,
27
+ };
28
+ }
29
+ };
30
+
31
+ void render(
32
+ <Profiler id={testName} onRender={onRender}>
33
+ {component}
34
+ </Profiler>,
35
+ );
36
+
37
+ setTimeout(() => resolve(metrics), 0);
38
+ });
39
+ }
40
+
41
+ expect.extend({
42
+ toRenderWithinTime(received: RenderMetrics, maxTime: number) {
43
+ const pass = received.renderTime <= maxTime;
44
+
45
+ return {
46
+ message: () => `Expected component to render in ${maxTime}ms, but took ${received.renderTime.toFixed(2)}ms`,
47
+ pass,
48
+ };
49
+ },
50
+
51
+ toHaveMaxReRenders(received: number, maxRenders: number) {
52
+ const pass = received <= maxRenders;
53
+
54
+ return {
55
+ message: () => `Expected component to re-render at most ${maxRenders} times, but re-rendered ${received} times`,
56
+ pass,
57
+ };
58
+ },
59
+ });
@@ -0,0 +1,116 @@
1
+ import { useField } from '#use-field';
2
+ import { FormApi } from 'oxform-core';
3
+ import 'react';
4
+ import { expect, it, test } from 'vitest';
5
+ import { render } from 'vitest-browser-react';
6
+ import { userEvent } from 'vitest/browser';
7
+ import { z } from 'zod';
8
+
9
+ const setup = async () => {
10
+ const form = new FormApi({
11
+ schema: z.object({
12
+ name: z.string(),
13
+ nested: z.object({
14
+ deep: z.object({
15
+ deeper: z.string().array(),
16
+ }),
17
+ }),
18
+ }),
19
+ defaultValues: {
20
+ name: 'John',
21
+ nested: {
22
+ deep: {
23
+ deeper: ['hello'],
24
+ },
25
+ },
26
+ },
27
+ });
28
+
29
+ const Component = () => {
30
+ const field = useField({ form, name: 'name' });
31
+
32
+ return (
33
+ <>
34
+ <input {...field.props} />
35
+ <button type='button'>outside</button>
36
+ </>
37
+ );
38
+ };
39
+
40
+ const utils = await render(<Component />);
41
+
42
+ const ui = {
43
+ input: utils.getByRole('textbox'),
44
+ outside: utils.getByRole('button'),
45
+ };
46
+
47
+ return { utils, ui, form };
48
+ };
49
+
50
+ it("should update the form's values on change", async () => {
51
+ const { form, ui } = await setup();
52
+
53
+ await userEvent.clear(ui.input);
54
+ await userEvent.type(ui.input, 'Jane');
55
+
56
+ expect(form.store.state.values.name).toBe('Jane');
57
+ });
58
+
59
+ it('should mark the field as touched on focus', async () => {
60
+ const { form, ui } = await setup();
61
+
62
+ await ui.input.click();
63
+
64
+ const meta = form.store.state.fields['name'];
65
+
66
+ expect(meta).toBeDefined();
67
+ expect(meta.touched).toBe(true);
68
+ });
69
+
70
+ it('should mark the fields as blurred on blur', async () => {
71
+ const { form, ui } = await setup();
72
+
73
+ await ui.input.click();
74
+ await ui.outside.click(); // blur the input
75
+
76
+ const meta = form.store.state.fields['name'];
77
+
78
+ expect(meta).toBeDefined();
79
+ expect(meta.blurred).toBe(true);
80
+ });
81
+
82
+ it('should keep the field as pristine when field is not dirty', async () => {
83
+ const { form, ui } = await setup();
84
+
85
+ await ui.input.click();
86
+ await ui.outside.click(); // blur the input
87
+
88
+ const meta = form.store.state.fields['name'];
89
+
90
+ expect(meta).toBeDefined();
91
+ expect(meta.pristine).toBe(true);
92
+ });
93
+
94
+ test('default is true when value is equal to default value', async () => {
95
+ const { form, ui } = await setup();
96
+
97
+ await userEvent.clear(ui.input);
98
+ await userEvent.type(ui.input, 'Jane');
99
+ await userEvent.clear(ui.input);
100
+ await userEvent.type(ui.input, 'John');
101
+
102
+ const meta = form.store.state.fields['name'];
103
+
104
+ expect(meta.default).toBe(true);
105
+ });
106
+
107
+ test('default is false when value is not equal to default value', async () => {
108
+ const { form, ui } = await setup();
109
+
110
+ await userEvent.clear(ui.input);
111
+ await userEvent.type(ui.input, 'Jane');
112
+
113
+ const meta = form.store.state.fields['name'];
114
+
115
+ expect(meta.default).toBe(false);
116
+ });
@@ -0,0 +1,81 @@
1
+ import type { FieldMeta, FormIssue, FormResetFieldOptions, FormSetErrorsOptions, ValidateOptions } from 'oxform-core';
2
+ import { FieldApi, type FieldOptions, type FieldProps } from 'oxform-core';
3
+
4
+ import { useIsomorphicLayoutEffect } from '#use-isomorphic-layout-effect';
5
+ import { useStore } from '@tanstack/react-store';
6
+ import type { DeepKeys, DeepValue, EventLike } from 'oxform-core';
7
+ import type { StandardSchema } from 'oxform-core/schema';
8
+ import { useCallback, useMemo, useState } from 'react';
9
+
10
+ export type UseFieldReturn<Value> = {
11
+ api: FieldApi<any, any, Value>;
12
+ value: Value;
13
+ change: (value: Value) => void;
14
+ blur: () => void;
15
+ focus: () => void;
16
+ register: () => void;
17
+ validate: (options?: ValidateOptions) => Promise<FormIssue[]>;
18
+ reset: (options?: FormResetFieldOptions<Value>) => void;
19
+ setErrors: (errors: FormIssue[], options?: FormSetErrorsOptions) => void;
20
+ props: FieldProps<Value>;
21
+ meta: FieldMeta;
22
+ errors: FormIssue[];
23
+ };
24
+
25
+ export const useField = <Schema extends StandardSchema, Name extends DeepKeys<StandardSchema.InferInput<Schema>>>(
26
+ options: FieldOptions<Schema, Name>,
27
+ ) => {
28
+ type Value = DeepValue<StandardSchema.InferInput<Schema>, Name>;
29
+
30
+ const [api] = useState(() => {
31
+ return new FieldApi({ ...options });
32
+ });
33
+
34
+ useIsomorphicLayoutEffect(api['~mount'], [api]);
35
+ useIsomorphicLayoutEffect(() => {
36
+ api['~update'](options);
37
+ });
38
+
39
+ const value = useStore(api.store, state => state.value);
40
+ const defaultValue = useStore(api.store, state => state.defaultValue);
41
+ const dirty = useStore(api.store, state => state.meta.dirty);
42
+ const touched = useStore(api.store, state => state.meta.touched);
43
+ const blurred = useStore(api.store, state => state.meta.blurred);
44
+ const pristine = useStore(api.store, state => state.meta.pristine);
45
+ const valid = useStore(api.store, state => state.meta.valid);
46
+ const isDefault = useStore(api.store, state => state.meta.default);
47
+ const errors = useStore(api.store, state => state.errors);
48
+
49
+ const onChange = useCallback((event: EventLike) => api.change(event.target?.value), [api]);
50
+
51
+ return useMemo(() => {
52
+ return {
53
+ api,
54
+ value,
55
+ blur: api.blur,
56
+ focus: api.focus,
57
+ change: api.change,
58
+ register: api.register,
59
+ validate: api.validate,
60
+ reset: api.reset,
61
+ setErrors: api.setErrors,
62
+ errors,
63
+ props: {
64
+ value,
65
+ defaultValue,
66
+ ref: api.register(),
67
+ onBlur: api.blur,
68
+ onFocus: api.focus,
69
+ onChange,
70
+ },
71
+ meta: {
72
+ dirty,
73
+ touched,
74
+ blurred,
75
+ pristine,
76
+ valid,
77
+ default: isDefault,
78
+ },
79
+ } satisfies UseFieldReturn<Value> as UseFieldReturn<Value>;
80
+ }, [api, errors, value, defaultValue, onChange, dirty, touched, blurred, pristine, valid, isDefault]);
81
+ };
@@ -0,0 +1,28 @@
1
+ import { useStore } from '@tanstack/react-store';
2
+ import type { FormApi, FormStatus } from 'oxform-core';
3
+ import type { StandardSchema } from 'oxform-core/schema';
4
+ import { useMemo } from 'react';
5
+
6
+ export type UseFormStatusReturn = FormStatus;
7
+
8
+ export const useFormStatus = <Schema extends StandardSchema>({ form }: { form: FormApi<Schema> }) => {
9
+ const dirty = useStore(form.store, state => state.status.dirty);
10
+ const valid = useStore(form.store, state => state.status.valid);
11
+ const submitting = useStore(form.store, state => state.status.submitting);
12
+ const successful = useStore(form.store, state => state.status.successful);
13
+ const validating = useStore(form.store, state => state.status.validating);
14
+ const submits = useStore(form.store, state => state.status.submits);
15
+ const submitted = useStore(form.store, state => state.status.submitted);
16
+
17
+ return useMemo(() => {
18
+ return {
19
+ dirty,
20
+ valid,
21
+ submitting,
22
+ successful,
23
+ validating,
24
+ submits,
25
+ submitted,
26
+ } satisfies UseFormStatusReturn as UseFormStatusReturn;
27
+ }, [dirty, valid, submitting, successful, validating, submits, submitted]);
28
+ };
@@ -0,0 +1,20 @@
1
+ import { useIsomorphicLayoutEffect } from '#use-isomorphic-layout-effect';
2
+ import type { FormOptions } from 'oxform-core';
3
+ import { FormApi } from 'oxform-core';
4
+ import type { StandardSchema } from 'oxform-core/schema';
5
+ import { useState } from 'react';
6
+
7
+ export type UseFormReturn<Schema extends StandardSchema> = FormApi<Schema>;
8
+
9
+ export const useForm = <Schema extends StandardSchema>(options: FormOptions<Schema>) => {
10
+ const [api] = useState(() => {
11
+ return new FormApi({ ...options });
12
+ });
13
+
14
+ useIsomorphicLayoutEffect(api['~mount'], [api]);
15
+ useIsomorphicLayoutEffect(() => {
16
+ api['~update'](options);
17
+ });
18
+
19
+ return api satisfies UseFormReturn<Schema> as UseFormReturn<Schema>;
20
+ };
@@ -0,0 +1,3 @@
1
+ import { useEffect, useLayoutEffect } from 'react';
2
+
3
+ export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx"
5
+ }
6
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'tsdown';
2
+ import base from '../../tsdown.config';
3
+
4
+ export default defineConfig({
5
+ ...base,
6
+ entry: {
7
+ index: 'export/index.ts',
8
+ },
9
+ });
@@ -0,0 +1,10 @@
1
+ import react from '@vitejs/plugin-react';
2
+ import { defineConfig, mergeConfig } from 'vitest/config';
3
+ import base from '../../vitest.config';
4
+
5
+ export default mergeConfig(
6
+ base,
7
+ defineConfig({
8
+ plugins: [react()],
9
+ }),
10
+ );