diff --git a/.changeset/shaky-ideas-roll.md b/.changeset/shaky-ideas-roll.md new file mode 100644 index 00000000..d07831ca --- /dev/null +++ b/.changeset/shaky-ideas-roll.md @@ -0,0 +1,5 @@ +--- +"@devup-ui/components": patch +--- + +Checkbox component has been implemented. diff --git a/packages/components/src/__tests__/index.browser.test.ts b/packages/components/src/__tests__/index.browser.test.ts index 04405e3c..52ef27c8 100644 --- a/packages/components/src/__tests__/index.browser.test.ts +++ b/packages/components/src/__tests__/index.browser.test.ts @@ -20,6 +20,7 @@ describe('export', () => { SelectContext: expect.any(Object), useSelect: expect.any(Function), Toggle: expect.any(Function), + Checkbox: expect.any(Function), }) }) }) diff --git a/packages/components/src/components/Checkbox/CheckIcon.tsx b/packages/components/src/components/Checkbox/CheckIcon.tsx new file mode 100644 index 00000000..e33c72af --- /dev/null +++ b/packages/components/src/components/Checkbox/CheckIcon.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from 'react' + +type CheckIconProps = SVGProps & { + color?: string +} + +export function CheckIcon({ color, ...props }: CheckIconProps) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Checkbox/Checkbox.stories.tsx b/packages/components/src/components/Checkbox/Checkbox.stories.tsx new file mode 100644 index 00000000..ec7177aa --- /dev/null +++ b/packages/components/src/components/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,42 @@ +import { Meta, StoryObj } from '@storybook/react-vite' + +import { Checkbox } from './index' + +type Story = StoryObj + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta: Meta = { + title: 'Devfive/Checkbox', + component: Checkbox, + decorators: [ + (Story, context) => { + const theme = + context.parameters.theme || context.globals.theme || 'default' + const isDark = theme === 'dark' + + return ( +
+ +
+ ) + }, + ], +} + +export const Default: Story = { + args: { + children: 'Checkbox', + disabled: false, + checked: true, + }, +} + +export default meta diff --git a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap new file mode 100644 index 00000000..fed6d362 --- /dev/null +++ b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -0,0 +1,303 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Checkbox > should render basic checkbox 1`] = ` +
+
+
+ +
+ +
+
+`; + +exports[`Checkbox > should render checkbox with custom child 1`] = ` +
+
+
+ +
+ +
+
+`; + +exports[`Checkbox > should render checked checkbox 1`] = ` +
+
+
+ + + + +
+ +
+
+`; + +exports[`Checkbox > should render checked checkbox with custom colors 1`] = ` +
+
+
+ + + + +
+ +
+
+`; + +exports[`Checkbox > should render disabled and checked checkbox 1`] = ` +
+
+
+ + + + +
+ +
+
+`; + +exports[`Checkbox > should render disabled checkbox 1`] = ` +
+
+
+ +
+ +
+
+`; + +exports[`Checkbox > should render disabled checkbox with custom colors 1`] = ` +
+
+
+ +
+ +
+
+`; + +exports[`Checkbox > should render with custom colors 1`] = ` +
+
+
+ +
+ +
+
+`; + +exports[`Checkbox > should render with partial custom colors 1`] = ` +
+
+
+ +
+ +
+
+`; diff --git a/packages/components/src/components/Checkbox/__tests__/index.browser.test.tsx b/packages/components/src/components/Checkbox/__tests__/index.browser.test.tsx new file mode 100644 index 00000000..7f8878c3 --- /dev/null +++ b/packages/components/src/components/Checkbox/__tests__/index.browser.test.tsx @@ -0,0 +1,424 @@ +import { act, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { Checkbox } from '../index' + +describe('Checkbox', () => { + it('should render basic checkbox', () => { + const { container } = render(Test Checkbox) + expect(container).toMatchSnapshot() + }) + + it('should render checked checkbox', () => { + const { container } = render(Test Checkbox) + expect(container).toMatchSnapshot() + expect(container.querySelector('input')).toBeChecked() + }) + + it('should render disabled checkbox', () => { + const { container } = render(Test Checkbox) + expect(container).toMatchSnapshot() + expect(container.querySelector('input')).toBeDisabled() + }) + + it('should render disabled and checked checkbox', () => { + const { container } = render( + + Test Checkbox + , + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('input')).toBeChecked() + expect(container.querySelector('input')).toBeDisabled() + }) + + it('should render checkbox with custom child', () => { + const { container } = render( + +
Custom Child
+
, + ) + expect(container).toMatchSnapshot() + }) + + // onChange 로직 테스트 + it('should call onChange with true when checkbox is clicked and unchecked', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') + expect(input).toBeInTheDocument() + + await act(async () => { + await userEvent.click(input!) + }) + + expect(onChange).toHaveBeenCalledWith(true) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should call onChange with false when checkbox is clicked and checked', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') + + await act(async () => { + await userEvent.click(input!) + }) + + expect(onChange).toHaveBeenCalledWith(false) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not call onChange when disabled is true', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') + + await act(async () => { + await userEvent.click(input!) + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should not call onChange when onChange prop is not provided', async () => { + const { container } = render(Test Checkbox) + + const input = container.querySelector('input') + + // Should not throw error when clicking without onChange + await act(async () => { + await userEvent.click(input!) + }) + + // Test passes if no error is thrown + expect(true).toBe(true) + }) + + it('should not call onChange when both disabled and onChange are provided', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') + + await act(async () => { + await userEvent.click(input!) + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should handle label click and trigger onChange', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const label = container.querySelector('label') + expect(label).toBeInTheDocument() + + await act(async () => { + await userEvent.click(label!) + }) + + expect(onChange).toHaveBeenCalledWith(true) + }) + + it('should not trigger onChange on label click when disabled', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const label = container.querySelector('label') + + await act(async () => { + await userEvent.click(label!) + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should pass correct event target checked value to onChange', async () => { + const onChange = vi.fn() + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') as HTMLInputElement + + await act(async () => { + await userEvent.click(input!) + }) + + expect(onChange).toHaveBeenCalledWith(true) + expect(input.checked).toBe(false) + }) + + it('should have proper accessibility attributes', () => { + const { container } = render(Test Checkbox) + + const input = container.querySelector('input') + + expect(input).toHaveAttribute('type', 'checkbox') + }) + + it('should display CheckIcon when checked', () => { + const { container } = render(Test Checkbox) + + const checkIcon = container.querySelector('svg') + expect(checkIcon).toBeInTheDocument() + }) + + it('should not display CheckIcon when unchecked', () => { + const { container } = render( + Test Checkbox, + ) + + const checkIcon = container.querySelector('svg') + expect(checkIcon).not.toBeInTheDocument() + }) + + it('should pass through additional props to input element', () => { + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') + expect(input).toHaveAttribute('data-testid', 'custom-checkbox') + expect(input).toHaveAttribute('name', 'test-name') + expect(input).toHaveAttribute('value', 'test-value') + }) + + // colors prop 테스트 + it('should render with custom colors', () => { + const customColors = { + primary: '#ff0000', + border: '#00ff00', + text: '#0000ff', + inputBg: '#ffff00', + } + + const { container } = render( + Test Checkbox, + ) + + expect(container).toMatchSnapshot() + }) + + it('should render with partial custom colors', () => { + const partialColors = { + primary: '#ff0000', + text: '#0000ff', + } + + const { container } = render( + Test Checkbox, + ) + + expect(container).toMatchSnapshot() + }) + + it('should render checked checkbox with custom colors', () => { + const customColors = { + primary: '#ff0000', + border: '#00ff00', + text: '#0000ff', + inputBg: '#ffff00', + checkIcon: '#000000', + } + + const { container } = render( + + Test Checkbox + , + ) + + expect(container).toMatchSnapshot() + }) + + it('should render disabled checkbox with custom colors', () => { + const customColors = { + primary: '#ff0000', + border: '#00ff00', + text: '#0000ff', + inputBg: '#ffff00', + checkIcon: '#000000', + } + + const { container } = render( + + Test Checkbox + , + ) + + expect(container).toMatchSnapshot() + }) + + it('should apply primary color to CSS variables', () => { + const customColors = { + primary: '#red-custom', + } + + const { container } = render( + Test Checkbox, + ) + + const input = container.querySelector('input') + expect(input).toHaveStyle({ + '--primary': '#red-custom', + }) + }) + + it('should apply border color to CSS variables', () => { + const customColors = { + border: '#border-custom', + } + + const { container } = render( + Test Checkbox, + ) + + const input = container.querySelector('input') + expect(input).toHaveStyle({ + '--border': '#border-custom', + }) + }) + + it('should apply text color to CSS variables', () => { + const customColors = { + text: '#text-custom', + } + + const { container } = render( + Test Checkbox, + ) + + const input = container.querySelector('input') + expect(input).toHaveStyle({ + '--text': '#text-custom', + }) + }) + + it('should apply inputBg color to CSS variables', () => { + const customColors = { + inputBg: '#inputBg-custom', + } + + const { container } = render( + Test Checkbox, + ) + + const input = container.querySelector('input') + expect(input).toHaveStyle({ + '--inputBg': '#inputBg-custom', + }) + }) + + it('should apply checkIcon color to CSS variables', () => { + const customColors = { + checkIcon: '#checkIcon-custom', + } + + const { container } = render( + Test Checkbox, + ) + + const input = container.querySelector('input') + expect(input).toHaveStyle({ + '--checkIcon': '#checkIcon-custom', + }) + }) + + it('should apply all custom colors to CSS variables', () => { + const customColors = { + primary: '#primary-custom', + border: '#border-custom', + text: '#text-custom', + inputBg: '#inputBg-custom', + checkIcon: '#checkIcon-custom', + } + + const { container } = render( + Test Checkbox, + ) + + const input = container.querySelector('input') + expect(input).toHaveStyle({ + '--primary': '#primary-custom', + '--border': '#border-custom', + '--text': '#text-custom', + '--inputBg': '#inputBg-custom', + '--checkIcon': '#checkIcon-custom', + }) + }) + + it('should not apply CSS variables when colors prop is not provided', () => { + const { container } = render(Test Checkbox) + + const input = container.querySelector('input') + // CSS 변수가 undefined로 설정되지 않아야 함 + expect(input?.style.getPropertyValue('--primary')).toBe('') + expect(input?.style.getPropertyValue('--border')).toBe('') + expect(input?.style.getPropertyValue('--text')).toBe('') + expect(input?.style.getPropertyValue('--inputBg')).toBe('') + expect(input?.style.getPropertyValue('--checkIcon')).toBe('') + }) + + it('should work properly with onChange when colors are applied', async () => { + const onChange = vi.fn() + const customColors = { + primary: '#ff0000', + border: '#00ff00', + text: '#0000ff', + inputBg: '#ffff00', + checkIcon: '#000000', + } + + const { container } = render( + + Test Checkbox + , + ) + + const input = container.querySelector('input') + + await act(async () => { + await userEvent.click(input!) + }) + + expect(onChange).toHaveBeenCalledWith(true) + expect(onChange).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/components/src/components/Checkbox/index.tsx b/packages/components/src/components/Checkbox/index.tsx new file mode 100644 index 00000000..1359438b --- /dev/null +++ b/packages/components/src/components/Checkbox/index.tsx @@ -0,0 +1,127 @@ +import { Box, css, Flex, Input, Text } from '@devup-ui/react' +import { ComponentProps, useId } from 'react' + +import { CheckIcon } from './CheckIcon' + +interface CheckboxProps + extends Omit, 'type' | 'onChange'> { + children: React.ReactNode + onChange?: (checked: boolean) => void + colors?: { + primary?: string + border?: string + text?: string + inputBg?: string + checkIcon?: string + } +} + +export function Checkbox({ + children, + disabled, + checked, + colors, + onChange, + ...props +}: CheckboxProps) { + const generateId = useId() + return ( + + + onChange(e.target.checked) + } + styleOrder={1} + styleVars={{ + primary: colors?.primary, + border: colors?.border, + text: colors?.text, + inputBg: colors?.inputBg, + checkIcon: colors?.checkIcon, + }} + type="checkbox" + {...props} + /> + {checked && ( + + )} + + + + + ) +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index a28105a8..26c083da 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,4 +1,5 @@ export { Button } from './components/Button' +export { Checkbox } from './components/Checkbox' export { Input } from './components/Input' export { Radio } from './components/Radio' export { RadioGroup } from './components/RadioGroup'