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 ? ( + + + {label} + + {smartInput} + + ) : ( <>