Skip to content

feat(use-presence): allow use-presence to be driven by arbitrary data #11

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

Merged
merged 13 commits into from
Feb 13, 2023
Merged
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
83 changes: 83 additions & 0 deletions packages/use-presence/README.md
Original file line number Diff line number Diff line change
@@ -82,6 +82,89 @@ const {
)
```

## `usePresenceSwitch`

If you have multiple items where only one is visible at a time, you can use the supplemental `usePresenceSwitch` hook to animate the items in and out. Previous items will exit before the next item transitions in.

### API

```tsx
const {
/** The item that should currently be rendered. */
mountedItem,
/** Returns all other properties from `usePresence`. */
...rest
} = usePresence<ItemType>(
/** The current item that should be visible. If `undefined` is passed, the previous item will animate out. */
item: ItemType | undefined,
/** See the `opts` argument of `usePresence`. */
opts: Parameters<typeof usePresence>[1]
)
```

### Example

```jsx
const tabs = [
{
title: 'Tab 1',
content: 'Tab 1 content'
},
{
title: 'Tab 2',
content: 'Tab 2 content'
},
{
title: 'Tab 3',
content: 'Tab 3 content'
},
];

function Tabs() {
const [tabIndex, setTabIndex] = useState(0);

return (
<>
{tabs.map((tab, index) => (
<button key={index} onClick={() => setTabIndex(index)} type="button">
{tab.title}
</button>
))}
<TabContent>
{tabs[tabIndex].content}
</TabContent>
</>
);
}

function TabContent({ children, transitionDuration = 500 }) {
const {
isMounted,
isVisible,
mountedItem,
} = usePresenceSwitch(children, { transitionDuration });

if (!isMounted) {
return null;
}

return (
<div
style={{
opacity: 0,
transitionDuration: `${transitionDuration}ms`,
transitionProperty: 'opacity',
...(isVisible && {
opacity: 1
})
}}
>
{mountedItem}
</div>
);
}
```

## Related

- [`AnimatePresence` of `framer-motion`](https://www.framer.com/docs/animate-presence/)
2 changes: 2 additions & 0 deletions packages/use-presence/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {default} from './usePresence';
export {default as usePresenceSwitch} from './usePresenceSwitch';
File renamed without changes.
32 changes: 32 additions & 0 deletions packages/use-presence/src/usePresenceSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {useState, useEffect} from 'react';
import usePresence from './usePresence';

export default function usePresenceSwitch<ItemType>(
item: ItemType | undefined,
opts: Parameters<typeof usePresence>[1]
) {
const [mountedItem, setMountedItem] = useState(item);
const [shouldBeMounted, setShouldBeMounted] = useState(item !== undefined);
const {isMounted, ...rest} = usePresence(shouldBeMounted, opts);

useEffect(() => {
if (mountedItem !== item) {
if (isMounted) {
setShouldBeMounted(false);
} else if (item !== undefined) {
setMountedItem(item);
setShouldBeMounted(true);
}
} else if (item === undefined) {
setShouldBeMounted(false);
} else if (item !== undefined) {
setShouldBeMounted(true);
}
}, [item, mountedItem, shouldBeMounted, isMounted]);

return {
...rest,
isMounted: isMounted && mountedItem !== undefined,
mountedItem
};
}
File renamed without changes.
75 changes: 75 additions & 0 deletions packages/use-presence/test/usePresenceSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {render, waitFor} from '@testing-library/react';
import * as React from 'react';
import {usePresenceSwitch} from '../src';

function Expander({
initialEnter,
text,
transitionDuration = 50
}: {
initialEnter?: boolean;
text?: string;
transitionDuration?: number;
}) {
const {mountedItem, ...values} = usePresenceSwitch(text, {
transitionDuration,
initialEnter
});
const {isMounted, isVisible} = values;
const testId =
Object.entries(values)
.filter(([, value]) => value)
.map(([key]) => key)
.join(', ') || 'none';

return (
<div data-testid={testId}>
{isMounted ? (
<div
style={{
opacity: 0,
transitionDuration: `${transitionDuration}ms`,
transitionProperty: 'opacity',
...(isVisible && {
opacity: 1
})
}}
>
{mountedItem}
</div>
) : (
<div>Nothing mounted</div>
)}
</div>
);
}

it("can animate the exit and re-entrance of a component that has changed it's rendered data", async () => {
const {getByTestId, getByText, rerender} = render(
<Expander text="initial value" />
);
getByTestId('isVisible, isMounted');
getByText('initial value');
rerender(<Expander text="re-assigned value" />);
getByTestId('isAnimating, isExiting, isMounted');
getByText('initial value');
await waitFor(() => getByTestId('isVisible, isMounted'));
getByText('re-assigned value');
});

it("can animate the initial entrance and exit of a component based on it's rendered data", async () => {
const {getByTestId, getByText, rerender} = render(
<Expander text={undefined} />
);
getByTestId('none');
getByText('Nothing mounted');
rerender(<Expander text="initial value" />);
getByTestId('isAnimating, isEntering, isMounted');
getByText('initial value');
await waitFor(() => getByTestId('isVisible, isMounted'));
rerender(<Expander text={undefined} />);
getByTestId('isAnimating, isExiting, isMounted');
getByText('initial value');
await waitFor(() => getByTestId('none'));
getByText('Nothing mounted');
});