Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: migrate Toast to new motion library #31516

Merged
merged 1 commit into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
layershifter marked this conversation as resolved.
Show resolved Hide resolved
"type": "patch",
"comment": "chore: export types for component props",
"packageName": "@fluentui/react-motions-preview",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "chore: migrate Toast to new motion library",
"packageName": "@fluentui/react-toast",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export const durations: {
readonly durationUltraSlow: 500;
};

// @public (undocumented)
export type MotionComponentProps = {
children: React_2.ReactElement;
imperativeRef?: React_2.Ref<MotionImperativeRef | undefined>;
};

// @public (undocumented)
export type MotionImperativeRef = {
setPlaybackRate: (rate: number) => void;
Expand Down Expand Up @@ -72,6 +78,18 @@ export const motionTokens: {
durationUltraSlow: 500;
};

// @public (undocumented)
export type PresenceComponentProps = {
appear?: boolean;
children: React_2.ReactElement;
imperativeRef?: React_2.Ref<MotionImperativeRef | undefined>;
onMotionFinish?: (ev: null, data: {
direction: 'enter' | 'exit';
}) => void;
visible?: boolean;
unmountOnExit?: boolean;
};

// @public (undocumented)
export class PresenceGroup extends React_2.Component<PresenceGroupProps, PresenceGroupState> {
constructor(props: PresenceGroupProps, context: unknown);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { animateAtoms } from '../utils/animateAtoms';
import { getChildElement } from '../utils/getChildElement';
import type { AtomMotion, AtomMotionFn, MotionImperativeRef } from '../types';

type MotionComponentProps = {
export type MotionComponentProps = {
children: React.ReactElement;

/** Provides imperative controls for the animation. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { motionTokens, durations, curves } from './motions/motionTokens';

export { createMotionComponent } from './factories/createMotionComponent';
export { createPresenceComponent } from './factories/createPresenceComponent';
export { createMotionComponent, type MotionComponentProps } from './factories/createMotionComponent';
export { createPresenceComponent, type PresenceComponentProps } from './factories/createPresenceComponent';

export { PresenceGroup } from './components/PresenceGroup';

Expand Down
2 changes: 1 addition & 1 deletion packages/react-components/react-toast/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@fluentui/scripts-tasks": "*"
},
"dependencies": {
"react-transition-group": "^4.4.1",
"@fluentui/react-motions-preview": "^0.3.2",
"@fluentui/keyboard-keys": "^9.0.7",
"@fluentui/react-aria": "^9.11.4",
"@fluentui/react-icons": "^2.0.239",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isConformant } from '../../testing/isConformant';
import { ToastContainerProps } from './ToastContainer.types';
import { toastContainerClassNames } from './useToastContainerStyles.styles';
import { resetIdsForTests } from '@fluentui/react-utilities';
import { type PresenceComponentProps } from '@fluentui/react-motions-preview';

const defaultToastContainerProps: ToastContainerProps = {
announce: () => null,
Expand Down Expand Up @@ -32,6 +33,27 @@ const defaultToastContainerProps: ToastContainerProps = {
const runningTimerSelector = '[data-timer-status="running"]';
const pausedTimerSelector = '[data-timer-status="paused"]';

const FAKE_MOTION_DURATION = 500;

jest.mock('./ToastContainerMotion', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
ToastContainerMotion: (props: PresenceComponentProps) => {
const { children, onMotionFinish, visible } = props;

React.useEffect(() => {
const timeout = setTimeout(() => {
onMotionFinish?.(null, { direction: visible ? 'enter' : 'exit' });
}, FAKE_MOTION_DURATION);

return () => {
clearTimeout(timeout);
};
}, [onMotionFinish, visible]);

return <>{children}</>;
},
}));

describe('ToastContainer', () => {
beforeEach(() => {
jest.useRealTimers();
Expand All @@ -43,7 +65,11 @@ describe('ToastContainer', () => {
displayName: 'ToastContainer',
requiredProps: defaultToastContainerProps,
isInternal: true,
disabledTests: ['consistent-callback-args'],
disabledTests: [
'consistent-callback-args',
// There are conflicts between ToastContainerMotion mock and React
'make-styles-overrides-win',
],
});

it('renders a default state', () => {
Expand Down Expand Up @@ -104,31 +130,32 @@ describe('ToastContainer', () => {
});

it('should start timer after toast on animationend', () => {
jest.useFakeTimers();

const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1 };
const { container } = render(<ToastContainer {...toastProps}>ToastContainer</ToastContainer>);

const toastElement = container.querySelector(`.${toastContainerClassNames.root}`);
expect(toastElement).not.toBeNull();
act(() => {
if (toastElement) {
fireEvent.animationEnd(toastElement);
}
jest.advanceTimersToNextTimer(FAKE_MOTION_DURATION);
});

expect(container.querySelector(runningTimerSelector)).not.toBeNull();
});

it('should close toast ontimeout', () => {
jest.useFakeTimers();

const close = jest.fn();
const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1, close };
const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 500, close };
const { container } = render(<ToastContainer {...toastProps}>ToastContainer</ToastContainer>);

const toastElement = container.querySelector(`.${toastContainerClassNames.root}`);
expect(toastElement).not.toBeNull();

act(() => {
if (toastElement) {
fireEvent.animationEnd(toastElement);
}
jest.advanceTimersByTime(FAKE_MOTION_DURATION);
});

const timer = container.querySelector(runningTimerSelector);
Expand All @@ -144,15 +171,16 @@ describe('ToastContainer', () => {
});

it('should pause on hover', () => {
jest.useFakeTimers();

const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1, pauseOnHover: true };
const { container } = render(<ToastContainer {...toastProps}>ToastContainer</ToastContainer>);

const toastElement = container.querySelector(`.${toastContainerClassNames.root}`);
expect(toastElement).not.toBeNull();

act(() => {
if (toastElement) {
fireEvent.animationEnd(toastElement);
}
jest.advanceTimersToNextTimer(FAKE_MOTION_DURATION);
});

expect(container.querySelector(runningTimerSelector)).not.toBeNull();
Expand All @@ -175,15 +203,16 @@ describe('ToastContainer', () => {
});

it('should pause on window blur', () => {
jest.useFakeTimers();

const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1, pauseOnWindowBlur: true };
const { container } = render(<ToastContainer {...toastProps}>ToastContainer</ToastContainer>);

const toastElement = container.querySelector(`.${toastContainerClassNames.root}`);
expect(toastElement).not.toBeNull();

act(() => {
if (toastElement) {
fireEvent.animationEnd(toastElement);
}
jest.advanceTimersByTime(FAKE_MOTION_DURATION);
});

expect(container.querySelector(runningTimerSelector)).not.toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,20 @@ export type ToastContainerProps = Omit<ComponentProps<Partial<ToastContainerSlot
export type ToastContainerState = ComponentState<ToastContainerSlots> &
Pick<ToastContainerProps, 'remove' | 'close' | 'updateId' | 'visible' | 'intent'> &
Pick<ToastContainerContextValue, 'titleId' | 'bodyId'> & {
/**
* @deprecated Will be always "0".
*/
transitionTimeout: number;
timerTimeout: number;
running: boolean;
/**
* @deprecated Will be always no-op.
*/
onTransitionEntering: () => void;
/**
* @deprecated
*/
nodeRef: React.Ref<HTMLDivElement>;

onMotionFinish?: (event: null, data: { direction: 'enter' | 'exit' }) => void;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createPresenceComponent } from '@fluentui/react-motions-preview';

export const ToastContainerMotion = createPresenceComponent(element => ({
enter: [
{
keyframes: [
{ marginTop: 0, minHeight: 0, maxHeight: 0, opacity: 0 },
{ marginTop: '16px', minHeight: 44, maxHeight: `${element.scrollHeight}px`, opacity: 0 },
],
duration: 200,
},
{
keyframes: [{ opacity: 0 }, { opacity: 1 }],
delay: 200,
duration: 400,
},
],

exit: [
{
keyframes: [
{ marginTop: '16px', minHeight: 44, maxHeight: `${element.scrollHeight}px` },
{ marginTop: 0, minHeight: 0, maxHeight: 0 },
],
delay: 400,
duration: 200,
},
{
keyframes: [{ opacity: 1 }, { opacity: 0 }],
duration: 400,
},
],
}));
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ exports[`ToastContainer renders a default state 1`] = `
class="fui-ToastContainer"
data-tabster="{\\"groupper\\":{\\"tabbability\\":2},\\"focusable\\":{\\"ignoreKeydown\\":{\\"Tab\\":true,\\"Escape\\":true,\\"Enter\\":true}}}"
role="listitem"
style="--fui-toast-height: 0px;"
tabindex="0"
>
Default ToastContainer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */
import { assertSlots } from '@fluentui/react-utilities';
import { Transition } from 'react-transition-group';
import type { ToastContainerState, ToastContainerSlots, ToastContainerContextValues } from './ToastContainer.types';
import { ToastContainerContextProvider } from '../../contexts/toastContainerContext';
import { ToastContainerMotion } from './ToastContainerMotion';

/**
* Render the final JSX of ToastContainer
Expand All @@ -12,23 +12,17 @@ export const renderToastContainer_unstable = (
state: ToastContainerState,
contextValues: ToastContainerContextValues,
) => {
const { onTransitionEntering, visible, transitionTimeout, remove, nodeRef, updateId } = state;
const { onMotionFinish, visible, updateId } = state;
assertSlots<ToastContainerSlots>(state);

return (
<Transition
in={visible}
appear
unmountOnExit
timeout={transitionTimeout}
onExited={remove}
onEntering={onTransitionEntering}
nodeRef={nodeRef}
>
<ToastContainerContextProvider value={contextValues.toast}>
<state.root />
<state.timer key={updateId} />
</ToastContainerContextProvider>
</Transition>
<ToastContainerContextProvider value={contextValues.toast}>
<ToastContainerMotion appear onMotionFinish={onMotionFinish} visible={visible} unmountOnExit>
<state.root>
{state.root.children}
<state.timer key={updateId} />
</state.root>
</ToastContainerMotion>
</ToastContainerContextProvider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -129,37 +129,21 @@ export const useToastContainer_unstable = (
}
}, [targetDocument, pause, play, pauseOnWindowBlur]);

// It's impossible to animate to height: auto in CSS, the actual pixel value must be known
// Get the height of the toast before animation styles have been applied and set a CSS
// variable with its height. The CSS variable will be used by the styles
const onTransitionEntering = () => {
if (!toastRef.current) {
return;
}

const element = toastRef.current;
element.style.setProperty('--fui-toast-height', `${element.scrollHeight}px`);
};

// Users never actually use ToastContainer as a JSX but imperatively through useToastContainerController
const userRootSlot = (data as { root?: ExtractSlotProps<Slot<'div'>> }).root;
const onMotionFinish: ToastContainerState['onMotionFinish'] = React.useCallback(
(_, { direction }) => {
if (direction === 'exit') {
remove();
}

// Using a ref callback here because addEventListener supports `once`
const toastAnimationRef = React.useCallback(
(el: HTMLDivElement | null) => {
if (el && toastRef.current) {
toastRef.current.addEventListener(
'animationend',
() => {
// start toast once it's fully animated in
play();
onStatusChange('visible');
},
{ once: true },
);
if (direction === 'enter') {
// start toast once it's fully animated in
play();
onStatusChange('visible');
}
},
[play, onStatusChange],
[onStatusChange, play, remove],
);

const onMouseEnter = useEventCallback((e: React.MouseEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -220,7 +204,7 @@ export const useToastContainer_unstable = (
// FIXME:
// `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement`
// but since it would be a breaking change to fix it, we are casting ref to it's proper type
ref: useMergedRefs(ref, toastRef, toastAnimationRef) as React.Ref<HTMLDivElement>,
ref: useMergedRefs(ref, toastRef) as React.Ref<HTMLDivElement>,
children,
tabIndex: 0,
role: 'listitem',
Expand All @@ -236,16 +220,19 @@ export const useToastContainer_unstable = (
{ elementType: 'div' },
),
timerTimeout,
transitionTimeout: 500,
transitionTimeout: 0,
running,
visible,
remove,
close,
onTransitionEntering,
onTransitionEntering: () => {
/* no-op */
},
updateId,
nodeRef: toastRef,
intent,
titleId,
bodyId,
onMotionFinish,
};
};
Loading
Loading