Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/assets/Chevron_down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/jazz-tag.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 29 additions & 30 deletions src/componets/Button/Button.scss
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
@use '../../styles/index' as v;
@use '../../styles/index' as s;
@use 'sass:map';

.xp-btn {
.btn {
background: #d4d4d4;
color: #5a5a5a;
@include s.text-style-extended(lg, 400, gray-900);
border: none;
font-family: 'Tahoma', 'Verdana', sans-serif;
font-size: 16px;
padding: 6px 18px;
cursor: pointer;
box-shadow:
inset 1px 1px 0 0 #fff,
1px 1px 0 0 #868686,
2px 2px 0 0 #222;
inset 1px 1px 0 0 s.color(white),
1px 1px 0 0 s.color(gray-600),
2px 2px 0 0 s.color(gray-800);

Choose a reason for hiding this comment

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

medium

box-shadow의 색상 값이 리팩터링 과정에서 기존과 달라져 버튼의 시각적 디자인이 변경될 수 있습니다. 기존 그림자 색상인 #222s.color(gray-900) (#212121)에 더 가깝지만, s.color(gray-800) (#424242)으로 변경되었습니다. 의도된 변경이 아니라면, 기존 디자인과 일관성을 유지하기 위해 gray-900 변수를 사용하는 것을 고려해 보세요.

Suggested change
inset 1px 1px 0 0 s.color(white),
1px 1px 0 0 s.color(gray-600),
2px 2px 0 0 s.color(gray-800);
inset 1px 1px 0 0 s.color(white),
1px 1px 0 0 s.color(gray-600),
2px 2px 0 0 s.color(gray-900);

transition:
background-color 0.12s,
color 0.12s,
box-shadow 0.12s;

&:hover,
&:focus {
background: #e5e5e5;
color: #222;
background: s.color(gray-300);
color: s.color(gray-900);
box-shadow:
inset 1px 1px 0 0 #fff,
2px 2px 0 0 #222;
inset 1px 1px 0 0 s.color(white),
2px 2px 0 0 s.color(gray-900);
}
&:active {
background: #bbbbbb;
color: #222;
color: s.color(gray-900);
box-shadow:
inset 1px 1px 0 0 #fff,
inset 1px 1px 0 0 s.color(white),
1px 1px 0 0 #868686,
0px 0px 0 0 #222;
0px 0px 0 0 s.color(gray-900);
}

@media (max-width: 640px) {
Expand All @@ -41,30 +40,30 @@
}

// 빨강 버튼 (secondary)
.xp-btn.secondary {
background: #d43c3c;
color: #fff;
.btn.secondary {
background: s.color(red_btn);
color: s.color(white);
border: none;
box-shadow:
inset 1px 1px 0 0 #fff,
1px 1px 0 0 #b92a2a,
2px 2px 0 0 #7a1818;
inset 1px 1px 0 0 s.color(white),
1px 1px 0 0 s.color(red_btn_hover),
2px 2px 0 0 s.color(red_btn_shadow);

&:hover,
&:focus {
background: #b92a2a;
color: #fff;
background: s.color(red_btn_hover);
color: s.color(white);
box-shadow:
inset 1px 1px 0 0 #fff,
2px 2px 0 0 #7a1818;
inset 1px 1px 0 0 s.color(white),
2px 2px 0 0 s.color(red_btn_shadow);
}
&:active {
background: #a12222;
color: #fff;
background: s.color(red_btn_active);
color: s.color(white);
box-shadow:
inset 1px 1px 0 0 #fff,
1px 1px 0 0 #b92a2a,
0px 0px 0 0 #7a1818;
inset 1px 1px 0 0 s.color(white),
1px 1px 0 0 s.color(red_btn_hover),
0px 0px 0 0 s.color(red_btn_shadow);
}

@media (max-width: 640px) {
Expand Down
10 changes: 9 additions & 1 deletion src/componets/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ type Story = StoryObj<typeof Button>;

export const Primary: Story = {
args: {
children: '클릭',
children: 'Button',
variant: 'default',
color: 'rgba(255, 0, 0, 1)'
}
};

export const Secondary: Story = {
args: {
children: 'Button',
variant: 'secondary',
color: 'rgba(255, 0, 0, 1)'
}
};
13 changes: 7 additions & 6 deletions src/componets/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { test, expect, vi } from "vitest";
import Button from "./Button";
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect, vi } from 'vitest';

test("renders 버튼과 클릭", async () => {
import Button from './Button';

test('renders 버튼과 클릭', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>클릭</Button>);
const btn = screen.getByRole("button", { name: /클릭/i });
const btn = screen.getByRole('button', { name: /클릭/i });
await userEvent.click(btn);
expect(onClick).toHaveBeenCalled();
});
9 changes: 5 additions & 4 deletions src/componets/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import React from 'react';
import './Button.scss';

export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'secondary';
};
}

const Button: React.FC<ButtonProps> = ({
children,
variant = 'default',
...rest
}) => {
// xp-btn, secondary 클래스 동적 조합
const classNames = ['xp-btn', variant === 'secondary' ? 'secondary' : '']
// btn, secondary 클래스 동적 조합
const classNames = ['btn', variant === 'secondary' ? 'secondary' : '']
.filter(Boolean)
.join(' ');

Choose a reason for hiding this comment

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

medium

classNames를 생성하는 현재 방식은 'secondary' variant에만 특정되어 있어 확장성이 떨어집니다. 예를 들어 'tertiary' 같은 새로운 variant가 추가될 경우, 이 로직을 수정해야 합니다. default가 아닌 모든 variant를 클래스 이름으로 동적으로 추가하도록 리팩터링하면 더 유연하고 유지보수하기 좋은 코드가 될 것입니다.

Suggested change
const classNames = ['btn', variant === 'secondary' ? 'secondary' : '']
.filter(Boolean)
.join(' ');
const classNames = ['btn', variant !== 'default' && variant]
.filter(Boolean)
.join(' ');


Expand Down
55 changes: 55 additions & 0 deletions src/componets/Checkbox/Checkbox.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
@use '../../styles/index' as s;
@use 'sass:map';

.checkbox {
display: flex;
align-items: center;
gap: 8px;
position: relative;
width: fit-content;
height: fit-content;
}

.checkbox_png {
position: absolute;
left: 3px;
top: -5px;
width: 20px;
height: 20px;
background: url('../../assets/Chevron_down.svg') center center no-repeat;
background-size: contain;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 3;
@media (max-width: 640px) {
width: 16px;
height: 16px;
left: 4px;
top: -2px;
}
}

.checkbox_input {
appearance: none;
width: 18px;
height: 18px;
border-radius: 0;
border: 2px solid s.color(gray-400);
background: s.color(white);
cursor: pointer;
transition:
background-color 0.2s,
border-color 0.2s;

&:checked {
~ .checkbox_png {
opacity: 1;
}
}

@media (max-width: 640px) {
width: 16px;
height: 16px;
}
}
24 changes: 24 additions & 0 deletions src/componets/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Checkbox from './Checkbox';

const meta: Meta<typeof Checkbox> = {
title: 'Components/Checkbox',
component: Checkbox,
argTypes: { onClick: { action: 'clicked' } }
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

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

The argTypes is set up for 'onClick' but checkboxes typically use 'onChange' event. This should be 'onChange: { action: 'changed' }' to properly track checkbox state changes in Storybook.

Suggested change
argTypes: { onClick: { action: 'clicked' } }
argTypes: { onChange: { action: 'changed' } }

Copilot uses AI. Check for mistakes.
};

export default meta;
type Story = StoryObj<typeof Checkbox>;

export const Primary: Story = {
args: {
label: 'Checkbox Default'
}
};

export const Secondary: Story = {
args: {
label: 'Checkbox Checked',
checked: true
}
};
15 changes: 15 additions & 0 deletions src/componets/Checkbox/Checkbox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect, vi } from 'vitest';

import Checkbox from './Checkbox';

test('체크박스 렌더링과 체크 이벤트', async () => {
const onChange = vi.fn();
render(<Checkbox label="동의" onChange={onChange} />);
const checkbox = screen.getByRole('checkbox', { name: /동의/i });
expect(checkbox).not.toBeChecked();
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
expect(onChange).toHaveBeenCalled();
});
21 changes: 21 additions & 0 deletions src/componets/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import './Checkbox.scss';

export interface CheckboxProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}

const Checkbox: React.FC<CheckboxProps> = ({ label, ...rest }) => {
const id = rest.id ?? `checkbox-${label}`;

return (
<label className="checkbox">
<input id={id} type="checkbox" className="checkbox_input" {...rest} />
<span className="checkbox_png" />
{label}
</label>
);
};

Choose a reason for hiding this comment

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

critical

idlabel prop을 기반으로 생성하면, 페이지 내에 동일한 label을 가진 체크박스가 여러 개 있을 때 id가 중복될 수 있습니다. HTML에서 id는 고유해야 하며, 중복 id는 접근성 문제를 일으키고 예기치 않은 버그의 원인이 될 수 있습니다. React 18 이상을 사용하신다면 React.useId() 훅을 사용하여 고유 id를 생성하는 것을 강력히 권장합니다. 또한, <label>htmlFor 속성을 추가하여 input과 명시적으로 연결하면 접근성이 더욱 향상됩니다.

Suggested change
const Checkbox: React.FC<CheckboxProps> = ({ label, ...rest }) => {
const id = rest.id ?? `checkbox-${label}`;
return (
<label className="checkbox">
<input id={id} type="checkbox" className="checkbox_input" {...rest} />
<span className="checkbox_png" />
{label}
</label>
);
};
const Checkbox: React.FC<CheckboxProps> = ({ label, ...rest }) => {
const reactId = React.useId();
const id = rest.id ?? reactId;
return (
<label htmlFor={id} className="checkbox">
<input id={id} type="checkbox" className="checkbox_input" {...rest} />
<span className="checkbox_png" />
{label}
</label>
);
};


export default Checkbox;
10 changes: 10 additions & 0 deletions src/styles/_functions.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@use 'sass:map';
@use 'variables' as v;

@function color($name) {
$val: map.get(v.$colors, $name);
@if $val == null {
@error "Unknown color token: #{$name}";
}
@return $val;
}
28 changes: 21 additions & 7 deletions src/styles/_mixins.scss
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
@use 'sass:map';
@use 'variables' as v;
@use 'functions' as f;

// 반응형 미디어 쿼리 믹스인
// 반응형 미디어 쿼리
@mixin mq($breakpoint) {
@if map-has-key(v.$breakpoints, $breakpoint) {
@media (min-width: map-get(v.$breakpoints, $breakpoint)) {
@if map.has-key(v.$breakpoints, $breakpoint) {
@media (min-width: map.get(v.$breakpoints, $breakpoint)) {
@content;
}
}
}
Comment on lines 6 to 12

Choose a reason for hiding this comment

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

medium

mq 믹스인은 존재하지 않는 breakpoint 값을 받았을 때 아무런 동작 없이 조용히 넘어갑니다. 이는 개발 중 실수를 알아차리기 어렵게 만들 수 있습니다. text-style 믹스인처럼, 유효하지 않은 값이 전달되었을 때 @error를 발생시켜 문제를 즉시 인지할 수 있도록 개선하는 것이 좋습니다.

Suggested change
@mixin mq($breakpoint) {
@if map-has-key(v.$breakpoints, $breakpoint) {
@media (min-width: map-get(v.$breakpoints, $breakpoint)) {
@if map.has-key(v.$breakpoints, $breakpoint) {
@media (min-width: map.get(v.$breakpoints, $breakpoint)) {
@content;
}
}
}
@mixin mq($breakpoint) {
@if map.has-key(v.$breakpoints, $breakpoint) {
@media (min-width: map.get(v.$breakpoints, $breakpoint)) {
@content;
}
} @else {
@error "Unknown breakpoint: #{$breakpoint}";
}
}


// 타이포그래피 믹스인
// 기본 타이포
@mixin text-style($size, $weight: 400) {
font-size: map-get(map-get(v.$typography, $size), fontSize);
line-height: map-get(map-get(v.$typography, $size), lineHeight);
$scale: map.get(v.$typography, $size);
@if $scale == null {
@error "Unknown typography scale: #{$size}";
}
font-size: map.get($scale, fontSize);
line-height: map.get($scale, lineHeight);
font-weight: $weight;
}

// flex 믹스인
// 확장 타이포 (색상)
@mixin text-style-extended($size, $weight: 400, $color: null) {
@include text-style($size, $weight);
@if $color != null {
color: f.color($color);
}
}

// flex
@mixin flex-box($justify: center, $align: center, $direction: row) {
display: flex;
flex-direction: $direction;
Expand Down
9 changes: 8 additions & 1 deletion src/styles/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ $colors: (
gray-300: #e0e0e0,
gray-400: #bdbdbd,
gray-500: #9e9e9e,
gray-600: #757575,
gray-700: #616161,
gray-800: #424242,
gray-900: #212121,

// 빨강 버튼
red_btn: #d43c3c,
red_btn_hover: #b92a2a
red_btn_active: #a12222,
red_btn_hover: #b92a2a,
red_btn_shadow: #7a1818
);

// 타이포그래피 변수
Expand Down
5 changes: 3 additions & 2 deletions src/styles/index.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
@forward "variables";
@forward "mixins";
@forward 'variables';
@forward 'functions';
@forward 'mixins';