Skip to content

Commit f0d6d6e

Browse files
authored
[#40] ✨ 공통 컴포넌트 Button 개발 (#78)
* [#40] 🌱 initial button commit * [#20] 💄 add primary-transparent to color palette * [#40] ✨ add clickable component * [#40] ✅ add clickable story * [#40] ✨ add Button component * [#40] ✅ add Button story * [#40] ♻️ update interface in Clickable component * [#40] ✨ add Link component * [#40] ✨ add common path to components under button directory by adding an index.ts * [#40] 🗑️ remove example Button components * [#40] ✅ add Link test code * [#40] 🔧 exclude assets from test run + delete useless test code * [#40] ✅ add more test cases with korean comments
1 parent a0a354d commit f0d6d6e

File tree

14 files changed

+503
-42
lines changed

14 files changed

+503
-42
lines changed

jest.config.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@ import type { Config } from 'jest'
22

33
const config: Config = {
44
testEnvironment: 'jsdom',
5-
globals: {
6-
'ts-jest': {
7-
tsconfig: 'tsconfig.jest.json',
8-
},
9-
},
105
transform: {
11-
'^.+\\.(ts|tsx)$': 'ts-jest',
6+
'^.+\\.(ts|tsx)$': [
7+
'ts-jest',
8+
{
9+
tsconfig: 'tsconfig.jest.json', // 사용할 tsconfig 파일 지정
10+
},
11+
],
1212
},
1313
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
1414
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
1515
collectCoverage: true,
1616
collectCoverageFrom: [
1717
'src/**/*.{ts,tsx}',
1818
'!src/**/*.d.ts',
19-
'!**/*.stories.{ts,tsx}',
19+
'!**/*.stories.{ts,tsx}', // stories 파일 제외
20+
'!src/assets/**', // assets 디렉토리 제외
2021
],
2122
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
2223
moduleNameMapper: {

src/components/common/Button.test.tsx

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/components/common/Button.tsx

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import '@testing-library/jest-dom'
2+
import { fireEvent, render, screen } from '@testing-library/react'
3+
4+
import { Button } from './Button'
5+
6+
describe('Button Component', () => {
7+
test('renders the Button with the correct label', () => {
8+
// 버튼이 올바른 레이블로 렌더링되는지 확인합니다.
9+
render(<Button label='Click Me' />)
10+
const buttonElement = screen.getByRole('button', { name: /click me/i })
11+
expect(buttonElement).toBeInTheDocument()
12+
})
13+
14+
test('calls onClick when the button is clicked', () => {
15+
// 버튼이 클릭될 때 onClick 핸들러가 호출되는지 확인합니다.
16+
const handleClick = jest.fn()
17+
render(<Button label='Click Me' onClick={handleClick} />)
18+
19+
const buttonElement = screen.getByRole('button', { name: /click me/i })
20+
fireEvent.click(buttonElement)
21+
22+
expect(handleClick).toHaveBeenCalledTimes(1)
23+
})
24+
25+
test('does not call onClick when the button is disabled', () => {
26+
// 버튼이 비활성화되었을 때 onClick 핸들러가 호출되지 않는지 확인합니다.
27+
const handleClick = jest.fn()
28+
render(<Button label='Click Me' onClick={handleClick} disabled />)
29+
30+
const buttonElement = screen.getByRole('button', { name: /click me/i })
31+
fireEvent.click(buttonElement)
32+
33+
expect(handleClick).not.toHaveBeenCalled()
34+
})
35+
36+
test('passes additional props to the Clickable component', () => {
37+
// Button 컴포넌트가 Clickable 컴포넌트에 추가적인 props를 올바르게 전달하는지 확인합니다.
38+
render(<Button label='Click Me' variant='outlined' size='lg' />)
39+
const clickableElement = screen.getByText(/click me/i)
40+
41+
expect(clickableElement).toHaveClass('text-primary-normal')
42+
expect(clickableElement).toHaveClass('h-48') // "lg" 크기가 h-48 클래스를 적용하는지 확인
43+
expect(clickableElement).toHaveClass('border-1') // "outlined" 변형이 border-1 클래스를 적용하는지 확인
44+
})
45+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Clickable, ClickableProps } from './Clickable'
2+
3+
interface ButtonProps
4+
extends ClickableProps,
5+
React.ButtonHTMLAttributes<HTMLButtonElement> {}
6+
7+
export const Button = ({
8+
onClick,
9+
type = 'button',
10+
disabled = false,
11+
...props
12+
}: ButtonProps): JSX.Element => (
13+
<button onClick={onClick} type={type} disabled={disabled}>
14+
<Clickable {...props} disabled={disabled} />
15+
</button>
16+
)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import '@testing-library/jest-dom'
2+
import { render, screen } from '@testing-library/react'
3+
import React from 'react'
4+
5+
import { Clickable, ClickableProps } from './Clickable'
6+
7+
describe('Clickable Component', () => {
8+
const defaultProps: ClickableProps = {
9+
label: 'Click me',
10+
}
11+
12+
test('renders label correctly', () => {
13+
// label이 제대로 렌더링되는지 확인합니다.
14+
render(<Clickable {...defaultProps} />)
15+
const element = screen.getByText('Click me')
16+
expect(element).toBeInTheDocument()
17+
})
18+
19+
test('applies the correct variant class', () => {
20+
// variant가 'outlined'일 때 해당 클래스가 제대로 적용되는지 확인합니다.
21+
render(<Clickable {...defaultProps} variant='outlined' />)
22+
const element = screen.getByText('Click me')
23+
expect(element).toHaveClass(
24+
'border-1 border-solid border-primary-normal bg-common-white text-primary-normal'
25+
)
26+
})
27+
28+
test('applies the correct size class', () => {
29+
// size가 'lg'일 때 크기 관련 클래스가 제대로 적용되는지 확인합니다.
30+
render(<Clickable {...defaultProps} size='lg' />)
31+
const element = screen.getByText('Click me')
32+
expect(element).toHaveClass('h-48 px-16')
33+
})
34+
35+
test('applies the fullWidth class when fullWidth is true', () => {
36+
// fullWidth가 true일 때 'w-full' 클래스가 적용되는지 확인합니다.
37+
render(<Clickable {...defaultProps} fullWidth />)
38+
const element = screen.getByText('Click me')
39+
expect(element).toHaveClass('w-full')
40+
})
41+
42+
test('applies the leftAlign class when leftAlign is true', () => {
43+
// leftAlign이 true일 때 'justify-start' 클래스가 적용되는지 확인합니다.
44+
render(<Clickable {...defaultProps} leftAlign />)
45+
const element = screen.getByText('Click me')
46+
expect(element).toHaveClass('justify-start')
47+
})
48+
49+
test('applies disabled styles when disabled is true', () => {
50+
// disabled가 true일 때 비활성화 관련 스타일과 aria-disabled 속성이 적용되는지 확인합니다.
51+
render(<Clickable {...defaultProps} disabled />)
52+
const element = screen.getByText('Click me')
53+
expect(element).toHaveAttribute('aria-disabled', 'true')
54+
expect(element).toHaveClass(
55+
'aria-disabled:border-0 aria-disabled:bg-gray-100 aria-disabled:text-gray-400 aria-disabled:cursor-not-allowed'
56+
)
57+
})
58+
59+
test('renders startIcon and endIcon if provided', () => {
60+
// startIcon과 endIcon이 제공되었을 때 각각 렌더링되는지 확인합니다.
61+
const StartIcon = () => <span data-testid='start-icon'>Start</span>
62+
const EndIcon = () => <span data-testid='end-icon'>End</span>
63+
render(
64+
<Clickable
65+
{...defaultProps}
66+
startIcon={<StartIcon />}
67+
endIcon={<EndIcon />}
68+
/>
69+
)
70+
71+
const startIcon = screen.getByTestId('start-icon')
72+
const endIcon = screen.getByTestId('end-icon')
73+
74+
expect(startIcon).toBeInTheDocument()
75+
expect(endIcon).toBeInTheDocument()
76+
})
77+
78+
test('applies the correct text color class', () => {
79+
// textColor가 'gray400'일 때 텍스트 색상 클래스가 올바르게 적용되는지 확인합니다.
80+
render(<Clickable {...defaultProps} textColor='gray400' />)
81+
const element = screen.getByText('Click me')
82+
expect(element).toHaveClass('text-gray-400')
83+
})
84+
85+
test('applies the correct background color class', () => {
86+
// backgroundColor가 'blue'일 때 배경 색상 클래스가 올바르게 적용되는지 확인합니다.
87+
render(<Clickable {...defaultProps} backgroundColor='blue' />)
88+
const element = screen.getByText('Click me')
89+
expect(element).toHaveClass('bg-primary-normal')
90+
})
91+
92+
test('applies the correct border color class', () => {
93+
// borderColor가 'gray'이고 variant가 'outlined'일 때 테두리 색상 클래스가 올바르게 적용되는지 확인합니다.
94+
render(
95+
<Clickable {...defaultProps} variant='outlined' borderColor='gray' />
96+
)
97+
const element = screen.getByText('Click me')
98+
expect(element).toHaveClass('border-gray-200')
99+
})
100+
})
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import clsx from 'clsx'
2+
import { twMerge } from 'tailwind-merge'
3+
4+
export interface ClickableProps {
5+
label?: string
6+
variant?: Variant
7+
size?: Size
8+
borderColor?: BorderColor
9+
backgroundColor?: BackgroundColor
10+
textColor?: TextColor
11+
startIcon?: React.ReactElement
12+
endIcon?: React.ReactElement
13+
fullWidth?: boolean
14+
leftAlign?: boolean
15+
disabled?: boolean
16+
className?: string
17+
}
18+
19+
type Variant = 'contained' | 'outlined' | 'text'
20+
type Size = 'sm' | 'md' | 'lg' | 'xl'
21+
type BorderColor = 'blue' | 'gray'
22+
type BackgroundColor = 'blue' | 'white' | 'gray' | 'transparentBlue'
23+
type TextColor = 'blue' | 'white' | 'black' | 'gray400' | 'gray500' | 'gray600'
24+
25+
const baseStyle =
26+
'flex items-center justify-center gap-4 rounded-8 text-body1 font-medium'
27+
28+
const disabledStyle =
29+
'aria-disabled:border-0 aria-disabled:bg-gray-100 aria-disabled:text-gray-400 aria-disabled:cursor-not-allowed'
30+
31+
const styleByVariant: Record<Variant, string> = {
32+
contained: 'bg-primary-normal text-common-white active:bg-primary-strong',
33+
outlined:
34+
'border-1 border-solid border-primary-normal bg-common-white text-primary-normal',
35+
text: 'text-gray-1000',
36+
}
37+
38+
const styleBySize: Record<Size, string> = {
39+
sm: 'h-40 px-12',
40+
md: 'h-44 px-16',
41+
lg: 'h-48 px-16',
42+
xl: 'h-52 px-20',
43+
}
44+
45+
const styleByBorderColor: Record<BorderColor, string> = {
46+
gray: 'border-gray-200',
47+
blue: 'border-primary-normal',
48+
}
49+
50+
const styleByBackgroundColor: Record<BackgroundColor, string> = {
51+
blue: 'bg-primary-normal',
52+
white: 'bg-common-white',
53+
gray: 'bg-gray-100',
54+
transparentBlue: 'bg-primary-transparent',
55+
}
56+
57+
const styleByTextColor: Record<TextColor, string> = {
58+
blue: 'text-primary-normal',
59+
white: 'text-common-white',
60+
black: 'text-common-black',
61+
gray400: 'text-gray-400',
62+
gray500: 'text-gray-500',
63+
gray600: 'text-gray-600',
64+
}
65+
66+
export const Clickable = ({
67+
label = '',
68+
variant = 'contained',
69+
size = 'md',
70+
borderColor,
71+
backgroundColor,
72+
textColor,
73+
startIcon,
74+
endIcon,
75+
fullWidth = false,
76+
leftAlign = false,
77+
disabled = false,
78+
className = '',
79+
}: ClickableProps): JSX.Element => {
80+
const borderColorClass = borderColor ? styleByBorderColor[borderColor] : ''
81+
const backgroundColorClass = backgroundColor
82+
? styleByBackgroundColor[backgroundColor]
83+
: ''
84+
const textColorClass = textColor ? styleByTextColor[textColor] : ''
85+
86+
const clickableStyle = twMerge(
87+
baseStyle,
88+
styleByVariant[variant],
89+
styleBySize[size],
90+
textColorClass,
91+
clsx({
92+
[borderColorClass]: variant === 'outlined',
93+
[backgroundColorClass]: variant !== 'text',
94+
[disabledStyle]: disabled,
95+
'w-full': fullWidth,
96+
'justify-start': leftAlign,
97+
}),
98+
className
99+
)
100+
101+
return (
102+
<span className={clickableStyle} aria-disabled={disabled}>
103+
{startIcon}
104+
{label}
105+
{endIcon}
106+
</span>
107+
)
108+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import '@testing-library/jest-dom'
2+
import { fireEvent, render, screen } from '@testing-library/react'
3+
4+
import { Link } from './Link'
5+
6+
describe('Link Component', () => {
7+
test('renders the link with correct href', () => {
8+
// 올바른 href 속성을 가진 링크가 렌더링되는지 확인합니다.
9+
render(<Link href='/test' label='Test Link' />)
10+
const linkElement = screen.getByRole('link', { name: /test link/i })
11+
expect(linkElement).toHaveAttribute('href', '/test')
12+
})
13+
14+
test('prevents click when disabled', () => {
15+
// disabled 상태일 때 클릭 이벤트가 실행되지 않는지 확인합니다.
16+
const handleClick = jest.fn()
17+
render(
18+
<Link href='/test' label='Disabled Link' disabled onClick={handleClick} />
19+
)
20+
21+
const linkElement = screen.getByRole('link', { name: /disabled link/i })
22+
fireEvent.click(linkElement)
23+
expect(handleClick).not.toHaveBeenCalled()
24+
expect(linkElement).toHaveAttribute('aria-disabled', 'true')
25+
})
26+
27+
test('renders Clickable component with proper props', () => {
28+
// Clickable 컴포넌트가 올바른 props와 함께 렌더링되는지 확인합니다.
29+
render(<Link href='/test' label='Clickable Test' />)
30+
const clickableElement = screen.getByText(/clickable test/i)
31+
expect(clickableElement).toBeInTheDocument()
32+
})
33+
34+
test('applies custom className to the Clickable component', () => {
35+
// Link 컴포넌트가 전달된 className을 Clickable 컴포넌트에 적용하는지 확인합니다.
36+
render(<Link href='/test' label='Styled Link' className='custom-class' />)
37+
const clickableElement = screen.getByText(/styled link/i)
38+
expect(clickableElement).toHaveClass('custom-class')
39+
})
40+
})

0 commit comments

Comments
 (0)