diff --git a/apps/docs-v2/app/routes/_docs.input-types.mdx b/apps/docs-v2/app/routes/_docs.input-types.mdx index 8552a5f5..698b3c0e 100644 --- a/apps/docs-v2/app/routes/_docs.input-types.mdx +++ b/apps/docs-v2/app/routes/_docs.input-types.mdx @@ -46,16 +46,16 @@ that you would get out of `form.value("myField")`. ## Number inputs +### Relevant types + +- `NumberInputValue` + - `number | null` + ### Setting default values - - Number inputs can be set using either a `number` or a `string`, - and both approaches can be used for different fields in the same form. - - When you access the value of the input using `form.value("age")`, the type - of that value will be consistent with the type of the default value, even after - the user has changed the value. - - If there is no default value, the type of the value will be a `number`. + Number inputs can be set using either a `number`, a `string`, or `null`. ```tsx @@ -75,6 +75,12 @@ that you would get out of `form.value("myField")`. +### Observing / setting values + +- An empty number input will always have a value of `null`. +- If you set the default value using a `string`, the value will always be a `string` (or `null`). +- If you set the default value using a `number` or `null`, the value will always be a `number` (or `null`). + ### Validating Like most input types, the value inside the native `form` element is always a string. diff --git a/apps/docs-v2/app/routes/_docs.recipes.typesafe-input.mdx b/apps/docs-v2/app/routes/_docs.recipes.typesafe-input.mdx new file mode 100644 index 00000000..6fb9327f --- /dev/null +++ b/apps/docs-v2/app/routes/_docs.recipes.typesafe-input.mdx @@ -0,0 +1 @@ +# Typesafe input component diff --git a/apps/docs-v2/app/routes/_docs.scoping.mdx b/apps/docs-v2/app/routes/_docs.scoping.mdx index 8f6025de..3c7576fd 100644 --- a/apps/docs-v2/app/routes/_docs.scoping.mdx +++ b/apps/docs-v2/app/routes/_docs.scoping.mdx @@ -19,12 +19,12 @@ code in this demo. When you call `useForm`, it returns a [`ReactFormApi`](/reference/form-api) object. - The type of this object takes a single generic paramter, which is the type of the default values. + The type of this object takes a single generic parameter, which is the type of the form's default values. ```tsx // `form` has the type `ReactFormApi<{ foo: string }>` - const form = useForm({ + const form = useForm({ defaultValues: { foo: "bar" }, // ...etc }); @@ -35,13 +35,16 @@ code in this demo. One of the methods on `ReactFormApi` is `scope`. - When you call `scope` and pass it the name of a field, it returns a `FormScope` - where `FieldType` is the type of the field. + When you call `scope` and pass it the name of a field, it returns a `FormScope` + that's been scoped to that field. You can even continue chaining `scope` calls to get even deeper, which is useful for deeply nested or recursive data. + + Once you have a `FormScope`, you can pass it around to any component that needs it. + But in order to actually use it, you need to pass it to a `useFormScope`, `useField`, or `useFieldArray` hook. ```tsx - const form = useForm({ + const form = useForm({ defaultValues: { foo: "foo", bar: { baz: "baz" } @@ -61,3 +64,48 @@ code in this demo. + +## Typesafe text input + + + + Scoping allows us to encapsulate input logic in a typesafe way. + For this example, let's create an text input component that: + + - Is guranteed by the types to have a `string` value. + - Automatically shows the validation error message if there is one. + - Can be used without having to think about whether to use `getInputProps` or `getControlProps`. + + We can do this by accepting a `FormScope<string>` as a prop + and passing it to `useField`. + + + + ```tsx + type MyInputProps = { + scope: FormScope; + label: string; + }; + + const MyInput = ({ scope, label }: MyInputProps) => { + const field = useField(scope); + return ( +
+ + {/* There's more work to be done here for accesibility, + but we're keeping it simple for now. */} + {field.error() && {field.error()}} +
+ ); + } + ``` + + +
+ +This particular input component can only handle text inputs, +but it's also possible to create a typesafe input component that can handle any +`input` type. Checkout the [typesafe input component recipe](/recipes/typesafe-input) for more details. diff --git a/apps/docs-v2/app/ui/layout/Navigation.tsx b/apps/docs-v2/app/ui/layout/Navigation.tsx index 769cc71f..3b598a91 100644 --- a/apps/docs-v2/app/ui/layout/Navigation.tsx +++ b/apps/docs-v2/app/ui/layout/Navigation.tsx @@ -240,6 +240,12 @@ export const navigation: Array = [ { title: "Different input types", href: "/input-types" }, ], }, + { + title: "Recipes", + links: [ + { title: "Typesafe input component", href: "/recipes/typesafe-input" }, + ], + }, ]; export function Navigation({ diff --git a/packages/core/src/input-types.ts b/packages/core/src/input-types.ts index d9850b5c..89c08d6b 100644 --- a/packages/core/src/input-types.ts +++ b/packages/core/src/input-types.ts @@ -9,3 +9,16 @@ export type SingleFileInputValue = null | File; * Empty file inputs are represented by an empty string. */ export type MultiFileInputValue = null | File[]; + +export type NumberInputValue = number | null; + +export type NativeValueByType = { + text: string; + number: NumberInputValue; + checkbox: boolean | string | string[]; + radio: string; + file: null | File | File[]; +}; + +export type NativeInputValue = + Type extends keyof NativeValueByType ? NativeValueByType[Type] : string; diff --git a/packages/core/src/type.test.ts b/packages/core/src/type.test.ts new file mode 100644 index 00000000..8b6b3a32 --- /dev/null +++ b/packages/core/src/type.test.ts @@ -0,0 +1,20 @@ +import { NativeInputValue } from "./input-types"; + +it("should give specific types for inputs with special handling", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + boolean | string | string[] + >(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + File | File[] | null + >(); +}); + +it("should just return string for every other type", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index bb46da37..eeb66f3d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -20,6 +20,9 @@ export { isValidationErrorResponse, type SingleFileInputValue, type MultiFileInputValue, + type NumberInputValue, + type NativeValueByType, + type NativeInputValue, } from "@rvf/core"; export { type ReactFormApi, type FormFields } from "./base"; export { useForm, FormOpts } from "./useForm"; diff --git a/packages/react/src/test/input-types.test.tsx b/packages/react/src/test/input-types.test.tsx index 616bcbf7..bf56b24c 100644 --- a/packages/react/src/test/input-types.test.tsx +++ b/packages/react/src/test/input-types.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { useForm } from "../useForm"; import { successValidator } from "./util/successValidator"; import userEvent from "@testing-library/user-event"; +import { ZeroCurvatureEnding } from "three"; describe("number inputs", () => { it("default values set as numbers", async () => { @@ -29,6 +30,10 @@ describe("number inputs", () => { await userEvent.type(screen.getByTestId("age"), "4"); expect(screen.getByTestId("age")).toHaveValue(254); expect(screen.getByTestId("age-value").textContent).toEqual("254"); + + await userEvent.clear(screen.getByTestId("age")); + expect(screen.getByTestId("age")).toHaveValue(null); + expect(screen.getByTestId("age-value").textContent).toEqual("null"); }); it("no default value", async () => { @@ -87,6 +92,37 @@ describe("number inputs", () => { await userEvent.type(screen.getByTestId("age"), "4"); expect(screen.getByTestId("age")).toHaveValue(254); expect(screen.getByTestId("age-value").textContent).toEqual('"254"'); + + await userEvent.clear(screen.getByTestId("age")); + expect(screen.getByTestId("age")).toHaveValue(null); + expect(screen.getByTestId("age-value").textContent).toEqual("null"); + }); + + it("null default", async () => { + const TestComp = () => { + const form = useForm({ + defaultValues: { age: null }, + validator: successValidator, + }); + + return ( +
+ +
{JSON.stringify(form.value("age"))}
+
+ ); + }; + render(); + + expect(screen.getByTestId("age")).toHaveValue(null); + expect(screen.getByTestId("age-value").textContent).toEqual("null"); + + await userEvent.type(screen.getByTestId("age"), "4"); + expect(screen.getByTestId("age")).toHaveValue(4); + expect(screen.getByTestId("age-value").textContent).toEqual("4"); }); }); diff --git a/packages/remix/src/index.ts b/packages/remix/src/index.ts index 54ff3f3b..92468bdb 100644 --- a/packages/remix/src/index.ts +++ b/packages/remix/src/index.ts @@ -37,6 +37,9 @@ export { useNativeValidity, type SingleFileInputValue, type MultiFileInputValue, + type NumberInputValue, + type NativeValueByType, + type NativeInputValue, } from "@rvf/react"; export { useForm, type RemixFormOpts as FormScopeRemixOpts } from "./useForm"; export { ValidatedForm, type ValidatedFormProps } from "./ValidatedForm";