Skip to content

Commit

Permalink
Add CrossFlow component
Browse files Browse the repository at this point in the history
- Queues children to make sure children aren't forcefully removed.
- Uses TransitionPresence and thus works just like TransitionPresence
  • Loading branch information
leroykorterink committed Oct 6, 2023
1 parent 6a7f42e commit 4146706
Show file tree
Hide file tree
Showing 7 changed files with 322 additions and 4 deletions.
57 changes: 57 additions & 0 deletions packages/react-transition-presence/src/CrossFlow/CrossFlow.mdx
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 packages/react-transition-presence/src/CrossFlow/CrossFlow.stories.tsx
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 packages/react-transition-presence/src/CrossFlow/CrossFlow.tsx
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>
))}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import * as stories from './TransitionPresence.stories';

# TransitionPresence

`<TransitionPresence />` is a component that allows you to defer unmount of a component.
`<TransitionPresence />` 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
`<TransitionPresence />` will be called just before a component is unmounted. The promises that are
created using the callback are await before unmounting a component.
created using the callback are awaited before unmounting a component. New children are rendered
after old children are unmounted.

The most common use-case for `<TransitionPresence />` is animations, but it's not limited to this
use case.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export type TransitionPresenceProps = {
previousChildren: ReactElement | null,
children: ReactElement | null,
): void | Promise<void>;
onChildrenMounted?(children: ReactElement | null): void | Promise<void>;
onChildrenMounted?(
previousChildren: ReactElement | null,
children: ReactElement | null,
): void | Promise<void>;
};

/**
Expand Down Expand Up @@ -84,7 +87,7 @@ export function TransitionPresence({
setPreviousChildren(children);
await tick();

onChildrenMountedRef.current?.(children);
onChildrenMountedRef.current?.(previousChildren, children);
})();

return () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/react-transition-presence/src/_hooks/useIsMounted.ts
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;
}
5 changes: 5 additions & 0 deletions packages/react-transition-presence/src/_utils/getId.ts
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();
}

0 comments on commit 4146706

Please sign in to comment.