diff options
| author | Max Nanis | 2025-05-28 04:41:37 +0100 |
|---|---|---|
| committer | Max Nanis | 2025-05-28 04:41:37 +0100 |
| commit | 8caa77413ea372e5cbd2980a9922d701af359c04 (patch) | |
| tree | 9341e2f70fab6b2678fdff53c002954ef69c7b3e /src/components/ui/carousel.tsx | |
| download | panel-ui-8caa77413ea372e5cbd2980a9922d701af359c04.tar.gz panel-ui-8caa77413ea372e5cbd2980a9922d701af359c04.zip | |
initial commit
Diffstat (limited to 'src/components/ui/carousel.tsx')
| -rw-r--r-- | src/components/ui/carousel.tsx | 241 |
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, +} |
