Skip to content

Commit ea0f11a

Browse files
committed
Merge branch 'main' into radiogroup-theme-fix
2 parents e19c470 + ecd616f commit ea0f11a

File tree

7 files changed

+252
-4
lines changed

7 files changed

+252
-4
lines changed

src/components/ui/Radio/Radio.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client';
2+
import React from 'react';
3+
import RadioPrimitive, { RadioPrimitiveProps } from '~/core/primitives/Radio';
4+
5+
import clsx from 'clsx';
6+
import { customClassSwitcher } from '~/core';
7+
8+
import { useCreateDataAttribute, useComposeAttributes, useCreateDataAccentColorAttribute } from '~/core/hooks/createDataAttribute';
9+
10+
const COMPONENT_NAME = 'Radio';
11+
12+
export type RadioProps = RadioPrimitiveProps & {
13+
customRootClass?: string;
14+
className?: string;
15+
size?: string;
16+
color?: string;
17+
variant?: string;
18+
};
19+
20+
function Radio({ name, value, id, checked = false, required, onChange, disabled, asChild, className, customRootClass, variant = '', size = '', color = '', ...props }: RadioProps) {
21+
const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME);
22+
const [isChecked, setIsChecked] = React.useState(checked);
23+
24+
const dataAttributes = useCreateDataAttribute('button', { variant, size });
25+
const accentAttributes = useCreateDataAccentColorAttribute(color);
26+
const composedAttributes = useComposeAttributes(dataAttributes(), accentAttributes());
27+
28+
const handleChange = () => {
29+
if (onChange) {
30+
onChange();
31+
}
32+
setIsChecked(!isChecked);
33+
};
34+
return (
35+
<RadioPrimitive
36+
name={name}
37+
id={id}
38+
value={value}
39+
checked={isChecked}
40+
required={required}
41+
onChange={handleChange}
42+
disabled={disabled}
43+
asChild={asChild}
44+
className={clsx(rootClass, className)}
45+
data-checked={isChecked}
46+
{...composedAttributes()}
47+
{...props}
48+
/>
49+
50+
);
51+
}
52+
53+
export default Radio;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react';
2+
import Radio from '../Radio';
3+
import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor';
4+
5+
export default {
6+
title: 'WIP/Radio',
7+
component: Radio,
8+
render: (args: React.ComponentProps<typeof Radio>) => (
9+
<SandboxEditor>
10+
<form>
11+
<Radio {...args} />
12+
<label htmlFor={args.id || 'radio'} style={{ marginLeft: 8 }}>
13+
Radio 1
14+
</label>
15+
</form>
16+
</SandboxEditor>
17+
)
18+
};
19+
20+
export const Default = {
21+
args: {
22+
name: 'radio',
23+
value: 'radio',
24+
id: 'radio',
25+
required: true,
26+
onChange: () => {
27+
// action handler
28+
}
29+
}
30+
};
31+
32+
export const Disabled = {
33+
args: {
34+
disabled: true,
35+
name: 'radio',
36+
value: 'radio1',
37+
id: 'radio1',
38+
onChange: () => {
39+
// action handler
40+
}
41+
}
42+
};
43+
44+
export const Variants = {
45+
render: (args: React.ComponentProps<typeof Radio>) => (
46+
<SandboxEditor>
47+
<div style={{ display: 'flex', gap: 16 }}>
48+
<div>
49+
<Radio {...args} variant="outline" id="radio-outline" />
50+
<label htmlFor="radio-outline" style={{ marginLeft: 8 }}>Outline</label>
51+
</div>
52+
<div>
53+
<Radio {...args} variant="solid" id="radio-solid" />
54+
<label htmlFor="radio-solid" style={{ marginLeft: 8 }}>Solid</label>
55+
</div>
56+
</div>
57+
</SandboxEditor>
58+
),
59+
args: {
60+
name: 'radio-variant',
61+
value: 'radio',
62+
required: false,
63+
onChange: () => {}
64+
}
65+
};
66+
67+
export const Sizes = {
68+
render: (args: React.ComponentProps<typeof Radio>) => (
69+
<SandboxEditor>
70+
<div style={{ display: 'flex', gap: 16 }}>
71+
<div>
72+
<Radio {...args} size="small" id="radio-small" />
73+
<label htmlFor="radio-small" style={{ marginLeft: 8 }}>Small</label>
74+
</div>
75+
<div>
76+
<Radio {...args} size="medium" id="radio-medium" />
77+
<label htmlFor="radio-medium" style={{ marginLeft: 8 }}>Medium</label>
78+
</div>
79+
<div>
80+
<Radio {...args} size="large" id="radio-large" />
81+
<label htmlFor="radio-large" style={{ marginLeft: 8 }}>Large</label>
82+
</div>
83+
</div>
84+
</SandboxEditor>
85+
),
86+
args: {
87+
name: 'radio-size',
88+
value: 'radio',
89+
required: false,
90+
onChange: () => {}
91+
}
92+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import Radio from '../Radio';
4+
5+
describe('Radio', () => {
6+
const baseProps = {
7+
name: 'test-radio',
8+
value: 'option1',
9+
id: 'radio1'
10+
};
11+
12+
it('renders with required props', () => {
13+
render(<Radio {...baseProps} />);
14+
const radio = screen.getByRole('radio');
15+
expect(radio).toBeInTheDocument();
16+
expect(radio).toHaveAttribute('name', 'test-radio');
17+
expect(radio).toHaveAttribute('value', 'option1');
18+
expect(radio).toHaveAttribute('id', 'radio1');
19+
});
20+
21+
it('applies checked, required, and disabled props', () => {
22+
render(
23+
<Radio {...baseProps} checked required disabled />
24+
);
25+
const radio = screen.getByRole('radio');
26+
expect(radio).toBeChecked();
27+
expect(radio).toBeRequired();
28+
expect(radio).toBeDisabled();
29+
expect(radio).toHaveAttribute('aria-disabled', 'true');
30+
expect(radio).toHaveAttribute('aria-required', 'true');
31+
});
32+
33+
it('toggles checked state on click', () => {
34+
render(<Radio {...baseProps} />);
35+
const radio = screen.getByRole('radio');
36+
expect(radio).not.toBeChecked();
37+
fireEvent.click(radio);
38+
expect(radio).toBeChecked();
39+
});
40+
41+
it('calls onChange when clicked', () => {
42+
const handleChange = jest.fn();
43+
render(
44+
<Radio {...baseProps} onChange={handleChange} />
45+
);
46+
const radio = screen.getByRole('radio');
47+
fireEvent.click(radio);
48+
expect(handleChange).toHaveBeenCalled();
49+
});
50+
51+
it('applies custom class names', () => {
52+
render(
53+
<Radio {...baseProps} className="custom-class" customRootClass="root-class" />
54+
);
55+
const radio = screen.getByRole('radio');
56+
expect(radio.className).toMatch(/custom-class/);
57+
expect(radio.className).toMatch(/root-class/);
58+
});
59+
60+
it('applies data attributes for variant, size, and color', () => {
61+
render(
62+
<Radio {...baseProps} variant="filled" size="lg" color="red" />
63+
);
64+
const radio = screen.getByRole('radio');
65+
expect(radio).toHaveAttribute('data-button-variant', 'filled');
66+
expect(radio).toHaveAttribute('data-button-size', 'lg');
67+
expect(radio).toHaveAttribute('data-rad-ui-accent-color', 'red');
68+
});
69+
});

src/core/primitives/Radio/RadioPrimitive.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const All = {
2727
},
2828
name: 'radio',
2929
value: 'radio',
30+
id: 'radio',
3031
required: true
3132
}
3233
};

src/core/primitives/Radio/index.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import React from 'react';
22
import Primitive from '../Primitive';
33

4-
type RadioPrimitiveProps = {
4+
export type RadioPrimitiveProps = {
55
name: string;
66
value: string;
77
id: string;
88
onChange?: () => void;
99
checked?: boolean;
1010
required?: boolean;
1111
disabled?: boolean;
12-
asChild?: boolean
12+
asChild?: boolean;
13+
className?: string;
1314
}
1415

15-
function RadioPrimitive({ name, value, id, checked, required, onChange, disabled, asChild, ...props }: RadioPrimitiveProps) {
16+
function RadioPrimitive({ name, value, id, checked, required, onChange, disabled, asChild, className, ...props }: RadioPrimitiveProps) {
1617
return (
1718
<Primitive.input
1819
type="radio"
@@ -21,12 +22,13 @@ function RadioPrimitive({ name, value, id, checked, required, onChange, disabled
2122
tabIndex={-1}
2223
value={value}
2324
onChange={onChange}
24-
id={value}
25+
id={id}
2526
aria-disabled={disabled}
2627
disabled={disabled}
2728
required={required}
2829
aria-required={required}
2930
asChild={asChild}
31+
className={className}
3032
{...props}
3133
/>
3234
);

src/core/primitives/Radio/tests/Radio.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ describe('RadioPrimitive', () => {
88
value: 'option1',
99
id: 'radio1'
1010
};
11+
const baseProps = {
12+
name: 'test-radio',
13+
value: 'option1',
14+
id: 'radio1'
15+
};
1116

1217
it('renders with required props', () => {
1318
render(<RadioPrimitive {...baseProps} />);
@@ -18,6 +23,17 @@ describe('RadioPrimitive', () => {
1823
expect(radio).toHaveAttribute('id', 'option1');
1924
});
2025

26+
it('applies checked, required, and disabled props', () => {
27+
render(
28+
<RadioPrimitive {...baseProps} checked required disabled />
29+
);
30+
const radio = screen.getByRole('radio');
31+
expect(radio).toBeChecked();
32+
expect(radio).toBeRequired();
33+
expect(radio).toBeDisabled();
34+
expect(radio).toHaveAttribute('aria-disabled', 'true');
35+
expect(radio).toHaveAttribute('aria-required', 'true');
36+
});
2137
it('applies checked, required, and disabled props', () => {
2238
render(
2339
<RadioPrimitive {...baseProps} checked required disabled />
@@ -30,6 +46,15 @@ describe('RadioPrimitive', () => {
3046
expect(radio).toHaveAttribute('aria-required', 'true');
3147
});
3248

49+
it('calls onChange when clicked', () => {
50+
const handleChange = jest.fn();
51+
render(
52+
<RadioPrimitive {...baseProps} onChange={handleChange} />
53+
);
54+
const radio = screen.getByRole('radio');
55+
fireEvent.click(radio);
56+
expect(handleChange).toHaveBeenCalled();
57+
});
3358
it('calls onChange when clicked', () => {
3459
const handleChange = jest.fn();
3560
render(
@@ -45,4 +70,9 @@ describe('RadioPrimitive', () => {
4570
const radio = screen.getByRole('radio');
4671
expect(radio).toBeInTheDocument();
4772
});
73+
it('supports asChild prop (renders without error)', () => {
74+
render(<RadioPrimitive {...baseProps} asChild />);
75+
const radio = screen.getByRole('radio');
76+
expect(radio).toBeInTheDocument();
77+
});
4878
});

styles/themes/default.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
@use "components/toggle";
2626
@use "components/toggle-group";
2727
@use "components/radio-group";
28+
@use "components/radio";
2829
@use "components/radiocards";
2930
@use "components/slider";
3031
@use "components/scroll-area";

0 commit comments

Comments
 (0)