| 1 | /** |
| 2 | * Carousel - Embla 轮播组件封装 |
| 3 | * 公共组件,基于 embla-carousel-react |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type { UseEmblaCarouselType } from 'embla-carousel-react' |
| 9 | import type { CSSProperties, ReactNode } from 'react' |
| 10 | import Autoplay from 'embla-carousel-autoplay' |
| 11 | import useEmblaCarousel from 'embla-carousel-react' |
| 12 | import { ChevronLeft, ChevronRight } from 'lucide-react' |
| 13 | import { createContext, useCallback, useContext, useEffect, useState } from 'react' |
| 14 | |
| 15 | import { cn } from '@/utils/className' |
| 16 | |
| 17 | type EmblaCarouselType = UseEmblaCarouselType[1] |
| 18 | type EmblaOptionsType = Parameters<typeof useEmblaCarousel>[0] |
| 19 | |
| 20 | interface CarouselContextValue { |
| 21 | emblaRef: (node: HTMLElement | null) => void |
| 22 | emblaApi: EmblaCarouselType | undefined |
| 23 | selectedIndex: number |
| 24 | scrollPrev: () => void |
| 25 | scrollNext: () => void |
| 26 | canScrollPrev: boolean |
| 27 | canScrollNext: boolean |
| 28 | } |
| 29 | |
| 30 | const CarouselContext = createContext<CarouselContextValue | null>(null) |
| 31 | |
| 32 | export function useCarousel() { |
| 33 | const context = useContext(CarouselContext) |
| 34 | if (!context) { |
| 35 | throw new Error('useCarousel must be used within a Carousel') |
| 36 | } |
| 37 | return context |
| 38 | } |
| 39 | |
| 40 | interface CarouselProps { |
| 41 | children: ReactNode |
| 42 | options?: EmblaOptionsType |
| 43 | autoplay?: boolean |
| 44 | autoplayDelay?: number |
| 45 | className?: string |
| 46 | /** 当 slide 变化时回调 */ |
| 47 | onSlideChange?: (index: number) => void |
| 48 | } |
| 49 | |
| 50 | export function Carousel({ |
| 51 | children, |
| 52 | options, |
| 53 | autoplay = false, |
| 54 | autoplayDelay = 5000, |
| 55 | className, |
| 56 | onSlideChange, |
| 57 | }: CarouselProps) { |
| 58 | const plugins = autoplay |
| 59 | ? [Autoplay({ delay: autoplayDelay, stopOnInteraction: false })] |
| 60 | : [] |
| 61 | |
| 62 | const [emblaRef, emblaApi] = useEmblaCarousel( |
| 63 | { loop: true, ...options }, |
| 64 | plugins, |
| 65 | ) |
| 66 | const [selectedIndex, setSelectedIndex] = useState(0) |
| 67 | const [canScrollPrev, setCanScrollPrev] = useState(false) |
| 68 | const [canScrollNext, setCanScrollNext] = useState(false) |
| 69 | |
| 70 | const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]) |
| 71 | const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]) |
| 72 | |
| 73 | const onSelect = useCallback(() => { |
| 74 | if (!emblaApi) |
| 75 | return |
| 76 | const index = emblaApi.selectedScrollSnap() |
| 77 | setSelectedIndex(index) |
| 78 | setCanScrollPrev(emblaApi.canScrollPrev()) |
| 79 | setCanScrollNext(emblaApi.canScrollNext()) |
| 80 | onSlideChange?.(index) |
| 81 | }, [emblaApi, onSlideChange]) |
| 82 | |
| 83 | useEffect(() => { |
| 84 | if (!emblaApi) |
| 85 | return |
| 86 | onSelect() |
| 87 | emblaApi.on('select', onSelect) |
| 88 | emblaApi.on('reInit', onSelect) |
| 89 | return () => { |
| 90 | emblaApi.off('select', onSelect) |
| 91 | emblaApi.off('reInit', onSelect) |
| 92 | } |
| 93 | }, [emblaApi, onSelect]) |
| 94 | |
| 95 | return ( |
| 96 | <CarouselContext.Provider |
| 97 | value={{ |
| 98 | emblaRef, |
| 99 | emblaApi, |
| 100 | selectedIndex, |
| 101 | scrollPrev, |
| 102 | scrollNext, |
| 103 | canScrollPrev, |
| 104 | canScrollNext, |
| 105 | }} |
| 106 | > |
| 107 | <div className={cn('relative', className)}> |
| 108 | {children} |
| 109 | </div> |
| 110 | </CarouselContext.Provider> |
| 111 | ) |
| 112 | } |
| 113 | |
| 114 | interface CarouselContentProps { |
| 115 | children: ReactNode |
| 116 | className?: string |
| 117 | viewportClassName?: string |
| 118 | viewportStyle?: CSSProperties |
| 119 | } |
| 120 | |
| 121 | export function CarouselContent({ children, className, viewportClassName, viewportStyle }: CarouselContentProps) { |
| 122 | const { emblaRef } = useCarousel() |
| 123 | |
| 124 | return ( |
| 125 | <div ref={emblaRef} className={cn('overflow-hidden', viewportClassName)} style={viewportStyle}> |
| 126 | <div className={cn('flex', className)}> |
| 127 | {children} |
| 128 | </div> |
| 129 | </div> |
| 130 | ) |
| 131 | } |
| 132 | |
| 133 | interface CarouselSlideProps { |
| 134 | children: ReactNode |
| 135 | className?: string |
| 136 | } |
| 137 | |
| 138 | export function CarouselSlide({ children, className }: CarouselSlideProps) { |
| 139 | return ( |
| 140 | <div className={cn('min-w-0 shrink-0 grow-0 basis-full', className)}> |
| 141 | {children} |
| 142 | </div> |
| 143 | ) |
| 144 | } |
| 145 | |
| 146 | interface CarouselArrowsProps { |
| 147 | className?: string |
| 148 | prevClassName?: string |
| 149 | nextClassName?: string |
| 150 | showLabels?: boolean |
| 151 | labels?: { |
| 152 | previous: string |
| 153 | next: string |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | export function CarouselArrows({ |
| 158 | className, |
| 159 | prevClassName, |
| 160 | nextClassName, |
| 161 | showLabels = false, |
| 162 | labels = { previous: 'Previous', next: 'Next' }, |
| 163 | }: CarouselArrowsProps) { |
| 164 | const { scrollPrev, scrollNext, canScrollPrev, canScrollNext } = useCarousel() |
| 165 | |
| 166 | return ( |
| 167 | <div className={cn('flex items-center gap-2', className)}> |
| 168 | <button |
| 169 | type="button" |
| 170 | onClick={scrollPrev} |
| 171 | disabled={!canScrollPrev} |
| 172 | className={cn( |
| 173 | 'flex items-center gap-2 rounded-full p-2 transition-opacity hover:opacity-70 disabled:opacity-30', |
| 174 | prevClassName, |
| 175 | )} |
| 176 | > |
| 177 | <ChevronLeft className="size-5" /> |
| 178 | {showLabels && ( |
| 179 | <span className="hidden text-sm text-muted-foreground md:inline"> |
| 180 | {labels.previous} |
| 181 | </span> |
| 182 | )} |
| 183 | </button> |
| 184 | <button |
| 185 | type="button" |
| 186 | onClick={scrollNext} |
| 187 | disabled={!canScrollNext} |
| 188 | className={cn( |
| 189 | 'flex items-center gap-2 rounded-full p-2 transition-opacity hover:opacity-70 disabled:opacity-30', |
| 190 | nextClassName, |
| 191 | )} |
| 192 | > |
| 193 | {showLabels && ( |
| 194 | <span className="hidden text-sm text-muted-foreground md:inline"> |
| 195 | {labels.next} |
| 196 | </span> |
| 197 | )} |
| 198 | <ChevronRight className="size-5" /> |
| 199 | </button> |
| 200 | </div> |
| 201 | ) |
| 202 | } |
| 203 | |
| 204 | interface CarouselDotsProps { |
| 205 | className?: string |
| 206 | dotClassName?: string |
| 207 | activeDotClassName?: string |
| 208 | } |
| 209 | |
| 210 | export function CarouselDots({ |
| 211 | className, |
| 212 | dotClassName, |
| 213 | activeDotClassName, |
| 214 | }: CarouselDotsProps) { |
| 215 | const { emblaApi, selectedIndex } = useCarousel() |
| 216 | const [scrollSnaps, setScrollSnaps] = useState<number[]>([]) |
| 217 | |
| 218 | useEffect(() => { |
| 219 | if (!emblaApi) |
| 220 | return |
| 221 | setScrollSnaps(emblaApi.scrollSnapList()) |
| 222 | }, [emblaApi]) |
| 223 | |
| 224 | return ( |
| 225 | <div className={cn('flex justify-center gap-2', className)}> |
| 226 | {scrollSnaps.map((_, index) => ( |
| 227 | <button |
| 228 | key={index} |
| 229 | type="button" |
| 230 | onClick={() => emblaApi?.scrollTo(index)} |
| 231 | className={cn( |
| 232 | 'size-2 rounded-full bg-current opacity-30 transition-opacity', |
| 233 | dotClassName, |
| 234 | index === selectedIndex && cn('opacity-100', activeDotClassName), |
| 235 | )} |
| 236 | /> |
| 237 | ))} |
| 238 | </div> |
| 239 | ) |
| 240 | } |
| 241 |