| '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, |
| } |
|
|