diff --git a/app/components/form/field-radio-group/docs.stories.tsx b/app/components/form/field-radio-group/docs.stories.tsx new file mode 100644 index 000000000..03993e359 --- /dev/null +++ b/app/components/form/field-radio-group/docs.stories.tsx @@ -0,0 +1,248 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { CheckIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { cn } from '@/lib/tailwind/utils'; +import { zu } from '@/lib/zod/zod-utils'; + +import { FormFieldController } from '@/components/form'; +import { onSubmit } from '@/components/form/docs.utils'; +import { Button } from '@/components/ui/button'; +import { Radio, RadioProps } from '@/components/ui/radio-group'; + +import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../'; + +export default { + title: 'Form/FieldRadioGroup', +}; + +const zFormSchema = () => + z.object({ + bear: zu.string.nonEmpty(z.string(), { + required_error: 'Please select your favorite bearstronaut', + }), + }); + +const formOptions = { + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), + defaultValues: { + bear: '', + }, +} as const; + +const options = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, +]; + +export const Default = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const DefaultValue = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + bear: 'pawdrin', + }, + }); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const Disabled = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + bear: 'pawdrin', + }, + }); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const Row = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const WithDisabledOption = () => { + const form = useForm(formOptions); + + const optionsWithDisabled = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin', disabled: true }, + ]; + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const WithCustomRadio = () => { + // Let's say we have a custom radio component: + // eslint-disable-next-line @eslint-react/no-nested-component-definitions + const CardRadio = ({ + value, + id, + children, + containerProps, + ...props + }: RadioProps & { containerProps?: React.ComponentProps<'label'> }) => { + return ( + + ); + }; + + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + { + // We can then customize the render of our field's radios + return {label}; + }} + /> + +
+ +
+
+
+ ); +}; diff --git a/app/components/form/field-radio-group/field-radio-group.spec.tsx b/app/components/form/field-radio-group/field-radio-group.spec.tsx new file mode 100644 index 000000000..6cc8ee61c --- /dev/null +++ b/app/components/form/field-radio-group/field-radio-group.spec.tsx @@ -0,0 +1,282 @@ +import { expect, test, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { z } from 'zod'; + +import { render, screen, setupUser } from '@/tests/utils'; + +import { FormField, FormFieldController, FormFieldLabel } from '..'; +import { FormMocked } from '../form-test-utils'; + +const options = [ + { + value: 'bearstrong', + label: 'Bearstrong', + }, + { + value: 'pawdrin', + label: 'Buzz Pawdrin', + }, + { + value: 'grizzlyrin', + label: 'Yuri Grizzlyrin', + }, + { + value: 'jemibear', + label: 'Mae Jemibear', + disabled: true, + }, +]; + +test('should have no a11y violations', async () => { + const mockedSubmit = vi.fn(); + + HTMLCanvasElement.prototype.getContext = vi.fn(); + + const { container } = render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); + +test('should select radio on button click', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const radio = screen.getByRole('radio', { name: 'Buzz Pawdrin' }); + expect(radio).not.toBeChecked(); + + await user.click(radio); + expect(radio).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bear: 'pawdrin' }); +}); + +test('should select radio on label click', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const radio = screen.getByRole('radio', { name: 'Buzz Pawdrin' }); + const label = screen.getByText('Buzz Pawdrin'); + + expect(radio).not.toBeChecked(); + + // Test clicking the label specifically + await user.click(label); + expect(radio).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bear: 'pawdrin' }); +}); + +test('should handle keyboard navigation', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const firstRadio = screen.getByRole('radio', { name: 'Bearstrong' }); + const secondRadio = screen.getByRole('radio', { name: 'Buzz Pawdrin' }); + const thirdRadio = screen.getByRole('radio', { name: 'Yuri Grizzlyrin' }); + + await user.tab(); + expect(firstRadio).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(secondRadio).toHaveFocus(); + await user.keyboard(' '); + expect(secondRadio).toBeChecked(); + + await user.keyboard('{ArrowDown}'); + expect(thirdRadio).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + expect(secondRadio).toHaveFocus(); + expect(secondRadio).toBeChecked(); // Second radio should still be checked + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bear: 'pawdrin' }); +}); + +test('default value', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const radio = screen.getByRole('radio', { name: 'Yuri Grizzlyrin' }); + expect(radio).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bear: 'grizzlyrin' }); +}); + +test('disabled', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const radio = screen.getByRole('radio', { name: 'Buzz Pawdrin' }); + expect(radio).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bear: undefined }); +}); + +test('disabled option', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const disabledRadio = screen.getByRole('radio', { name: 'Mae Jemibear' }); + expect(disabledRadio).toBeDisabled(); + + await user.click(disabledRadio); + expect(disabledRadio).not.toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bear: '' }); +}); diff --git a/app/components/form/field-radio-group/index.tsx b/app/components/form/field-radio-group/index.tsx new file mode 100644 index 000000000..3784a4d3c --- /dev/null +++ b/app/components/form/field-radio-group/index.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { Controller, FieldPath, FieldValues } from 'react-hook-form'; + +import { cn } from '@/lib/tailwind/utils'; + +import { FormFieldError } from '@/components/form'; +import { useFormField } from '@/components/form/form-field'; +import { FieldProps } from '@/components/form/form-field-controller'; +import { + Radio, + RadioGroup, + RadioGroupProps, + RadioProps, +} from '@/components/ui/radio-group'; + +type RadioOption = Omit & { + label: string; +}; + +export type FieldRadioGroupProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = FieldProps & { + type: 'radio-group'; + options: Array; + renderOption?: (props: RadioOption) => React.JSX.Element; +} & Omit & { + containerProps?: React.ComponentProps<'div'>; + }; + +export const FieldRadioGroup = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + props: FieldRadioGroupProps +) => { + const { + name, + control, + disabled, + defaultValue, + shouldUnregister, + containerProps, + options, + renderOption, + ...rest + } = props; + const ctx = useFormField(); + + return ( + { + return ( +
+ + {options.map(({ label, ...option }) => { + const radioId = `${ctx.id}-${option.value}`; + + if (renderOption) { + return ( + + {renderOption({ + label, + ...field, + ...option, + })} + + ); + } + + return ( + + {label} + + ); + })} + + +
+ ); + }} + /> + ); +}; diff --git a/app/components/form/form-field-controller.tsx b/app/components/form/form-field-controller.tsx index 4496bc0b7..99a8e05dc 100644 --- a/app/components/form/form-field-controller.tsx +++ b/app/components/form/form-field-controller.tsx @@ -10,6 +10,7 @@ import { FieldNumber, FieldNumberProps } from '@/components/form/field-number'; import { FieldDate, FieldDateProps } from './field-date'; import { FieldOtp, FieldOtpProps } from './field-otp'; +import { FieldRadioGroup, FieldRadioGroupProps } from './field-radio-group'; import { FieldSelect, FieldSelectProps } from './field-select'; import { FieldText, FieldTextProps } from './field-text'; import { useFormField } from './form-field'; @@ -47,7 +48,8 @@ export type FormFieldControllerProps< | FieldSelectProps | FieldDateProps | FieldTextProps - | FieldOtpProps; + | FieldOtpProps + | FieldRadioGroupProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -83,6 +85,9 @@ export const FormFieldController = < case 'number': return ; + + case 'radio-group': + return ; // -- ADD NEW FIELD COMPONENT HERE -- } }; diff --git a/app/components/form/form-field-helper.tsx b/app/components/form/form-field-helper.tsx index ae4fb7b9e..3b4c1f92e 100644 --- a/app/components/form/form-field-helper.tsx +++ b/app/components/form/form-field-helper.tsx @@ -2,12 +2,16 @@ import { ComponentProps } from 'react'; import { cn } from '@/lib/tailwind/utils'; +import { useFormField } from '@/components/form/form-field'; + export const FormFieldHelper = ({ className, ...props }: ComponentProps<'div'>) => { + const ctx = useFormField(); return (
diff --git a/app/components/form/form-field-label.tsx b/app/components/form/form-field-label.tsx index cf404d8c3..0da3fb744 100644 --- a/app/components/form/form-field-label.tsx +++ b/app/components/form/form-field-label.tsx @@ -13,6 +13,7 @@ export const FormFieldLabel = ({ const ctx = useFormField(); return (