1"use client"
2
3import * as React from "react"
4import { cn } from "@/lib/utils"
5import useEmblaCarousel, {
6 type UseEmblaCarouselType,
7} from "embla-carousel-react"
8import { CaretLeft, CaretRight } from "@phosphor-icons/react"
9
10import { Button } from "@/components/ui/button"
11
12type CarouselApi = UseEmblaCarouselType[1]
13type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
14type CarouselOptions = UseCarouselParameters[0]
15type CarouselPlugin = UseCarouselParameters[1]
16
17type CarouselProps = {
18 opts?: CarouselOptions
19 plugins?: CarouselPlugin
20 orientation?: "horizontal" | "vertical"
21 setApi?: (api: CarouselApi) => void
22}
23
24type CarouselContextProps = {
25 carouselRef: UseEmblaCarouselType[0]
26 api: UseEmblaCarouselType[1]
27 scrollPrev: () => void
28 scrollNext: () => void
29 canScrollPrev: boolean
30 canScrollNext: boolean
31} & CarouselProps
32
33const CarouselContext = React.createContext<CarouselContextProps | null>(null)
34
35function useCarousel() {
36 const context = React.useContext(CarouselContext)
37
38 if (!context) {
39 throw new Error("useCarousel must be used within a <Carousel />")
40 }
41
42 return context
43}
44
45function Carousel({
46 orientation = "horizontal",
47 opts,
48 setApi,
49 plugins,
50 className,
51 children,
52 ...props
53}: React.ComponentProps<"div"> & CarouselProps) {
54 const [carouselRef, api] = useEmblaCarousel(
55 {
56 ...opts,
57 axis: orientation === "horizontal" ? "x" : "y",
58 },
59 plugins
60 )
61 const [, setScrollTick] = React.useState(0)
62
63 /* Nilai tombol dibaca saat render; peristiwa embla hanya memicu render ulang.
64 Memanggil setState langsung di badan effect melanggar
65 react-hooks/set-state-in-effect. */
66 const canScrollPrev = api?.canScrollPrev() ?? false
67 const canScrollNext = api?.canScrollNext() ?? false
68
69 const scrollPrev = React.useCallback(() => {
70 api?.scrollPrev()
71 }, [api])
72
73 const scrollNext = React.useCallback(() => {
74 api?.scrollNext()
75 }, [api])
76
77 const handleKeyDown = React.useCallback(
78 (event: React.KeyboardEvent<HTMLDivElement>) => {
79 if (event.key === "ArrowLeft") {
80 event.preventDefault()
81 scrollPrev()
82 } else if (event.key === "ArrowRight") {
83 event.preventDefault()
84 scrollNext()
85 }
86 },
87 [scrollPrev, scrollNext]
88 )
89
90 React.useEffect(() => {
91 if (!api || !setApi) return
92 setApi(api)
93 }, [api, setApi])
94
95 React.useEffect(() => {
96 if (!api) return
97
98 const rerender = () => setScrollTick((tick) => tick + 1)
99 api.on("reInit", rerender)
100 api.on("select", rerender)
101
102 return () => {
103 api.off("reInit", rerender)
104 api.off("select", rerender)
105 }
106 }, [api])
107
108 return (
109 <CarouselContext.Provider
110 value={{
111 carouselRef,
112 api: api,
113 opts,
114 orientation:
115 orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
116 scrollPrev,
117 scrollNext,
118 canScrollPrev,
119 canScrollNext,
120 }}
121 >
122 <div
123 onKeyDownCapture={handleKeyDown}
124 className={cn("relative", className)}
125 role="region"
126 aria-roledescription="carousel"
127 data-slot="carousel"
128 {...props}
129 >
130 {children}
131 </div>
132 </CarouselContext.Provider>
133 )
134}
135
136function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
137 const { carouselRef, orientation } = useCarousel()
138
139 return (
140 <div
141 ref={carouselRef}
142 className="overflow-hidden"
143 data-slot="carousel-content"
144 >
145 <div
146 className={cn(
147 "flex",
148 orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
149 className
150 )}
151 {...props}
152 />
153 </div>
154 )
155}
156
157function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
158 const { orientation } = useCarousel()
159
160 return (
161 <div
162 role="group"
163 aria-roledescription="slide"
164 data-slot="carousel-item"
165 className={cn(
166 "min-w-0 shrink-0 grow-0 basis-full",
167 orientation === "horizontal" ? "pl-4" : "pt-4",
168 className
169 )}
170 {...props}
171 />
172 )
173}
174
175function CarouselPrevious({
176 className,
177 variant = "neutral",
178 size = "icon",
179 ...props
180}: React.ComponentProps<typeof Button>) {
181 const { orientation, scrollPrev, canScrollPrev } = useCarousel()
182
183 return (
184 <Button
185 data-slot="carousel-previous"
186 variant={variant}
187 size={size}
188 className={cn(
189 "absolute touch-manipulation",
190 orientation === "horizontal"
191 ? "inset-y-0 -left-12 my-auto"
192 : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
193 className
194 )}
195 disabled={!canScrollPrev}
196 onClick={scrollPrev}
197 {...props}
198 >
199 <CaretLeft weight="bold" className="size-5" />
200 <span className="sr-only">Slide sebelumnya</span>
201 </Button>
202 )
203}
204
205function CarouselNext({
206 className,
207 variant = "neutral",
208 size = "icon",
209 ...props
210}: React.ComponentProps<typeof Button>) {
211 const { orientation, scrollNext, canScrollNext } = useCarousel()
212
213 return (
214 <Button
215 data-slot="carousel-next"
216 variant={variant}
217 size={size}
218 className={cn(
219 "absolute touch-manipulation",
220 orientation === "horizontal"
221 ? "inset-y-0 -right-12 my-auto"
222 : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
223 className
224 )}
225 disabled={!canScrollNext}
226 onClick={scrollNext}
227 {...props}
228 >
229 <CaretRight weight="bold" className="size-5" />
230 <span className="sr-only">Slide berikutnya</span>
231 </Button>
232 )
233}
234
235export {
236 type CarouselApi,
237 Carousel,
238 CarouselContent,
239 CarouselItem,
240 CarouselPrevious,
241 CarouselNext,
242 useCarousel,
243}
244