diff --git a/apps/web/app/routes/examples.tsx b/apps/web/app/routes/examples.tsx index f2313ef..23f35e9 100644 --- a/apps/web/app/routes/examples.tsx +++ b/apps/web/app/routes/examples.tsx @@ -94,6 +94,9 @@ export default function Component() { useFormState + + useField + Multiple forms diff --git a/apps/web/app/routes/examples/forms/use-field.tsx b/apps/web/app/routes/examples/forms/use-field.tsx new file mode 100644 index 0000000..5b23b8c --- /dev/null +++ b/apps/web/app/routes/examples/forms/use-field.tsx @@ -0,0 +1,84 @@ +import type { + ActionFunction, + LoaderFunction, + MetaFunction, +} from '@remix-run/node' +import { makeDomainFunction } from 'domain-functions' +import hljs from 'highlight.js/lib/common' +import * as React from 'react' +import { useField } from 'remix-forms' +import { z } from 'zod' +import { formAction } from '~/formAction' +import { cx, metaTags } from '~/helpers' +import Example from '~/ui/example' +import Form from '~/ui/form' + +const title = 'useField' +const description = `In this example, we use the useField hook to display error, dirty and required indicators in custom components.` + +export const meta: MetaFunction = () => metaTags({ title, description }) + +const code = `const schema = z.object({ + email: z.string().email(), +}) + +const Input = React.forwardRef< + HTMLInputElement, + JSX.IntrinsicElements['input'] +>(({ type = 'text', ...props }, ref) => { + const { errors } = useField() + return ( + + ) +}) + +export default () => ( +
+)` + +const schema = z.object({ + email: z.string().email(), +}) + +export const loader: LoaderFunction = () => ({ + code: hljs.highlight(code, { language: 'ts' }).value, +}) + +const mutation = makeDomainFunction(schema)(async (values) => values) + +export const action: ActionFunction = async ({ request }) => + formAction({ request, schema, mutation }) + +const Input = React.forwardRef< + HTMLInputElement, + JSX.IntrinsicElements['input'] +>(({ type = 'text', ...props }, ref) => { + const { errors } = useField() + return ( + + ) +}) + +export default () => ( + + + +) diff --git a/apps/web/tests/examples/forms/use-field.spec.ts b/apps/web/tests/examples/forms/use-field.spec.ts new file mode 100644 index 0000000..722c2f4 --- /dev/null +++ b/apps/web/tests/examples/forms/use-field.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from 'tests/setup/tests' + +const route = '/examples/forms/use-field' + +test('With JS enabled', async ({ example }) => { + const { email, button, page } = example + + await page.goto(route) + + // Render + await example.expectField(email, { + label: 'Email', + }) + await expect(button).toBeEnabled() + + // Fill in an invalid email address + await email.input.fill('john@doe') + + // Client-side validation + await button.click() + + // Show fields that are invalid using class + const invalidClass = 'border-red-600 focus:border-red-600 focus:ring-red-600' + + expect(await email.input.getAttribute('class')).toContain(invalidClass) + + // Fill in a valid email + await email.input.fill('default@domain.tld') + + expect(await email.input.getAttribute('class')).not.toContain(invalidClass) +}) diff --git a/packages/remix-forms/src/createField.tsx b/packages/remix-forms/src/createField.tsx index 90ea50d..c0ff735 100644 --- a/packages/remix-forms/src/createField.tsx +++ b/packages/remix-forms/src/createField.tsx @@ -140,6 +140,18 @@ type SmartInputProps = { a11yProps?: Record<`aria-${string}`, string | boolean | undefined> } +const FieldContext = React.createContext< + Partial, 'name'>> | undefined +>(undefined) + +export function useField() { + const context = React.useContext(FieldContext) + + if (!context) throw new Error('useField used outside of field context') + + return context +} + const makeSelectOption = ({ name, value }: Option) => (