Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wrap fields with context to expose a useField hook #133

Merged
merged 5 commits into from
Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/app/routes/examples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export default function Component() {
<SidebarLayout.NavLink to={'/examples/forms/use-form-state'}>
useFormState
</SidebarLayout.NavLink>
<SidebarLayout.NavLink to={'/examples/forms/use-field'}>
useField
</SidebarLayout.NavLink>
<SidebarLayout.NavLink to={'/examples/forms/multiple-forms'}>
Multiple forms
</SidebarLayout.NavLink>
Expand Down
84 changes: 84 additions & 0 deletions apps/web/app/routes/examples/forms/use-field.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<input
ref={ref}
type={type}
className={errors
? 'border-red-600 focus:border-red-600 focus:ring-red-600'
: 'border-gray-300 focus:border-pink-500 focus:ring-pink-500',
}
{...props}
/>
)
})

export default () => (
<Form schema={schema} inputComponent={Input} />
)`

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 (
<input
ref={ref}
type={type}
className={cx(
'block w-full rounded-md text-gray-800 shadow-sm sm:text-sm',
errors
? 'border-red-600 focus:border-red-600 focus:ring-red-600'
: 'border-gray-300 focus:border-pink-500 focus:ring-pink-500',
)}
{...props}
/>
)
})

export default () => (
<Example title={title} description={description}>
<Form schema={schema} inputComponent={Input} />
</Example>
)
31 changes: 31 additions & 0 deletions apps/web/tests/examples/forms/use-field.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
137 changes: 79 additions & 58 deletions packages/remix-forms/src/createField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ type SmartInputProps = {
a11yProps?: Record<`aria-${string}`, string | boolean | undefined>
}

const FieldContext = React.createContext<
Partial<Omit<Field<never>, 'name'>> | undefined
>(undefined)

export function useField() {
Copy link
Contributor

@diogob diogob Dec 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do yo think of the name userFieldContext ? It seems to make it clearer that the hook is returning a context and creates a neat parallel with the useFormContext provided by react-hook-form.
@danielweinmann thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like useFormContext and I think the only reason they named it like that is because useForm was already taken 😂

const context = React.useContext(FieldContext)

if (!context) throw new Error('useField used outside of field context')

return context
}

const makeSelectOption = ({ name, value }: Option) => (
<option key={String(value)} value={value}>
{name}
Expand Down Expand Up @@ -279,6 +291,21 @@ function createField<Schema extends SomeZodObject>({
ref,
) => {
const value = fieldType === 'date' ? parseDate(rawValue) : rawValue
const field = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be great to reuse this constant to pass the childrenFn parameters (line 320) for the sake of readability, it makes it clearer that we are talking about the same field object.

dirty,
autoFocus,
errors,
fieldType,
hidden,
label,
multiline,
options,
placeholder,
radio,
required,
shape,
value,
}

const errorsChildren = errors?.length
? errors.map((error) => <Error key={error}>{error}</Error>)
Expand Down Expand Up @@ -331,20 +358,9 @@ function createField<Schema extends SomeZodObject>({
Errors,
Error,
ref,
shape,
fieldType,
name,
required,
label,
type,
options,
errors,
autoFocus,
value,
hidden,
multiline,
radio,
placeholder,
...field,
})

const children = mapChildren(childrenDefinition, (child) => {
Expand Down Expand Up @@ -418,7 +434,7 @@ function createField<Schema extends SomeZodObject>({
})
} else if (child.type === Radio) {
return React.cloneElement(child, {
id: `${name}-${child.props.value}`,
id: `${String(name)}-${child.props.value}`,
type: 'radio',
autoFocus,
...registerProps,
Expand Down Expand Up @@ -447,28 +463,31 @@ function createField<Schema extends SomeZodObject>({
}
})

const fixRadioLabels = (children: React.ReactNode) => mapChildren(children, (child) => {
if (child.type === Label) {
const parent = findParent(children, child)
if (parent && parent.type === RadioWrapper) {
const radioChild = findElement(
parent.props?.children,
(ch) => ch.type === Radio,
)
if (radioChild) {
return React.cloneElement(child, {
htmlFor: radioChild.props.id,
})
const fixRadioLabels = (children: React.ReactNode) =>
mapChildren(children, (child) => {
if (child.type === Label) {
const parent = findParent(children, child)
if (parent && parent.type === RadioWrapper) {
const radioChild = findElement(
parent.props?.children,
(ch) => ch.type === Radio,
)
if (radioChild) {
return React.cloneElement(child, {
htmlFor: radioChild.props.id,
})
}
}
}
}
return child
})
return child
})

return (
<Field hidden={hidden} style={style} {...props}>
{fixRadioLabels(children)}
</Field>
<FieldContext.Provider value={field}>
<Field hidden={hidden} style={style} {...props}>
{fixRadioLabels(children)}
</Field>
</FieldContext.Provider>
)
}

Expand All @@ -488,33 +507,35 @@ function createField<Schema extends SomeZodObject>({
)

return (
<Field hidden={hidden} style={style} {...props}>
{fieldType === 'boolean' ? (
<CheckboxWrapper>
{smartInput}
<Label id={labelId} htmlFor={String(name)}>
{label}
</Label>
</CheckboxWrapper>
) : radio ? (
<>
<Label id={labelId}>{label}</Label>
<RadioGroup {...a11yProps}>{smartInput}</RadioGroup>
</>
) : (
<>
<Label id={labelId} htmlFor={String(name)}>
{label}
</Label>
{smartInput}
</>
)}
{Boolean(errorsChildren) && (
<Errors role="alert" id={errorsId}>
{errorsChildren}
</Errors>
)}
</Field>
<FieldContext.Provider value={field}>
<Field hidden={hidden} style={style} {...props}>
{fieldType === 'boolean' ? (
<CheckboxWrapper>
{smartInput}
<Label id={labelId} htmlFor={String(name)}>
{label}
</Label>
</CheckboxWrapper>
) : radio ? (
<>
<Label id={labelId}>{label}</Label>
<RadioGroup {...a11yProps}>{smartInput}</RadioGroup>
</>
) : (
<>
<Label id={labelId} htmlFor={String(name)}>
{label}
</Label>
{smartInput}
</>
)}
{Boolean(errorsChildren) && (
<Errors role="alert" id={errorsId}>
{errorsChildren}
</Errors>
)}
</Field>
</FieldContext.Provider>
)
},
)
Expand Down
1 change: 1 addition & 0 deletions packages/remix-forms/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createForm } from './createForm'
export { useField } from './createField'
export { createFormAction, performMutation } from './mutations'

export type {
Expand Down