carousel.tsx raw

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