diff --git a/apps/web/app/routes/examples.tsx b/apps/web/app/routes/examples.tsx
index 3928d61..ade05b4 100644
--- a/apps/web/app/routes/examples.tsx
+++ b/apps/web/app/routes/examples.tsx
@@ -64,6 +64,9 @@ export default function Component() {
Labels, options, etc
+
+ Radio buttons
+
Hidden field
diff --git a/apps/web/app/routes/examples/forms/form-with-children.tsx b/apps/web/app/routes/examples/forms/form-with-children.tsx
index 7ab34fe..6d76476 100644
--- a/apps/web/app/routes/examples/forms/form-with-children.tsx
+++ b/apps/web/app/routes/examples/forms/form-with-children.tsx
@@ -39,6 +39,7 @@ export default () => (
{ name: 'Friend', value: 'fromAFriend' },
{ name: 'Search', value: 'google' },
]}
+ radio
/>
@@ -81,6 +82,7 @@ export default function Component() {
{ name: 'Friend', value: 'fromAFriend' },
{ name: 'Search', value: 'google' },
]}
+ radio
/>
diff --git a/apps/web/app/routes/examples/forms/radio-buttons.tsx b/apps/web/app/routes/examples/forms/radio-buttons.tsx
new file mode 100644
index 0000000..df73cb1
--- /dev/null
+++ b/apps/web/app/routes/examples/forms/radio-buttons.tsx
@@ -0,0 +1,49 @@
+import hljs from 'highlight.js/lib/common'
+import type {
+ ActionFunction,
+ LoaderFunction,
+ MetaFunction,
+} from '@remix-run/node'
+import { formAction } from '~/formAction'
+import { z } from 'zod'
+import Form from '~/ui/form'
+import { metaTags } from '~/helpers'
+import { makeDomainFunction } from 'domain-functions'
+import Example from '~/ui/example'
+
+const title = 'Radio buttons'
+const description =
+ 'In this example, we render enum options in a radio button group.'
+
+export const meta: MetaFunction = () => metaTags({ title, description })
+
+const code = `const schema = z.object({
+ name: z.string().min(1),
+ role: z.enum(['Designer', 'Dev']),
+})
+
+export default () => (
+
+)`
+
+const schema = z.object({
+ name: z.string().min(1),
+ role: z.enum(['Designer', 'Dev']),
+})
+
+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 })
+
+export default function Component() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/app/ui/form.tsx b/apps/web/app/ui/form.tsx
index 47684b2..b759d95 100644
--- a/apps/web/app/ui/form.tsx
+++ b/apps/web/app/ui/form.tsx
@@ -8,7 +8,11 @@ import Label from './label'
import Select from './select'
import SubmitButton from './submit-button'
import Checkbox from './checkbox'
+import Radio from './radio'
+
import CheckboxWrapper from './checkbox-wrapper'
+import RadioWrapper from './radio-wrapper'
+
import TextArea from './text-area'
import {
Form as RemixForm,
@@ -36,6 +40,8 @@ export default function Form(
multilineComponent={TextArea}
selectComponent={Select}
checkboxComponent={Checkbox}
+ radioComponent={Radio}
+ radioGroupComponent={RadioWrapper}
checkboxWrapperComponent={CheckboxWrapper}
buttonComponent={SubmitButton}
globalErrorsComponent={Errors}
diff --git a/apps/web/app/ui/radio-wrapper.tsx b/apps/web/app/ui/radio-wrapper.tsx
new file mode 100644
index 0000000..31889dd
--- /dev/null
+++ b/apps/web/app/ui/radio-wrapper.tsx
@@ -0,0 +1,3 @@
+export default function RadioWrapper(props: JSX.IntrinsicElements['fieldset']) {
+ return
+}
diff --git a/apps/web/app/ui/radio.tsx b/apps/web/app/ui/radio.tsx
new file mode 100644
index 0000000..8e0ff9b
--- /dev/null
+++ b/apps/web/app/ui/radio.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react'
+import { cx } from '~/helpers'
+
+const Radio = React.forwardRef<
+ HTMLInputElement,
+ JSX.IntrinsicElements['input']
+>(({ type = 'radio', className, ...props }, ref) => (
+
+))
+
+export default Radio
diff --git a/apps/web/tests/examples/forms/form-with-children.spec.ts b/apps/web/tests/examples/forms/form-with-children.spec.ts
index 26f0d20..9793682 100644
--- a/apps/web/tests/examples/forms/form-with-children.spec.ts
+++ b/apps/web/tests/examples/forms/form-with-children.spec.ts
@@ -20,13 +20,12 @@ test('With JS enabled', async ({ example }) => {
await expect(page.locator('form em:visible')).toHaveText(
"You'll hear from us at this address 👆🏽",
)
+ await example.expectRadioToHaveOptions('howYouFoundOutAboutUs', [
+ { name: 'Friend', value: 'fromAFriend' },
+ { name: 'Search', value: 'google' },
+ ])
- await example.expectSelect(howYouFoundOutAboutUs, { value: '' })
- await howYouFoundOutAboutUs.input.selectOption({ value: 'fromAFriend' })
-
- const options = howYouFoundOutAboutUs.input.locator('option')
- await expect(options.first()).toHaveText('Friend')
- await expect(options.last()).toHaveText('Search')
+ await howYouFoundOutAboutUs.input.first().click()
await example.expectField(message, {
multiline: true,
@@ -65,10 +64,9 @@ test('With JS enabled', async ({ example }) => {
// Make form be valid
await email.input.fill('john@doe.com')
- await howYouFoundOutAboutUs.input.selectOption('google')
+ await howYouFoundOutAboutUs.input.last().click()
await message.input.fill('My message')
await example.expectValid(email)
- await example.expectValid(howYouFoundOutAboutUs)
// Submit form
button.click()
@@ -126,7 +124,7 @@ testWithoutJS('With JS disabled', async ({ example }) => {
// Make form be valid and test selecting an option
await email.input.fill('john@doe.com')
- await howYouFoundOutAboutUs.input.selectOption('google')
+ await howYouFoundOutAboutUs.input.last().click()
// Submit form
await button.click()
diff --git a/apps/web/tests/examples/forms/radio-buttons.spec.ts b/apps/web/tests/examples/forms/radio-buttons.spec.ts
new file mode 100644
index 0000000..856f336
--- /dev/null
+++ b/apps/web/tests/examples/forms/radio-buttons.spec.ts
@@ -0,0 +1,83 @@
+import { test, testWithoutJS, expect } from 'tests/setup/tests'
+
+const route = '/examples/forms/radio-buttons'
+
+test('With JS enabled', async ({ example }) => {
+ const { button, page } = example
+ const name = example.field('name')
+ const radio = example.field('role')
+
+ await page.goto(route)
+
+ // Render
+ await example.expectField(name)
+ await example.expectRadioToHaveOptions('role', [
+ { name: 'Dev', value: 'Dev' },
+ { name: 'Designer', value: 'Designer' },
+ ])
+ await expect(button).toBeEnabled()
+
+ // Client-side validation
+ await button.click()
+
+ // Show field errors and focus on the first field
+
+ await example.expectError(name, 'String must contain at least 1 character(s)')
+ await example.expectErrorMessage(
+ 'role',
+ "Invalid enum value. Expected 'Designer' | 'Dev', received ''",
+ )
+
+ await expect(name.input).toBeFocused()
+
+ await name.input.type('John Corn')
+ await radio.input.first().click()
+
+ // Submit form
+ await button.click()
+
+ await example.expectData({
+ name: 'John Corn',
+ role: 'Designer',
+ })
+})
+
+testWithoutJS('With JS disabled', async ({ example }) => {
+ const { button, page } = example
+ const name = example.field('name')
+ const radio = example.field('role')
+
+ await page.goto(route)
+
+ // Render
+ await example.expectField(name)
+ await example.expectRadioToHaveOptions('role', [
+ { name: 'Dev', value: 'Dev' },
+ { name: 'Designer', value: 'Designer' },
+ ])
+ await expect(button).toBeEnabled()
+
+ // Client-side validation
+ await button.click()
+
+ // Show field errors and focus on the first field
+
+ await example.expectError(name, 'String must contain at least 1 character(s)')
+ await example.expectErrorMessage(
+ 'role',
+ "Invalid enum value. Expected 'Designer' | 'Dev', received ''",
+ )
+
+ await expect(name.input).toBeFocused()
+
+ await name.input.type('John Corn')
+ await radio.input.first().click()
+
+ // Submit form
+ await button.click()
+
+ await example.expectData({
+ name: 'John Corn',
+ role: 'Designer',
+ })
+})
diff --git a/apps/web/tests/setup/example.ts b/apps/web/tests/setup/example.ts
index 244539a..d1434fa 100644
--- a/apps/web/tests/setup/example.ts
+++ b/apps/web/tests/setup/example.ts
@@ -16,6 +16,7 @@ type FieldOptions = {
required?: boolean
invalid?: boolean
multiline?: boolean
+ radio?: boolean
options?: { name: string; value: string }[]
}
@@ -79,9 +80,20 @@ class Example {
`label-for-${field.name}`,
)
- required
- ? await expect(field.input).toHaveAttribute('aria-required', 'true')
- : await expect(field.input).not.toHaveAttribute('aria-required', 'true')
+ await expect(field.input).toHaveAttribute('aria-required', String(required))
+ }
+
+ async expectRadioToHaveOptions(
+ radioName: string,
+ options: { name: string; value: string }[],
+ ) {
+ Promise.all(
+ options.map(({ name, value }) => {
+ expect(
+ this.page.locator(`[name="${radioName}"][value="${value}"]:visible`),
+ ).toBeVisible()
+ }),
+ )
}
async expectSelect(field: Field, options: FieldOptions = {}) {
@@ -108,8 +120,11 @@ class Example {
async expectError(field: Field, message: string) {
await this.expectInvalid(field)
+ await this.expectErrorMessage(field.name, message)
+ }
+ async expectErrorMessage(fieldName: string, message: string) {
await expect(
- this.page.locator(`#errors-for-${field.name}:visible`),
+ this.page.locator(`#errors-for-${fieldName}:visible`),
).toHaveText(message)
}
diff --git a/packages/remix-forms/src/createField.tsx b/packages/remix-forms/src/createField.tsx
index 44828fa..a66e915 100644
--- a/packages/remix-forms/src/createField.tsx
+++ b/packages/remix-forms/src/createField.tsx
@@ -4,36 +4,47 @@ import type { UseFormRegister, UseFormRegisterReturn } from 'react-hook-form'
import type { Field } from './createForm'
import { mapChildren } from './childrenTraversal'
import { coerceValue } from './coercions'
-import { ComponentOrTagName, parseDate } from './prelude'
+import { ComponentOrTagName, mapObject, parseDate } from './prelude'
+
+type Option = { name: string } & Required<
+ Pick, 'value'>
+>
type Children = (
helpers: FieldBaseProps & {
Label: ComponentOrTagName<'label'>
SmartInput: React.ComponentType
Input:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
Multiline:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
Select:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
Checkbox:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
+ Radio:
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
+ RadioWrapper: ComponentOrTagName<'fieldset'>
CheckboxWrapper: ComponentOrTagName<'div'>
Errors: ComponentOrTagName<'div'>
Error: ComponentOrTagName<'div'>
@@ -69,30 +80,38 @@ type ComponentMappings = {
fieldComponent?: ComponentOrTagName<'div'>
labelComponent?: ComponentOrTagName<'label'>
inputComponent?:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
multilineComponent?:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
selectComponent?:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
checkboxComponent?:
- | React.ForwardRefExoticComponent<
- React.PropsWithoutRef &
- React.RefAttributes
- >
- | string
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
+ radioComponent?:
+ | React.ForwardRefExoticComponent<
+ React.PropsWithoutRef &
+ React.RefAttributes
+ >
+ | string
checkboxWrapperComponent?: ComponentOrTagName<'div'>
+ radioWrapperComponent?: ComponentOrTagName<'div'>
+ radioGroupComponent?: ComponentOrTagName<'fieldset'>
fieldErrorsComponent?: ComponentOrTagName<'div'>
errorComponent?: ComponentOrTagName<'div'>
}
@@ -103,18 +122,33 @@ type SmartInputProps = {
value?: any
autoFocus?: boolean
selectChildren?: JSX.Element[]
+ options?: Option[]
multiline?: boolean
+ radio?: boolean
placeholder?: string
registerProps?: UseFormRegisterReturn
className?: string
a11yProps?: Record<`aria-${string}`, string | boolean | undefined>
}
+const makeSelectOption = ({ name, value }: Option) => (
+
+)
+
+const makeOptionComponents = (
+ fn: (option: Option) => JSX.Element,
+ options: Option[] | undefined,
+) => (options ? options.map(fn) : undefined)
+
function createSmartInput({
inputComponent: Input = 'input',
multilineComponent: Multiline = 'textarea',
selectComponent: Select = 'select',
checkboxComponent: Checkbox = 'input',
+ radioComponent: Radio = 'input',
+ radioWrapperComponent: RadioWrapper = React.Fragment
}: ComponentMappings) {
// eslint-disable-next-line react/display-name
return ({
@@ -123,7 +157,9 @@ function createSmartInput({
value,
autoFocus,
selectChildren,
+ options,
multiline,
+ radio,
placeholder,
registerProps,
a11yProps,
@@ -131,62 +167,57 @@ function createSmartInput({
}: SmartInputProps) => {
if (!registerProps) return null
- const { name } = registerProps
-
- if (fieldType === 'boolean') {
- return (
-
- )
- }
+ const makeRadioOption =
+ (props: Record) =>
+ ({ name, value }: Option) => {
+ const propsWithUniqueId = mapObject(props, (key, propValue) =>
+ key === 'id' ? [key, `${propValue}-${value}`] : [key, propValue],
+ )
+ return (
+
+
+
+
+ )
+ }
- if (selectChildren) {
- return (
-
- )
- }
+ const { name } = registerProps
- if (multiline) {
- return (
-
- )
+ const commonProps = {
+ id: name,
+ autoFocus,
+ ...registerProps,
+ ...props,
}
- return (
+ return fieldType === 'boolean' ? (
+
+ ) : (selectChildren || options) && !radio ? (
+
+ ) : options && radio ? (
+ <>{makeOptionComponents(makeRadioOption(commonProps), options)}>
+ ) : multiline ? (
+
+ ) : (
)
}
@@ -199,8 +230,10 @@ function createField({
inputComponent: Input = 'input',
multilineComponent: Multiline = 'textarea',
selectComponent: Select = 'select',
+ radioComponent: Radio = 'input',
checkboxComponent: Checkbox = 'input',
checkboxWrapperComponent: CheckboxWrapper = 'div',
+ radioGroupComponent: RadioGroup = 'fieldset',
fieldErrorsComponent: Errors = 'div',
errorComponent: Error = 'div',
}: {
@@ -222,6 +255,7 @@ function createField({
autoFocus = false,
value: rawValue,
multiline = false,
+ radio = false,
placeholder,
hidden = false,
children: childrenFn,
@@ -231,20 +265,12 @@ function createField({
) => {
const value = fieldType === 'date' ? parseDate(rawValue) : rawValue
- const selectChildren = options
- ? options.map(({ name, value }) => (
-
- ))
- : undefined
-
const errorsChildren = errors?.length
? errors.map((error) => {error})
: undefined
const style = hidden ? { display: 'none' } : undefined
- const type = typeProp || types[fieldType]
+ const type = typeProp ?? types[fieldType]
const registerProps = register(String(name), {
setValueAs: (value) => coerceValue(value, shape),
@@ -267,9 +293,10 @@ function createField({
multilineComponent: Multiline,
selectComponent: Select,
checkboxComponent: Checkbox,
+ radioComponent: Radio,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
- [Input, Multiline, Select, Checkbox],
+ [Input, Multiline, Select, Checkbox, Radio],
)
if (childrenFn) {
@@ -280,6 +307,8 @@ function createField({
Multiline,
Select,
Checkbox,
+ Radio,
+ RadioWrapper: RadioGroup,
CheckboxWrapper,
Errors,
Error,
@@ -296,6 +325,7 @@ function createField({
value,
hidden,
multiline,
+ radio,
placeholder,
})
@@ -315,8 +345,13 @@ function createField({
return React.cloneElement(child, {
fieldType,
type,
- selectChildren,
+ selectChildren: makeOptionComponents(
+ makeSelectOption,
+ options,
+ ),
+ options: options,
multiline,
+ radio,
placeholder,
registerProps,
autoFocus,
@@ -352,7 +387,7 @@ function createField({
...a11yProps,
autoFocus,
defaultValue: value,
- children: selectChildren,
+ children: makeOptionComponents(makeSelectOption, options),
...child.props,
})
} else if (child.type === Checkbox) {
@@ -395,8 +430,10 @@ function createField({
({
{label}
+ ) : radio ? (
+
+
+ {smartInput}
+
+
) : (
<>