aboutsummaryrefslogtreecommitdiff
path: root/src/components/ui/carousel.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ui/carousel.tsx')
-rw-r--r--src/components/ui/carousel.tsx241
1 files changed, 241 insertions, 0 deletions
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..0e05a77
--- /dev/null
+++ b/src/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0]
+ api: ReturnType<typeof useEmblaCarousel>[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext<CarouselContextProps | null>(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a <Carousel />")
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+ <CarouselContext.Provider
+ value={{
+ carouselRef,
+ api: api,
+ opts,
+ orientation:
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
+ scrollPrev,
+ scrollNext,
+ canScrollPrev,
+ canScrollNext,
+ }}
+ >
+ <div
+ onKeyDownCapture={handleKeyDown}
+ className={cn("relative", className)}
+ role="region"
+ aria-roledescription="carousel"
+ data-slot="carousel"
+ {...props}
+ >
+ {children}
+ </div>
+ </CarouselContext.Provider>
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+ <div
+ ref={carouselRef}
+ className="overflow-hidden"
+ data-slot="carousel-content"
+ >
+ <div
+ className={cn(
+ "flex",
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
+ className
+ )}
+ {...props}
+ />
+ </div>
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel()
+
+ return (
+ <div
+ role="group"
+ aria-roledescription="slide"
+ data-slot="carousel-item"
+ className={cn(
+ "min-w-0 shrink-0 grow-0 basis-full",
+ orientation === "horizontal" ? "pl-4" : "pt-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+ <Button
+ data-slot="carousel-previous"
+ variant={variant}
+ size={size}
+ className={cn(
+ "absolute size-8 rounded-full",
+ orientation === "horizontal"
+ ? "top-1/2 -left-12 -translate-y-1/2"
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
+ className
+ )}
+ disabled={!canScrollPrev}
+ onClick={scrollPrev}
+ {...props}
+ >
+ <ArrowLeft />
+ <span className="sr-only">Previous slide</span>
+ </Button>
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+ <Button
+ data-slot="carousel-next"
+ variant={variant}
+ size={size}
+ className={cn(
+ "absolute size-8 rounded-full",
+ orientation === "horizontal"
+ ? "top-1/2 -right-12 -translate-y-1/2"
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
+ className
+ )}
+ disabled={!canScrollNext}
+ onClick={scrollNext}
+ {...props}
+ >
+ <ArrowRight />
+ <span className="sr-only">Next slide</span>
+ </Button>
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}