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 (
+
+ );
+};
+
+export const DefaultValue = () => {
+ const form = useForm({
+ ...formOptions,
+ defaultValues: {
+ bear: 'pawdrin',
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const Disabled = () => {
+ const form = useForm({
+ ...formOptions,
+ defaultValues: {
+ bear: 'pawdrin',
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const Row = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
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 (