Skip to content

Commit

Permalink
feat(Carousel): implement peeking (microsoft#31545)
Browse files Browse the repository at this point in the history
Co-authored-by: Mitch-At-Work <[email protected]>
  • Loading branch information
layershifter and Mitch-At-Work authored Jun 4, 2024
1 parent bfcc1ca commit 2a5c067
Show file tree
Hide file tree
Showing 21 changed files with 224 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export type CarouselCardSlots = {
// @public
export type CarouselCardState = ComponentState<CarouselCardSlots> & {
visible: boolean;
peekDir?: 'prev' | 'next' | null;
} & Pick<CarouselCardProps, 'value'>;

// @public (undocumented)
Expand Down Expand Up @@ -167,6 +168,7 @@ export type CarouselProps = ComponentProps<CarouselSlots> & {
value?: string;
onValueChange?: EventHandler<CarouselValueChangeData>;
circular?: Boolean;
peeking?: Boolean;
};

// @public (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCarousel_unstable } from './useCarousel';
import { renderCarousel_unstable } from './renderCarousel';
import { useCarouselStyles_unstable } from './useCarouselStyles.styles';
import type { CarouselProps } from './Carousel.types';
import { useCarouselContextValues_unstable } from '../CarouselContext';
import { useCarouselContextValues_unstable } from './useCarouselContextValues';

/**
* Carousel is the context wrapper and container for all carousel content/controls,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export type CarouselProps = ComponentProps<CarouselSlots> & {
* 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
*/
peeking?: Boolean;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
slot,
useControllableState,
useEventCallback,
useIsomorphicLayoutEffect,
useMergedRefs,
} from '@fluentui/react-utilities';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
Expand All @@ -25,18 +26,19 @@ import type { CarouselContextValue } from '../CarouselContext.types';
* @param ref - reference to root HTMLDivElement of Carousel
*/
export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDivElement>): CarouselState {
const { onValueChange, circular } = props;
const { onValueChange, circular, peeking } = props;

const { targetDocument } = useFluent();
const win = targetDocument?.defaultView;
const { ref: carouselRef, walker: carouselWalker } = useCarouselWalker_unstable();
const [store] = React.useState(() => createCarouselStore());

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

const rootRef = React.useRef<HTMLDivElement>(null);

if (process.env.NODE_ENV !== 'production') {
Expand All @@ -51,6 +53,10 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
}, [value]);
}

useIsomorphicLayoutEffect(() => {
store.setActiveValue(value);
}, [store, value]);

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

Expand All @@ -59,7 +65,7 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
}

return () => {
store.clear();
store.clearValues();
};
}, [store]);

Expand Down Expand Up @@ -149,9 +155,9 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
{ elementType: 'div' },
),
store,
value,
selectPageByDirection,
selectPageByValue,
circular,
peeking,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

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 carousel = React.useMemo(
() => ({
store,
selectPageByDirection,
selectPageByValue,
circular,
peeking,
}),
[store, selectPageByDirection, selectPageByValue, circular, peeking],
);

return { carousel };
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import type { CarouselSlots, CarouselState } from './Carousel.types';

export const carouselClassNames: SlotClassNames<CarouselSlots> = {
root: 'fui-Carousel',
// TODO: add class names for all slots on CarouselSlots.
// Should be of the form `<slotName>: 'fui-Carousel__<slotName>`
};

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

/**
* Styles for the root slot
*/
const useStyles = makeStyles({
root: {
// TODO Add default styles for the root element
root: {},
rootPeek: {
position: 'relative',
marginRight: PeekSize,
marginLeft: PeekSize,
},

// TODO add additional classes for different states and/or slots
Expand All @@ -23,11 +27,15 @@ const useStyles = makeStyles({
* Apply styling to the Carousel slots based on the state
*/
export const useCarouselStyles_unstable = (state: CarouselState): CarouselState => {
const { peeking } = state;
const styles = useStyles();
state.root.className = mergeClasses(carouselClassNames.root, styles.root, state.root.className);

// TODO Add class names to slots, for example:
// state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className);
state.root.className = mergeClasses(
carouselClassNames.root,
styles.root,
peeking && styles.rootPeek,
state.root.className,
);

return state;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mergeCallbacks, useEventCallback } from '@fluentui/react-utilities';
import type { CarouselButtonProps, CarouselButtonState } from './CarouselButton.types';
import { useButton_unstable } from '@fluentui/react-button';
import { useCarouselContext_unstable } from '../CarouselContext';
import { useCarouselValues_unstable } from '../useCarouselValues';
import { useCarouselStore_unstable } from '../useCarouselStore';
import { slot } from '@fluentui/react-utilities';
import { ChevronLeftRegular, ChevronRightRegular } from '@fluentui/react-icons';
import { ARIAButtonElement } from '@fluentui/react-aria';
Expand All @@ -23,10 +23,18 @@ export const useCarouselButton_unstable = (
): CarouselButtonState => {
const { navType } = props;

const selectPageByDirection = useCarouselContext_unstable(c => c.selectPageByDirection);
const values = useCarouselValues_unstable(snapshot => snapshot);
const activeValue = useCarouselContext_unstable(c => c.value);
const circular = useCarouselContext_unstable(c => c.circular);
const { circular, selectPageByDirection } = useCarouselContext_unstable();
const isTrailing = useCarouselStore_unstable(snapshot => {
if (!snapshot.activeValue || circular) {
return false;
}

if (navType === 'prev') {
return snapshot.values.indexOf(snapshot.activeValue) === 0;
}

return snapshot.values.indexOf(snapshot.activeValue) === snapshot.values.length - 1;
});

const handleClick = (event: React.MouseEvent<HTMLButtonElement & HTMLAnchorElement>) => {
if (event.isDefaultPrevented()) {
Expand All @@ -38,18 +46,6 @@ export const useCarouselButton_unstable = (

const handleButtonClick = useEventCallback(mergeCallbacks(handleClick, props.onClick));

const isTrailing = React.useMemo(() => {
if (!activeValue || circular) {
return false;
}

if (navType === 'prev') {
return values.indexOf(activeValue) === 0;
}

return values.indexOf(activeValue) === values.length - 1;
}, [navType, activeValue, values, circular]);

return {
navType,
// We lean on react-button class to handle styling and icon enhancements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ export type CarouselCardProps = ComponentProps<CarouselCardSlots> & {
*/
export type CarouselCardState = ComponentState<CarouselCardSlots> & {
visible: boolean;
/**
* Declares if card should be peeking as previous/next card
*/
peekDir?: 'prev' | 'next' | null;
} & Pick<CarouselCardProps, 'value'>;
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities';
import type { CarouselCardProps, CarouselCardState } from './CarouselCard.types';
import { CAROUSEL_ACTIVE_ITEM, CAROUSEL_ITEM } from '../constants';
import { useCarouselContext_unstable } from '../CarouselContext';
import { useCarouselStore_unstable } from '../useCarouselStore';

/**
* Create the state required to render CarouselCard.
Expand All @@ -18,11 +19,38 @@ export const useCarouselCard_unstable = (
ref: React.Ref<HTMLDivElement>,
): CarouselCardState => {
const { value } = props;
const { circular, peeking } = useCarouselContext_unstable();

const visible = useCarouselStore_unstable(snapshot => snapshot.activeValue === value);
const peekDir: 'prev' | 'next' | undefined = useCarouselStore_unstable(snapshot => {
if (!peeking) {
return;
}

const currentIndex = snapshot.activeValue ? snapshot.values.indexOf(snapshot.activeValue) : null;

if (currentIndex !== null && currentIndex >= 0) {
let nextValue = currentIndex + 1 < snapshot.values.length ? snapshot.values[currentIndex + 1] : null;
let prevValue = currentIndex - 1 >= 0 ? snapshot.values[currentIndex - 1] : null;

if (!nextValue && circular) {
nextValue = snapshot.values[0];
}

if (!prevValue && circular) {
prevValue = snapshot.values[snapshot.values.length - 1];
}

if (nextValue === value || prevValue === value) {
return nextValue === value ? 'next' : 'prev';
}
}
});

const visible = useCarouselContext_unstable(c => c.value === value);
const state: CarouselCardState = {
value,
visible,
peekDir,
components: {
root: 'div',
},
Expand All @@ -31,15 +59,17 @@ export const useCarouselCard_unstable = (
ref,
[CAROUSEL_ITEM]: value,
[CAROUSEL_ACTIVE_ITEM]: visible,
hidden: !visible,
hidden: !visible && !peekDir,
'aria-hidden': !visible,
inert: !visible,
role: 'presentation',
...props,
}),
{ elementType: 'div' },
),
};

if (!visible) {
if (!visible && !peekDir) {
state.root.children = null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,48 @@ import type { CarouselCardSlots, CarouselCardState } from './CarouselCard.types'

export const carouselCardClassNames: SlotClassNames<CarouselCardSlots> = {
root: 'fui-CarouselCard',
// TODO: add class names for all slots on CarouselCardSlots.
// Should be of the form `<slotName>: 'fui-CarouselCard__<slotName>`
};

// TODO: Enable varying sizes w/ tokens
const GapSize = 10;

/**
* Styles for the root slot
*/
const useStyles = makeStyles({
root: {
// TODO Add default styles for the root element
marginLeft: GapSize / 2 + 'px',
marginRight: GapSize / 2 + 'px',
},
peekLeft: {
position: 'absolute',
float: 'left',
right: '100%',
top: 0,
width: '100%',
},
peekRight: {
position: 'absolute',
float: 'right',
left: '100%',
top: 0,
width: '100%',
},

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

/**
* Apply styling to the CarouselCard slots based on the state
*/
export const useCarouselCardStyles_unstable = (state: CarouselCardState): CarouselCardState => {
const { peekDir } = state;
const styles = useStyles();
state.root.className = mergeClasses(carouselCardClassNames.root, styles.root, state.root.className);
state.root.className = mergeClasses(
carouselCardClassNames.root,
styles.root,
peekDir === 'next' && styles.peekRight,
peekDir === 'prev' && styles.peekLeft,
state.root.className,
);

// TODO Add class names to slots, for example:
// state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className);
Expand Down
Loading

0 comments on commit 2a5c067

Please sign in to comment.