Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(checkbox): Checkbox 컴포넌트 구현 #106

Closed
wants to merge 10 commits into from
15 changes: 15 additions & 0 deletions packages/checkbox/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { StorybookConfig } from '@storybook/react-vite';

export default {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
Comment on lines +5 to +10
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

접근성 애드온 추가가 필요합니다.

체크박스는 접근성이 매우 중요한 컴포넌트입니다. 접근성 테스트를 위한 애드온을 추가하면 좋을 것 같습니다.

다음과 같이 a11y 애드온을 추가해주세요:

   addons: [
     '@storybook/addon-onboarding',
     '@storybook/addon-links',
     '@storybook/addon-essentials',
     '@storybook/addon-interactions',
+    '@storybook/addon-a11y',
   ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
],

framework: {
name: '@storybook/react-vite',
options: {},
},
} satisfies StorybookConfig;
17 changes: 17 additions & 0 deletions packages/checkbox/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Preview } from '@storybook/react';
import 'sanitize.css';

const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};

export default preview;

1 change: 1 addition & 0 deletions packages/checkbox/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.module.css';
70 changes: 70 additions & 0 deletions packages/checkbox/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"name": "@sipe-team/checkbox",
"description": "Checkbox for Sipe Design System",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/sipe-team/3-2_side"
},
"type": "module",
"exports": "./src/index.ts",
"files": ["dist"],
"scripts": {
"build": "tsup",
"build:storybook": "storybook build",
"dev:storybook": "storybook dev -p 6006",
"lint:biome": "pnpm exec biome lint",
"lint:eslint": "pnpm exec eslint --flag unstable_ts_config",
"test": "vitest",
"typecheck": "tsc",
"prepack": "pnpm run build"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@sipe-team/tokens": "workspace:^",
"@sipe-team/typography": "workspace:^",
"clsx": "^2.1.1",
"lucide-react": "^0.344.0"
},
"devDependencies": {
"@storybook/addon-essentials": "catalog:",
"@storybook/addon-interactions": "catalog:",
"@storybook/addon-links": "catalog:",
"@storybook/blocks": "catalog:",
"@storybook/react": "catalog:",
"@storybook/react-vite": "catalog:",
"@storybook/test": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.12",
"happy-dom": "catalog:",
"react": "^18.3.1",
"storybook": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"react": ">= 18"
},
"publishConfig": {
"access": "public",
"registry": "https://npm.pkg.github.com",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./styles.css": "./dist/index.css"
}
},
"sideEffects": false
}
57 changes: 57 additions & 0 deletions packages/checkbox/src/Checkbox.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
.root {
all: unset;
display: flex;
align-items: center;
gap: var(--padding);
margin: 4px;
}

.label {
display: flex;
align-items: center;
gap: var(--padding);
}

.checkbox {
all: unset;
width: var(--size);
height: var(--size);
border-radius: 4px;
border: 2px solid #a1a1aa;
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}

.checkbox:focus-visible {
border-color: #06b6d4;
outline: none;
}

.checkbox[data-disabled] {
cursor: not-allowed;
opacity: 0.4;
}

.checkbox[data-state="checked"] {
border-color: #00ffff;
background-color: #00ffff;
}

.indicator {
color: #ffffff;
height: var(--indicator-size);
width: var(--indicator-size);
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
}

.group {
display: flex;
flex-direction: column;
gap: 8px;
}
102 changes: 102 additions & 0 deletions packages/checkbox/src/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Checkbox, CheckboxGroup } from './Checkbox';

const meta = {
title: 'Components/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',
},
argTypes: {
size: {
description: '체크박스의 크기를 지정합니다',
options: ['sm', 'md', 'lg'],
control: { type: 'radio' },
},
disabled: {
description: '체크박스의 비활성화 상태를 지정합니다',
control: { type: 'boolean' },
},
checked: {
description: '체크박스의 선택 상태를 지정합니다',
control: { type: 'boolean' },
},
label: {
description: '체크박스의 레이블을 지정합니다',
control: 'text',
},
},
Comment on lines +11 to +29
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

접근성 속성에 대한 설명이 필요합니다.

argTypes에 aria-label과 같은 접근성 속성에 대한 설명이 누락되어 있습니다.

다음과 같이 접근성 관련 argTypes를 추가해주세요:

     argTypes: {
+        'aria-label': {
+            description: '스크린 리더를 위한 레이블을 지정합니다',
+            control: 'text',
+        },
+        'aria-describedby': {
+            description: '스크린 리더를 위한 추가 설명을 지정합니다',
+            control: 'text',
+        },
         size: {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
argTypes: {
size: {
description: '체크박스의 크기를 지정합니다',
options: ['sm', 'md', 'lg'],
control: { type: 'radio' },
},
disabled: {
description: '체크박스의 비활성화 상태를 지정합니다',
control: { type: 'boolean' },
},
checked: {
description: '체크박스의 선택 상태를 지정합니다',
control: { type: 'boolean' },
},
label: {
description: '체크박스의 레이블을 지정합니다',
control: 'text',
},
},
argTypes: {
'aria-label': {
description: '스크린 리더를 위한 레이블을 지정합니다',
control: 'text',
},
'aria-describedby': {
description: '스크린 리더를 위한 추가 설명을 지정합니다',
control: 'text',
},
size: {
description: '체크박스의 크기를 지정합니다',
options: ['sm', 'md', 'lg'],
control: { type: 'radio' },
},
disabled: {
description: '체크박스의 비활성화 상태를 지정합니다',
control: { type: 'boolean' },
},
checked: {
description: '체크박스의 선택 상태를 지정합니다',
control: { type: 'boolean' },
},
label: {
description: '체크박스의 레이블을 지정합니다',
control: 'text',
},
},

} satisfies Meta<typeof Checkbox>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Basic: Story = {
args: {
label: '체크박스',
size: 'md',
},
};

export const Sizes: Story = {
args: {
label: '체크박스',
},
render: (args) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<Checkbox {...args} size="sm" label="Small" />
<Checkbox {...args} size="md" label="Medium (Default)" />
<Checkbox {...args} size="lg" label="Large" />
</div>
),
};

export const States: Story = {
args: {
label: '체크박스',
},
render: (args) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<Checkbox {...args} label="Unchecked (Default)" />
<Checkbox {...args} label="Checked" defaultChecked />
<Checkbox {...args} label="Disabled" disabled />
<Checkbox {...args} label="Disabled & Checked" disabled defaultChecked />
</div>
),
};
Comment on lines +56 to +68
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

키보드 상호작용 예제가 필요합니다.

접근성을 위해 키보드 상호작용을 보여주는 스토리를 추가하면 좋을 것 같습니다.

다음과 같은 키보드 인터랙션 스토리를 추가해보세요:

export const KeyboardInteraction: Story = {
    args: {
        label: '키보드 상호작용',
    },
    parameters: {
        docs: {
            description: {
                story: '스페이스바 또는 엔터 키를 사용하여 체크박스를 토글할 수 있습니다.',
            },
        },
    },
    play: async ({ canvasElement }) => {
        const canvas = within(canvasElement);
        const checkbox = canvas.getByRole('checkbox');
        
        // 포커스
        await userEvent.tab();
        // 토글
        await userEvent.keyboard('[Space]');
    },
};


export const Controlled: Story = {
args: {
label: 'Controlled Checkbox',
},
render: (args) => {
const [checked, setChecked] = useState<boolean | 'indeterminate'>(false);

return (
<Checkbox
{...args}
checked={checked}
onCheckedChange={(state) => setChecked(state)}
/>
);
},
};

export const Group: Story = {
args: {
size: 'md',
},
render: (args) => {
const [selected, setSelected] = useState<string[]>([]);

return (
<CheckboxGroup value={selected} onChange={setSelected}>
<Checkbox {...args} value="apple" label="사과" />
<Checkbox {...args} value="banana" label="바나나" />
<Checkbox {...args} value="orange" label="오렌지" />
</CheckboxGroup>
);
},
};
97 changes: 97 additions & 0 deletions packages/checkbox/src/Checkbox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import { useState } from 'react';
import { Checkbox, CheckboxGroup } from './Checkbox';

test('체크박스를 클릭하면 체크 상태가 활성화된다.', async () => {
const user = userEvent.setup();
render(<Checkbox label="테스트" />);

const checkbox = screen.getByRole('checkbox', { name: '테스트' });
expect(checkbox).not.toBeChecked();

await user.click(checkbox);
expect(checkbox).toBeChecked();
});

test('disabled 상태의 체크박스를 클릭해도 체크 상태가 바뀌지 않는다.', async () => {
const user = userEvent.setup();
render(<Checkbox disabled label="테스트" />);

const checkbox = screen.getByRole('checkbox', { name: '테스트' });
expect(checkbox).toBeDisabled();

await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});

test('size를 주입하지 않으면 기본값 md로 설정된다.', () => {
render(<Checkbox label="테스트" />);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveStyle({ width: '20px', height: '20px' });
});

test.each([
{ size: 'sm', expected: '16px' },
{ size: 'md', expected: '20px' },
{ size: 'lg', expected: '24px' },
] as const)('size가 $size일 때 체크박스 크기가 $expected로 설정된다.', ({ size, expected }) => {
render(<Checkbox size={size} label="테스트" />);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveStyle({ width: expected, height: expected });
});

test('체크박스 그룹에서 여러 항목을 선택할 수 있다.', async () => {
const user = userEvent.setup();

const TestComponent = () => {
const [selected, setSelected] = useState<string[]>([]);
return (
<CheckboxGroup value={selected} onChange={setSelected}>
<Checkbox value="1" label="항목 1" />
<Checkbox value="2" label="항목 2" />
<Checkbox value="3" label="항목 3" />
</CheckboxGroup>
);
};

render(<TestComponent />);

const checkbox1 = screen.getByRole('checkbox', { name: '항목 1' });
const checkbox2 = screen.getByRole('checkbox', { name: '항목 2' });

await user.click(checkbox1);
expect(checkbox1).toBeChecked();
expect(checkbox2).not.toBeChecked();

await user.click(checkbox2);
expect(checkbox1).toBeChecked();
expect(checkbox2).toBeChecked();

await user.click(checkbox1);
expect(checkbox1).not.toBeChecked();
expect(checkbox2).toBeChecked();
});

test('체크박스 그룹에서 value prop으로 선택된 항목을 제어할 수 있다.', async () => {
const selectedValues = ['1', '3'];

render(
<CheckboxGroup value={selectedValues}>
<Checkbox value="1" label="항목 1" />
<Checkbox value="2" label="항목 2" />
<Checkbox value="3" label="항목 3" />
</CheckboxGroup>
);

const checkbox1 = screen.getByRole('checkbox', { name: '항목 1' });
const checkbox2 = screen.getByRole('checkbox', { name: '항목 2' });
const checkbox3 = screen.getByRole('checkbox', { name: '항목 3' });

expect(checkbox1).toBeChecked();
expect(checkbox2).not.toBeChecked();
expect(checkbox3).toBeChecked();
});
Loading
Loading