Repository: kss761036/Epigram
Pull request #45: Feat/30/토글_라디오_컴포넌트_추가
Author: login: kss761036
Labels: name: Priority: Medium :bookmark:
description: 우선순위 보통
name: Type: Feature :sparkles:
description: 신규 기능
name: Type: Test :test_tube:
description: 테스트
Created: 2025-03-12T08:38:41Z | Merged: 2025-03-12T11:18:20Z

## ❓이슈

- close #30

## :memo: Description

1. 토글 컴포넌트 추가
2. 라디오 컴포넌트 추가

토글은 svg로 작업했고, 라디오는 직접 만들어서 작업했습니다.


<!-- 어떤 내용의 PR인지 작성해 주세요. (ex. 메인 페이지 레이아웃 작업) -->
<!-- ⚠️ PR에는 해당 PR의 제목에 해당하는 내용만 들어가 있어야 합니다!  -->

## :cyclone: PR Type

어떤 변경 사항이 있나요?

<!-- 해당 사항에 체크해 주세요. -->

- [x] 새로운 기능 추가
- [ ] 버그 수정
- [ ] CSS 등 사용자 UI 디자인 변경
- [ ] 코드에 영향을 주지 않는 변경사항 (오타 수정, 탭 사이즈 변경, 변수명 변경)
- [ ] 코드 리팩토링
- [ ] 주석 추가 및 수정
- [ ] 문서 수정
- [x] 테스트 추가, 테스트 리팩토링
- [ ] 빌드 부분 혹은 패키지 매니저 수정
- [ ] 파일 혹은 폴더명 수정
- [ ] 파일 혹은 폴더 삭제

### PR

<!-- 작성중인 PR인 경우, Draft 모드로 생성해주세요. -->

- [x] Branch Convention 확인
  > `feat/*` 기능 구현, `fix/*` 버그 수정, `refactor/*` 개선
- [x] Base Branch 확인
- [x] 적절한 Label 지정
- [x] Assignee 및 Reviewer 지정


Linked issue #30: Feat: 토글, 라디오 버튼 컴포넌트
### ❓ 설명

토글, 라디오 버튼 컴포넌트

### ✅ 작업할 내용

- [ ] 토글 컴포넌트
- [ ] 라디오 컴포넌트


### 📄 참고 자료

_No response_

Diff:
diff --git a/src/components/Radio.stories.tsx b/src/components/Radio.stories.tsx
new file mode 100644
index 0000000..e33ce0b
--- /dev/null
+++ b/src/components/Radio.stories.tsx
@@ -0,0 +1,35 @@
+import { Meta, StoryObj } from '@storybook/react';
+import Radio from './Radio';
+
+const meta: Meta<typeof Radio> = {
+  title: 'Components/Radio',
+  component: Radio,
+  tags: ['autodocs'],
+  argTypes: {
+    isChecked: { control: 'boolean' },
+  },
+};
+
+export default meta;
+
+type Story = StoryObj<typeof Radio>;
+
+export const Default: Story = {
+  args: {
+    isChecked: false,
+    label: '직접입력',
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
+
+export const Checked: Story = {
+  args: {
+    isChecked: true,
+    label: '직접입력',
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
diff --git a/src/components/Radio.test.tsx b/src/components/Radio.test.tsx
new file mode 100644
index 0000000..fc4d80b
--- /dev/null
+++ b/src/components/Radio.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import Radio, { RadioProps } from '@/components/Radio';
+
+describe('Radio Component', () => {
+  const defaultProps: RadioProps = {
+    isChecked: false,
+    id: 'test-radio',
+    name: 'test-group',
+    label: '테스트 라디오',
+    onChange: jest.fn(),
+  };
+
+  it('라디오 버튼이 렌더링 되는지 확인', () => {
+    render(<Radio {...defaultProps} />);
+    const radioInput = screen.getByRole('radio', { name: '테스트 라디오' });
+
+    expect(radioInput).toBeInTheDocument();
+    expect(radioInput).not.toBeChecked();
+  });
+
+  it('라벨을 클릭하면 `onChange`가 호출되는지 확인', () => {
+    const onChangeMock = jest.fn();
+    render(<Radio {...defaultProps} onChange={onChangeMock} />);
+
+    const radioInput = screen.getByRole('radio', { name: '테스트 라디오' });
+    fireEvent.click(radioInput);
+
+    expect(onChangeMock).toHaveBeenCalledWith(true);
+  });
+
+  it('isChecked가 true이면 라디오 버튼이 체크 상태인지 확인', () => {
+    render(<Radio {...defaultProps} isChecked={true} />);
+    const radioInput = screen.getByRole('radio', { name: '테스트 라디오' });
+
+    expect(radioInput).toBeChecked();
+  });
+});
diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx
new file mode 100644
index 0000000..f3d4643
--- /dev/null
+++ b/src/components/Radio.tsx
@@ -0,0 +1,36 @@
+import { HTMLAttributes } from 'react';
+
+export interface RadioProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange?: (checked: boolean) => void;
+  label?: string;
+  name?: string;
+}
+
+export default function Radio({
+  isChecked = false,
+  label = undefined,
+  onChange,
+  id = '',
+  name = '',
+}: RadioProps) {
+  return (
+    <div className='group'>
+      <input
+        className='peer sr-only'
+        type='radio'
+        name={name}
+        id={id}
+        checked={isChecked}
+        onChange={() => onChange?.(!isChecked)}
+      />
+      <label className='flex cursor-pointer items-center gap-2' htmlFor={id}>
+        <span className='relative block h-5 w-5 rounded-full border-2 border-blue-300'>
+          <span className='absolute top-1/2 left-1/2 block h-2 w-2 -translate-1/2 scale-0 rounded-full bg-blue-800 transition-transform duration-100 group-has-[:checked]:scale-100'></span>
+        </span>
+        {label && <span className='leading-none'>{label}</span>}
+      </label>
+    </div>
+  );
+}
diff --git a/src/components/Toggle.stories.tsx b/src/components/Toggle.stories.tsx
new file mode 100644
index 0000000..9dd1f61
--- /dev/null
+++ b/src/components/Toggle.stories.tsx
@@ -0,0 +1,40 @@
+import { Meta, StoryObj } from '@storybook/react';
+import Toggle from './Toggle';
+
+const meta: Meta<typeof Toggle> = {
+  title: 'Components/Toggle',
+  component: Toggle,
+  tags: ['autodocs'],
+  argTypes: {
+    isChecked: { control: 'boolean' },
+    color: { control: 'text' },
+    label: { control: 'text' },
+    onChange: { action: 'changed' },
+  },
+};
+
+export default meta;
+
+type Story = StoryObj<typeof Toggle>;
+
+export const Default: Story = {
+  args: {
+    isChecked: false,
+    label: '공개',
+    id: 'toggle_1',
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
+
+export const Checked: Story = {
+  args: {
+    isChecked: true,
+    label: '공개',
+    id: 'toggle_2',
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
diff --git a/src/components/Toggle.test.tsx b/src/components/Toggle.test.tsx
new file mode 100644
index 0000000..1552ccb
--- /dev/null
+++ b/src/components/Toggle.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import Toggle from './Toggle';
+
+describe('Toggle 컴포넌트', () => {
+  it('초기 상태가 체크되지 않은 상태로 렌더링된다', () => {
+    render(<Toggle isChecked={false} onChange={jest.fn()} id='test-toggle' />);
+    const toggleInput = screen.getByRole('checkbox');
+    expect(toggleInput).not.toBeChecked();
+  });
+
+  it('초기 상태가 체크된 상태로 렌더링된다', () => {
+    render(<Toggle isChecked={true} onChange={jest.fn()} id='test-toggle' />);
+    const toggleInput = screen.getByRole('checkbox');
+    expect(toggleInput).toBeChecked();
+  });
+
+  it('클릭 시 onChange가 호출되고 상태가 변경된다', () => {
+    const onChangeMock = jest.fn();
+    render(<Toggle isChecked={false} onChange={onChangeMock} id='test-toggle' />);
+    const toggleInput = screen.getByRole('checkbox');
+    fireEvent.click(toggleInput);
+    expect(onChangeMock).toHaveBeenCalledTimes(1);
+    expect(onChangeMock).toHaveBeenCalledWith(true);
+  });
+
+  it('label이 전달되면 표시된다', () => {
+    render(<Toggle isChecked={false} onChange={jest.fn()} label='공개' id='test-toggle' />);
+    const labelElement = screen.getByText('공개');
+    expect(labelElement).toBeInTheDocument();
+  });
+
+  it('label이 전달되지 않으면 표시되지 않는다', () => {
+    render(<Toggle isChecked={false} onChange={jest.fn()} id='test-toggle' />);
+    const labelElement = screen.queryByText('공개');
+    expect(labelElement).not.toBeInTheDocument();
+  });
+
+  it('color prop이 적용된다', () => {
+    render(
+      <Toggle
+        isChecked={false}
+        onChange={jest.fn()}
+        color='text-red-500'
+        label='테스트'
+        id='test-toggle'
+      />,
+    );
+    const labelElement = screen.getByText('테스트');
+    expect(labelElement).toHaveClass('text-red-500');
+  });
+
+  it('name prop이 올바르게 적용된다', () => {
+    render(<Toggle isChecked={false} onChange={jest.fn()} name='toggle-name' id='test-toggle' />);
+    const toggleInput = screen.getByRole('checkbox');
+    expect(toggleInput).toHaveAttribute('name', 'toggle-name');
+  });
+
+  it('id prop이 올바르게 적용된다', () => {
+    render(<Toggle isChecked={false} onChange={jest.fn()} id='custom-toggle-id' />);
+    const toggleInput = screen.getByRole('checkbox');
+    expect(toggleInput).toHaveAttribute('id', 'custom-toggle-id');
+  });
+});
diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx
new file mode 100644
index 0000000..7b9253f
--- /dev/null
+++ b/src/components/Toggle.tsx
@@ -0,0 +1,86 @@
+import { HTMLAttributes } from 'react';
+
+export interface ToggleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange: (checked: boolean) => void;
+  color?: string;
+  label?: string;
+  name?: string;
+}
+
+export default function Toggle({
+  isChecked = false,
+  onChange,
+  color = 'text-gray-400',
+  label = undefined,
+  id = '',
+  name = '',
+}: ToggleProps) {
+  return (
+    <div>
+      <input
+        className='sr-only'
+        type='checkbox'
+        id={id}
+        name={name}
+        checked={isChecked}
+        onChange={() => onChange(!isChecked)}
+      />
+      <label className='flex cursor-pointer items-center justify-center gap-2' htmlFor={id}>
+        {label && <span className={`${color} text-[12px] md:text-lg`}>{label}</span>}
+        <div className='block md:hidden'>
+          <svg
+            xmlns='http://www.w3.org/2000/svg'
+            width='32'
+            height='16'
+            viewBox='0 0 32 16'
+            fill='none'
+          >
+            <rect
+              width='32'
+              height='16'
+              rx='8'
+              className={`transition-colors ${isChecked ? 'fill-black-600' : 'fill-gray-300'}`}
+            />
+            <circle
+              cx='8'
+              cy='8'
+              r='5'
+              className={`fill-white transition-transform duration-300`}
+              style={{
+                transform: isChecked ? 'translateX(16px)' : 'translateX(0px)',
+              }}
+            />
+          </svg>
+        </div>
+        <div className='hidden md:block'>
+          <svg
+            xmlns='http://www.w3.org/2000/svg'
+            width='42'
+            height='24'
+            viewBox='0 0 42 24'
+            fill='none'
+          >
+            <rect
+              width='42'
+              height='24'
+              rx='12'
+              className={`transition-colors ${isChecked ? 'fill-black-600' : 'fill-gray-300'}`}
+            />
+            <circle
+              cx='12'
+              cy='12'
+              r='8'
+              fill='white'
+              className={`fill-white transition-transform duration-300`}
+              style={{
+                transform: isChecked ? 'translateX(18px)' : 'translateX(0px)',
+              }}
+            />
+          </svg>
+        </div>
+      </label>
+    </div>
+  );
+}


Reviews:
user: login: kss761036
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-12T08:41:30Z
user: login: cksrlcks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T08:43:52Z
user: login: cksrlcks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T08:44:31Z
user: login: cksrlcks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T10:37:23Z
user: login: cksrlcks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T10:44:53Z
user: login: cksrlcks
body: 수고하셨습니다.
리뷰하면서 남겨놓은 코멘트는 꼭 지금 무조건 해야하는건 아니에욥.
나중에 작성 및 수정페이지 진행하시면서 한번더 고민해보셔도 되는 사항이라 승인 눌러두겠습니다!

state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-12T10:55:42Z

Review comments:
diff_hunk: @@ -0,0 +1,86 @@
+import { HTMLAttributes } from 'react';
+
+export interface ToggleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange: (checked: boolean) => void;
+  color?: string;
+  label?: string;
+  name?: string;
+}
+
+export default function Toggle({
+  isChecked = false,
+  onChange,
+  color = 'text-gray-400',
+  label = undefined,
+  id = '',
+  name = '',
+}: ToggleProps) {
+  return (
+    <div>
+      <input
+        className='sr-only'
+        type='checkbox'
+        id={id}
+        name={name}
+        checked={isChecked}
+        onChange={() => onChange(!isChecked)}
+      />
+      <label className='flex cursor-pointer items-center justify-center gap-2' htmlFor={id}>
path: src/components/Toggle.tsx
user: login: kss761036
body: cva를 안써서 작업했는데 쓰면 훨씬 편해질까요??
created_at: 2025-03-12T08:40:26Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,86 @@
+import { HTMLAttributes } from 'react';
+
+export interface ToggleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange: (checked: boolean) => void;
+  color?: string;
+  label?: string;
+  name?: string;
+}
+
+export default function Toggle({
+  isChecked = false,
+  onChange,
+  color = 'text-gray-400',
+  label = undefined,
+  id = '',
+  name = '',
+}: ToggleProps) {
+  return (
+    <div>
+      <input
+        className='sr-only'
+        type='checkbox'
+        id={id}
+        name={name}
+        checked={isChecked}
+        onChange={() => onChange(!isChecked)}
+      />
+      <label className='flex cursor-pointer items-center justify-center gap-2' htmlFor={id}>
+        {label && <span className={`${color} text-[12px] md:text-lg`}>{label}</span>}
+        <div className='block md:hidden'>
+          <svg
+            xmlns='http://www.w3.org/2000/svg'
+            width='32'
+            height='16'
+            viewBox='0 0 32 16'
+            fill='none'
+          >
+            <rect
+              width='32'
+              height='16'
+              rx='8'
+              className={`transition-colors ${isChecked ? 'fill-black-600' : 'fill-gray-300'}`}
+            />
+            <circle
+              cx='8'
+              cy='8'
+              r='5'
+              className={`fill-white transition-transform duration-300`}
+              style={{
+                transform: isChecked ? 'translateX(16px)' : 'translateX(0px)',
+              }}
+            />
+          </svg>
+        </div>
+        <div className='hidden md:block'>
+          <svg
path: src/components/Toggle.tsx
user: login: kss761036
body: 애니메이션을 주려고 svg를 써서,, 테일윈드 적용이 어렵더라구요
이것도 html, css로 만드는게 나을까요?
created_at: 2025-03-12T08:41:23Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,86 @@
+import { HTMLAttributes } from 'react';
+
+export interface ToggleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange: (checked: boolean) => void;
+  color?: string;
+  label?: string;
+  name?: string;
+}
+
+export default function Toggle({
+  isChecked = false,
+  onChange,
+  color = 'text-gray-400',
+  label = undefined,
+  id = '',
+  name = '',
+}: ToggleProps) {
+  return (
+    <div>
+      <input
+        className='sr-only'
+        type='checkbox'
+        id={id}
+        name={name}
+        checked={isChecked}
+        onChange={() => onChange(!isChecked)}
+      />
+      <label className='flex cursor-pointer items-center justify-center gap-2' htmlFor={id}>
+        {label && <span className={`${color} text-[12px] md:text-lg`}>{label}</span>}
+        <div className='block md:hidden'>
+          <svg
+            xmlns='http://www.w3.org/2000/svg'
+            width='32'
+            height='16'
+            viewBox='0 0 32 16'
+            fill='none'
+          >
+            <rect
+              width='32'
+              height='16'
+              rx='8'
+              className={`transition-colors ${isChecked ? 'fill-black-600' : 'fill-gray-300'}`}
+            />
+            <circle
+              cx='8'
+              cy='8'
+              r='5'
+              className={`fill-white transition-transform duration-300`}
+              style={{
+                transform: isChecked ? 'translateX(16px)' : 'translateX(0px)',
+              }}
+            />
+          </svg>
+        </div>
+        <div className='hidden md:block'>
+          <svg
path: src/components/Toggle.tsx
user: login: cksrlcks
body: 꼭 svg로 안하고 div, span으로도 많이 만들어욤

created_at: 2025-03-12T08:43:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,86 @@
+import { HTMLAttributes } from 'react';
+
+export interface ToggleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange: (checked: boolean) => void;
+  color?: string;
+  label?: string;
+  name?: string;
+}
+
+export default function Toggle({
+  isChecked = false,
+  onChange,
+  color = 'text-gray-400',
+  label = undefined,
+  id = '',
+  name = '',
+}: ToggleProps) {
+  return (
+    <div>
+      <input
+        className='sr-only'
+        type='checkbox'
+        id={id}
+        name={name}
+        checked={isChecked}
+        onChange={() => onChange(!isChecked)}
+      />
+      <label className='flex cursor-pointer items-center justify-center gap-2' htmlFor={id}>
path: src/components/Toggle.tsx
user: login: cksrlcks
body: cva에는 받은 props의 조합으로 복잡한 classname을 만들어야할때 쓰면되서
꼭 쓸필요는 없다고 봐욤 ㅎ
created_at: 2025-03-12T08:44:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,36 @@
+import { HTMLAttributes } from 'react';
+
+export interface RadioProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
+  isChecked: boolean;
+  id: string;
+  onChange?: (checked: boolean) => void;
+  label?: string;
+  name?: string;
+}
+
+export default function Radio({
+  isChecked = false,
path: src/components/Radio.tsx
user: login: cksrlcks
body: isChecked를 input의 attributes인 checked로 그대로 표현하면,
사용할때 명확할것같아욤~ (꼭 checked일 필요는 없어욤)
created_at: 2025-03-12T10:37:23Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,36 @@
+import { HTMLAttributes } from 'react';
+
+export interface RadioProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
path: src/components/Radio.tsx
user: login: cksrlcks
body: 확장한 타입이, input의 attribute이면 더 좋을것 같아요
value,
onChange, 
checked,
name, 
id,
disabeld 같은 속성등이 전부 input한테 들어있어서, rest 파라미터로 그대로 컴포넌트 내부에 전달하는 이점도 있거든욤


created_at: 2025-03-12T10:44:52Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-12T08:38:45Z
body: [vc]: #EmrBATxsXI7B9luu+uQdjivJEGa9cWF90C7iVdaAlJM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJlcGlncmFtLWdpbHYiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiZXBpZ3JhbS1naWx2LWdpdC0zMC1mZWF0LWtpbXNldW5nc2Vva3MtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20va2ltc2V1bmdzZW9rcy1wcm9qZWN0cy9lcGlncmFtLWdpbHYvOUY3eFhyaXp2cHFxWlpQQWFpaUc4Y2dvTk5xSCIsInByZXZpZXdVcmwiOiJlcGlncmFtLWdpbHYtZ2l0LTMwLWZlYXQta2ltc2V1bmdzZW9rcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **epigram-gilv** | ✅ Ready ([Inspect](https://vercel.com/kimseungseoks-projects/epigram-gilv/9F7xXrizvpqqZZPAaiiG8cgoNNqH)) | [Visit Preview](https://epigram-gilv-git-30-feat-kimseungseoks-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/epigram-gilv-git-30-feat-kimseungseoks-projects.vercel.app?via=pr-comment-feedback-link) | Mar 12, 2025 11:09am |


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-12T08:40:56Z
body: Storybook URL: https://storybook-static-6mkt2w0tk-cksrlcks-projects.vercel.app
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-12T11:10:44Z
body: Storybook URL: https://storybook-static-gapifpky3-cksrlcks-projects.vercel.app
author_association: NONE