-
Notifications
You must be signed in to change notification settings - Fork 0
[feat/carousel] - 캐러셀 컴포넌트 구현 #22
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
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
| } | ||
| } |
| 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 | ||
| } | ||
| }; |
| 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
|
||
| 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
|
||
| 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(); | ||
| }); | ||
| 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 | ||
| }) => { | ||
| 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"> | ||
|
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.
아래와 같이 핸들러 함수와 버튼을 추가할 수 있습니다. // 핸들러 함수 추가
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"><</button>
<button onClick={goToNext} className="carousel__button carousel__button--next">></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; | ||
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.
The
...restprops 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 extendingReact.HTMLAttributes<HTMLDivElement>in CarouselProps or removing the rest spread if not needed.