Skip to content

Commit b312651

Browse files
EthanStandelamannn
andauthored
feat(use-presence): Add usePresenceSwitch (#11)
* feat(use-presence): allow use-presence to be driven by arbitrary data * fix(use-presence): make use-presence for aribtrary data separate hook * feat(use-presence): separate options into their own types so that if the option types for the original hook change, the wrapper hook will receive the update by default * fix(use-presence): update useUniqueDataPresence to return all state values from usePresence, and export both of their options types * fix(use-presence): removes unused variable * feat(use-presence): separates use-presence and use-presence-switch to separate files and performs some name updates and configuration and type remodeling * feat(use-presence): adds test for use-presence-switch * feat(use-presence): simplify use-presence-switch test component tree * fix: adds test, updates names, code style cleanup * docs(use-presence): first pass attempt at usePresenceSwitch documentation * Cleanup * Inline checks --------- Co-authored-by: Jan Amann <[email protected]>
1 parent 7fa7278 commit b312651

File tree

6 files changed

+192
-0
lines changed

6 files changed

+192
-0
lines changed

packages/use-presence/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,89 @@ const {
8282
)
8383
```
8484

85+
## `usePresenceSwitch`
86+
87+
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.
88+
89+
### API
90+
91+
```tsx
92+
const {
93+
/** The item that should currently be rendered. */
94+
mountedItem,
95+
/** Returns all other properties from `usePresence`. */
96+
...rest
97+
} = usePresence<ItemType>(
98+
/** The current item that should be visible. If `undefined` is passed, the previous item will animate out. */
99+
item: ItemType | undefined,
100+
/** See the `opts` argument of `usePresence`. */
101+
opts: Parameters<typeof usePresence>[1]
102+
)
103+
```
104+
105+
### Example
106+
107+
```jsx
108+
const tabs = [
109+
{
110+
title: 'Tab 1',
111+
content: 'Tab 1 content'
112+
},
113+
{
114+
title: 'Tab 2',
115+
content: 'Tab 2 content'
116+
},
117+
{
118+
title: 'Tab 3',
119+
content: 'Tab 3 content'
120+
},
121+
];
122+
123+
function Tabs() {
124+
const [tabIndex, setTabIndex] = useState(0);
125+
126+
return (
127+
<>
128+
{tabs.map((tab, index) => (
129+
<button key={index} onClick={() => setTabIndex(index)} type="button">
130+
{tab.title}
131+
</button>
132+
))}
133+
<TabContent>
134+
{tabs[tabIndex].content}
135+
</TabContent>
136+
</>
137+
);
138+
}
139+
140+
function TabContent({ children, transitionDuration = 500 }) {
141+
const {
142+
isMounted,
143+
isVisible,
144+
mountedItem,
145+
} = usePresenceSwitch(children, { transitionDuration });
146+
147+
if (!isMounted) {
148+
return null;
149+
}
150+
151+
return (
152+
<div
153+
style={{
154+
opacity: 0,
155+
transitionDuration: `${transitionDuration}ms`,
156+
transitionProperty: 'opacity',
157+
...(isVisible && {
158+
opacity: 1
159+
})
160+
}}
161+
>
162+
{mountedItem}
163+
</div>
164+
);
165+
}
166+
```
167+
85168
## Related
86169

87170
- [`AnimatePresence` of `framer-motion`](https://www.framer.com/docs/animate-presence/)

packages/use-presence/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export {default} from './usePresence';
2+
export {default as usePresenceSwitch} from './usePresenceSwitch';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {useState, useEffect} from 'react';
2+
import usePresence from './usePresence';
3+
4+
export default function usePresenceSwitch<ItemType>(
5+
item: ItemType | undefined,
6+
opts: Parameters<typeof usePresence>[1]
7+
) {
8+
const [mountedItem, setMountedItem] = useState(item);
9+
const [shouldBeMounted, setShouldBeMounted] = useState(item !== undefined);
10+
const {isMounted, ...rest} = usePresence(shouldBeMounted, opts);
11+
12+
useEffect(() => {
13+
if (mountedItem !== item) {
14+
if (isMounted) {
15+
setShouldBeMounted(false);
16+
} else if (item !== undefined) {
17+
setMountedItem(item);
18+
setShouldBeMounted(true);
19+
}
20+
} else if (item === undefined) {
21+
setShouldBeMounted(false);
22+
} else if (item !== undefined) {
23+
setShouldBeMounted(true);
24+
}
25+
}, [item, mountedItem, shouldBeMounted, isMounted]);
26+
27+
return {
28+
...rest,
29+
isMounted: isMounted && mountedItem !== undefined,
30+
mountedItem
31+
};
32+
}
File renamed without changes.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {render, waitFor} from '@testing-library/react';
2+
import * as React from 'react';
3+
import {usePresenceSwitch} from '../src';
4+
5+
function Expander({
6+
initialEnter,
7+
text,
8+
transitionDuration = 50
9+
}: {
10+
initialEnter?: boolean;
11+
text?: string;
12+
transitionDuration?: number;
13+
}) {
14+
const {mountedItem, ...values} = usePresenceSwitch(text, {
15+
transitionDuration,
16+
initialEnter
17+
});
18+
const {isMounted, isVisible} = values;
19+
const testId =
20+
Object.entries(values)
21+
.filter(([, value]) => value)
22+
.map(([key]) => key)
23+
.join(', ') || 'none';
24+
25+
return (
26+
<div data-testid={testId}>
27+
{isMounted ? (
28+
<div
29+
style={{
30+
opacity: 0,
31+
transitionDuration: `${transitionDuration}ms`,
32+
transitionProperty: 'opacity',
33+
...(isVisible && {
34+
opacity: 1
35+
})
36+
}}
37+
>
38+
{mountedItem}
39+
</div>
40+
) : (
41+
<div>Nothing mounted</div>
42+
)}
43+
</div>
44+
);
45+
}
46+
47+
it("can animate the exit and re-entrance of a component that has changed it's rendered data", async () => {
48+
const {getByTestId, getByText, rerender} = render(
49+
<Expander text="initial value" />
50+
);
51+
getByTestId('isVisible, isMounted');
52+
getByText('initial value');
53+
rerender(<Expander text="re-assigned value" />);
54+
getByTestId('isAnimating, isExiting, isMounted');
55+
getByText('initial value');
56+
await waitFor(() => getByTestId('isVisible, isMounted'));
57+
getByText('re-assigned value');
58+
});
59+
60+
it("can animate the initial entrance and exit of a component based on it's rendered data", async () => {
61+
const {getByTestId, getByText, rerender} = render(
62+
<Expander text={undefined} />
63+
);
64+
getByTestId('none');
65+
getByText('Nothing mounted');
66+
rerender(<Expander text="initial value" />);
67+
getByTestId('isAnimating, isEntering, isMounted');
68+
getByText('initial value');
69+
await waitFor(() => getByTestId('isVisible, isMounted'));
70+
rerender(<Expander text={undefined} />);
71+
getByTestId('isAnimating, isExiting, isMounted');
72+
getByText('initial value');
73+
await waitFor(() => getByTestId('none'));
74+
getByText('Nothing mounted');
75+
});

0 commit comments

Comments
 (0)