Skip to content

Commit

Permalink
Update carousel autoplay
Browse files Browse the repository at this point in the history
  • Loading branch information
Mitch-At-Work committed Nov 18, 2024
1 parent ee9cb98 commit f427173
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 152 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Add autoplay index change callback and fix autoplay pause on interaction",
"packageName": "@fluentui/react-carousel",
"email": "[email protected]",
"dependentChangeType": "patch"
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,9 @@
"doctrine": "3.0.0",
"dotparser": "1.1.1",
"ejs": "3.1.10",
"embla-carousel": "8.3.0",
"embla-carousel-autoplay": "8.3.0",
"embla-carousel-fade": "8.3.0",
"embla-carousel": "8.4.0",
"embla-carousel-autoplay": "8.4.0",
"embla-carousel-fade": "8.4.0",
"enquirer": "2.3.6",
"enzyme": "3.10.0",
"enzyme-to-json": "3.6.2",
Expand Down
6 changes: 3 additions & 3 deletions packages/react-components/react-carousel/library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
"@fluentui/react-utilities": "^9.18.17",
"@griffel/react": "^1.5.22",
"@swc/helpers": "^0.5.1",
"embla-carousel": "^8.3.0",
"embla-carousel-autoplay": "^8.3.0",
"embla-carousel-fade": "^8.3.0"
"embla-carousel": "^8.4.0",
"embla-carousel-autoplay": "^8.4.0",
"embla-carousel-fade": "^8.4.0"
},
"peerDependencies": {
"@types/react": ">=16.14.0 <19.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ComponentProps, ComponentState, EventHandler, Slot } from '@fluentui/react-utilities';
import type { CarouselContextValue, CarouselIndexChangeData } from '../CarouselContext.types';
import type {
CarouselAutoplayIndexChangeData,
CarouselContextValue,
CarouselIndexChangeData,
} from '../CarouselContext.types';

export type CarouselSlots = {
root: Slot<'div'>;
Expand Down Expand Up @@ -39,6 +43,12 @@ export type CarouselProps = ComponentProps<CarouselSlots> & {
*/
onActiveIndexChange?: EventHandler<CarouselIndexChangeData>;

/**
* Callback to notify a page change.
*/
// eslint-disable-next-line @nx/workspace-consistent-callback-type -- EventHandler<T> does not support "null"
onAutoplayIndexChange?: (ev: null, data: CarouselAutoplayIndexChangeData) => void;

/**
* Circular enables the carousel to loop back around on navigation past trailing index.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
whitespace = false,
announcement,
motion = 'slide',
onAutoplayIndexChange,
} = props;

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

const selectPageByElement: CarouselContextValue['selectPageByElement'] = useEventCallback((event, element, jump) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,13 @@ export const useCarouselAutoplayButton_unstable = (
const enableAutoplay = useCarouselContext(ctx => ctx.enableAutoplay);

React.useEffect(() => {
// Update carousel autoplay based on button state
enableAutoplay(autoplay);

return () => {
// We disable autoplay if the button gets unmounted.
enableAutoplay(false);
};
}, [enableAutoplay]);

useIsomorphicLayoutEffect(() => {
// Enable/disable autoplay on state change
enableAutoplay(autoplay);
}, [autoplay, enableAutoplay]);

const handleClick = (event: React.MouseEvent<HTMLButtonElement & HTMLAnchorElement>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export type CarouselIndexChangeData = (
index: number;
};

export type CarouselAutoplayIndexChangeData = EventData<'autoplay', null> & {
/**
* The index to be set after event has occurred.
*/
index: number;
};

export type CarouselContextValue = {
activeIndex: number;
circular: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { type EventHandler, useControllableState, useEventCallback } from '@fluentui/react-utilities';
import {
type EventHandler,
useAnimationFrame,
useControllableState,
useEventCallback,
useTimeout,
} from '@fluentui/react-utilities';
import EmblaCarousel, { EmblaPluginType, type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel';
import * as React from 'react';

import { carouselCardClassNames } from './CarouselCard/useCarouselCardStyles.styles';
import { carouselSliderClassNames } from './CarouselSlider/useCarouselSliderStyles.styles';
import { CarouselMotion, CarouselUpdateData, CarouselVisibilityEventDetail } from '../Carousel';
import { CarouselMotion, CarouselProps, CarouselUpdateData, CarouselVisibilityEventDetail } from '../Carousel';
import Autoplay from 'embla-carousel-autoplay';
import Fade from 'embla-carousel-fade';
import { pointerEventPlugin } from './pointerEvents';
Expand Down Expand Up @@ -43,9 +49,20 @@ export function useEmblaCarousel(
activeIndex: number | undefined;
motion?: CarouselMotion;
onDragIndexChange?: EventHandler<CarouselIndexChangeData>;
onAutoplayIndexChange?: CarouselProps['onAutoplayIndexChange'];
},
) {
const { align, direction, loop, slidesToScroll, watchDrag, containScroll, motion, onDragIndexChange } = options;
const {
align,
direction,
loop,
slidesToScroll,
watchDrag,
containScroll,
motion,
onDragIndexChange,
onAutoplayIndexChange,
} = options;
const [activeIndex, setActiveIndex] = useControllableState({
defaultState: options.defaultActiveIndex,
state: options.activeIndex,
Expand All @@ -67,52 +84,68 @@ export function useEmblaCarousel(
});

const emblaApi = React.useRef<EmblaCarouselType | null>(null);
/* We store the autoplay as both a ref and as state:
* State: Used to trigger a re-init on the carousel engine itself
* Ref: Used to prevent getPlugin dependencies from recreating embla carousel
*/
const [autoplay, setAutoplay] = React.useState<boolean>(false);
const autoplayRef = React.useRef<boolean>(false);

const resetAutoplay = React.useCallback(() => {
emblaApi.current?.plugins().autoplay.reset();
emblaApi.current?.plugins().autoplay?.reset();
}, []);

/* Our autoplay button, which is required by standards for autoplay to be enabled, will handle controlled state */
const enableAutoplay = React.useCallback(
(autoplay: boolean) => {
autoplayRef.current = autoplay;
if (autoplay) {
emblaApi.current?.plugins().autoplay.play();
(_autoplay: boolean) => {
autoplayRef.current = _autoplay;
setAutoplay(_autoplay);

if (_autoplay) {
emblaApi.current?.plugins().autoplay?.play();
// Reset after play to ensure timing and any focus/mouse pause state is reset.
resetAutoplay();
} else {
emblaApi.current?.plugins().autoplay.stop();
emblaApi.current?.plugins().autoplay?.stop();
}
},
[resetAutoplay],
);

const getPlugins = React.useCallback(() => {
const plugins: EmblaPluginType[] = [
Autoplay({
playOnInit: autoplayRef.current,
stopOnInteraction: !autoplayRef.current,
stopOnMouseEnter: true,
stopOnFocusIn: true,
}),
];

// Optionally add Fade plugin
if (motion === 'fade') {
plugins.push(Fade());
}
const getPlugins = React.useCallback(
(initAutoplay: boolean) => {
const plugins: EmblaPluginType[] = [];

if (initAutoplay) {
plugins.push(
Autoplay({
playOnInit: true,
/* stopOnInteraction: false causes autoplay to restart on interaction end*/
/* we must remove/re-add plugin on autoplay state change*/
stopOnInteraction: false,
stopOnMouseEnter: true,
stopOnFocusIn: true,
}),
);
}

if (watchDrag) {
plugins.push(
pointerEventPlugin({
onSelectViaDrag: onDragEvent,
}),
);
}
// Optionally add Fade plugin
if (motion === 'fade') {
plugins.push(Fade());
}

return plugins;
}, [motion, onDragEvent, watchDrag]);
if (watchDrag) {
plugins.push(
pointerEventPlugin({
onSelectViaDrag: onDragEvent,
}),
);
}

return plugins;
},
[motion, onDragEvent, watchDrag],
);

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

const handleIndexChange = React.useCallback(() => {
const newIndex = emblaApi.current?.selectedScrollSnap() ?? 0;
const slides = emblaApi.current?.slideNodes();
const actualIndex = emblaApi.current?.internalEngine().slideRegistry[newIndex][0] ?? 0;

// We set the active or first index of group on-screen as the selected tabster index
slides?.forEach((slide, slideIndex) => {
setTabsterDefault(slide, slideIndex === actualIndex);
});
setActiveIndex(newIndex);
}, [setActiveIndex]);

const handleAutoplayIndexChange = useEventCallback(() => {
handleIndexChange();
const newIndex = emblaApi.current?.selectedScrollSnap() ?? 0;
onAutoplayIndexChange?.(null, { event: null, type: 'autoplay', index: newIndex });
});

const viewportRef: React.RefObject<HTMLDivElement> = React.useRef(null);
const currentElementRef = React.useRef<HTMLDivElement | null>();
const containerRef: React.RefObject<HTMLDivElement> = React.useMemo(() => {
let currentElement: HTMLDivElement | null = null;

const handleIndexChange = () => {
const newIndex = emblaApi.current?.selectedScrollSnap() ?? 0;
const slides = emblaApi.current?.slideNodes();
const actualIndex = emblaApi.current?.internalEngine().slideRegistry[newIndex][0] ?? 0;

// We set the active or first index of group on-screen as the selected tabster index
slides?.forEach((slide, slideIndex) => {
setTabsterDefault(slide, slideIndex === actualIndex);
});
setActiveIndex(newIndex);
};

const handleVisibilityChange = () => {
const cardElements = emblaApi.current?.slideNodes();
const visibleIndexes = emblaApi.current?.slidesInView() ?? [];
Expand All @@ -172,21 +210,23 @@ export function useEmblaCarousel(
});
};

const plugins = getPlugins();
// Get plugins using autoplayRef to prevent state change recreating EmblaCarousel
const plugins = getPlugins(autoplayRef.current);

return {
set current(newElement: HTMLDivElement | null) {
if (currentElement) {
if (currentElementRef.current) {
emblaApi.current?.off('slidesInView', handleVisibilityChange);
emblaApi.current?.off('select', handleIndexChange);
emblaApi.current?.off('reInit', handleReinit);
emblaApi.current?.off('autoplay:select', handleAutoplayIndexChange);
emblaApi.current?.destroy();
}

// Use direct viewport if available, else fallback to container (includes Carousel controls).
const wrapperElement = viewportRef.current ?? newElement;
currentElementRef.current = wrapperElement;
if (wrapperElement) {
currentElement = wrapperElement;
emblaApi.current = EmblaCarousel(
wrapperElement,
{
Expand All @@ -199,10 +239,11 @@ export function useEmblaCarousel(
emblaApi.current?.on('reInit', handleReinit);
emblaApi.current?.on('slidesInView', handleVisibilityChange);
emblaApi.current?.on('select', handleIndexChange);
emblaApi.current?.on('autoplay:select', handleAutoplayIndexChange);
}
},
};
}, [getPlugins, setActiveIndex, handleReinit]);
}, [getPlugins, handleAutoplayIndexChange, handleIndexChange, handleReinit]);

const carouselApi = React.useMemo(
() => ({
Expand Down Expand Up @@ -246,7 +287,8 @@ export function useEmblaCarousel(
}, [activeIndex]);

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

emblaOptions.current = {
startIndex: emblaOptions.current.startIndex,
Expand All @@ -257,14 +299,15 @@ export function useEmblaCarousel(
watchDrag,
containScroll,
};

emblaApi.current?.reInit(
{
...DEFAULT_EMBLA_OPTIONS,
...emblaOptions.current,
},
plugins,
);
}, [align, direction, loop, slidesToScroll, watchDrag, containScroll, getPlugins]);
}, [align, direction, loop, slidesToScroll, watchDrag, containScroll, getPlugins, autoplay]);

return {
activeIndex,
Expand Down
Loading

0 comments on commit f427173

Please sign in to comment.