-
Notifications
You must be signed in to change notification settings - Fork 25
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
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d8bde92
add a context to each field, allowing custom components to obtain the…
bvangraafeiland 236b6c3
reuse the field const for the childrenFn
bvangraafeiland 016de8d
add useField example
bvangraafeiland 85cf291
add e2e test for the useField
bvangraafeiland 61d4c27
simplify the useField example and test
bvangraafeiland File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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() { | ||
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} | ||
|
@@ -279,6 +291,21 @@ function createField<Schema extends SomeZodObject>({ | |
ref, | ||
) => { | ||
const value = fieldType === 'date' ? parseDate(rawValue) : rawValue | ||
const field = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it would be great to reuse this constant to pass the |
||
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>) | ||
|
@@ -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) => { | ||
|
@@ -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, | ||
|
@@ -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> | ||
) | ||
} | ||
|
||
|
@@ -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> | ||
) | ||
}, | ||
) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 theuseFormContext
provided by react-hook-form.@danielweinmann thoughts?
There was a problem hiding this comment.
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 😂