diff --git a/examples/react/custom-component-wrapper/.eslintrc.cjs b/examples/react/custom-component-wrapper/.eslintrc.cjs new file mode 100644 index 000000000..35853b617 --- /dev/null +++ b/examples/react/custom-component-wrapper/.eslintrc.cjs @@ -0,0 +1,11 @@ +// @ts-check + +/** @type {import('eslint').Linter.Config} */ +const config = { + extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'], + rules: { + 'react/no-children-prop': 'off', + }, +} + +module.exports = config diff --git a/examples/react/custom-component-wrapper/.gitignore b/examples/react/custom-component-wrapper/.gitignore new file mode 100644 index 000000000..4673b022e --- /dev/null +++ b/examples/react/custom-component-wrapper/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +pnpm-lock.yaml +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/custom-component-wrapper/README.md b/examples/react/custom-component-wrapper/README.md new file mode 100644 index 000000000..1cf889265 --- /dev/null +++ b/examples/react/custom-component-wrapper/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/react/custom-component-wrapper/index.html b/examples/react/custom-component-wrapper/index.html new file mode 100644 index 000000000..5d0e76cd4 --- /dev/null +++ b/examples/react/custom-component-wrapper/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Form React Simple Example App + + + +
+ + + diff --git a/examples/react/custom-component-wrapper/package.json b/examples/react/custom-component-wrapper/package.json new file mode 100644 index 000000000..16d4e22ff --- /dev/null +++ b/examples/react/custom-component-wrapper/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tanstack/form-example-react-custom-component-wrapper", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/react-form": "^0.26.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.3" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/custom-component-wrapper/public/emblem-light.svg b/examples/react/custom-component-wrapper/public/emblem-light.svg new file mode 100644 index 000000000..a58e69ad5 --- /dev/null +++ b/examples/react/custom-component-wrapper/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/custom-component-wrapper/src/index.tsx b/examples/react/custom-component-wrapper/src/index.tsx new file mode 100644 index 000000000..7bc91d2e4 --- /dev/null +++ b/examples/react/custom-component-wrapper/src/index.tsx @@ -0,0 +1,132 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { useForm } from '@tanstack/react-form' +import type { + DeepKeyValueName, + FieldOptions, + ReactFormApi, +} from '@tanstack/react-form' + +/** + * Export these components to your design system or a dedicated component location + */ +interface TextInputFieldProps< + TFormData extends unknown, + TName extends DeepKeyValueName, +> extends FieldOptions { + form: ReactFormApi + // Your custom props + label: string +} + +function TextInputField< + TFormData extends unknown, + TName extends DeepKeyValueName, +>({ form, name, label, ...fieldProps }: TextInputFieldProps) { + return ( + // Manually type-cast form.Field to work around this issue: + // https://twitter.com/crutchcorn/status/1809827621485900049 + + {...fieldProps} + name={name} + children={(field) => { + return ( +
+
+ +
+ field.handleChange(e.target.value)} + /> + {field.state.meta.isValidating ? ( +
Validating...
+ ) : null} + {field.state.meta.isTouched && field.state.meta.errors.length ? ( +
+ {field.state.meta.errors.join(', ')} +
+ ) : null} +
+ ) + }} + /> + ) +} + +function SubmitButton({ form }: { form: ReactFormApi }) { + return ( + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + <> + + + )} + /> + ) +} + +/** + * Then use it in your application + */ +export default function App() { + const form = useForm({ + defaultValues: { + firstName: '', + age: 0, + }, + onSubmit: async ({ value }) => { + // Do something with form data + console.log(value) + }, + }) + + return ( +
+

Wrapped Fields Form Example

+
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + {/* A type-safe, wrapped field component*/} + { + await new Promise((resolve) => setTimeout(resolve, 1000)) + if (value.length < 3) { + return 'Name must be at least 3 characters long' + } + return undefined + }, + }} + /> + {/* Correctly throws a warning when the wrong data type is passed */} + + + + +
+ ) +} + +const rootElement = document.getElementById('root')! + +createRoot(rootElement).render( + + + , +) diff --git a/examples/react/custom-component-wrapper/tsconfig.json b/examples/react/custom-component-wrapper/tsconfig.json new file mode 100644 index 000000000..666a0ea71 --- /dev/null +++ b/examples/react/custom-component-wrapper/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "jsx": "react", + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"] + } +} diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index 8a9a107cd..e00642173 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -111,7 +111,6 @@ export type DeepValue< TValue, // A string representing the path of the property we're trying to access TAccessor, - TNullable extends boolean = IsNullable, > = // If TValue is any it will recurse forever, this terminates the recursion unknown extends TValue @@ -138,9 +137,21 @@ export type DeepValue< : TAccessor extends `${infer TBefore}.${infer TAfter}` ? DeepValue, TAfter> : TAccessor extends string - ? TNullable extends true + ? IsNullable extends true ? Nullable : TValue[TAccessor] : never : // Do not allow `TValue` to be anything else never + +type SelfKeys = { + [K in keyof T]: K +}[keyof T] + +// Utility type to narrow allowed TName values to only specific types +// IE: DeepKeyValueName<{ foo: string; bar: number }, string> = 'foo' +export type DeepKeyValueName = SelfKeys<{ + [K in DeepKeys as DeepValue extends TField + ? K + : never]: K +}> diff --git a/packages/form-core/tests/util-types.test-d.ts b/packages/form-core/tests/util-types.test-d.ts index cf692c845..8870709f5 100644 --- a/packages/form-core/tests/util-types.test-d.ts +++ b/packages/form-core/tests/util-types.test-d.ts @@ -1,5 +1,5 @@ import { assertType } from 'vitest' -import type { DeepKeys, DeepValue } from '../src/index' +import { DeepKeys, DeepKeyValueName, DeepValue } from '../src/index' /** * Properly recognizes that `0` is not an object and should not have subkeys @@ -169,3 +169,30 @@ type DoubleDeepArray = DeepValue< > assertType(0 as never as DoubleDeepArray) + +type FooBarOther = { + foo: string + bar: string + other: number +} + +type StringFromFooBar = DeepKeyValueName + +assertType<'foo' | 'bar'>(0 as never as StringFromFooBar) + +type DeepFooBarOther = { + foo: string + bar: string + other: number + one: { + foo: string + bar: string + other: number + } +} + +type StringFromDeepFooBar = DeepKeyValueName + +assertType<'foo' | 'bar' | 'one.foo' | 'one.bar'>( + 0 as never as StringFromDeepFooBar, +) diff --git a/packages/react-form/src/index.ts b/packages/react-form/src/index.ts index e35e2c602..80234f658 100644 --- a/packages/react-form/src/index.ts +++ b/packages/react-form/src/index.ts @@ -1,6 +1,6 @@ export * from '@tanstack/form-core' -export { useForm } from './useForm' +export { useForm, type ReactFormApi } from './useForm' export type { UseField, FieldComponent } from './useField' export { useField, Field } from './useField' diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 9bb8c77a1..25401f86e 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -5,18 +5,6 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { NodeType, UseFieldOptions } from './types' import type { DeepKeys, DeepValue, Validator } from '@tanstack/form-core' -interface ReactFieldApi< - TParentData, - TFormValidator extends - | Validator - | undefined = undefined, -> { - /** - * A pre-bound and type-safe sub-field component using this field as a root. - */ - Field: FieldComponent -} - /** * A type representing a hook for using a field in a form with the given form data type. * @@ -66,18 +54,11 @@ export function useField< >, ) { const [fieldApi] = useState(() => { - const api = new FieldApi({ + return new FieldApi({ ...opts, form: opts.form, name: opts.name, }) - - const extendedApi: typeof api & ReactFieldApi = - api as never - - extendedApi.Field = Field as never - - return extendedApi }) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 173a505ad..cab1f19cc 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -10,7 +10,7 @@ import type { NodeType } from './types' /** * Fields that are added onto the `FormAPI` from `@tanstack/form-core` and returned from `useForm` */ -interface ReactFormApi< +export interface ReactFormApi< TFormData, TFormValidator extends Validator | undefined = undefined, > { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3445fdcd..8a2b80ebb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -437,6 +437,31 @@ importers: specifier: ^5.3.3 version: 5.3.3(@types/node@20.14.10)(less@4.2.0)(sass@1.72.0)(sugarss@4.0.1(postcss@8.4.39))(terser@5.29.1) + examples/react/custom-component-wrapper: + dependencies: + '@tanstack/react-form': + specifier: ^0.26.1 + version: link:../../../packages/react-form + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.3 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.3.1(vite@5.3.3(@types/node@20.10.6)(less@4.2.0)(sass@1.72.0)(sugarss@4.0.1(postcss@8.4.39))(terser@5.29.1)) + vite: + specifier: ^5.3.3 + version: 5.3.3(@types/node@20.10.6)(less@4.2.0)(sass@1.72.0)(sugarss@4.0.1(postcss@8.4.39))(terser@5.29.1) + examples/react/next-server-actions: dependencies: '@tanstack/react-form':