-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Queues children to make sure children aren't forcefully removed. - Uses TransitionPresence and thus works just like TransitionPresence
- Loading branch information
1 parent
6a7f42e
commit 4146706
Showing
7 changed files
with
322 additions
and
4 deletions.
There are no files selected for viewing
57 changes: 57 additions & 0 deletions
57
packages/react-transition-presence/src/CrossFlow/CrossFlow.mdx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import { Meta, Canvas, Controls } from '@storybook/blocks'; | ||
import * as stories from './CrossFlow.stories'; | ||
|
||
<Meta title="components/CrossFlow" of={stories} /> | ||
|
||
# CrossFlow | ||
|
||
`<CrossFlow />` is a component that allows you to defer the unmount lifecycle of a component. | ||
|
||
The callback function used in `useBeforeUnmount` in components that are rendered in the context of | ||
`<CrossFlow />` will be called just before a component is unmounted. The promises that are created | ||
using the callback are awaited before unmounting a component. In the CrossFlow component new | ||
children are immediately rendered. | ||
|
||
The most common use-case for `<CrossFlow />` is animations, but it's not limited to this use case. | ||
|
||
## Props | ||
|
||
### Children | ||
|
||
`<CrossFlow />` accepts a single child, use a `Fragment` when you want to render multiple components | ||
on the root level. New children are rendered when the component type for the children (`<p />` to | ||
`<div />`) change, or when the key of the children changes (`<p key="one" />` to `null`). | ||
|
||
```tsx | ||
// Transition between two instances | ||
<CrossFlow> | ||
{myBoolean ? <MyComponent key="first-instance" /> : <MyComponent key="second-instance" />} | ||
</CrossFlow> | ||
|
||
// Transition between component types (new instances are automatically created) | ||
<CrossFlow> | ||
{myBoolean ? <FirstComponent /> : <SecondComponent />} | ||
</CrossFlow> | ||
``` | ||
|
||
### onChildrenMounted | ||
|
||
The `onChildrenMounted` is called when the new children are mounted. | ||
|
||
```tsx | ||
onChildrenMounted?: () => void; | ||
|
||
<CrossFlow onChildrenMounted={() => console.log('onChildrenMounted')}> | ||
... | ||
</CrossFlow> | ||
``` | ||
|
||
## Demo | ||
|
||
### Basic | ||
|
||
<Canvas of={stories.CrossFlowExample} /> | ||
|
||
### Basic with Fragments | ||
|
||
<Canvas of={stories.CrossFlowFragmentExample} /> |
144 changes: 144 additions & 0 deletions
144
packages/react-transition-presence/src/CrossFlow/CrossFlow.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
/* eslint-disable react/no-multi-comp, react/jsx-no-literals,no-console */ | ||
// eslint-disable-next-line import/no-extraneous-dependencies | ||
import { useAnimation } from '@mediamonks/react-animation'; | ||
import type { Meta } from '@storybook/react'; | ||
import gsap from 'gsap'; | ||
import { Fragment, useEffect, useRef, useState, type ReactElement } from 'react'; | ||
import { useBeforeUnmount } from '../useBeforeUnmount/useBeforeUnmount.js'; | ||
import { CrossFlow } from './CrossFlow.js'; | ||
|
||
export default { | ||
title: 'components/CrossFlow', | ||
argTypes: { | ||
children: { | ||
description: 'ReactElement | null', | ||
type: 'string', | ||
}, | ||
onChildrenMounted: { | ||
description: '() => void | undefined', | ||
type: 'string', | ||
}, | ||
}, | ||
} satisfies Meta; | ||
|
||
type ChildProps = { | ||
background: string; | ||
duration?: number; | ||
delayIn?: number; | ||
onClick?(): void; | ||
}; | ||
|
||
function Child({ background, onClick, duration = 1, delayIn = 0 }: ChildProps): ReactElement { | ||
const ref = useRef<HTMLButtonElement>(null); | ||
|
||
// show when mounted/unmounted | ||
useEffect(() => { | ||
console.log('mounted', background); | ||
|
||
return () => { | ||
console.log('unmounted', background); | ||
}; | ||
}, [background]); | ||
|
||
useAnimation(() => { | ||
console.log('animate-in', background); | ||
return gsap.fromTo(ref.current, { opacity: 0 }, { opacity: 1, duration, delay: delayIn }); | ||
}, []); | ||
|
||
// show visible animation during "before unmount" lifecycle | ||
useBeforeUnmount(async (abortSignal) => { | ||
console.log('animate-out', background); | ||
|
||
const animation = gsap.fromTo(ref.current, { opacity: 1 }, { opacity: 0, duration }); | ||
|
||
abortSignal.addEventListener('abort', () => { | ||
animation.pause(0); | ||
}); | ||
|
||
return animation; | ||
}); | ||
|
||
return ( | ||
<button | ||
ref={ref} | ||
aria-label="Click to change color" | ||
type="button" | ||
style={{ | ||
background, | ||
border: 'none', | ||
width: 200, | ||
height: 200, | ||
}} | ||
onClick={onClick} | ||
/> | ||
); | ||
} | ||
|
||
export function CrossFlowExample(): ReactElement { | ||
const [hue, setHue] = useState(0); | ||
|
||
return ( | ||
<> | ||
<CrossFlow> | ||
<Child | ||
// Changing a key will create a new component instance and thus animates out the previous child | ||
key={hue} | ||
background={`repeating-linear-gradient( | ||
-45deg, | ||
hsl(${hue}, 100%, 50%), | ||
hsl(${hue}, 100%, 50%) 10px, | ||
hsl(${(hue + 180) % 360}, 100%, 50%) 10px, | ||
hsl(${(hue + 180) % 360}, 100%, 50%) 20px | ||
)`} | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setHue(hue + ((90 + Math.random() * 270) % 360)); | ||
}} | ||
/> | ||
</CrossFlow> | ||
|
||
<div style={{ marginTop: 24 }}>Click the square to create a new element</div> | ||
</> | ||
); | ||
} | ||
|
||
export function CrossFlowFragmentExample(): ReactElement { | ||
const [hue, setHue] = useState(0); | ||
|
||
return ( | ||
<> | ||
<CrossFlow> | ||
<Fragment key={hue}> | ||
<Child | ||
background={`repeating-linear-gradient( | ||
-45deg, | ||
hsl(${hue}, 100%, 50%), | ||
hsl(${hue}, 100%, 50%) 10px, | ||
hsl(${(hue + 180) % 360}, 100%, 50%) 10px, | ||
hsl(${(hue + 180) % 360}, 100%, 50%) 20px | ||
)`} | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setHue(hue + ((90 + Math.random() * 270) % 360)); | ||
}} | ||
/> | ||
<Child | ||
background={`repeating-linear-gradient( | ||
-45deg, | ||
hsl(${hue + (90 % 360)}, 100%, 50%), | ||
hsl(${hue + (90 % 360)}, 100%, 50%) 10px, | ||
hsl(${(hue + 270) % 360}, 100%, 50%) 10px, | ||
hsl(${(hue + 270) % 360}, 100%, 50%) 20px | ||
)`} | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setHue(hue + ((90 + Math.random() * 270) % 360)); | ||
}} | ||
/> | ||
</Fragment> | ||
</CrossFlow> | ||
|
||
<div style={{ marginTop: 24 }}>Click the square to create a new element</div> | ||
</> | ||
); | ||
} |
90 changes: 90 additions & 0 deletions
90
packages/react-transition-presence/src/CrossFlow/CrossFlow.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import { cloneElement, useCallback, useEffect, useRef, useState, type ReactElement } from 'react'; | ||
import { | ||
TransitionPresence, | ||
type TransitionPresenceProps, | ||
} from '../TransitionPresence/TransitionPresence.js'; | ||
import { useIsMounted } from '../_hooks/useIsMounted.js'; | ||
import { childrenAreEqual } from '../_utils/childrenAreEqual.js'; | ||
import { getId } from '../_utils/getId.js'; | ||
|
||
type CrossFlowQueue = Record<string, TransitionPresenceProps['children']>; | ||
|
||
type CrossFlowProps = TransitionPresenceProps; | ||
|
||
export function CrossFlow({ | ||
children, | ||
onChildrenMounted, | ||
...props | ||
}: TransitionPresenceProps): ReactElement { | ||
const previousChildren = useRef<typeof children>(children); | ||
const [queue, setQueue] = useState<CrossFlowQueue>(() => | ||
children === null | ||
? {} | ||
: { | ||
[getId()]: children, | ||
}, | ||
); | ||
|
||
const isMounted = useIsMounted(); | ||
|
||
useEffect(() => { | ||
// Don't do anything during the first render | ||
if (!isMounted.current) { | ||
return; | ||
} | ||
|
||
// If the children are equal we don't want to do anything | ||
const areChildrenEqual = childrenAreEqual(children, previousChildren.current); | ||
previousChildren.current = children; | ||
|
||
if (areChildrenEqual) { | ||
return; | ||
} | ||
|
||
// Add new children to the queue and remove the old children instance for | ||
// its key to start the out transition | ||
setQueue((value) => { | ||
const emptyQueue: Record<string, null> = {}; | ||
|
||
// eslint-disable-next-line guard-for-in | ||
for (const key in value) { | ||
emptyQueue[key] = null; | ||
} | ||
|
||
if (children === null) { | ||
return emptyQueue; | ||
} | ||
|
||
return { | ||
...emptyQueue, | ||
[getId()]: children, | ||
}; | ||
}); | ||
}, [children, isMounted, previousChildren]); | ||
|
||
const onChildrenMountedCleanup = useCallback( | ||
(_children: ReactElement) => { | ||
setQueue((value) => { | ||
if ('key' in _children && _children.key !== null) { | ||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete | ||
delete value[_children.key]; | ||
} | ||
|
||
return { ...value }; | ||
}); | ||
|
||
onChildrenMounted?.(_children, null); | ||
}, | ||
[onChildrenMounted], | ||
); | ||
|
||
return ( | ||
<> | ||
{Object.entries(queue).map(([key, queuedChildren]) => ( | ||
<TransitionPresence onChildrenMounted={onChildrenMountedCleanup} {...props} key={key}> | ||
{queuedChildren && cloneElement(queuedChildren, { key })} | ||
</TransitionPresence> | ||
))} | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
packages/react-transition-presence/src/_hooks/useIsMounted.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { useEffect, useRef, type RefObject } from 'react'; | ||
|
||
export function useIsMounted(): RefObject<boolean> { | ||
const isMounted = useRef(false); | ||
|
||
useEffect(() => { | ||
queueMicrotask(() => { | ||
isMounted.current = true; | ||
}); | ||
|
||
return () => { | ||
isMounted.current = false; | ||
}; | ||
}, []); | ||
|
||
return isMounted; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
let id = 0; | ||
|
||
export function getId(): string { | ||
return (id++ % Number.MAX_SAFE_INTEGER).toString(); | ||
} |