Skip to content

Commit f427173

Browse files
committed
Update carousel autoplay
1 parent ee9cb98 commit f427173

File tree

11 files changed

+205
-152
lines changed

11 files changed

+205
-152
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: Add autoplay index change callback and fix autoplay pause on interaction",
4+
"packageName": "@fluentui/react-carousel",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@
218218
"doctrine": "3.0.0",
219219
"dotparser": "1.1.1",
220220
"ejs": "3.1.10",
221-
"embla-carousel": "8.3.0",
222-
"embla-carousel-autoplay": "8.3.0",
223-
"embla-carousel-fade": "8.3.0",
221+
"embla-carousel": "8.4.0",
222+
"embla-carousel-autoplay": "8.4.0",
223+
"embla-carousel-fade": "8.4.0",
224224
"enquirer": "2.3.6",
225225
"enzyme": "3.10.0",
226226
"enzyme-to-json": "3.6.2",

packages/react-components/react-carousel/library/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636
"@fluentui/react-utilities": "^9.18.17",
3737
"@griffel/react": "^1.5.22",
3838
"@swc/helpers": "^0.5.1",
39-
"embla-carousel": "^8.3.0",
40-
"embla-carousel-autoplay": "^8.3.0",
41-
"embla-carousel-fade": "^8.3.0"
39+
"embla-carousel": "^8.4.0",
40+
"embla-carousel-autoplay": "^8.4.0",
41+
"embla-carousel-fade": "^8.4.0"
4242
},
4343
"peerDependencies": {
4444
"@types/react": ">=16.14.0 <19.0.0",

packages/react-components/react-carousel/library/src/components/Carousel/Carousel.types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { ComponentProps, ComponentState, EventHandler, Slot } from '@fluentui/react-utilities';
2-
import type { CarouselContextValue, CarouselIndexChangeData } from '../CarouselContext.types';
2+
import type {
3+
CarouselAutoplayIndexChangeData,
4+
CarouselContextValue,
5+
CarouselIndexChangeData,
6+
} from '../CarouselContext.types';
37

48
export type CarouselSlots = {
59
root: Slot<'div'>;
@@ -39,6 +43,12 @@ export type CarouselProps = ComponentProps<CarouselSlots> & {
3943
*/
4044
onActiveIndexChange?: EventHandler<CarouselIndexChangeData>;
4145

46+
/**
47+
* Callback to notify a page change.
48+
*/
49+
// eslint-disable-next-line @nx/workspace-consistent-callback-type -- EventHandler<T> does not support "null"
50+
onAutoplayIndexChange?: (ev: null, data: CarouselAutoplayIndexChangeData) => void;
51+
4252
/**
4353
* Circular enables the carousel to loop back around on navigation past trailing index.
4454
*/

packages/react-components/react-carousel/library/src/components/Carousel/useCarousel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
3434
whitespace = false,
3535
announcement,
3636
motion = 'slide',
37+
onAutoplayIndexChange,
3738
} = props;
3839

3940
const { dir } = useFluent();
@@ -49,6 +50,7 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
4950
containScroll: whitespace ? false : 'keepSnaps',
5051
motion,
5152
onDragIndexChange: onActiveIndexChange,
53+
onAutoplayIndexChange,
5254
});
5355

5456
const selectPageByElement: CarouselContextValue['selectPageByElement'] = useEventCallback((event, element, jump) => {

packages/react-components/react-carousel/library/src/components/CarouselAutoplayButton/useCarouselAutoplayButton.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,13 @@ export const useCarouselAutoplayButton_unstable = (
3636
const enableAutoplay = useCarouselContext(ctx => ctx.enableAutoplay);
3737

3838
React.useEffect(() => {
39+
// Update carousel autoplay based on button state
40+
enableAutoplay(autoplay);
41+
3942
return () => {
4043
// We disable autoplay if the button gets unmounted.
4144
enableAutoplay(false);
4245
};
43-
}, [enableAutoplay]);
44-
45-
useIsomorphicLayoutEffect(() => {
46-
// Enable/disable autoplay on state change
47-
enableAutoplay(autoplay);
4846
}, [autoplay, enableAutoplay]);
4947

5048
const handleClick = (event: React.MouseEvent<HTMLButtonElement & HTMLAnchorElement>) => {

packages/react-components/react-carousel/library/src/components/CarouselContext.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export type CarouselIndexChangeData = (
1414
index: number;
1515
};
1616

17+
export type CarouselAutoplayIndexChangeData = EventData<'autoplay', null> & {
18+
/**
19+
* The index to be set after event has occurred.
20+
*/
21+
index: number;
22+
};
23+
1724
export type CarouselContextValue = {
1825
activeIndex: number;
1926
circular: boolean;

packages/react-components/react-carousel/library/src/components/useEmblaCarousel.ts

Lines changed: 95 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
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';
28
import EmblaCarousel, { EmblaPluginType, type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel';
39
import * as React from 'react';
410

511
import { carouselCardClassNames } from './CarouselCard/useCarouselCardStyles.styles';
612
import { carouselSliderClassNames } from './CarouselSlider/useCarouselSliderStyles.styles';
7-
import { CarouselMotion, CarouselUpdateData, CarouselVisibilityEventDetail } from '../Carousel';
13+
import { CarouselMotion, CarouselProps, CarouselUpdateData, CarouselVisibilityEventDetail } from '../Carousel';
814
import Autoplay from 'embla-carousel-autoplay';
915
import Fade from 'embla-carousel-fade';
1016
import { pointerEventPlugin } from './pointerEvents';
@@ -43,9 +49,20 @@ export function useEmblaCarousel(
4349
activeIndex: number | undefined;
4450
motion?: CarouselMotion;
4551
onDragIndexChange?: EventHandler<CarouselIndexChangeData>;
52+
onAutoplayIndexChange?: CarouselProps['onAutoplayIndexChange'];
4653
},
4754
) {
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;
4966
const [activeIndex, setActiveIndex] = useControllableState({
5067
defaultState: options.defaultActiveIndex,
5168
state: options.activeIndex,
@@ -67,52 +84,68 @@ export function useEmblaCarousel(
6784
});
6885

6986
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);
7092
const autoplayRef = React.useRef<boolean>(false);
7193

7294
const resetAutoplay = React.useCallback(() => {
73-
emblaApi.current?.plugins().autoplay.reset();
95+
emblaApi.current?.plugins().autoplay?.reset();
7496
}, []);
7597

7698
/* Our autoplay button, which is required by standards for autoplay to be enabled, will handle controlled state */
7799
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();
82106
// Reset after play to ensure timing and any focus/mouse pause state is reset.
83107
resetAutoplay();
84108
} else {
85-
emblaApi.current?.plugins().autoplay.stop();
109+
emblaApi.current?.plugins().autoplay?.stop();
86110
}
87111
},
88112
[resetAutoplay],
89113
);
90114

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+
}
105131

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+
}
113136

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+
);
116149

117150
// Listeners contains callbacks for UI elements that may require state update based on embla changes
118151
const listeners = React.useRef(new Set<(data: CarouselUpdateData) => void>());
@@ -142,22 +175,27 @@ export function useEmblaCarousel(
142175
}
143176
});
144177

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+
145196
const viewportRef: React.RefObject<HTMLDivElement> = React.useRef(null);
197+
const currentElementRef = React.useRef<HTMLDivElement | null>();
146198
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-
161199
const handleVisibilityChange = () => {
162200
const cardElements = emblaApi.current?.slideNodes();
163201
const visibleIndexes = emblaApi.current?.slidesInView() ?? [];
@@ -172,21 +210,23 @@ export function useEmblaCarousel(
172210
});
173211
};
174212

175-
const plugins = getPlugins();
213+
// Get plugins using autoplayRef to prevent state change recreating EmblaCarousel
214+
const plugins = getPlugins(autoplayRef.current);
176215

177216
return {
178217
set current(newElement: HTMLDivElement | null) {
179-
if (currentElement) {
218+
if (currentElementRef.current) {
180219
emblaApi.current?.off('slidesInView', handleVisibilityChange);
181220
emblaApi.current?.off('select', handleIndexChange);
182221
emblaApi.current?.off('reInit', handleReinit);
222+
emblaApi.current?.off('autoplay:select', handleAutoplayIndexChange);
183223
emblaApi.current?.destroy();
184224
}
185225

186226
// Use direct viewport if available, else fallback to container (includes Carousel controls).
187227
const wrapperElement = viewportRef.current ?? newElement;
228+
currentElementRef.current = wrapperElement;
188229
if (wrapperElement) {
189-
currentElement = wrapperElement;
190230
emblaApi.current = EmblaCarousel(
191231
wrapperElement,
192232
{
@@ -199,10 +239,11 @@ export function useEmblaCarousel(
199239
emblaApi.current?.on('reInit', handleReinit);
200240
emblaApi.current?.on('slidesInView', handleVisibilityChange);
201241
emblaApi.current?.on('select', handleIndexChange);
242+
emblaApi.current?.on('autoplay:select', handleAutoplayIndexChange);
202243
}
203244
},
204245
};
205-
}, [getPlugins, setActiveIndex, handleReinit]);
246+
}, [getPlugins, handleAutoplayIndexChange, handleIndexChange, handleReinit]);
206247

207248
const carouselApi = React.useMemo(
208249
() => ({
@@ -246,7 +287,8 @@ export function useEmblaCarousel(
246287
}, [activeIndex]);
247288

248289
React.useEffect(() => {
249-
const plugins = getPlugins();
290+
// Get plugins with autoplay state to trigger re-init when nessecary
291+
const plugins = getPlugins(autoplay);
250292

251293
emblaOptions.current = {
252294
startIndex: emblaOptions.current.startIndex,
@@ -257,14 +299,15 @@ export function useEmblaCarousel(
257299
watchDrag,
258300
containScroll,
259301
};
302+
260303
emblaApi.current?.reInit(
261304
{
262305
...DEFAULT_EMBLA_OPTIONS,
263306
...emblaOptions.current,
264307
},
265308
plugins,
266309
);
267-
}, [align, direction, loop, slidesToScroll, watchDrag, containScroll, getPlugins]);
310+
}, [align, direction, loop, slidesToScroll, watchDrag, containScroll, getPlugins, autoplay]);
268311

269312
return {
270313
activeIndex,

0 commit comments

Comments
 (0)