Skip to content

Commit 2d1f5f7

Browse files
committed
refactor(RadioGroup): use base-ui radio
1 parent ac9b58e commit 2d1f5f7

File tree

7 files changed

+200
-382
lines changed

7 files changed

+200
-382
lines changed

app/components/form/field-radio-group/docs.stories.tsx

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { zu } from '@/lib/zod/zod-utils';
99
import { FormFieldController } from '@/components/form';
1010
import { onSubmit } from '@/components/form/docs.utils';
1111
import { Button } from '@/components/ui/button';
12-
import { RadioItem } from '@/components/ui/radio-group';
12+
import { Radio, RadioProps } from '@/components/ui/radio-group';
1313

1414
import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../';
1515

@@ -135,7 +135,7 @@ export const Row = () => {
135135
type="radio-group"
136136
name="bear"
137137
options={options}
138-
className="flex-row gap-4"
138+
className="flex-row gap-6"
139139
/>
140140
</FormField>
141141
<div>
@@ -176,7 +176,50 @@ export const WithDisabledOption = () => {
176176
);
177177
};
178178

179-
export const RenderOption = () => {
179+
export const WithCustomRadio = () => {
180+
// Let's say we have a custom radio component:
181+
// eslint-disable-next-line @eslint-react/no-nested-component-definitions
182+
const CardRadio = ({
183+
value,
184+
id,
185+
children,
186+
containerProps,
187+
...props
188+
}: RadioProps & { containerProps?: React.ComponentProps<'label'> }) => {
189+
return (
190+
<label
191+
className="relative flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-border p-4 transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none hover:bg-muted/50 has-[&[data-checked]]:border-primary/90 has-[&[data-checked]]:bg-primary/5"
192+
{...containerProps}
193+
>
194+
<Radio
195+
value={value}
196+
id={id}
197+
noLabel
198+
render={(props, { checked }) => {
199+
return (
200+
<div
201+
{...props}
202+
className="flex w-full justify-between outline-none"
203+
>
204+
<div className="flex flex-col">
205+
<span className="font-medium">{children}</span>
206+
</div>
207+
<div
208+
className={cn('rounded-full bg-primary p-1 opacity-0', {
209+
'opacity-100': checked,
210+
})}
211+
>
212+
<CheckIcon className="h-4 w-4 text-primary-foreground" />
213+
</div>
214+
</div>
215+
);
216+
}}
217+
{...props}
218+
/>
219+
</label>
220+
);
221+
};
222+
180223
const form = useForm(formOptions);
181224

182225
return (
@@ -190,33 +233,12 @@ export const RenderOption = () => {
190233
type="radio-group"
191234
name="bear"
192235
options={options}
193-
renderRadio={({ radio, controller: { field } }) => {
194-
const radioId = `radio-card-${radio.value}`;
195-
236+
renderOption={({ value, label, id, key }) => {
237+
// We can then customize the render of our field's radios
196238
return (
197-
<label
198-
htmlFor={radioId}
199-
className={cn(
200-
'relative flex cursor-pointer items-center justify-between gap-4 rounded-lg border p-4 transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none hover:bg-muted/50',
201-
radio.checked
202-
? 'border-primary bg-primary/5'
203-
: 'border-border'
204-
)}
205-
>
206-
<RadioItem
207-
id={radioId}
208-
className="peer sr-only"
209-
value={radio.value}
210-
disabled={radio.disabled}
211-
onBlur={field.onBlur}
212-
/>
213-
<div className="flex flex-col gap-1">
214-
<span className="font-medium">{radio.label}</span>
215-
</div>
216-
<div className="rounded-full bg-primary p-1 opacity-0 peer-data-[state=checked]:opacity-100">
217-
<CheckIcon className="h-4 w-4 text-primary-foreground" />
218-
</div>
219-
</label>
239+
<CardRadio value={value} id={id} key={key}>
240+
{label}
241+
</CardRadio>
220242
);
221243
}}
222244
/>

app/components/form/field-radio-group/index.tsx

Lines changed: 40 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,32 @@
1-
import { getUiState } from '@bearstudio/ui-state';
2-
import { ReactNode } from '@tanstack/react-router';
3-
import { ComponentProps } from 'react';
4-
import {
5-
Controller,
6-
ControllerProps,
7-
ControllerRenderProps,
8-
FieldPath,
9-
FieldValues,
10-
} from 'react-hook-form';
11-
import { isNonNullish } from 'remeda';
1+
import * as React from 'react';
2+
import { Controller, FieldPath, FieldValues } from 'react-hook-form';
123

134
import { cn } from '@/lib/tailwind/utils';
145

15-
import { Radio, RadioGroup } from '@/components/ui/radio-group';
16-
17-
import { useFormField } from '../form-field';
18-
import { FieldProps } from '../form-field-controller';
19-
import { FormFieldError } from '../form-field-error';
6+
import { FormFieldError } from '@/components/form';
7+
import { useFormField } from '@/components/form/form-field';
8+
import { FieldProps } from '@/components/form/form-field-controller';
9+
import {
10+
Radio,
11+
RadioGroup,
12+
RadioGroupProps,
13+
RadioProps,
14+
} from '@/components/ui/radio-group';
2015

21-
type RadioOptionProps = {
22-
value: string;
16+
type RadioOption = Omit<RadioProps, 'children' | 'render'> & {
2317
label: string;
24-
disabled?: boolean;
2518
};
2619

2720
export type FieldRadioGroupProps<
2821
TFieldValues extends FieldValues = FieldValues,
2922
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
3023
> = FieldProps<TFieldValues, TName> & {
3124
type: 'radio-group';
32-
options: Array<RadioOptionProps>;
33-
renderRadio?: (options: {
34-
radio: RadioOptionProps & { checked: boolean };
35-
controller: Parameters<
36-
Pick<ControllerProps<TFieldValues, TName>, 'render'>['render']
37-
>[0];
38-
}) => ReactNode;
39-
containerProps?: ComponentProps<'div'>;
40-
} & RemoveFromType<
41-
Omit<
42-
ComponentProps<typeof RadioGroup>,
43-
'id' | 'aria-invalid' | 'aria-describedby'
44-
>,
45-
ControllerRenderProps
46-
>;
25+
options: Array<RadioOption>;
26+
renderOption?: (props: RadioOption) => React.JSX.Element;
27+
} & Omit<RadioGroupProps, 'id' | 'aria-invalid' | 'aria-describedby'> & {
28+
containerProps?: React.ComponentProps<'div'>;
29+
};
4730

4831
export const FieldRadioGroup = <
4932
TFieldValues extends FieldValues = FieldValues,
@@ -53,40 +36,25 @@ export const FieldRadioGroup = <
5336
) => {
5437
const {
5538
name,
56-
type,
57-
options,
39+
control,
5840
disabled,
5941
defaultValue,
6042
shouldUnregister,
61-
control,
6243
containerProps,
63-
renderRadio,
44+
options,
45+
renderOption,
6446
...rest
6547
} = props;
66-
6748
const ctx = useFormField();
6849

69-
const ui = getUiState((set) => {
70-
if (isNonNullish(renderRadio)) {
71-
return set('render-radio', { renderRadio });
72-
}
73-
74-
return set('default');
75-
});
76-
7750
return (
7851
<Controller
7952
name={name}
8053
control={control}
8154
disabled={disabled}
8255
defaultValue={defaultValue}
8356
shouldUnregister={shouldUnregister}
84-
render={(controllerRenderOptions) => {
85-
const {
86-
field: { onChange, onBlur, ...field },
87-
fieldState,
88-
} = controllerRenderOptions;
89-
57+
render={({ field: { onBlur, onChange, ...field }, fieldState }) => {
9058
return (
9159
<div
9260
{...containerProps}
@@ -107,31 +75,25 @@ export const FieldRadioGroup = <
10775
onValueChange={onChange}
10876
{...field}
10977
>
110-
{ui
111-
.match('render-radio', ({ renderRadio }) =>
112-
options.map((option) =>
113-
renderRadio({
114-
radio: {
115-
...option,
116-
checked: option.value === field.value,
117-
},
118-
controller: controllerRenderOptions,
119-
})
120-
)
121-
)
122-
.match('default', () =>
123-
options.map((option) => (
124-
<Radio
125-
key={`${ctx.id}-${option.value}`}
126-
value={option.value}
127-
disabled={option.disabled}
128-
onBlur={onBlur}
129-
>
130-
{option.label}
131-
</Radio>
132-
))
133-
)
134-
.exhaustive()}
78+
{options.map(({ label, ...option }) => {
79+
if (renderOption) {
80+
return renderOption({
81+
key: `${ctx.id}-${option.value}`,
82+
label,
83+
...option,
84+
});
85+
}
86+
87+
return (
88+
<Radio
89+
key={`${ctx.id}-${option.value}`}
90+
onBlur={onBlur}
91+
{...option}
92+
>
93+
{label}
94+
</Radio>
95+
);
96+
})}
13597
</RadioGroup>
13698
<FormFieldError />
13799
</div>

app/components/ui/radio-group.stories.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { Meta } from '@storybook/react-vite';
22
import { CheckIcon } from 'lucide-react';
33
import { useId } from 'react';
44

5-
import { Radio, RadioGroup, RadioItem } from '@/components/ui/radio-group';
5+
import { cn } from '@/lib/tailwind/utils';
6+
7+
import { Radio, RadioGroup } from '@/components/ui/radio-group';
68

79
export default {
810
title: 'RadioGroup',
@@ -82,17 +84,32 @@ export const WithCustomRadio = () => {
8284
const radioId = `${radioGroupId}-${value}`;
8385
return (
8486
<label
87+
className="relative flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-border p-4 transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none hover:bg-muted/50 has-[&[data-checked]]:border-primary/90 has-[&[data-checked]]:bg-primary/5"
8588
key={radioId}
86-
htmlFor={radioId}
87-
className="relative flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-border p-4 transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none hover:bg-muted/50 has-[&[data-state=checked]]:border-primary has-[&[data-state=checked]]:bg-primary/5"
8889
>
89-
<RadioItem id={radioId} className="peer sr-only" value={value} />
90-
<div className="flex flex-col gap-1">
91-
<span className="font-medium">{label}</span>
92-
</div>
93-
<div className="rounded-full bg-primary p-1 opacity-0 peer-data-[state=checked]:opacity-100">
94-
<CheckIcon className="h-4 w-4 text-primary-foreground" />
95-
</div>
90+
<Radio
91+
value={value}
92+
noLabel
93+
render={(props, { checked }) => {
94+
return (
95+
<div
96+
{...props}
97+
className="flex w-full justify-between outline-none"
98+
>
99+
<div className="flex flex-col">
100+
<span className="font-medium">{label}</span>
101+
</div>
102+
<div
103+
className={cn('rounded-full bg-primary p-1 opacity-0', {
104+
'opacity-100': checked,
105+
})}
106+
>
107+
<CheckIcon className="h-4 w-4 text-primary-foreground" />
108+
</div>
109+
</div>
110+
);
111+
}}
112+
/>
96113
</label>
97114
);
98115
})}

0 commit comments

Comments
 (0)