From 2158a8719eaa712de83899fc4215a24ebb82fe97 Mon Sep 17 00:00:00 2001 From: dladncks1217 Date: Sun, 7 Jul 2024 16:51:05 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20carousel=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Carousel/Carousel.tsx | 55 ++++++++++++ src/components/Carousel/CarouselItem.tsx | 30 +++++++ src/components/Carousel/Dots.tsx | 53 ++++++++++++ src/components/Carousel/Move.tsx | 29 +++++++ src/components/Carousel/Wrapper.tsx | 30 +++++++ src/components/Carousel/useCarousel.ts | 66 ++++++++++++++ src/stories/Carousel.stories.tsx | 106 +++++++++++++++++++++++ src/stories/Chip.stories.tsx | 2 +- 8 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 src/components/Carousel/Carousel.tsx create mode 100644 src/components/Carousel/CarouselItem.tsx create mode 100644 src/components/Carousel/Dots.tsx create mode 100644 src/components/Carousel/Move.tsx create mode 100644 src/components/Carousel/Wrapper.tsx create mode 100644 src/components/Carousel/useCarousel.ts create mode 100644 src/stories/Carousel.stories.tsx diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx new file mode 100644 index 0000000..c451ccc --- /dev/null +++ b/src/components/Carousel/Carousel.tsx @@ -0,0 +1,55 @@ +import { createContext, useMemo } from 'react'; +import type { MouseEvent, PropsWithChildren } from 'react'; + +import CarouselItem from './CarouselItem'; +import useCarousel from './useCarousel'; +import Move from './Move'; +import Wrapper from './Wrapper'; +import Dots from './Dots'; + +export interface CarouselProps extends PropsWithChildren { + width: number; + height: number; + length: number; + // children?: JSX.Element | JSX.Element[]; +} + +export const CarouselContext = createContext<{ + viewIndex: number; + width: number; + height: number; + length: number; + itemRef: React.MutableRefObject; + carouselBoxRef: React.MutableRefObject; + handleMoveImage: (imageNumber: number) => void; + handleMoveNext: (e: MouseEvent) => void; + handleMovePrev: (e: MouseEvent) => void; +} | null>(null); + +const Carousel = ({ width, height, length, children }: CarouselProps) => { + const { viewIndex, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev } = useCarousel(length); + + const context = useMemo( + () => ({ + width, + height, + length, + viewIndex, + itemRef, + carouselBoxRef, + handleMoveImage, + handleMoveNext, + handleMovePrev, + }), + [width, height, length, viewIndex, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev], + ); + + return {children}; +}; + +Carousel.Wrapper = Wrapper; +Carousel.Item = CarouselItem; +Carousel.Move = Move; +Carousel.Dots = Dots; + +export default Carousel; diff --git a/src/components/Carousel/CarouselItem.tsx b/src/components/Carousel/CarouselItem.tsx new file mode 100644 index 0000000..6bdf8a9 --- /dev/null +++ b/src/components/Carousel/CarouselItem.tsx @@ -0,0 +1,30 @@ +import { useContext, useEffect, useRef } from 'react'; +import type { PropsWithChildren } from 'react'; +import { CarouselContext } from './Carousel'; + +export interface CarouselItemProps extends PropsWithChildren { + index: number; +} + +const CarouselItem = ({ index, children }: CarouselItemProps) => { + const ref = useRef(null); + const context = useContext(CarouselContext); + + if (!context) throw Error('Carousel.Item is only available within Carousel.'); + + const { width, height, viewIndex, itemRef } = context; + + useEffect(() => { + if (ref.current) { + if (index === viewIndex) itemRef.current = ref.current; + } + }, [viewIndex]); + + return ( +
+ {children} +
+ ); +}; + +export default CarouselItem; diff --git a/src/components/Carousel/Dots.tsx b/src/components/Carousel/Dots.tsx new file mode 100644 index 0000000..130b9f4 --- /dev/null +++ b/src/components/Carousel/Dots.tsx @@ -0,0 +1,53 @@ +import { useContext, type MouseEvent } from 'react'; +import { CarouselContext } from './Carousel'; + +interface DotsProps { + imageLength: number; + size?: number; + selectedColor?: string; + unSelectedColor?: string; +} + +const Dots = ({ imageLength, size = 6, selectedColor = '#fff', unSelectedColor = '#000' }: DotsProps) => { + const images = Array.from({ length: imageLength }, () => ''); + + const context = useContext(CarouselContext); + + if (!context) throw Error('Carousel.Dots is only available within Carousel.'); + + const { viewIndex, handleMoveImage } = context; + + return ( +
+ {images.map((_, index) => { + if (viewIndex === index) + return ( +
+ ); +}; + +export default Dots; diff --git a/src/components/Carousel/Move.tsx b/src/components/Carousel/Move.tsx new file mode 100644 index 0000000..1c27647 --- /dev/null +++ b/src/components/Carousel/Move.tsx @@ -0,0 +1,29 @@ +import { ComponentPropsWithoutRef, MouseEvent, useContext } from 'react'; +import { CarouselContext } from './Carousel'; + +export interface MoveProps extends ComponentPropsWithoutRef<'button'> { + direction: 'prev' | 'next'; + addFunc?: CallableFunction; +} + +const Move = ({ direction = 'next', addFunc, children, ...attributes }: MoveProps) => { + const context = useContext(CarouselContext); + + if (!context) throw Error('Carousel.Move is only available within Carousel.'); + + const { handleMovePrev, handleMoveNext } = context; + + const handleMove = (e: MouseEvent) => { + if (addFunc) addFunc(); + if (direction === 'prev') return handleMovePrev(e); + return handleMoveNext(e); + }; + + return ( + + ); +}; + +export default Move; diff --git a/src/components/Carousel/Wrapper.tsx b/src/components/Carousel/Wrapper.tsx new file mode 100644 index 0000000..6aa3e13 --- /dev/null +++ b/src/components/Carousel/Wrapper.tsx @@ -0,0 +1,30 @@ +import { useContext } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { CarouselContext } from './Carousel'; + +interface WrapperProps extends PropsWithChildren { + height?: string; +} + +const Wrapper = ({ height = '100%', children }: WrapperProps) => { + const context = useContext(CarouselContext); + + if (!context) throw Error('Carousel.Wrapper is only available within Carousel.'); + + const { carouselBoxRef, width } = context; + + return ( +
+
+ {children} +
+
+ ); +}; + +export default Wrapper; diff --git a/src/components/Carousel/useCarousel.ts b/src/components/Carousel/useCarousel.ts new file mode 100644 index 0000000..7b8be5b --- /dev/null +++ b/src/components/Carousel/useCarousel.ts @@ -0,0 +1,66 @@ +import { useRef, useState } from 'react'; +import type { MouseEvent } from 'react'; +import { flushSync } from 'react-dom'; + +const useCarousel = (itemLength: number) => { + const [viewIndex, setViewIndex] = useState(0); + const carouselBoxRef = useRef(null); + const itemRef = useRef(null); + + const handleMoveImage = (imageNumber: number) => { + if (itemRef.current) { + flushSync(() => { + setViewIndex(imageNumber); + }); + + itemRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + } + }; + + const handleMovePrev = (e: MouseEvent) => { + e.stopPropagation(); + if (itemRef.current) { + flushSync(() => { + if (viewIndex === 0) setViewIndex(0); + else setViewIndex(viewIndex - 1); + }); + + itemRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + } + }; + + const handleMoveNext = (e: MouseEvent) => { + e.stopPropagation(); + if (itemRef.current) { + flushSync(() => { + if (viewIndex === itemLength - 1) setViewIndex(viewIndex); + else setViewIndex(viewIndex + 1); + }); + + itemRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + } + }; + + return { + viewIndex, + itemRef, + carouselBoxRef, + handleMoveImage, + handleMovePrev, + handleMoveNext, + }; +}; + +export default useCarousel; diff --git a/src/stories/Carousel.stories.tsx b/src/stories/Carousel.stories.tsx new file mode 100644 index 0000000..1c05383 --- /dev/null +++ b/src/stories/Carousel.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Carousel from '@/components/Carousel/Carousel'; + +const meta = { + title: 'Example/Carousel', + component: Carousel, + parameters: { + layout: 'centered', + }, + + tags: ['autodocs'], + + argTypes: {}, + args: { width: 200, height: 200, length: 3 }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: args => { + return ( + + + +
1번컴포넌트
+
+ +
2번컴포넌트
+
+ +
3번컴포넌트
+
+
+ +
+
+ +
+
+
+ console.log('이전')}> + + + console.log('다음')}> + + +
+
+ ); + }, +}; + +export const NoDots: Story = { + render: args => { + return ( + + + +
1번컴포넌트
+
+ +
2번컴포넌트
+
+ +
3번컴포넌트
+
+
+ +
+ console.log('이전')}> + + + console.log('다음')}> + + +
+
+ ); + }, +}; + +export const OnlyDots: Story = { + render: args => { + return ( + + + +
1번컴포넌트
+
+ +
2번컴포넌트
+
+ +
3번컴포넌트
+
+
+
+
+ +
+
+
+ ); + }, +}; diff --git a/src/stories/Chip.stories.tsx b/src/stories/Chip.stories.tsx index 31540e9..1443757 100644 --- a/src/stories/Chip.stories.tsx +++ b/src/stories/Chip.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; -import { Chip } from './Chip'; +import { Chip } from '../components/Chip'; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { From 295d79ccf79f8ad973ee2f824ec5d077ddad5ab5 Mon Sep 17 00:00:00 2001 From: dladncks1217 Date: Sun, 7 Jul 2024 16:56:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Carousel/Carousel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx index c451ccc..3f1b5d6 100644 --- a/src/components/Carousel/Carousel.tsx +++ b/src/components/Carousel/Carousel.tsx @@ -11,7 +11,6 @@ export interface CarouselProps extends PropsWithChildren { width: number; height: number; length: number; - // children?: JSX.Element | JSX.Element[]; } export const CarouselContext = createContext<{ From ac0ed64bc0784f1855ed056a3db834be56ade8da Mon Sep 17 00:00:00 2001 From: moonki kim Date: Mon, 8 Jul 2024 10:46:26 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20viewIndex=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Carousel/Carousel.tsx | 16 +++++--- src/components/Carousel/CarouselItem.tsx | 7 +++- src/components/Carousel/Dots.tsx | 49 ++++++++++-------------- src/components/Carousel/Wrapper.tsx | 2 +- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx index 3f1b5d6..746e0d1 100644 --- a/src/components/Carousel/Carousel.tsx +++ b/src/components/Carousel/Carousel.tsx @@ -13,8 +13,8 @@ export interface CarouselProps extends PropsWithChildren { length: number; } +// viewIndex context 분리 export const CarouselContext = createContext<{ - viewIndex: number; width: number; height: number; length: number; @@ -25,25 +25,31 @@ export const CarouselContext = createContext<{ handleMovePrev: (e: MouseEvent) => void; } | null>(null); +export const CarouselIndexContext = createContext(0); + const Carousel = ({ width, height, length, children }: CarouselProps) => { const { viewIndex, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev } = useCarousel(length); - const context = useMemo( + const carouselContextValue = useMemo( () => ({ width, height, length, - viewIndex, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev, }), - [width, height, length, viewIndex, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev], + [width, height, length, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev], ); - return {children}; + // viewIndex context 분리하여 다른 상태를 구독중인 component가 리렌더링 되지 않도록 함 + return ( + + {children} + + ); }; Carousel.Wrapper = Wrapper; diff --git a/src/components/Carousel/CarouselItem.tsx b/src/components/Carousel/CarouselItem.tsx index 6bdf8a9..0a3123a 100644 --- a/src/components/Carousel/CarouselItem.tsx +++ b/src/components/Carousel/CarouselItem.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect, useRef } from 'react'; import type { PropsWithChildren } from 'react'; -import { CarouselContext } from './Carousel'; +import { CarouselContext, CarouselIndexContext } from './Carousel'; export interface CarouselItemProps extends PropsWithChildren { index: number; @@ -9,10 +9,13 @@ export interface CarouselItemProps extends PropsWithChildren { const CarouselItem = ({ index, children }: CarouselItemProps) => { const ref = useRef(null); const context = useContext(CarouselContext); + // viewIndex context 추가 + const viewIndex = useContext(CarouselIndexContext); if (!context) throw Error('Carousel.Item is only available within Carousel.'); - const { width, height, viewIndex, itemRef } = context; + // viewIndex 분리 + const { width, height, itemRef } = context; useEffect(() => { if (ref.current) { diff --git a/src/components/Carousel/Dots.tsx b/src/components/Carousel/Dots.tsx index 130b9f4..fa85eaa 100644 --- a/src/components/Carousel/Dots.tsx +++ b/src/components/Carousel/Dots.tsx @@ -1,5 +1,5 @@ import { useContext, type MouseEvent } from 'react'; -import { CarouselContext } from './Carousel'; +import { CarouselContext, CarouselIndexContext } from './Carousel'; interface DotsProps { imageLength: number; @@ -12,40 +12,31 @@ const Dots = ({ imageLength, size = 6, selectedColor = '#fff', unSelectedColor = const images = Array.from({ length: imageLength }, () => ''); const context = useContext(CarouselContext); + const viewIndex = useContext(CarouselIndexContext); if (!context) throw Error('Carousel.Dots is only available within Carousel.'); - const { viewIndex, handleMoveImage } = context; + const { handleMoveImage } = context; return (
- {images.map((_, index) => { - if (viewIndex === index) - return ( -
); }; diff --git a/src/components/Carousel/Wrapper.tsx b/src/components/Carousel/Wrapper.tsx index 6aa3e13..e2b841d 100644 --- a/src/components/Carousel/Wrapper.tsx +++ b/src/components/Carousel/Wrapper.tsx @@ -20,7 +20,7 @@ const Wrapper = ({ height = '100%', children }: WrapperProps) => { className='relative overflow-hidden' style={{ width, height, minWidth: width, minHeight: height }} > -
+
{children}
From 8e621d8a5e68cffce5ad0982e7dd24edc4bba201 Mon Sep 17 00:00:00 2001 From: dladncks1217 Date: Mon, 8 Jul 2024 22:00:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20move=EB=B2=84=ED=8A=BC=20throttle?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Carousel/Move.tsx | 5 +++-- src/hooks/useThrottleCallback.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useThrottleCallback.ts diff --git a/src/components/Carousel/Move.tsx b/src/components/Carousel/Move.tsx index 1c27647..f1c7688 100644 --- a/src/components/Carousel/Move.tsx +++ b/src/components/Carousel/Move.tsx @@ -1,5 +1,6 @@ import { ComponentPropsWithoutRef, MouseEvent, useContext } from 'react'; import { CarouselContext } from './Carousel'; +import useThrottleCallback from '@/hooks/useThrottleCallback'; export interface MoveProps extends ComponentPropsWithoutRef<'button'> { direction: 'prev' | 'next'; @@ -13,11 +14,11 @@ const Move = ({ direction = 'next', addFunc, children, ...attributes }: MoveProp const { handleMovePrev, handleMoveNext } = context; - const handleMove = (e: MouseEvent) => { + const handleMove = useThrottleCallback((e: MouseEvent) => { if (addFunc) addFunc(); if (direction === 'prev') return handleMovePrev(e); return handleMoveNext(e); - }; + }, 500); return (