diff --git a/src/components/Carousel/Carousel.scss b/src/components/Carousel/Carousel.scss new file mode 100644 index 0000000..a974dc1 --- /dev/null +++ b/src/components/Carousel/Carousel.scss @@ -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; + } + } +} diff --git a/src/components/Carousel/Carousel.stories.tsx b/src/components/Carousel/Carousel.stories.tsx new file mode 100644 index 0000000..6de00c9 --- /dev/null +++ b/src/components/Carousel/Carousel.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import Carousel from './Carousel'; + +const meta: Meta = { + title: 'Components/Carousel', + component: Carousel, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +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 + } +}; diff --git a/src/components/Carousel/Carousel.test.tsx b/src/components/Carousel/Carousel.test.tsx new file mode 100644 index 0000000..30fcea4 --- /dev/null +++ b/src/components/Carousel/Carousel.test.tsx @@ -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(); + const slide1 = screen.getByRole('heading', { name: /Slide 1/i }); + const slide2 = screen.getByRole('heading', { name: /Slide 2/i }); + 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(); + + const indicators = screen.getAllByRole('button'); + + expect(indicators[0]).toHaveClass('active'); + + vi.advanceTimersByTime(1000); + expect(indicators[0]).not.toHaveClass('active'); + 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(); +}); diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx new file mode 100644 index 0000000..6c868fd --- /dev/null +++ b/src/components/Carousel/Carousel.tsx @@ -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 { + items: CarouselItem[]; + autoPlay?: boolean; + interval?: number; +} + +const Carousel: React.FC = ({ + items, + autoPlay = false, + interval = 3000, + ...rest +}) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [touchStartX, setTouchStartX] = useState(null); + const [touchEndX, setTouchEndX] = useState(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 ( +
+
+
    + {items.map((item, index) => ( +
  • + {item.image && ( + {item.title + )} +
    + {item.title && ( +

    {item.title}

    + )} + {item.text &&

    {item.text}

    } +
    +
  • + ))} +
+
+ +
+ {items.map((_, index) => ( +
+
+ ); +}; + +export default Carousel; diff --git a/src/components/Select/Select.scss b/src/components/Select/Select.scss index 3891c1b..b6eb910 100644 --- a/src/components/Select/Select.scss +++ b/src/components/Select/Select.scss @@ -5,7 +5,6 @@ .select { position: relative; cursor: pointer; - width: 100%; font-family: 'Times New Roman', serif; } @@ -15,7 +14,9 @@ box-sizing: border-box; padding: 6px 12px; background: s.color(paper-background); - background-size: cover; + background-image: url('../../assets/paper.png'); + background-repeat: repeat; + background-size: auto; font-family: 'Times New Roman', serif; border: 2px solid s.color(black); @include s.text-style-extended(sm, 800, gray-900); @@ -65,7 +66,9 @@ left: 0; width: 100%; background: s.color(paper-background); - background-size: cover; + background-image: url('../../assets/paper.png'); + background-repeat: repeat; + background-size: auto; font-family: 'Times New Roman', serif; border: 2px solid s.color(black); margin: 4px 0 0 0; @@ -109,7 +112,6 @@ &:focus { @include s.text-style-extended(sm, 700, red-900); text-shadow: 1px 1px s.color(white); - transition: all 0.15s ease-in-out; } // sm 이상 diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 2d79457..514bc7f 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react'; import type { KeyboardEvent } from 'react'; import './Select.scss'; -import paperTexture from '../../assets/paper.png'; export interface SelectProps extends Omit, 'onChange'> { @@ -83,7 +82,6 @@ const Select: React.FC = ({ return (
{open && (