Skip to content

Commit 3f8ce12

Browse files
feat: Carousel 컴포넌트 추가 (#38)
* chore: embla-carousel-react 추가 (#37) * feat: Carousel 컴포넌트 추가 (#37) * feat: Carousel 컴포넌트 스토리 추가 (#37) * feat: fds/components 내 클릭 가능한 영역에 cursor-pointer 스타일 적용 (#37)
1 parent 1c15619 commit 3f8ce12

11 files changed

Lines changed: 389 additions & 8 deletions

File tree

packages/fds/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"class-variance-authority": "^0.7.1",
6161
"clsx": "^2.1.1",
6262
"date-fns": "^4.1.0",
63+
"embla-carousel-react": "^8.6.0",
6364
"lucide-react": "^0.509.0",
6465
"next-themes": "^0.4.6",
6566
"react-hook-form": "^7.59.0",

packages/fds/src/components/Accordian/Accordian.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function AccordionTrigger({
3333
<AccordionPrimitive.Trigger
3434
data-slot="accordion-trigger"
3535
className={cn(
36-
'flex-between-align flex-1 p-[20px] text-left b2 transition-all outline-none disabled:pointer-events-none [&[data-state=open]>span]:rotate-180',
36+
'flex-between-align flex-1 p-[20px] text-left b2 transition-all outline-none disabled:pointer-events-none [&[data-state=open]>span]:rotate-180 cursor-pointer',
3737
className,
3838
)}
3939
{...props}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
5+
import { ArrowLeft, ArrowRight } from 'lucide-react'
6+
7+
import { cn } from '@/lib/utils'
8+
import { Button } from '@/components'
9+
10+
type CarouselApi = UseEmblaCarouselType[1]
11+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
12+
type CarouselOptions = UseCarouselParameters[0]
13+
type CarouselPlugin = UseCarouselParameters[1]
14+
15+
type CarouselProps = {
16+
opts?: CarouselOptions
17+
plugins?: CarouselPlugin
18+
orientation?: 'horizontal' | 'vertical'
19+
setApi?: (api: CarouselApi) => void
20+
}
21+
22+
type CarouselContextProps = {
23+
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
24+
api: ReturnType<typeof useEmblaCarousel>[1]
25+
scrollPrev: () => void
26+
scrollNext: () => void
27+
canScrollPrev: boolean
28+
canScrollNext: boolean
29+
} & CarouselProps
30+
31+
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
32+
33+
function useCarousel() {
34+
const context = React.useContext(CarouselContext)
35+
36+
if (!context) {
37+
throw new Error('useCarousel must be used within a <Carousel />')
38+
}
39+
40+
return context
41+
}
42+
43+
function Carousel({
44+
orientation = 'horizontal',
45+
opts,
46+
setApi,
47+
plugins,
48+
className,
49+
children,
50+
...props
51+
}: React.ComponentProps<'div'> & CarouselProps) {
52+
const [carouselRef, api] = useEmblaCarousel(
53+
{
54+
...opts,
55+
axis: orientation === 'horizontal' ? 'x' : 'y',
56+
},
57+
plugins,
58+
)
59+
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
60+
const [canScrollNext, setCanScrollNext] = React.useState(false)
61+
62+
const onSelect = React.useCallback((api: CarouselApi) => {
63+
if (!api) return
64+
setCanScrollPrev(api.canScrollPrev())
65+
setCanScrollNext(api.canScrollNext())
66+
}, [])
67+
68+
const scrollPrev = React.useCallback(() => {
69+
api?.scrollPrev()
70+
}, [api])
71+
72+
const scrollNext = React.useCallback(() => {
73+
api?.scrollNext()
74+
}, [api])
75+
76+
const handleKeyDown = React.useCallback(
77+
(event: React.KeyboardEvent<HTMLDivElement>) => {
78+
if (event.key === 'ArrowLeft') {
79+
event.preventDefault()
80+
scrollPrev()
81+
} else if (event.key === 'ArrowRight') {
82+
event.preventDefault()
83+
scrollNext()
84+
}
85+
},
86+
[scrollPrev, scrollNext],
87+
)
88+
89+
React.useEffect(() => {
90+
if (!api || !setApi) return
91+
setApi(api)
92+
}, [api, setApi])
93+
94+
React.useEffect(() => {
95+
if (!api) return
96+
onSelect(api)
97+
api.on('reInit', onSelect)
98+
api.on('select', onSelect)
99+
100+
return () => {
101+
api?.off('select', onSelect)
102+
}
103+
}, [api, onSelect])
104+
105+
return (
106+
<CarouselContext.Provider
107+
value={{
108+
carouselRef,
109+
api: api,
110+
opts,
111+
orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
112+
scrollPrev,
113+
scrollNext,
114+
canScrollPrev,
115+
canScrollNext,
116+
}}
117+
>
118+
<div
119+
onKeyDownCapture={handleKeyDown}
120+
className={cn('relative', className)}
121+
role="region"
122+
aria-roledescription="carousel"
123+
data-slot="carousel"
124+
{...props}
125+
>
126+
{children}
127+
</div>
128+
</CarouselContext.Provider>
129+
)
130+
}
131+
132+
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
133+
const { carouselRef, orientation } = useCarousel()
134+
135+
return (
136+
<div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
137+
<div
138+
className={cn(
139+
'flex cursor-pointer',
140+
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
141+
className,
142+
)}
143+
{...props}
144+
/>
145+
</div>
146+
)
147+
}
148+
149+
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
150+
const { orientation } = useCarousel()
151+
152+
return (
153+
<div
154+
role="group"
155+
aria-roledescription="slide"
156+
data-slot="carousel-item"
157+
className={cn(
158+
'min-w-0 shrink-0 grow-0 basis-full',
159+
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
160+
className,
161+
)}
162+
{...props}
163+
/>
164+
)
165+
}
166+
167+
function CarouselPrevious({ className, ...props }: React.ComponentProps<'button'>) {
168+
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
169+
170+
return (
171+
<button
172+
data-slot="carousel-previous"
173+
className={cn(
174+
'absolute size-8 rounded-full',
175+
orientation === 'horizontal'
176+
? 'top-1/2 -left-12 -translate-y-1/2'
177+
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
178+
className,
179+
)}
180+
disabled={!canScrollPrev}
181+
onClick={scrollPrev}
182+
{...props}
183+
>
184+
<ArrowLeft />
185+
<span className="sr-only">Previous slide</span>
186+
</button>
187+
)
188+
}
189+
190+
function CarouselNext({ className, ...props }: React.ComponentProps<'button'>) {
191+
const { orientation, scrollNext, canScrollNext } = useCarousel()
192+
193+
return (
194+
<button
195+
data-slot="carousel-next"
196+
className={cn(
197+
'absolute size-8 rounded-full',
198+
orientation === 'horizontal'
199+
? 'top-1/2 -right-12 -translate-y-1/2'
200+
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
201+
className,
202+
)}
203+
disabled={!canScrollNext}
204+
onClick={scrollNext}
205+
{...props}
206+
>
207+
<ArrowRight />
208+
<span className="sr-only">Next slide</span>
209+
</button>
210+
)
211+
}
212+
213+
function CarouselDots({ length }: { length: number }) {
214+
const { api } = useCarousel()
215+
const [selectedIndex, setSelectedIndex] = React.useState(0)
216+
217+
React.useEffect(() => {
218+
if (!api) return
219+
const onSelect = () => {
220+
setSelectedIndex(api.selectedScrollSnap())
221+
}
222+
223+
api.on('select', onSelect)
224+
api.on('reInit', onSelect)
225+
226+
return () => {
227+
api.off('select', onSelect)
228+
api.off('reInit', onSelect)
229+
}
230+
}, [api])
231+
232+
return (
233+
<div className="flex justify-center gap-[5px]">
234+
{Array.from({ length }).map((_, index) => (
235+
<button
236+
key={index}
237+
className={cn(
238+
'h-[4px] w-[4px] rounded-full bg-gs-8 transition-all duration-300 cursor-pointer',
239+
selectedIndex === index && 'w-[17px] bg-pm-1',
240+
)}
241+
onClick={() => api?.scrollTo(index)}
242+
/>
243+
))}
244+
</div>
245+
)
246+
}
247+
248+
export {
249+
type CarouselApi,
250+
Carousel,
251+
CarouselContent,
252+
CarouselItem,
253+
CarouselPrevious,
254+
CarouselNext,
255+
CarouselDots,
256+
}

packages/fds/src/components/RadioGroup/RadioGroup.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function RadioGroupItem({
2525
<RadioGroupPrimitive.Item
2626
data-slot="radio-group-item"
2727
className={cn(
28-
'border-[1.7px] border-gs-8 data-[state=checked]:border-pm-1 text-gs-2 aspect-square size-[24px] shrink-0 rounded-full outline-none disabled:cursor-not-allowed',
28+
'border-[1.7px] border-gs-8 data-[state=checked]:border-pm-1 text-gs-2 aspect-square size-[24px] shrink-0 rounded-full outline-none disabled:cursor-not-allowed cursor-pointer',
2929
className,
3030
)}
3131
{...props}

packages/fds/src/components/Sheet/Sheet.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function SheetOverlay({
3030
<SheetPrimitive.Overlay
3131
data-slot="sheet-overlay"
3232
className={cn(
33-
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50',
33+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 cursor-pointer',
3434
className,
3535
)}
3636
{...props}

packages/fds/src/components/Tabs/Tabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
2626
<TabsPrimitive.Trigger
2727
data-slot="tabs-trigger"
2828
className={cn(
29-
'b9 text-gs-6 border-b border-gs-9 box-border h-[46px]',
29+
'b9 text-gs-6 border-b border-gs-9 box-border h-[46px] cursor-pointer',
3030
'data-[state=active]:border-b-[3px] data-[state=active]:border-pm-1 data-[state=active]:b6 data-[state=active]:text-pm-1',
3131
'inline-flex flex-1 items-center justify-center px-[20px] py-[12px] whitespace-nowrap disabled:pointer-events-none',
3232
className,

packages/fds/src/components/WheelPicker/WheelPicker.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ export function WheelPicker({ items, value, onChange }: WheelPickerProps) {
109109
}
110110

111111
return (
112-
<div className="relative w-[100px] overflow-hidden" style={{ height: CONTAINER_HEIGHT }}>
112+
<div
113+
className="relative w-[100px] overflow-hidden cursor-pointer"
114+
style={{ height: CONTAINER_HEIGHT }}
115+
>
113116
<div className="absolute top-1/2 left-0 right-0 h-[30.4px] -translate-y-1/2 z-10" />
114117
<List
115118
height={CONTAINER_HEIGHT}

packages/fds/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './Select/Select'
1212
export * from './Accordian/Accordian'
1313
export * from './Toaster/Toaster'
1414
export * from './Tabs/Tabs'
15+
export * from './Carousel/Carousel'

0 commit comments

Comments
 (0)