Skip to content

Commit

Permalink
Carousel 컴포넌트 추가 (#5)
Browse files Browse the repository at this point in the history
* feat: carousel컴포넌트 추가

* chore: 주석제거

* fix: viewIndex 로직 분리

* feat: move버튼 throttle 적용

* chore: 범용 hook 경로 수정

---------

Co-authored-by: moonki kim <[email protected]>
  • Loading branch information
dladncks1217 and moong23 authored Jul 8, 2024
1 parent 36aa38e commit 2631941
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 1 deletion.
60 changes: 60 additions & 0 deletions src/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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;
}

// viewIndex context 분리
export const CarouselContext = createContext<{
width: number;
height: number;
length: number;
itemRef: React.MutableRefObject<HTMLDivElement | null>;
carouselBoxRef: React.MutableRefObject<HTMLDivElement | null>;
handleMoveImage: (imageNumber: number) => void;
handleMoveNext: (e: MouseEvent<HTMLButtonElement>) => void;
handleMovePrev: (e: MouseEvent<HTMLButtonElement>) => void;
} | null>(null);

export const CarouselIndexContext = createContext<number>(0);

const Carousel = ({ width, height, length, children }: CarouselProps) => {
const { viewIndex, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev } = useCarousel(length);

const carouselContextValue = useMemo(
() => ({
width,
height,
length,
itemRef,
carouselBoxRef,
handleMoveImage,
handleMoveNext,
handleMovePrev,
}),
[width, height, length, itemRef, carouselBoxRef, handleMoveImage, handleMoveNext, handleMovePrev],
);

// viewIndex context 분리하여 다른 상태를 구독중인 component가 리렌더링 되지 않도록 함
return (
<CarouselContext.Provider value={carouselContextValue}>
<CarouselIndexContext.Provider value={viewIndex}>{children}</CarouselIndexContext.Provider>
</CarouselContext.Provider>
);
};

Carousel.Wrapper = Wrapper;
Carousel.Item = CarouselItem;
Carousel.Move = Move;
Carousel.Dots = Dots;

export default Carousel;
33 changes: 33 additions & 0 deletions src/components/Carousel/CarouselItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useContext, useEffect, useRef } from 'react';
import type { PropsWithChildren } from 'react';
import { CarouselContext, CarouselIndexContext } from './Carousel';

export interface CarouselItemProps extends PropsWithChildren {
index: number;
}

const CarouselItem = ({ index, children }: CarouselItemProps) => {
const ref = useRef<HTMLDivElement | null>(null);
const context = useContext(CarouselContext);
// viewIndex context 추가
const viewIndex = useContext(CarouselIndexContext);

if (!context) throw Error('Carousel.Item is only available within Carousel.');

// viewIndex 분리
const { width, height, itemRef } = context;

useEffect(() => {
if (ref.current) {
if (index === viewIndex) itemRef.current = ref.current;
}
}, [viewIndex]);

return (
<div ref={ref} style={{ width, height }}>
{children}
</div>
);
};

export default CarouselItem;
44 changes: 44 additions & 0 deletions src/components/Carousel/Dots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useContext, type MouseEvent } from 'react';
import { CarouselContext, CarouselIndexContext } 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);
const viewIndex = useContext(CarouselIndexContext);

if (!context) throw Error('Carousel.Dots is only available within Carousel.');

const { handleMoveImage } = context;

return (
<div className='flex justify-between'>
{images.map((_, index) => (
// viewIndex, index 비교 로직을 backgroundColor로 이동
<button
type='button'
key={crypto.randomUUID()}
onClick={(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleMoveImage(index);
}}
className='border-none rounded-full cursor-pointer opacity-60'
style={{
width: `${size}px`,
height: `${size}px`,
backgroundColor: viewIndex === index ? selectedColor : unSelectedColor,
}}
/>
))}
</div>
);
};

export default Dots;
30 changes: 30 additions & 0 deletions src/components/Carousel/Move.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ComponentPropsWithoutRef, MouseEvent, useContext } from 'react';
import { CarouselContext } from './Carousel';
import useThrottleCallback from '@/hooks/useThrottleCallback';

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 = useThrottleCallback((e: MouseEvent<HTMLButtonElement>) => {
if (addFunc) addFunc();
if (direction === 'prev') return handleMovePrev(e);
return handleMoveNext(e);
}, 500);

return (
<button onClick={handleMove} {...attributes}>
{children}
</button>
);
};

export default Move;
30 changes: 30 additions & 0 deletions src/components/Carousel/Wrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
ref={carouselBoxRef}
className='relative overflow-hidden'
style={{ width, height, minWidth: width, minHeight: height }}
>
<div className='flex w-full p-0 m-0 overflow-hidden' style={{ height }}>
{children}
</div>
</div>
);
};

export default Wrapper;
66 changes: 66 additions & 0 deletions src/components/Carousel/useCarousel.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const itemRef = useRef<HTMLDivElement | null>(null);

const handleMoveImage = (imageNumber: number) => {
if (itemRef.current) {
flushSync(() => {
setViewIndex(imageNumber);
});

itemRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
};

const handleMovePrev = (e: MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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;
106 changes: 106 additions & 0 deletions src/stories/Carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Carousel>;

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

export const Default: Story = {
render: args => {
return (
<Carousel {...args}>
<Carousel.Wrapper>
<Carousel.Item index={0}>
<div style={{ width: '200px', height: '200px', backgroundColor: 'red' }}>1번컴포넌트</div>
</Carousel.Item>
<Carousel.Item index={1}>
<div style={{ width: '200px', height: '200px', backgroundColor: 'green' }}>2번컴포넌트</div>
</Carousel.Item>
<Carousel.Item index={2}>
<div style={{ width: '200px', height: '200px' }}>3번컴포넌트</div>
</Carousel.Item>
</Carousel.Wrapper>

<div className='flex justify-center pt-4'>
<div style={{ width: '80px' }}>
<Carousel.Dots size={8} imageLength={3} unSelectedColor='black' selectedColor='blue' />
</div>
</div>
<div className='flex justify-between'>
<Carousel.Move direction='prev' addFunc={() => console.log('이전')}>
<button>이전버튼</button>
</Carousel.Move>
<Carousel.Move direction='next' addFunc={() => console.log('다음')}>
<button>다음버튼</button>
</Carousel.Move>
</div>
</Carousel>
);
},
};

export const NoDots: Story = {
render: args => {
return (
<Carousel {...args}>
<Carousel.Wrapper>
<Carousel.Item index={0}>
<div style={{ width: '200px', height: '200px', backgroundColor: 'red' }}>1번컴포넌트</div>
</Carousel.Item>
<Carousel.Item index={1}>
<div style={{ width: '200px', height: '200px', backgroundColor: 'green' }}>2번컴포넌트</div>
</Carousel.Item>
<Carousel.Item index={2}>
<div style={{ width: '200px', height: '200px' }}>3번컴포넌트</div>
</Carousel.Item>
</Carousel.Wrapper>

<div className='flex justify-between'>
<Carousel.Move direction='prev' addFunc={() => console.log('이전')}>
<button>이전버튼</button>
</Carousel.Move>
<Carousel.Move direction='next' addFunc={() => console.log('다음')}>
<button>다음버튼</button>
</Carousel.Move>
</div>
</Carousel>
);
},
};

export const OnlyDots: Story = {
render: args => {
return (
<Carousel {...args}>
<Carousel.Wrapper>
<Carousel.Item index={0}>
<div style={{ width: '200px', height: '200px', backgroundColor: 'red' }}>1번컴포넌트</div>
</Carousel.Item>
<Carousel.Item index={1}>
<div style={{ width: '200px', height: '200px', backgroundColor: 'green' }}>2번컴포넌트</div>
</Carousel.Item>
<Carousel.Item index={2}>
<div style={{ width: '200px', height: '200px' }}>3번컴포넌트</div>
</Carousel.Item>
</Carousel.Wrapper>
<div className='flex justify-center pt-4'>
<div style={{ width: '80px' }}>
<Carousel.Dots size={8} imageLength={3} unSelectedColor='black' selectedColor='blue' />
</div>
</div>
</Carousel>
);
},
};
2 changes: 1 addition & 1 deletion src/stories/Chip.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
Loading

0 comments on commit 2631941

Please sign in to comment.