Skip to content
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

Carousel 컴포넌트 추가 #5

Merged
merged 5 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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;
29 changes: 29 additions & 0 deletions src/components/Carousel/Move.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => {
if (addFunc) addFunc();
if (direction === 'prev') return handleMovePrev(e);
return handleMoveNext(e);
};

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