Skip to content

Commit

Permalink
feat(react-motions): add PresenceGroup (#30543)
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter authored Feb 20, 2024
1 parent 0caeee1 commit 5fb68e9
Show file tree
Hide file tree
Showing 18 changed files with 850 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add PresenceGroup component",
"packageName": "@fluentui/react-motions-preview",
"email": "olfedias@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ declare namespace motionTokens {
}
export { motionTokens }

// @public (undocumented)
export class PresenceGroup extends React_2.Component<PresenceGroupProps, PresenceGroupState> {
constructor(props: PresenceGroupProps, context: unknown);
// (undocumented)
componentDidMount(): void;
// (undocumented)
componentWillUnmount(): void;
// (undocumented)
static getDerivedStateFromProps(nextProps: PresenceGroupProps, { childMapping: prevChildMapping, firstRender }: PresenceGroupState): {
childMapping: PresenceGroupChildMapping;
firstRender: boolean;
};
// (undocumented)
render(): JSX.Element;
}

// @public (undocumented)
export type PresenceMotion = {
enter: AtomMotion;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render } from '@testing-library/react';
import * as React from 'react';

import { PresenceGroupChildContext, PresenceGroupChildContextValue } from '../contexts/PresenceGroupChildContext';
import { PresenceGroup } from './PresenceGroup';

const TestComponent: React.FC<{
id: string;
onRender: (data: { id: string; appear: boolean; visible: boolean }) => void;
}> = props => {
const { id, onRender } = props;
const context = React.useContext(PresenceGroupChildContext) as PresenceGroupChildContextValue;

React.useEffect(() => {
onRender({
id,
appear: context.appear,
visible: context.visible,
});
});

return <div>{id}</div>;
};

describe('PresenceGroup', () => {
it('renders items an provides context to them', () => {
const onItemRender = jest.fn();

render(
<PresenceGroup>
<TestComponent id="1" onRender={onItemRender} />
<TestComponent id="2" onRender={onItemRender} />
</PresenceGroup>,
);

expect(onItemRender).toHaveBeenCalledTimes(2);
expect(onItemRender).toHaveBeenNthCalledWith(1, { id: '1', appear: false, visible: true });
expect(onItemRender).toHaveBeenNthCalledWith(2, { id: '2', appear: false, visible: true });
});

it('updates context when items are added or removed', () => {
const onItemRender = jest.fn();

const { rerender } = render(
<PresenceGroup>
<TestComponent key="1" id="1" onRender={onItemRender} />
<TestComponent key="2" id="2" onRender={onItemRender} />
</PresenceGroup>,
);

expect(onItemRender).toHaveBeenCalledTimes(2);
onItemRender.mockClear();

rerender(
<PresenceGroup>
<TestComponent key="1" id="1" onRender={onItemRender} />
<TestComponent key="3" id="3" onRender={onItemRender} />
</PresenceGroup>,
);

expect(onItemRender).toHaveBeenCalledTimes(3);
expect(onItemRender).toHaveBeenNthCalledWith(1, { id: '1', appear: false, visible: true });
expect(onItemRender).toHaveBeenNthCalledWith(2, { id: '3', appear: true, visible: true });
expect(onItemRender).toHaveBeenNthCalledWith(3, { id: '2', appear: false, visible: false });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as React from 'react';

import { getNextChildMapping } from '../utils/groups/getNextChildMapping';
import { getChildMapping } from '../utils/groups/getChildMapping';
import type { PresenceGroupChildMapping } from '../utils/groups/types';
import { PresenceGroupItemProvider } from './PresenceGroupItemProvider';

type PresenceGroupProps = {
children: React.ReactNode;
};

type PresenceGroupState = {
childMapping: PresenceGroupChildMapping;
firstRender: boolean;
};

/* eslint-disable @typescript-eslint/explicit-member-accessibility */
/* eslint-disable @typescript-eslint/naming-convention */

export class PresenceGroup extends React.Component<PresenceGroupProps, PresenceGroupState> {
#mounted: boolean = false;

static getDerivedStateFromProps(
nextProps: PresenceGroupProps,
{ childMapping: prevChildMapping, firstRender }: PresenceGroupState,
) {
const nextChildMapping = getChildMapping(nextProps.children);

return {
childMapping: firstRender ? nextChildMapping : getNextChildMapping(prevChildMapping, nextChildMapping),
firstRender: false,
};
}

constructor(props: PresenceGroupProps, context: unknown) {
super(props, context);

this.state = {
childMapping: {},
firstRender: true,
};
}

#handleExit(childKey: string) {
const currentChildMapping = getChildMapping(this.props.children);

if (childKey in currentChildMapping) {
return;
}

if (this.#mounted) {
this.setState(state => {
const childMapping = { ...state.childMapping };
delete childMapping[childKey];

return { childMapping };
});
}
}

componentDidMount() {
this.#mounted = true;
}

componentWillUnmount() {
this.#mounted = false;
}

render() {
return (
<>
{Object.entries(this.state.childMapping).map(([childKey, childProps]) => (
<PresenceGroupItemProvider {...childProps} childKey={childKey} key={childKey} onExit={this.#handleExit}>
{childProps.element}
</PresenceGroupItemProvider>
))}
</>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react';

import { PresenceGroupChildContext } from '../contexts/PresenceGroupChildContext';
import type { PresenceGroupChildContextValue } from '../contexts/PresenceGroupChildContext';

type PresenceGroupItemProviderProps = PresenceGroupChildContextValue & {
children: React.ReactElement;
childKey: string;
};

/**
* @internal
*
* Provides context for a single child of a `PresenceGroup`. Exists only to make a stable context value for a child.
* Not intended for direct use.
*/
export const PresenceGroupItemProvider: React.FC<PresenceGroupItemProviderProps> = props => {
const { appear, childKey, onExit, visible, unmountOnExit } = props;
const contextValue = React.useMemo(
() => ({
appear,
visible,
onExit: () => onExit(childKey),
unmountOnExit,
}),
[appear, childKey, onExit, visible, unmountOnExit],
);

return <PresenceGroupChildContext.Provider value={contextValue}>{props.children}</PresenceGroupChildContext.Provider>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from 'react';

/**
* @internal
*/
export type PresenceGroupChildContextValue = {
appear: boolean;
visible: boolean;
unmountOnExit: boolean;

onExit: (childKey: string) => void;
};

/**
* @internal
*/
export const PresenceGroupChildContext = React.createContext<PresenceGroupChildContextValue | undefined>(undefined);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEventCallback, useIsomorphicLayoutEffect, useMergedRefs } from '@flu
import type { EventData, EventHandler } from '@fluentui/react-utilities';
import * as React from 'react';

import { PresenceGroupChildContext } from '../contexts/PresenceGroupChildContext';
import { useIsReducedMotion } from '../hooks/useIsReducedMotion';
import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
import { getChildElement } from '../utils/getChildElement';
Expand Down Expand Up @@ -38,13 +39,15 @@ type PresenceComponentProps = {

export function createPresenceComponent(motion: PresenceMotion | PresenceMotionFn) {
const Presence: React.FC<PresenceComponentProps> = props => {
const { appear, children, imperativeRef, onMotionFinish, visible, unmountOnExit } = props;
const itemContext = React.useContext(PresenceGroupChildContext);
const { appear, children, imperativeRef, onMotionFinish, visible, unmountOnExit } = { ...itemContext, ...props };

const child = getChildElement(children);

const animationRef = useMotionImperativeRef(imperativeRef);
const elementRef = React.useRef<HTMLElement>();
const ref = useMergedRefs(elementRef, child.ref);
const optionsRef = React.useRef<{ appear?: boolean }>();

const [mounted, setMounted] = React.useState(() => (unmountOnExit ? visible : true));

Expand All @@ -62,6 +65,10 @@ export function createPresenceComponent(motion: PresenceMotion | PresenceMotionF
}
});

useIsomorphicLayoutEffect(() => {
optionsRef.current = { appear };
});

useIsomorphicLayoutEffect(() => {
if (visible) {
setMounted(true);
Expand Down Expand Up @@ -101,7 +108,7 @@ export function createPresenceComponent(motion: PresenceMotion | PresenceMotionF
return;
}

const shouldEnter = isFirstMount.current ? appear && visible : mounted && visible;
const shouldEnter = isFirstMount.current ? optionsRef.current?.appear && visible : mounted && visible;

if (shouldEnter) {
const definition = typeof motion === 'function' ? motion(elementRef.current) : motion;
Expand All @@ -121,7 +128,7 @@ export function createPresenceComponent(motion: PresenceMotion | PresenceMotionF
animation.cancel();
};
}
}, [animationRef, appear, isReducedMotion, mounted, onEnterFinish, visible]);
}, [animationRef, isReducedMotion, mounted, onEnterFinish, visible]);

useIsomorphicLayoutEffect(() => {
isFirstMount.current = false;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-components/react-motions-preview/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as motionTokens from './motions/motionTokens';
export { createMotionComponent } from './factories/createMotionComponent';
export { createPresenceComponent } from './factories/createPresenceComponent';

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

export { motionTokens };

export type { AtomMotion, AtomMotionFn, PresenceMotion, PresenceMotionFn, MotionImperativeRef } from './types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from 'react';
import { getChildMapping } from './getChildMapping';
import { render } from '@testing-library/react';

const TestComponent: React.FC<{
children: React.ReactNode;
onMapping: (mapping: ReturnType<typeof getChildMapping>) => void;
}> = props => {
React.useEffect(() => {
props.onMapping(getChildMapping(props.children));
});

return null;
};

describe('getChildMapping', () => {
it('should return an object mapping key to child', () => {
const onMapping = jest.fn();

render(
<TestComponent onMapping={onMapping}>
<div>Hello</div>
<div>World</div>
</TestComponent>,
);

expect(onMapping).toHaveBeenCalledTimes(1);
expect(onMapping.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
".0": Object {
"appear": false,
"element": <div>
Hello
</div>,
"unmountOnExit": true,
"visible": true,
},
".1": Object {
"appear": false,
"element": <div>
World
</div>,
"unmountOnExit": true,
"visible": true,
},
},
]
`);
});

it('should return an empty object if children is nullable', () => {
expect(getChildMapping(null)).toEqual({});
expect(getChildMapping(undefined)).toEqual({});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';
import type { PresenceGroupChildMapping } from './types';

/**
* Given `children`, return an object mapping key to child.
*/
export function getChildMapping(children: React.ReactNode | undefined) {
const childMapping: PresenceGroupChildMapping = {};

if (children) {
React.Children.toArray(children).forEach(child => {
if (React.isValidElement(child)) {
childMapping[child.key ?? ''] = {
appear: false,
element: child,
visible: true,
unmountOnExit: true,
};
}
});
}

return childMapping;
}
Loading

0 comments on commit 5fb68e9

Please sign in to comment.