UNPKG

@atlaskit/motion

Version:

A set of utilities to apply motion in your application.

179 lines (170 loc) 5.89 kB
import React, { Children, createContext, memo, useContext, useEffect, useState } from 'react'; import { fg } from '@atlaskit/platform-feature-flags'; /** * Internally we will be playing with an element that will always have a key defined. */ /** * Internal data passed to child motions. */ // We define empty context here so the object doesn't change. const emptyContext = { // Motions will always appear if not inside a exiting persistence component. appear: true, isExiting: false }; /** * __Exiting context__ * * An exiting context. */ const ExitingContext = /*#__PURE__*/createContext(emptyContext); /** * This method will wrap any React element with a context provider. We're using context (instead of * cloneElement) so we can communicate between multiple elements without the need of prop drilling * (results in a better API for consumers). */ const wrapChildWithContextProvider = (child, value = emptyContext) => { return /*#__PURE__*/React.createElement(ExitingContext.Provider, { key: `${child.key}-provider`, value: value }, child); }; /** * This function will convert all children types to an array while also filtering out non-valid React elements. */ const childrenToArray = children => { const childrenAsArray = []; // We convert children to an array using this helper method as it will add keys to children that do not // have them, such as when we have hardcoded children that are conditionally rendered. Children.toArray(children).forEach(child => { // We ignore any boolean children to make our code a little more simple later on, // and also filter out any falsies (empty strings, nulls, and undefined). if (typeof child !== 'boolean' && Boolean(child)) { // Children WILL have a key after being forced into an array using the React.Children helper. childrenAsArray.push(child); } }); return childrenAsArray; }; const spliceNewElementsIntoPrevious = (current, previous) => { const splicedChildren = previous.concat([]); const previousMap = childrenToObj(previous); for (let i = 0; i < current.length; i++) { const child = current[i]; const childIsNew = !previousMap[child.key]; if (childIsNew) { // This will insert the new element after the previous element. splicedChildren.splice(i + 1, 0, child); } } return splicedChildren; }; const childrenToObj = children => { return children.reduce((acc, child) => { acc[child.key] = child; return acc; }, {}); }; const getMissingKeys = (current, previous) => { const currentMapKeys = new Set(current.map(child => child.key)); const missing = new Set(); for (let i = 0; i < previous.length; i++) { const element = previous[i]; const key = element.key; if (!currentMapKeys.has(key)) { missing.add(key); } } return missing; }; /** * How does this component work? * * It looks at changes in its children to see what is removed. * * If a child is removed it clones it and wraps it with context providing an `onFinish` callback. * * The cloned child will call the `onFinish` when it finishes its exit animation, * which lets `ExitingPersistence` know to stop rendering it. */ /** * __ExitingPersistence__ * * Useful for enabling elements to persist and animate away when they are removed from the DOM. * * - [Examples](https://atlaskit.atlassian.com/packages/design-system/motion/docs/entering-motion) */ const ExitingPersistence = /*#__PURE__*/memo(({ appear = false, children, exitThenEnter }) => { const [stateChildren, setChildren] = useState([null, children]); const [exitingChildren, setExitingChildren] = useState([]); const [defaultContext, setDefaultContext] = useState(() => ({ appear, isExiting: false })); useEffect(() => { if (!defaultContext.appear) { setDefaultContext({ appear: true, isExiting: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** * NOTE: This is a workaround for the test case written in Jira where the stateChildren is a boolean value because * useState is mocked to return a boolean value. */ if (typeof stateChildren === 'boolean') { return children; } const [previousChildren, currentChildren] = stateChildren; const previous = childrenToArray(previousChildren); const current = childrenToArray(currentChildren); if (currentChildren !== children) { setChildren([currentChildren, children]); } const missingKeys = getMissingKeys(current, previous); const isSomeChildRemoved = !!missingKeys.size; let visibleChildren = current; if (isSomeChildRemoved) { visibleChildren = spliceNewElementsIntoPrevious(current, previous); } if (exitThenEnter) { if (exitingChildren.length) { visibleChildren = fg('platform-dst-motion-uplift') ? previous : exitingChildren; } else { const nextExitingChildren = visibleChildren.filter(child => missingKeys.has(child.key)); if (nextExitingChildren.length) { setExitingChildren(nextExitingChildren); } } } if (missingKeys.size) { visibleChildren = visibleChildren.map(child => { const isExiting = missingKeys.has(child.key); return wrapChildWithContextProvider(child, { appear: true, isExiting, onFinish: isExiting ? () => { missingKeys.delete(child.key); if (missingKeys.size === 0) { setChildren([null, children]); setExitingChildren([]); } } : undefined }); }); } else { visibleChildren = visibleChildren.map(child => wrapChildWithContextProvider(child, defaultContext)); } return visibleChildren; }); export const useExitingPersistence = () => { return useContext(ExitingContext); }; ExitingPersistence.displayName = 'ExitingPersistence'; export default ExitingPersistence;