Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a9b39ed
feat: Radio(basic) 컴포넌트를 구현합니다
rkdcodus Oct 1, 2025
4b32e07
fix: interaction 함수에 borderRadius inherit 속성을 추가합니다
rkdcodus Oct 1, 2025
207c47b
docs: Radio(basic) 컴포넌트 스토리북을 작성합니다
rkdcodus Oct 1, 2025
17ea064
refactor: Radio 컴포넌트에서 ComponentPropsWithoutRef를 확장하여 구현합니다.
rkdcodus Oct 2, 2025
4b0271f
move: Radio 컴포넌트를 radioBasic 폴더로 이동합니다
rkdcodus Oct 2, 2025
12c73cd
feat: RadioContent(empty, left, non-checked) 컴포넌트 스타일을 구현합니다
rkdcodus Oct 2, 2025
27d75c0
fix: Radio(basic) 컴포넌트에서 checked, disabled 조건 여부에 따른 색상을 수정합니다
rkdcodus Oct 3, 2025
576b1bd
docs: Radio(basic) 컴포넌트 checked 스토리를 작성합니다
rkdcodus Oct 3, 2025
9869c91
feat: RadioContent 컴포넌트 (empty, left, checked) 스타일을 구현합니다
rkdcodus Oct 3, 2025
47e3cc6
feat: RadioContent에 left, right 속성에 따른 레이블 위치, 서브 레이블을 구현합니다
rkdcodus Oct 3, 2025
61a8cfc
fix: SUB_LABEL_SIZE 임포트 경로로 인한 버그를 수정합니다
rkdcodus Oct 3, 2025
1181295
fix: align이 right일 때의 서브 레이블 위치를 수정합니다
rkdcodus Oct 3, 2025
df2df93
feat: outline RadioContent 컴포넌트 스타일을 추가합니다
rkdcodus Oct 3, 2025
e5b4d5c
fix: RADIO_CONTAINER_SIZE에 borderRadius 속성을 추가합니다
rkdcodus Oct 3, 2025
7091720
docs: outline RadioContent를 스토리를 추가합니다
rkdcodus Oct 3, 2025
57fcc46
feat: outline RadioContent에서 disabled 일 때 border Color를 수정합니다
rkdcodus Oct 3, 2025
66805da
refactor: style 컴포넌트명을 수정합니다
rkdcodus Oct 3, 2025
bdfebff
fix: interaction 스프레드 시 발생하는 타입에러를 해결합니다
rkdcodus Oct 3, 2025
a517759
feat: scheme 토큰화 및 반응형으로 구현합니다
rkdcodus Oct 3, 2025
7256538
feat: Radio 컴포넌트에 cursor pointer 효과를 추가합니다
rkdcodus Oct 3, 2025
5ca8b1f
feat: interaction에 pointerEvents none 속성을 추가합니다
rkdcodus Oct 3, 2025
ce95916
fix: subLabel에 하이퍼링크가 들어올 경우, 커서 모양으로 링크를 분별하기 위해 cursor 스타일을 default…
rkdcodus Oct 3, 2025
3842fa9
docs: RadioContent의 하이퍼링크가 달린 레이블 스토리를 작성합니다
rkdcodus Oct 3, 2025
6e80840
refactor: Radio 컴포넌트에 반응형 토큰을 사용합니다
rkdcodus Oct 4, 2025
399dcf1
fix: Radio 컴포넌트 체크 스타일 버그를 수정합니다
rkdcodus Oct 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/jds/src/components/Radio/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './radioBasic/Radio';
63 changes: 63 additions & 0 deletions packages/jds/src/components/Radio/radioBasic/Radio.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Radio, RadioProps, RadioSize } from './Radio';

const meta: Meta<typeof Radio> = {
title: 'Components/Radio',
component: Radio,
parameters: {
layout: 'centered',
},
argTypes: {
radioSize: {
control: { type: 'radio' },
options: ['lg', 'md', 'sm', 'xs'],
},
},
};

export default meta;

type Story = StoryObj<typeof Radio>;

export const Default: Story = {
args: {
radioSize: 'lg',
},
};

export const Checked: Story = {
render: () => <Radio radioSize='md' name='disabledGroup' value='1' checked={true} />,
};

export const Disabled: Story = {
render: () => (
<div style={{ display: 'flex', gap: 20 }}>
<Radio radioSize='md' name='disabledGroup' value='2' checked={false} disabled={true} />
<Radio radioSize='md' name='disabledGroup' value='1' checked={true} disabled={true} />
</div>
),
};

export const Sizes: Story = {
render: () => {
const [checkedSize, setCheckedSize] = useState<RadioSize | undefined>('md');

const sizes: RadioProps['radioSize'][] = ['lg', 'md', 'sm', 'xs'];

return (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
{sizes.map(radioSize => (
<Radio
key={radioSize}
radioSize={radioSize}
name='sizeGroup'
value={radioSize}
checked={checkedSize === radioSize}
onChange={() => setCheckedSize(radioSize)}
/>
))}
</div>
);
},
};
80 changes: 80 additions & 0 deletions packages/jds/src/components/Radio/radioBasic/Radio.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import styled from '@emotion/styled';
import { RadioSize } from './Radio';
import { RADIO_SIZE } from './radio.variants';
import { interaction, pxToRem } from 'utils';

interface RadioStyledProps {
radioSize: RadioSize;
}

export const RadioLabel = styled.label<RadioStyledProps>(({ theme, radioSize }) => {
const borderSize = RADIO_SIZE[radioSize]
.border as keyof typeof theme.scheme.desktop.stroke.weight;

return {
display: 'inline-flex',
position: 'relative',

[`input[type="radio"]:not(:disabled):checked + .visual`]: {
backgroundColor: theme.color.surface.static.standard,
border: `${pxToRem(theme.scheme.desktop.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`,
[theme.breakPoint.tablet]: {
border: `${pxToRem(theme.scheme.tablet.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`,
},
[theme.breakPoint.mobile]: {
border: `${pxToRem(theme.scheme.mobile.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`,
},
},

[`input[type="radio"]:not(:checked):disabled + .visual`]: {
backgroundColor: theme.color.surface.standard,
borderColor: theme.color.stroke.alpha.subtler,
cursor: 'default',
...interaction(theme, 'normal', 'normal', 'default', 'disabled'),
},

[`input[type="radio"]:checked:disabled + .visual`]: {
backgroundColor: theme.color.fill.subtle,
border: `${pxToRem(theme.scheme.desktop.stroke.weight[borderSize])}px solid ${theme.color.stroke.subtler}`,
cursor: 'default',
...interaction(theme, 'normal', 'normal', 'default', 'disabled'),

[theme.breakPoint.tablet]: {
border: `${pxToRem(theme.scheme.tablet.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`,
},
[theme.breakPoint.mobile]: {
border: `${pxToRem(theme.scheme.mobile.stroke.weight[borderSize])} solid ${theme.color.accent.neutral}`,
},
},

[`input[type="radio"]:focus-visible + .visual`]: {
boxShadow: `0 0 0 3px ${theme.color.interaction.focus}`,
},
};
});

export const RadioInput = styled.input({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
border: 0,
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
});

export const RadioSpan = styled.span<RadioStyledProps>(({ theme, radioSize }) => {
const sizeValue = pxToRem(RADIO_SIZE[radioSize].radioSize);

return {
borderRadius: theme.scheme.desktop.radius.max,
width: sizeValue,
height: sizeValue,
border: `1px solid ${theme.color.stroke.alpha.assistive}`,
backgroundColor: theme.color.surface.shallow,
cursor: 'pointer',
...interaction(theme, 'normal', 'normal', 'default'),
};
});
21 changes: 21 additions & 0 deletions packages/jds/src/components/Radio/radioBasic/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { RadioInput, RadioLabel, RadioSpan } from './Radio.style';

export type RadioSize = 'lg' | 'md' | 'sm' | 'xs';

export interface RadioProps extends ComponentPropsWithoutRef<'input'> {
radioSize?: RadioSize;
Comment on lines +6 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

radioContent의 스토리북을 보면 비제어형 컴포넌트, 제어형 컴포넌트로 사용될 때의 경우를 모두 표현해주셨는데, 이미 ComponentPropsWithoutRef<'input'> 에 포함되어 있는 값이지만 checked, onChange, defaultChecked(비제어형까지 고려 시) 정도는 명시적으로 표현을 해줘도 좋을 거 같습니다!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JDS 라디오 설명 란에 사용자는 동시에 하나의 옵션만 선택할 수 있다. 고 되어있는데, 해당 부분을 고려 + 라디오 버튼의 주 케이스를 고려했을 때 Controlled Component로 사용되는 케이스가 더 많을 거 같아요.

이런 경우, Radio의 상태를 관리할 수 있는 Context까지 생성하는 것도 좋을 거 같아요!

++) 요거는 구현하기에 따라서 어느정도 제어형 컴포넌트의 리렌더링 문제도 해소 가능할 거 같습니다.

}

export const Radio = forwardRef<HTMLInputElement, RadioProps>(
({ radioSize = 'md', ...props }, ref) => {
return (
<RadioLabel radioSize={radioSize}>
<RadioInput ref={ref} type='radio' {...props} />
<RadioSpan className='visual' aria-hidden='true' radioSize={radioSize} />
</RadioLabel>
Comment on lines +13 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

설명해 주신대로 태그 구조가 잘 설정되어있네요~

);
},
);

Radio.displayName = 'Radio';
26 changes: 26 additions & 0 deletions packages/jds/src/components/Radio/radioBasic/radio.variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { RadioSize } from './Radio';

export const RADIO_SIZE: Record<
RadioSize,
{
radioSize: number;
border: string;
}
> = {
lg: {
radioSize: 20,
border: '6',
},
md: {
radioSize: 18,
border: '5',
},
sm: {
radioSize: 16,
border: '5',
},
xs: {
radioSize: 14,
border: '4',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { ChangeEvent, FormEvent, useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { RadioContent } from './RadioContent';

const meta: Meta<typeof RadioContent> = {
title: 'Components/RadioContent',
component: RadioContent,
parameters: {
layout: 'centered',
},
argTypes: {
radioSize: {
control: { type: 'radio' },
options: ['lg', 'md', 'sm', 'xs'],
},
},
};

export default meta;

type Story = StoryObj<typeof RadioContent>;

export const Default: Story = {
args: {
radioSize: 'lg',
radioStyle: 'empty',
align: 'left',
disabled: false,
subLabelVisible: false,
subLabel: '레이블',
children: '레이블',
},
};

export const OutlineRadio: Story = {
render: () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
<RadioContent radioSize='md' radioStyle='outline' value='1'>
레이블
</RadioContent>
<RadioContent
radioSize='md'
radioStyle='outline'
subLabelVisible={true}
subLabel='레이블'
value='2'
>
레이블
</RadioContent>
<RadioContent radioSize='md' radioStyle='outline' align='right' value='3'>
레이블
</RadioContent>
<RadioContent
radioSize='md'
radioStyle='outline'
align='right'
subLabelVisible={true}
subLabel='레이블'
value='4'
>
레이블
</RadioContent>
</div>
),
};

export const SubLabelWithHyperlink: Story = {
render: () => (
<RadioContent
radioSize='md'
radioStyle='outline'
subLabelVisible={true}
subLabel={
<>
하이퍼링크&nbsp;
<a href='https://www.naver.com/' style={{ textDecoration: 'underline' }}>
(네이버 바로가기)
</a>
</>
}
value='1'
>
레이블
</RadioContent>
),
};

export const controlledRadio: Story = {
render: () => {
const [checked, setChecked] = useState('korea');

const handleGenderChange = (e: ChangeEvent<HTMLInputElement>) => {
setChecked(e.target.value);
};

const items = ['korea', 'japan', 'us', 'uk'];

return (
<div style={{ display: 'flex', gap: 20 }}>
{items.map(item => (
<RadioContent
key={item}
radioSize='md'
name='radioGroup'
value={item}
checked={checked === item}
onChange={handleGenderChange}
>
{item}
</RadioContent>
))}
<p>결과: {checked}</p>
</div>
);
},
};

export const uncontrolledRadio: Story = {
render: () => (
<div>
<form
style={{ display: 'flex', gap: 20 }}
onSubmit={(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
alert(`${form.namedItem('groupName').value} 확인!`);
}}
>
<RadioContent radioSize='md' name='groupName' value='apple' defaultChecked>
apple
</RadioContent>
<RadioContent radioSize='md' name='groupName' value='banana'>
banana
</RadioContent>
<RadioContent radioSize='md' name='groupName' value='orange'>
orange
</RadioContent>
<button type='submit'>제출 버튼</button>
</form>
</div>
),
};
Loading