-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] 카드 컴포넌트 제작 #8
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
Changes from all commits
201dc07
15184bd
87fdac1
7aa1748
ea82975
5c28ef4
cd4b7a4
86d5ffe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { render, screen } from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
| import { Button } from './Button' | ||
|
|
||
| describe('Button', () => { | ||
| it('variant가 disable이면 별도 disabled prop 없이도 비활성화된다', () => { | ||
| render( | ||
| <Button variant="disable" size="m"> | ||
| 저장하기 | ||
| </Button>, | ||
| ) | ||
|
|
||
| expect(screen.getByRole<HTMLButtonElement>('button', { name: '저장하기' }).disabled).toBe(true) | ||
| }) | ||
|
|
||
| it.each(['primary', 'strong', 'error', 'outlined'] as const)( | ||
| 'variant가 %s이면 기본적으로 활성화된다', | ||
| (variant) => { | ||
| render( | ||
| <Button variant={variant} size="m"> | ||
| 저장하기 | ||
| </Button>, | ||
| ) | ||
|
|
||
| expect(screen.getByRole<HTMLButtonElement>('button', { name: '저장하기' }).disabled).toBe( | ||
| false, | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| it('disabled prop을 명시하면 variant와 무관하게 그 값을 따른다', () => { | ||
| render( | ||
| <Button variant="primary" size="m" disabled> | ||
| 저장하기 | ||
| </Button>, | ||
| ) | ||
|
|
||
| expect(screen.getByRole<HTMLButtonElement>('button', { name: '저장하기' }).disabled).toBe(true) | ||
| }) | ||
|
|
||
| it('클릭 시 onClick이 호출된다', async () => { | ||
| const user = userEvent.setup() | ||
| const onClick = vi.fn() | ||
| render( | ||
| <Button variant="primary" size="m" onClick={onClick}> | ||
| 저장하기 | ||
| </Button>, | ||
| ) | ||
|
|
||
| await user.click(screen.getByRole('button', { name: '저장하기' })) | ||
|
|
||
| expect(onClick).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('leftIcon과 rightIcon을 함께 렌더링한다', () => { | ||
| render( | ||
| <Button | ||
| variant="primary" | ||
| size="m" | ||
| leftIcon={<span data-testid="left-icon" />} | ||
| rightIcon={<span data-testid="right-icon" />} | ||
| > | ||
| 저장하기 | ||
| </Button>, | ||
| ) | ||
|
|
||
| expect(screen.getByTestId('left-icon')).toBeTruthy() | ||
| expect(screen.getByTestId('right-icon')).toBeTruthy() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import type { ButtonHTMLAttributes, ReactNode } from 'react' | ||
| import type { VariantProps } from 'tailwind-variants' | ||
| import { tv } from '@lib/tv' | ||
|
|
||
| const buttonVariants = tv({ | ||
| base: 'flex items-center justify-center gap-1 text-center transition-colors', | ||
| variants: { | ||
| variant: { | ||
| primary: 'bg-blue-5 text-brand-primary', | ||
| strong: 'bg-brand-primary text-fill-normal', | ||
| error: 'bg-red-10 text-red-70', | ||
| disable: 'bg-fill-alternative text-label-light cursor-not-allowed', | ||
| outlined: 'bg-fill-normal border-line-normal text-label-netural border', | ||
| }, | ||
| // pl-*/pr-*로 좌우를 각각 선언한다: tailwind-merge는 px-*를 pl-*/pr-*와 같은 그룹으로 | ||
| // 취급하지 않아 compoundVariants의 좌우 오버라이드가 조용히 씹힌다(둘 다 남아 CSS 생성 | ||
| // 순서에 결과가 좌우됨). 같은 축의 유틸리티끼리만 둬야 확실히 병합된다. | ||
| size: { | ||
| s: 'text-label-14sb h-9 rounded-md pr-4 pl-4', | ||
| m: 'text-label-14sb h-10 rounded-lg pr-5 pl-5', | ||
| l: 'text-body-16sb h-11 rounded-lg pr-6 pl-6', | ||
| xl: 'text-body-16sb h-12 rounded-lg pr-6 pl-6', | ||
| }, | ||
| }, | ||
| compoundVariants: [ | ||
| // size=m의 strong만 Figma에서 8px 16px로, 같은 size의 다른 variant(8px 20px)보다 좁다. | ||
| { variant: 'strong', size: 'm', class: 'pr-4 pl-4' }, | ||
| // outlined는 사이즈와 무관하게 radius 8px + 아이콘 쪽(왼쪽) 패딩이 좁은 비대칭 패딩을 쓴다. | ||
| { variant: 'outlined', size: ['s', 'm'], class: 'rounded-md py-2 pr-4 pl-3' }, | ||
| { variant: 'outlined', size: ['l', 'xl'], class: 'rounded-md py-2 pr-5 pl-[18px]' }, | ||
| ], | ||
| }) | ||
|
|
||
| type ButtonVariants = VariantProps<typeof buttonVariants> | ||
| export type ButtonVariant = NonNullable<ButtonVariants['variant']> | ||
| export type ButtonSize = NonNullable<ButtonVariants['size']> | ||
|
|
||
| interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { | ||
| variant: ButtonVariant | ||
| size: ButtonSize | ||
| leftIcon?: ReactNode | ||
| rightIcon?: ReactNode | ||
| } | ||
|
|
||
| export function Button({ | ||
| variant, | ||
| size, | ||
| leftIcon, | ||
| rightIcon, | ||
| className, | ||
| disabled, | ||
| children, | ||
| ...props | ||
| }: ButtonProps) { | ||
| return ( | ||
| <button | ||
| type="button" | ||
| disabled={disabled ?? variant === 'disable'} | ||
| className={buttonVariants({ variant, size, className })} | ||
| {...props} | ||
| > | ||
| {leftIcon} | ||
| {children} | ||
| {rightIcon} | ||
| </button> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| // @generated | ||
|
|
||
| export * from './common' | ||
| export * from './introductions' | ||
| export * from './projects' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import UploadIcon from '@/assets/icons/generated/UploadIcon' | ||
| import { cn } from '@lib/utils' | ||
|
|
||
| interface ImageBoxThumbnailProps { | ||
| imageUrl?: string | ||
| alt?: string | ||
| uploadLabel?: string | ||
| className?: string | ||
| } | ||
|
|
||
| export function ImageBoxThumbnail({ | ||
| imageUrl, | ||
| alt = '', | ||
| uploadLabel = '업로드', | ||
| className, | ||
| }: ImageBoxThumbnailProps) { | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| 'border-fill-alternative relative h-20 w-[150px] overflow-hidden rounded-md border', | ||
| !imageUrl && 'bg-fill-normal flex items-center justify-center', | ||
| className, | ||
| )} | ||
| > | ||
| {imageUrl ? ( | ||
| <img src={imageUrl} alt={alt} className="h-full w-full object-cover" /> | ||
| ) : ( | ||
| <span className="text-caption-12sb text-label-alternative flex items-center gap-1 pr-1.5"> | ||
| <UploadIcon className="size-[18px]" aria-hidden="true" /> | ||
| {uploadLabel} | ||
| </span> | ||
| )} | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { InputHTMLAttributes } from 'react' | ||
| import { cn } from '@lib/utils' | ||
|
|
||
| interface InputfieldProps extends InputHTMLAttributes<HTMLInputElement> { | ||
| className?: string | ||
| } | ||
|
|
||
| export function Inputfield({ className, ...props }: InputfieldProps) { | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| 'bg-fill-netural border-line-normal flex h-10 items-center rounded-lg border px-3 py-1.5', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -n 'fill-netural|label-assitive|fill-neutral|label-assistive' --type=ts --type=tsx -g '!**/dist/**'Repository: kusitms-com/makers-admin-fe Length of output: 193 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== matching files =="
git ls-files 'src/components/introductions/*' 'src/**/*.{ts,tsx}' | sed -n '1,120p'
echo
echo "== search typos/usages =="
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' --glob '!**/node_modules/**' \
'fill-netural|label-assitive|fill-neutral|label-assistive' src
echo
echo "== inspect relevant files =="
for f in \
src/components/introductions/Inputfield.tsx \
src/components/introductions/IntroductionCard.tsx \
src/components/introductions/PartImageCard.tsx
do
if [ -f "$f" ]; then
echo "--- $f ---"
nl -ba "$f" | sed -n '1,120p'
fi
doneRepository: kusitms-com/makers-admin-fe Length of output: 1552 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate file names =="
fd -a 'Inputfield.tsx|IntroductionCard.tsx|PartImageCard.tsx' src 2>/dev/null || true
echo
echo "== search exact strings in repo =="
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' --glob '!**/node_modules/**' \
'fill-netural|label-assitive|fill-neutral|label-assistive|bg-fill-netural|text-label-assitive' .
echo
echo "== show line numbers around Inputfield.tsx if present =="
f='src/components/introductions/Inputfield.tsx'
if [ -f "$f" ]; then
nl -ba "$f" | sed -n '1,80p'
fiRepository: kusitms-com/makers-admin-fe Length of output: 1413 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files of interest =="
git ls-files 'src/components/introductions/Inputfield.tsx' 'src/components/introductions/IntroductionCard.tsx' 'src/components/introductions/PartImageCard.tsx' 'src/**/*tokens*' 'src/**/*token*' 'tailwind.config.*' 'postcss.config.*' 'src/**/*css*' | sed -n '1,200p'
echo
echo "== exact string search =="
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' --glob '!**/node_modules/**' \
'fill-netural|label-assitive|fill-neutral|label-assistive|bg-fill-netural|text-label-assitive' src . \
|| true
echo
echo "== inspect the three introduction files =="
for f in \
src/components/introductions/Inputfield.tsx \
src/components/introductions/IntroductionCard.tsx \
src/components/introductions/PartImageCard.tsx
do
if [ -f "$f" ]; then
echo "--- $f ---"
nl -ba "$f" | sed -n '1,120p'
fi
doneRepository: kusitms-com/makers-admin-fe Length of output: 2264 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== tailwind / design token files =="
git ls-files | rg '(^|/)(tailwind\.config\.[jt]s|postcss\.config\.[jt]s|tokens?|theme|globals\.css|index\.css|app\.css|styles\.css|tailwind).*'
echo
echo "== search token definitions / class names =="
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' --glob '!**/node_modules/**' \
'fill-netural|label-assitive|fill-neutral|label-assistive|addUtilities|matchUtilities|theme\(|tokens|design tokens|colors:' \
src . \
|| true
echo
echo "== inspect tailwind config candidates =="
for f in $(git ls-files | rg '(^|/)tailwind\.config\.[jt]s$|(^|/)postcss\.config\.[jt]s$'); do
echo "--- $f ---"
sed -n '1,240p' "$f"
doneRepository: kusitms-com/makers-admin-fe Length of output: 3917 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== src/index.css =="
sed -n '1,240p' src/index.css
echo
echo "== package.json =="
sed -n '1,220p' package.json
echo
echo "== src/lib/utils.ts =="
sed -n '1,120p' src/lib/utils.ts
echo
echo "== search for `@import/`@theme/@source/tokens references =="
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' --glob '!**/node_modules/**' \
'`@import`|`@theme`|`@source`|`@utility`|`@custom-variant`|`@plugin`|tokens' src . \
|| trueRepository: kusitms-com/makers-admin-fe Length of output: 11055 🌐 Web query:
💡 Result: Result summary for query: site:github.com fill-netural label-assitive Citations:
클래스명 오타 수정 🤖 Prompt for AI AgentsSource: Path instructions |
||
| className, | ||
| )} | ||
| > | ||
| <input | ||
| className="text-label-14m text-label-normal placeholder:text-label-assitive w-full bg-transparent outline-none" | ||
| {...props} | ||
| /> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { render, screen } from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
| import { IntroductionCard } from './IntroductionCard' | ||
|
|
||
| describe('IntroductionCard', () => { | ||
| it('default variant에서는 플레이스홀더를 보여주고 추가하기 버튼이 비활성화된다', () => { | ||
| render(<IntroductionCard variant="default" />) | ||
|
|
||
| expect(screen.getByPlaceholderText('타이틀')).toBeTruthy() | ||
| expect(screen.getByPlaceholderText('설명을 입력해주세요')).toBeTruthy() | ||
| expect(screen.getByRole<HTMLButtonElement>('button', { name: '추가하기' }).disabled).toBe(true) | ||
| expect(screen.getByRole<HTMLButtonElement>('button', { name: '삭제하기' }).disabled).toBe(false) | ||
| }) | ||
|
|
||
| it('active variant에서는 입력값을 보여주고 교체하기 버튼이 활성화된다', () => { | ||
| render( | ||
| <IntroductionCard | ||
| variant="active" | ||
| title="내용" | ||
| description="큐시즘 오리엔테이션 소개" | ||
| thumbnailUrl="/thumb.png" | ||
| />, | ||
| ) | ||
|
|
||
| expect(screen.getByDisplayValue('내용')).toBeTruthy() | ||
| expect(screen.getByDisplayValue('큐시즘 오리엔테이션 소개')).toBeTruthy() | ||
| expect(screen.getByRole<HTMLButtonElement>('button', { name: '교체하기' }).disabled).toBe(false) | ||
| }) | ||
|
|
||
| it('삭제하기 버튼 클릭 시 onDelete가 호출된다', async () => { | ||
| const user = userEvent.setup() | ||
| const onDelete = vi.fn() | ||
| render(<IntroductionCard variant="active" onDelete={onDelete} />) | ||
|
|
||
| await user.click(screen.getByRole('button', { name: '삭제하기' })) | ||
|
|
||
| expect(onDelete).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('active variant에서 교체하기 버튼 클릭 시 onAddOrReplace가 호출된다', async () => { | ||
| const user = userEvent.setup() | ||
| const onAddOrReplace = vi.fn() | ||
| render(<IntroductionCard variant="active" onAddOrReplace={onAddOrReplace} />) | ||
|
|
||
| await user.click(screen.getByRole('button', { name: '교체하기' })) | ||
|
|
||
| expect(onAddOrReplace).toHaveBeenCalledTimes(1) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
text-label-netural오타 - outlined 버튼 텍스트 색상 미적용.neutral의 오타로 보이며, 존재하지 않는 Tailwind 클래스라 outlined variant에서 텍스트 색상 토큰이 실제로 적용되지 않습니다. 다른 variant는text-label-light(L12),ProjectThumbnailCard의text-label-normal등 정상적인 네이밍을 따릅니다.🐛 제안 수정
📝 Committable suggestion
🤖 Prompt for AI Agents