Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
170 changes: 170 additions & 0 deletions src/components/Carousel/Carousel.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
@use '../../styles/index' as s;
@use 'sass:map';

.carousel {
position: relative;
overflow: hidden;
border: 2px solid s.color(black);
background-image: url('../../assets/paper.png');
background-repeat: repeat;
background-size: auto;
font-family: 'Times New Roman', serif;

&__container {
width: 100%;
overflow: hidden;
}

&__list {
display: flex;
transition: transform 0.4s ease-in-out;
}

&__item {
@include s.flex-box(space-around, center, row);
cursor: pointer;
flex: 0 0 100%;
padding: 20px;
text-align: center;
background-size: cover;

&:last-child {
border-right: none;
}
}

&__image {
max-width: 100%;
height: auto;
background: s.color(white) url('../../assets/paper.png');
margin-bottom: 12px;
}

&__title {
@include s.text-style-extended('2xl', 800, red-900);
margin-bottom: 6px;
text-transform: uppercase;
}

&__text {
@include s.text-style-extended('lg', 400, gray-700);
}

&__button {
position: absolute;
top: 50%;
transform: translateY(-50%);
padding: 6px 12px;
@include s.text-style-extended('lg', 400, gray-700);
background: s.color(white) url('../../assets/paper.png');
background-size: cover;
border: 2px solid s.color(black);
cursor: pointer;
transition: all 0.2s ease-in-out;

&:hover {
background: s.color(yellow-200) url('../../assets/paper.png');
transform: translateY(-50%) scale(1.05);
}

&--prev {
left: 10px;
}
&--next {
right: 10px;
}
}

&__indicators {
@include s.flex-box(center, center, row);
gap: 8px;
margin: 12px;
}

&__indicator {
width: 16px;
height: 16px;
border: 2px solid s.color(black);
background: s.color(white) url('../../assets/paper.png');
background-size: cover;
cursor: pointer;
transition: all 0.2s ease-in-out;

&.active {
background: s.color(yellow-600) url('../../assets/paper.png');
transform: scale(1.1);
}
}

// sm 이상
@include s.mq(sm) {
&__title {
font-size: 20px;
}
&__text {
font-size: 14px;
}
&__button {
padding: 4px 10px;
font-size: 14px;
}
&__indicator {
width: 12px;
height: 12px;
}
}

// md 이상
@include s.mq(md) {
&__title {
font-size: 22px;
}
&__text {
font-size: 15px;
}
&__button {
padding: 6px 12px;
font-size: 15px;
}
&__indicator {
width: 14px;
height: 14px;
}
}

// lg 이상
@include s.mq(lg) {
&__title {
font-size: 24px;
}
&__text {
font-size: 16px;
}
&__button {
padding: 8px 14px;
font-size: 16px;
}
&__indicator {
width: 16px;
height: 16px;
}
}

// xl 이상
@include s.mq(xl) {
&__title {
font-size: 28px;
}
&__text {
font-size: 18px;
}
&__button {
padding: 10px 16px;
font-size: 18px;
}
&__indicator {
width: 18px;
height: 18px;
}
}
}
41 changes: 41 additions & 0 deletions src/components/Carousel/Carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Carousel from './Carousel';

const meta: Meta<typeof Carousel> = {
title: 'Components/Carousel',
component: Carousel,
tags: ['autodocs']
};

export default meta;

type Story = StoryObj<typeof Carousel>;

export const Default: Story = {
args: {
items: [
{
image: 'https://wikidocs.net/images/page/279778/boardwalk.jpg',
title: 'Slide 1',
text: 'This is the first slide.',
onClick: () => alert('Slide 1 clicked!'),
imageStyle: { width: '500px', height: 'auto' }
},
{
image: 'https://wikidocs.net/images/page/279778/boardwalk.jpg',
title: 'Slide 2',
text: 'This is the second slide.',
onClick: () => alert('Slide 2 clicked!'),
imageStyle: { width: '500px', height: 'auto' }
},
{
image: 'https://wikidocs.net/images/page/279778/boardwalk.jpg',
title: 'Slide 3',
text: 'This is the third slide.',
onClick: () => alert('Slide 3 clicked!'),
imageStyle: { width: '500px', height: 'auto' }
}
],
autoPlay: true
}
};
65 changes: 65 additions & 0 deletions src/components/Carousel/Carousel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect, vi } from 'vitest';

import Carousel from './Carousel';

const items = [
{
image: 'https://wikidocs.net/images/page/279778/boardwalk.jpg',
title: 'Slide 1',
text: 'This is the first slide.',
onClick: vi.fn(),
imageStyle: { width: '500px', height: 'auto' }
},
{
image: 'https://wikidocs.net/images/page/279778/boardwalk.jpg',
title: 'Slide 2',
text: 'This is the second slide.',
onClick: vi.fn(),
imageStyle: { width: '500px', height: 'auto' }
},
{
image: 'https://wikidocs.net/images/page/279778/boardwalk.jpg',
title: 'Slide 3',
text: 'This is the third slide.',
onClick: vi.fn(),
imageStyle: { width: '500px', height: 'auto' }
}
];

test('renders Carousel and 클릭', async () => {
render(<Carousel items={items} autoPlay={false} />);
const slide1 = screen.getByRole('heading', { name: /Slide 1/i });
const slide2 = screen.getByRole('heading', { name: /Slide 2/i });

Check failure on line 34 in src/components/Carousel/Carousel.test.tsx

View workflow job for this annotation

GitHub Actions / build

src/components/Carousel/Carousel.test.tsx > renders Carousel and 클릭

TestingLibraryElementError: Unable to find an accessible element with the role "heading" and name `/Slide 2/i` Here are the accessible roles: list: Name "": <ul class="carousel__list" style="transform: translateX(-0%);" /> -------------------------------------------------- listitem: Name "": <li aria-hidden="false" class="carousel__item" /> -------------------------------------------------- img: Name "Slide 1": <img alt="Slide 1" class="carousel__image" src="https://wikidocs.net/images/page/279778/boardwalk.jpg" style="width: 500px; height: auto;" /> -------------------------------------------------- heading: Name "Slide 1": <h2 class="carousel__title" /> -------------------------------------------------- paragraph: Name "": <p class="carousel__text" /> -------------------------------------------------- button: Name "Go to slide 1": <button aria-label="Go to slide 1" class="carousel__indicator active" /> Name "Go to slide 2": <button aria-label="Go to slide 2" class="carousel__indicator " /> Name "Go to slide 3": <button aria-label="Go to slide 3" class="carousel__indicator " /> -------------------------------------------------- Ignored nodes: comments, script, style <body> <div> <div class="carousel" > <div class="carousel__container" > <ul class="carousel__list" style="transform: translateX(-0%);" > <li aria-hidden="false" class="carousel__item" > <img alt="Slide 1" class="carousel__image" src="https://wikidocs.net/images/page/279778/boardwalk.jpg" style="width: 500px; height: auto;" /> <div class="carousel__content" > <h2 class="carousel__title" > Slide 1 </h2> <p class="carousel__text" > This is the first slide. </p> </div> </li> <li aria-hidden="true" class="carousel__item" > <img alt="Slide 2" class="carousel__image" src="https://wikidocs.net/images/page/279778/boardwalk.jpg" style="width: 500px; height: auto;" /> <div class="carousel__content" > <h2 class="carousel__title" > Slide 2 </h2> <p class="carousel__text" > This is the second slide. </p> </div> </li> <li aria-hidden="true" class="carousel__item" > <img alt="Slide 3" class="carousel__image" src="https://wikidocs.net/images/page/279778/boardwalk.jpg" style="width: 500px; height: auto;" /> <div class="carousel__content" > <h2 class="carousel__title" > Slide 3 </h2> <p class="carousel__text" > This is the third slide. </p> </div> </li> </ul> </div> <div class="carousel__indicators" > <button aria-label="Go to slide 1" class="carousel__indicator active" /> <button aria-label="Go to slide 2" class="carousel__indicator " /> <button aria-label="Go to slide 3" class="carousel__indicator " /> </div> </div> </div> </body> ❯ Object.getElementError node_modules/.pnpm/@testing-library[email protected]/node_modules/@testing-li
const slide3 = screen.getByRole('heading', { name: /Slide 3/i });
await userEvent.click(slide1);
expect(items[0].onClick).toHaveBeenCalled();
await userEvent.click(slide2);
expect(items[1].onClick).toHaveBeenCalled();
await userEvent.click(slide3);
expect(items[2].onClick).toHaveBeenCalled();
});

test('자동 슬라이드 동작 (타이머 기반)', () => {
vi.useFakeTimers();
render(<Carousel items={items} autoPlay={true} interval={1000} />);

const indicators = screen.getAllByRole('button');

expect(indicators[0]).toHaveClass('active');

vi.advanceTimersByTime(1000);
expect(indicators[0]).not.toHaveClass('active');

Check failure on line 53 in src/components/Carousel/Carousel.test.tsx

View workflow job for this annotation

GitHub Actions / build

src/components/Carousel/Carousel.test.tsx > 자동 슬라이드 동작 (타이머 기반)

Error: expect(element).not.toHaveClass("active") Expected the element not to have class: active Received: carousel__indicator active ❯ src/components/Carousel/Carousel.test.tsx:53:29
expect(indicators[1]).toHaveClass('active');

vi.advanceTimersByTime(1000);
expect(indicators[1]).not.toHaveClass('active');
expect(indicators[2]).toHaveClass('active');

vi.advanceTimersByTime(1000);
expect(indicators[2]).not.toHaveClass('active');
expect(indicators[0]).toHaveClass('active');

vi.useRealTimers();
});
113 changes: 113 additions & 0 deletions src/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import './Carousel.scss';

export interface CarouselItem {
title?: string;
text?: string;
image?: string;
imageStyle?: React.CSSProperties;
onClick?: () => void;
}

export interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
items: CarouselItem[];
autoPlay?: boolean;
interval?: number;
}

const Carousel: React.FC<CarouselProps> = ({
items,
autoPlay = false,
interval = 3000,
...rest
Copy link

Copilot AI Sep 20, 2025

Choose a reason for hiding this comment

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

The ...rest props are spread on the container div but the component doesn't extend any HTML element interface. This could lead to type errors or unexpected behavior. Consider either extending React.HTMLAttributes<HTMLDivElement> in CarouselProps or removing the rest spread if not needed.

Copilot uses AI. Check for mistakes.
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const [touchEndX, setTouchEndX] = useState<number | null>(null);

// 터치 이벤트 핸들러
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStartX(e.touches[0].clientX);
};

const handleTouchMove = (e: React.TouchEvent) => {
setTouchEndX(e.touches[0].clientX);
};

const handleTouchEnd = () => {
if (touchStartX === null || touchEndX === null) return;
const delta = touchStartX - touchEndX;

if (delta > 50) {
setCurrentIndex((prev) => (prev + 1) % items.length);
} else if (delta < -50) {
setCurrentIndex((prev) => (prev - 1 + items.length) % items.length);
}
setTouchStartX(null);
setTouchEndX(null);
};
// 자동 슬라이드 기능
useEffect(() => {
if (!autoPlay) return;
const timer = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % items.length);
}, interval);
return () => clearInterval(timer);
}, [autoPlay, interval, items.length]);

return (
<div className="carousel">

Choose a reason for hiding this comment

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

medium

Carousel.scss 파일에는 이전/다음 버튼(.carousel__button--prev, .carousel__button--next)에 대한 스타일이 정의되어 있지만, 실제 Carousel.tsx 컴포넌트에는 이 버튼들이 구현되어 있지 않습니다. 사용자가 인디케이터 외에도 직접 슬라이드를 넘길 수 있도록 이전/다음 버튼을 추가하면 사용성이 크게 향상될 것입니다.

아래와 같이 핸들러 함수와 버튼을 추가할 수 있습니다.

// 핸들러 함수 추가
const goToNext = () => {
  setCurrentIndex((prevIndex) => (prevIndex + 1) % items.length);
};

const goToPrev = () => {
  setCurrentIndex((prevIndex) =>
    prevIndex === 0 ? items.length - 1 : prevIndex - 1
  );
};

// JSX에 버튼 추가
return (
  <div className="carousel">
    <button onClick={goToPrev} className="carousel__button carousel__button--prev">&lt;</button>
    <button onClick={goToNext} className="carousel__button carousel__button--next">&gt;</button>
    {/* ... aunchor ... */}
  </div>
);

<div
className="carousel__container"
{...rest}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<ul
className="carousel__list"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{items.map((item, index) => (
<li
key={index}
className="carousel__item"
onClick={item.onClick}
aria-hidden={index !== currentIndex}
>
{item.image && (
<img
src={item.image}
alt={item.title ?? ''}
className="carousel__image"
style={item.imageStyle}
/>
)}
<div className="carousel__content">
{item.title && (
<h2 className="carousel__title">{item.title}</h2>
)}
{item.text && <p className="carousel__text">{item.text}</p>}
</div>
</li>
))}
</ul>
</div>

<div className="carousel__indicators">
{items.map((_, index) => (
<button
key={index}
className={`carousel__indicator ${
index === currentIndex ? 'active' : ''
}`}
onClick={() => setCurrentIndex(index)}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
);
};

export default Carousel;
Loading
Loading