返回 AiToEarn
carousel.tsx
根目录 / project / aitoearn-web / src / components / ui / carousel.tsx
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
241 lines Plain Text