carousel.tsx 5.5 KB

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