Skip to content

Commit

Permalink
feat(Carousel): add looping & animations (#31957)
Browse files Browse the repository at this point in the history
Co-authored-by: Mitch-At-Work <mifraser@microsoft.com>
  • Loading branch information
layershifter and Mitch-At-Work authored Jul 15, 2024
1 parent 294d82a commit b5fa1fa
Show file tree
Hide file tree
Showing 35 changed files with 665 additions and 160 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ _Include background research done for this component_

### Card Peeking

When peeking is enabled, the previous and next card will be partially displayed on either side of the current active card.
Cards will not peek by default, but can be enabled by setting the cardWidth to less than 100% of the viewport width.

### Condensed Navigation

Expand Down Expand Up @@ -81,6 +81,10 @@ The defining wrapper of a carousel's indexed content, they will take up the full

Clickable actions within the content area are available via mouse and tab as expected, non-active index content will be set to inert until moved to active card.

### CarouselSlider

The container for animating and positioning the carousel cards, it should wrap all carousel cards to prevent the controls from affecting layout and responsiveness of card sizing.

### CarouselFooter

A unified navigation footer with all Carousel navigation components as slots, with the CarouselNav intended to be placed within the root children. The footer will have variant layouts that are condensed or extended, as well as options to null out slots if not required or placed externally.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,7 @@ export type CarouselCardSlots = {
};

// @public
export type CarouselCardState = ComponentState<CarouselCardSlots> & {
visible: boolean;
peekDir?: 'prev' | 'next' | null;
} & Pick<CarouselCardProps, 'value'>;
export type CarouselCardState = ComponentState<CarouselCardSlots> & Pick<CarouselCardProps, 'value'>;

// @public (undocumented)
export const carouselClassNames: SlotClassNames<CarouselSlots>;
Expand Down Expand Up @@ -165,12 +162,29 @@ export type CarouselNavState = ComponentState<CarouselNavSlots> & {
// @public
export type CarouselProps = ComponentProps<CarouselSlots> & {
defaultValue?: string;
align?: 'center' | 'start' | 'end';
value?: string;
onValueChange?: EventHandler<CarouselValueChangeData>;
circular?: Boolean;
peeking?: Boolean;
circular?: boolean;
};

// @public
export const CarouselSlider: ForwardRefComponent<CarouselSliderProps>;

// @public (undocumented)
export const carouselSliderClassNames: SlotClassNames<CarouselSliderSlots>;

// @public
export type CarouselSliderProps = Partial<ComponentProps<CarouselSliderSlots>>;

// @public (undocumented)
export type CarouselSliderSlots = {
root: Slot<'div'>;
};

// @public
export type CarouselSliderState = ComponentState<CarouselSliderSlots>;

// @public (undocumented)
export type CarouselSlots = {
root: Slot<'div'>;
Expand Down Expand Up @@ -206,6 +220,9 @@ export const renderCarouselNavButton_unstable: (state: CarouselNavButtonState) =
// @public
export const renderCarouselNavImageButton_unstable: (state: CarouselNavImageButtonState) => JSX.Element;

// @public
export const renderCarouselSlider_unstable: (state: CarouselSliderState) => JSX.Element;

// @public
export function useCarousel_unstable(props: CarouselProps, ref: React_2.Ref<HTMLDivElement>): CarouselState;

Expand Down Expand Up @@ -251,6 +268,12 @@ export const useCarouselNavImageButtonStyles_unstable: (state: CarouselNavImageB
// @public
export const useCarouselNavStyles_unstable: (state: CarouselNavState) => CarouselNavState;

// @public
export const useCarouselSlider_unstable: (props: CarouselSliderProps, ref: React_2.Ref<HTMLDivElement>) => CarouselSliderState;

// @public
export const useCarouselSliderStyles_unstable: (state: CarouselSliderState) => CarouselSliderState;

// @public
export const useCarouselStyles_unstable: (state: CarouselState) => CarouselState;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@
"@fluentui/react-aria": "^9.13.1",
"@fluentui/react-context-selector": "^9.1.64",
"@fluentui/react-icons": "^2.0.245",
"use-sync-external-store": "^1.2.0",
"@griffel/react": "^1.5.22",
"@swc/helpers": "^0.5.1"
"@swc/helpers": "^0.5.1",
"embla-carousel": "8.1.5",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"@types/react": ">=16.14.0 <19.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './components/CarouselSlider/index';
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { render } from '@testing-library/react';
import { isConformant } from '../../testing/isConformant';
import { Carousel } from './Carousel';

jest.mock('embla-carousel', () => ({
default: () => ({
on: jest.fn(),
off: jest.fn(),
destroy: jest.fn(),
slideNodes: jest.fn(),
slidesInView: jest.fn(),
scrollTo: jest.fn(),
reInit: jest.fn(),
}),
}));

describe('Carousel', () => {
isConformant({
Component: Carousel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export type CarouselProps = ComponentProps<CarouselSlots> & {
*/
defaultValue?: string;

/**
* The alignment of the carousel.
*/
align?: 'center' | 'start' | 'end';

/**
* The value of the currently active page.
*/
Expand All @@ -25,17 +30,18 @@ export type CarouselProps = ComponentProps<CarouselSlots> & {
onValueChange?: EventHandler<CarouselValueChangeData>;

/**
* Circular enables the carousel to loop back around on navigation past trailing index
*/
circular?: Boolean;

/**
* Peeking enables the next/prev carousel pages to 'peek' into the current view
* Circular enables the carousel to loop back around on navigation past trailing index.
*/
peeking?: Boolean;
circular?: boolean;
};

/**
* State used in rendering Carousel
*/
export type CarouselState = ComponentState<CarouselSlots> & CarouselContextValue;

export interface CarouselVisibilityEventDetail {
isVisible: boolean;
}

export type CarouselVisibilityChangeEvent = CustomEvent<CarouselVisibilityEventDetail>;
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import * as React from 'react';
import {
getIntrinsicElementProps,
isHTMLElement,
slot,
useControllableState,
useEventCallback,
useFirstMount,
useIsomorphicLayoutEffect,
useMergedRefs,
} from '@fluentui/react-utilities';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import * as React from 'react';

import type { CarouselProps, CarouselState } from './Carousel.types';
import { useCarouselWalker_unstable } from '../useCarouselWalker';
import { createCarouselStore } from '../createCarouselStore';
import { CAROUSEL_ITEM } from '../constants';
import type { CarouselContextValue } from '../CarouselContext.types';
import { useEmblaCarousel } from '../useEmblaCarousel';

/**
* Create the state required to render Carousel.
Expand All @@ -28,21 +30,23 @@ import type { CarouselContextValue } from '../CarouselContext.types';
export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDivElement>): CarouselState {
'use no memo';

const { onValueChange, circular, peeking } = props;

const { targetDocument } = useFluent();
const win = targetDocument?.defaultView;
const { ref: carouselRef, walker: carouselWalker } = useCarouselWalker_unstable();
const { align = 'center', onValueChange, circular = false } = props;

const [value, setValue] = useControllableState({
defaultState: props.defaultValue,
state: props.value,
initialState: null,
});
const [store] = React.useState(() => createCarouselStore(value));

const { targetDocument, dir } = useFluent();
const [emblaRef, emblaApi] = useEmblaCarousel({ align, direction: dir, loop: circular });

const [store] = React.useState(() => createCarouselStore(value));
const rootRef = React.useRef<HTMLDivElement>(null);

const { ref: carouselRef, walker: carouselWalker } = useCarouselWalker_unstable();
const isFirstMount = useFirstMount();

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
Expand All @@ -59,7 +63,7 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
store.setActiveValue(value);
}, [store, value]);

React.useEffect(() => {
useIsomorphicLayoutEffect(() => {
const allItems = rootRef.current?.querySelectorAll(`[${CAROUSEL_ITEM}]`)!;

for (let i = 0; i < allItems.length; i++) {
Expand All @@ -72,6 +76,8 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
}, [store]);

React.useEffect(() => {
const win = targetDocument?.defaultView;

if (!win) {
return;
}
Expand Down Expand Up @@ -119,7 +125,17 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
return () => {
observer.disconnect();
};
}, [carouselWalker, store, win]);
}, [carouselWalker, store, targetDocument]);

const scrollToValue = React.useCallback(
(_value: string, jump?: boolean) => {
const values = store.getSnapshot().values;
const index = values.indexOf(_value);

emblaApi?.scrollToIndex(index, jump);
},
[emblaApi, store],
);

const selectPageByDirection: CarouselContextValue['selectPageByDirection'] = useEventCallback((event, direction) => {
const active = carouselWalker.active();
Expand All @@ -136,21 +152,31 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
if (newPage) {
setValue(newPage?.value);
onValueChange?.(event, { event, type: 'click', value: newPage?.value });

emblaApi.scrollInDirection(direction);
}
});

const selectPageByValue: CarouselContextValue['selectPageByValue'] = useEventCallback((event, _value) => {
setValue(_value);
onValueChange?.(event, { event, type: 'click', value: _value });

scrollToValue(_value);
});

useIsomorphicLayoutEffect(() => {
if (isFirstMount && value) {
scrollToValue(value, true);
}
}, [isFirstMount, scrollToValue, value]);

return {
components: {
root: 'div',
},
root: slot.always(
getIntrinsicElementProps('div', {
ref: useMergedRefs(ref, carouselRef, rootRef),
ref: useMergedRefs(ref, carouselRef, rootRef, emblaRef),
role: 'region',
...props,
}),
Expand All @@ -160,6 +186,5 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
selectPageByDirection,
selectPageByValue,
circular,
peeking,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@ import type { CarouselContextValues } from '../CarouselContext.types';
import type { CarouselState } from './Carousel.types';

export function useCarouselContextValues_unstable(state: CarouselState): CarouselContextValues {
const { store, selectPageByDirection, selectPageByValue, circular, peeking } = state;
const { store, selectPageByDirection, selectPageByValue, circular } = state;

const carousel = React.useMemo(
() => ({
store,
selectPageByDirection,
selectPageByValue,
circular,
peeking,
}),
[store, selectPageByDirection, selectPageByValue, circular, peeking],
[store, selectPageByDirection, selectPageByValue, circular],
);

return { carousel };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,13 @@ export const carouselClassNames: SlotClassNames<CarouselSlots> = {
root: 'fui-Carousel',
};

// TODO: Enable varying sizes w/ tokens
const PeekSize = '100px';

/**
* Styles for the root slot
*/
const useStyles = makeStyles({
root: {},
rootPeek: {
position: 'relative',
marginRight: PeekSize,
marginLeft: PeekSize,
root: {
overflow: 'hidden',
},

// TODO add additional classes for different states and/or slots
});

/**
Expand All @@ -29,15 +21,9 @@ const useStyles = makeStyles({
export const useCarouselStyles_unstable = (state: CarouselState): CarouselState => {
'use no memo';

const { peeking } = state;
const styles = useStyles();

state.root.className = mergeClasses(
carouselClassNames.root,
styles.root,
peeking && styles.rootPeek,
state.root.className,
);
state.root.className = mergeClasses(carouselClassNames.root, styles.root, state.root.className);

return state;
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { CarouselCardProps } from './CarouselCard.types';

/**
* The defining wrapper of a carousel's indexed content, they will take up the full
* viewport of Carousel wrapper (with consideration for gap and peeking variants),
* viewport of CarouselSlider or div wrapper,
* users may place multiple items within this Card if desired, with consideration of viewport width.
*
* Clickable actions within the content area are available via mouse and tab as expected,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,4 @@ export type CarouselCardProps = ComponentProps<CarouselCardSlots> & {
/**
* State used in rendering CarouselCard
*/
export type CarouselCardState = ComponentState<CarouselCardSlots> & {
visible: boolean;
/**
* Declares if card should be peeking as previous/next card
*/
peekDir?: 'prev' | 'next' | null;
} & Pick<CarouselCardProps, 'value'>;
export type CarouselCardState = ComponentState<CarouselCardSlots> & Pick<CarouselCardProps, 'value'>;
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
exports[`CarouselCard renders a default state 1`] = `
<div>
<div
aria-hidden="true"
class="fui-CarouselCard"
data-carousel-active-item="false"
data-carousel-item="test-0"
hidden=""
role="presentation"
/>
>
Default CarouselCard
</div>
</div>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,5 @@ import type { CarouselCardState, CarouselCardSlots } from './CarouselCard.types'
export const renderCarouselCard_unstable = (state: CarouselCardState) => {
assertSlots<CarouselCardSlots>(state);

// TODO Add additional slots in the appropriate place
return <state.root />;
};
Loading

0 comments on commit b5fa1fa

Please sign in to comment.