1
- import { type EventHandler , useControllableState , useEventCallback } from '@fluentui/react-utilities' ;
1
+ import {
2
+ type EventHandler ,
3
+ useAnimationFrame ,
4
+ useControllableState ,
5
+ useEventCallback ,
6
+ useTimeout ,
7
+ } from '@fluentui/react-utilities' ;
2
8
import EmblaCarousel , { EmblaPluginType , type EmblaCarouselType , type EmblaOptionsType } from 'embla-carousel' ;
3
9
import * as React from 'react' ;
4
10
5
11
import { carouselCardClassNames } from './CarouselCard/useCarouselCardStyles.styles' ;
6
12
import { carouselSliderClassNames } from './CarouselSlider/useCarouselSliderStyles.styles' ;
7
- import { CarouselMotion , CarouselUpdateData , CarouselVisibilityEventDetail } from '../Carousel' ;
13
+ import { CarouselMotion , CarouselProps , CarouselUpdateData , CarouselVisibilityEventDetail } from '../Carousel' ;
8
14
import Autoplay from 'embla-carousel-autoplay' ;
9
15
import Fade from 'embla-carousel-fade' ;
10
16
import { pointerEventPlugin } from './pointerEvents' ;
@@ -43,9 +49,20 @@ export function useEmblaCarousel(
43
49
activeIndex : number | undefined ;
44
50
motion ?: CarouselMotion ;
45
51
onDragIndexChange ?: EventHandler < CarouselIndexChangeData > ;
52
+ onAutoplayIndexChange ?: CarouselProps [ 'onAutoplayIndexChange' ] ;
46
53
} ,
47
54
) {
48
- const { align, direction, loop, slidesToScroll, watchDrag, containScroll, motion, onDragIndexChange } = options ;
55
+ const {
56
+ align,
57
+ direction,
58
+ loop,
59
+ slidesToScroll,
60
+ watchDrag,
61
+ containScroll,
62
+ motion,
63
+ onDragIndexChange,
64
+ onAutoplayIndexChange,
65
+ } = options ;
49
66
const [ activeIndex , setActiveIndex ] = useControllableState ( {
50
67
defaultState : options . defaultActiveIndex ,
51
68
state : options . activeIndex ,
@@ -67,52 +84,68 @@ export function useEmblaCarousel(
67
84
} ) ;
68
85
69
86
const emblaApi = React . useRef < EmblaCarouselType | null > ( null ) ;
87
+ /* We store the autoplay as both a ref and as state:
88
+ * State: Used to trigger a re-init on the carousel engine itself
89
+ * Ref: Used to prevent getPlugin dependencies from recreating embla carousel
90
+ */
91
+ const [ autoplay , setAutoplay ] = React . useState < boolean > ( false ) ;
70
92
const autoplayRef = React . useRef < boolean > ( false ) ;
71
93
72
94
const resetAutoplay = React . useCallback ( ( ) => {
73
- emblaApi . current ?. plugins ( ) . autoplay . reset ( ) ;
95
+ emblaApi . current ?. plugins ( ) . autoplay ? .reset ( ) ;
74
96
} , [ ] ) ;
75
97
76
98
/* Our autoplay button, which is required by standards for autoplay to be enabled, will handle controlled state */
77
99
const enableAutoplay = React . useCallback (
78
- ( autoplay : boolean ) => {
79
- autoplayRef . current = autoplay ;
80
- if ( autoplay ) {
81
- emblaApi . current ?. plugins ( ) . autoplay . play ( ) ;
100
+ ( _autoplay : boolean ) => {
101
+ autoplayRef . current = _autoplay ;
102
+ setAutoplay ( _autoplay ) ;
103
+
104
+ if ( _autoplay ) {
105
+ emblaApi . current ?. plugins ( ) . autoplay ?. play ( ) ;
82
106
// Reset after play to ensure timing and any focus/mouse pause state is reset.
83
107
resetAutoplay ( ) ;
84
108
} else {
85
- emblaApi . current ?. plugins ( ) . autoplay . stop ( ) ;
109
+ emblaApi . current ?. plugins ( ) . autoplay ? .stop ( ) ;
86
110
}
87
111
} ,
88
112
[ resetAutoplay ] ,
89
113
) ;
90
114
91
- const getPlugins = React . useCallback ( ( ) => {
92
- const plugins : EmblaPluginType [ ] = [
93
- Autoplay ( {
94
- playOnInit : autoplayRef . current ,
95
- stopOnInteraction : ! autoplayRef . current ,
96
- stopOnMouseEnter : true ,
97
- stopOnFocusIn : true ,
98
- } ) ,
99
- ] ;
100
-
101
- // Optionally add Fade plugin
102
- if ( motion === 'fade' ) {
103
- plugins . push ( Fade ( ) ) ;
104
- }
115
+ const getPlugins = React . useCallback (
116
+ ( initAutoplay : boolean ) => {
117
+ const plugins : EmblaPluginType [ ] = [ ] ;
118
+
119
+ if ( initAutoplay ) {
120
+ plugins . push (
121
+ Autoplay ( {
122
+ playOnInit : true ,
123
+ /* stopOnInteraction: false causes autoplay to restart on interaction end*/
124
+ /* we must remove/re-add plugin on autoplay state change*/
125
+ stopOnInteraction : false ,
126
+ stopOnMouseEnter : true ,
127
+ stopOnFocusIn : true ,
128
+ } ) ,
129
+ ) ;
130
+ }
105
131
106
- if ( watchDrag ) {
107
- plugins . push (
108
- pointerEventPlugin ( {
109
- onSelectViaDrag : onDragEvent ,
110
- } ) ,
111
- ) ;
112
- }
132
+ // Optionally add Fade plugin
133
+ if ( motion === 'fade' ) {
134
+ plugins . push ( Fade ( ) ) ;
135
+ }
113
136
114
- return plugins ;
115
- } , [ motion , onDragEvent , watchDrag ] ) ;
137
+ if ( watchDrag ) {
138
+ plugins . push (
139
+ pointerEventPlugin ( {
140
+ onSelectViaDrag : onDragEvent ,
141
+ } ) ,
142
+ ) ;
143
+ }
144
+
145
+ return plugins ;
146
+ } ,
147
+ [ motion , onDragEvent , watchDrag ] ,
148
+ ) ;
116
149
117
150
// Listeners contains callbacks for UI elements that may require state update based on embla changes
118
151
const listeners = React . useRef ( new Set < ( data : CarouselUpdateData ) => void > ( ) ) ;
@@ -142,22 +175,27 @@ export function useEmblaCarousel(
142
175
}
143
176
} ) ;
144
177
178
+ const handleIndexChange = React . useCallback ( ( ) => {
179
+ const newIndex = emblaApi . current ?. selectedScrollSnap ( ) ?? 0 ;
180
+ const slides = emblaApi . current ?. slideNodes ( ) ;
181
+ const actualIndex = emblaApi . current ?. internalEngine ( ) . slideRegistry [ newIndex ] [ 0 ] ?? 0 ;
182
+
183
+ // We set the active or first index of group on-screen as the selected tabster index
184
+ slides ?. forEach ( ( slide , slideIndex ) => {
185
+ setTabsterDefault ( slide , slideIndex === actualIndex ) ;
186
+ } ) ;
187
+ setActiveIndex ( newIndex ) ;
188
+ } , [ setActiveIndex ] ) ;
189
+
190
+ const handleAutoplayIndexChange = useEventCallback ( ( ) => {
191
+ handleIndexChange ( ) ;
192
+ const newIndex = emblaApi . current ?. selectedScrollSnap ( ) ?? 0 ;
193
+ onAutoplayIndexChange ?.( null , { event : null , type : 'autoplay' , index : newIndex } ) ;
194
+ } ) ;
195
+
145
196
const viewportRef : React . RefObject < HTMLDivElement > = React . useRef ( null ) ;
197
+ const currentElementRef = React . useRef < HTMLDivElement | null > ( ) ;
146
198
const containerRef : React . RefObject < HTMLDivElement > = React . useMemo ( ( ) => {
147
- let currentElement : HTMLDivElement | null = null ;
148
-
149
- const handleIndexChange = ( ) => {
150
- const newIndex = emblaApi . current ?. selectedScrollSnap ( ) ?? 0 ;
151
- const slides = emblaApi . current ?. slideNodes ( ) ;
152
- const actualIndex = emblaApi . current ?. internalEngine ( ) . slideRegistry [ newIndex ] [ 0 ] ?? 0 ;
153
-
154
- // We set the active or first index of group on-screen as the selected tabster index
155
- slides ?. forEach ( ( slide , slideIndex ) => {
156
- setTabsterDefault ( slide , slideIndex === actualIndex ) ;
157
- } ) ;
158
- setActiveIndex ( newIndex ) ;
159
- } ;
160
-
161
199
const handleVisibilityChange = ( ) => {
162
200
const cardElements = emblaApi . current ?. slideNodes ( ) ;
163
201
const visibleIndexes = emblaApi . current ?. slidesInView ( ) ?? [ ] ;
@@ -172,21 +210,23 @@ export function useEmblaCarousel(
172
210
} ) ;
173
211
} ;
174
212
175
- const plugins = getPlugins ( ) ;
213
+ // Get plugins using autoplayRef to prevent state change recreating EmblaCarousel
214
+ const plugins = getPlugins ( autoplayRef . current ) ;
176
215
177
216
return {
178
217
set current ( newElement : HTMLDivElement | null ) {
179
- if ( currentElement ) {
218
+ if ( currentElementRef . current ) {
180
219
emblaApi . current ?. off ( 'slidesInView' , handleVisibilityChange ) ;
181
220
emblaApi . current ?. off ( 'select' , handleIndexChange ) ;
182
221
emblaApi . current ?. off ( 'reInit' , handleReinit ) ;
222
+ emblaApi . current ?. off ( 'autoplay:select' , handleAutoplayIndexChange ) ;
183
223
emblaApi . current ?. destroy ( ) ;
184
224
}
185
225
186
226
// Use direct viewport if available, else fallback to container (includes Carousel controls).
187
227
const wrapperElement = viewportRef . current ?? newElement ;
228
+ currentElementRef . current = wrapperElement ;
188
229
if ( wrapperElement ) {
189
- currentElement = wrapperElement ;
190
230
emblaApi . current = EmblaCarousel (
191
231
wrapperElement ,
192
232
{
@@ -199,10 +239,11 @@ export function useEmblaCarousel(
199
239
emblaApi . current ?. on ( 'reInit' , handleReinit ) ;
200
240
emblaApi . current ?. on ( 'slidesInView' , handleVisibilityChange ) ;
201
241
emblaApi . current ?. on ( 'select' , handleIndexChange ) ;
242
+ emblaApi . current ?. on ( 'autoplay:select' , handleAutoplayIndexChange ) ;
202
243
}
203
244
} ,
204
245
} ;
205
- } , [ getPlugins , setActiveIndex , handleReinit ] ) ;
246
+ } , [ getPlugins , handleAutoplayIndexChange , handleIndexChange , handleReinit ] ) ;
206
247
207
248
const carouselApi = React . useMemo (
208
249
( ) => ( {
@@ -246,7 +287,8 @@ export function useEmblaCarousel(
246
287
} , [ activeIndex ] ) ;
247
288
248
289
React . useEffect ( ( ) => {
249
- const plugins = getPlugins ( ) ;
290
+ // Get plugins with autoplay state to trigger re-init when nessecary
291
+ const plugins = getPlugins ( autoplay ) ;
250
292
251
293
emblaOptions . current = {
252
294
startIndex : emblaOptions . current . startIndex ,
@@ -257,14 +299,15 @@ export function useEmblaCarousel(
257
299
watchDrag,
258
300
containScroll,
259
301
} ;
302
+
260
303
emblaApi . current ?. reInit (
261
304
{
262
305
...DEFAULT_EMBLA_OPTIONS ,
263
306
...emblaOptions . current ,
264
307
} ,
265
308
plugins ,
266
309
) ;
267
- } , [ align , direction , loop , slidesToScroll , watchDrag , containScroll , getPlugins ] ) ;
310
+ } , [ align , direction , loop , slidesToScroll , watchDrag , containScroll , getPlugins , autoplay ] ) ;
268
311
269
312
return {
270
313
activeIndex,
0 commit comments