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

Add CrossFlow component #113

Merged
merged 1 commit into from
Oct 6, 2023
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
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>
</>
);
}
88 changes: 88 additions & 0 deletions packages/react-transition-presence/src/CrossFlow/CrossFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useIsMounted } from '@mediamonks/react-hooks';
import { cloneElement, useCallback, useEffect, useRef, useState, type ReactElement } from 'react';
import {
TransitionPresence,
type TransitionPresenceProps,
} from '../TransitionPresence/TransitionPresence.js';
import { childrenAreEqual } from '../_utils/childrenAreEqual.js';
import { getId } from '../_utils/getId.js';

type CrossFlowQueue = Record<string, TransitionPresenceProps['children']>;

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 @@ -5,7 +5,6 @@ import {
useMemo,
useState,
type ReactElement,
type ReactFragment,
type RefObject,
} from 'react';
import { childrenAreEqual } from '../_utils/childrenAreEqual.js';
Expand All @@ -17,16 +16,19 @@ import {
import { TransitionPresenceContext } from './TransitionPresence.context.js';

export type TransitionPresenceProps = {
children: ReactElement | ReactFragment | null;
children: ReactElement | null;
onPreviousChildrenUnmounting?(
previousChildren: ReactElement | ReactFragment | null,
children: ReactElement | ReactFragment | null,
previousChildren: ReactElement | null,
children: ReactElement | null,
): void | Promise<void>;
onPreviousChildrenUnmounted?(
previousChildren: ReactElement | ReactFragment | null,
children: ReactElement | ReactFragment | null,
previousChildren: ReactElement | null,
children: ReactElement | null,
): void | Promise<void>;
onChildrenMounted?(
previousChildren: ReactElement | null,
children: ReactElement | null,
): void | Promise<void>;
onChildrenMounted?(children: ReactElement | ReactFragment | null): void | Promise<void>;
};

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

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

return () => {
Expand Down
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();
}
Loading