Skip to content

Commit 97653a7

Browse files
committed
feat: Add keepMounted param to TabPanels for persisting panels when they are not active
1 parent 6d65594 commit 97653a7

5 files changed

Lines changed: 284 additions & 2 deletions

File tree

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"popper.js": "^1.16.1",
5151
"prop-types": "^15.7.2",
5252
"react-beautiful-dnd": "^13.1.0",
53+
"react-reverse-portal": "^2.3.0",
5354
"react-transition-group": "^4.4.2",
5455
"react-virtualized-auto-sizer": "1.0.6",
5556
"react-window": "^1.8.6"
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import React, { useState } from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import {
4+
defaultTheme,
5+
Item,
6+
Provider,
7+
TabList,
8+
Tabs,
9+
} from '@adobe/react-spectrum';
10+
import { DHCTabPanels } from './TabPanels';
11+
12+
function Counter({ label }: { label: string }) {
13+
const [count, setCount] = useState(0);
14+
return (
15+
<div>
16+
<button type="button" onClick={() => setCount(count + 1)}>
17+
{label}: {count}
18+
</button>
19+
</div>
20+
);
21+
}
22+
23+
describe('TabPanels', () => {
24+
it('should not persist panel state by default when switching tabs', () => {
25+
render(
26+
<Provider theme={defaultTheme}>
27+
<Tabs aria-label="test">
28+
<TabList>
29+
<Item key="1">Tab 1</Item>
30+
<Item key="2">Tab 2</Item>
31+
</TabList>
32+
<DHCTabPanels>
33+
<Item key="1">
34+
<Counter label="foo" />
35+
</Item>
36+
<Item key="2">
37+
<Counter label="bar" />
38+
</Item>
39+
</DHCTabPanels>
40+
</Tabs>
41+
</Provider>
42+
);
43+
44+
screen.getByRole('button', { name: /foo/ }).click();
45+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
46+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
47+
48+
screen.getByText('Tab 2', { selector: 'span' }).click();
49+
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
50+
expect(screen.queryByText('bar: 0')).toBeInTheDocument();
51+
52+
screen.getByText('Tab 1', { selector: 'span' }).click();
53+
expect(screen.getByText('foo: 0')).toBeInTheDocument();
54+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
55+
});
56+
57+
it('should persist panel state when keepMounted is true', () => {
58+
render(
59+
<Provider theme={defaultTheme}>
60+
<Tabs aria-label="test">
61+
<TabList>
62+
<Item key="1">Tab 1</Item>
63+
<Item key="2">Tab 2</Item>
64+
</TabList>
65+
<DHCTabPanels keepMounted>
66+
<Item key="1">
67+
<Counter label="foo" />
68+
</Item>
69+
<Item key="2">
70+
<Counter label="bar" />
71+
</Item>
72+
</DHCTabPanels>
73+
</Tabs>
74+
</Provider>
75+
);
76+
77+
screen.getByRole('button', { name: /foo/ }).click();
78+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
79+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
80+
81+
screen.getByText('Tab 2', { selector: 'span' }).click();
82+
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
83+
expect(screen.queryByText('bar: 0')).toBeInTheDocument();
84+
85+
screen.getByText('Tab 1', { selector: 'span' }).click();
86+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
87+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
88+
});
89+
90+
it('should not persist panel state when using a render function', () => {
91+
const tabs = [
92+
{
93+
id: '1',
94+
label: 'Tab 1',
95+
content: <Counter label="foo" />,
96+
},
97+
{
98+
id: '2',
99+
label: 'Tab 2',
100+
content: <Counter label="bar" />,
101+
},
102+
];
103+
type Tab = (typeof tabs)[0];
104+
render(
105+
<Provider theme={defaultTheme}>
106+
<Tabs items={tabs} aria-label="test">
107+
<TabList>{(tab: Tab) => <Item>{tab.label}</Item>}</TabList>
108+
<DHCTabPanels keepMounted>
109+
{(tab: Tab) => <Item>{tab.content}</Item>}
110+
</DHCTabPanels>
111+
</Tabs>
112+
</Provider>
113+
);
114+
115+
screen.getByRole('button', { name: /foo/ }).click();
116+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
117+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
118+
119+
screen.getByText('Tab 2', { selector: 'span' }).click();
120+
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
121+
expect(screen.queryByText('bar: 0')).toBeInTheDocument();
122+
123+
screen.getByText('Tab 1', { selector: 'span' }).click();
124+
expect(screen.getByText('foo: 0')).toBeInTheDocument();
125+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
126+
});
127+
128+
it('should pass through style props', () => {
129+
render(
130+
<Provider theme={defaultTheme}>
131+
<Tabs aria-label="test">
132+
<TabList>
133+
<Item key="1">Tab 1</Item>
134+
<Item key="2">Tab 2</Item>
135+
</TabList>
136+
<DHCTabPanels
137+
aria-label="panels"
138+
UNSAFE_style={{ backgroundColor: 'red' }}
139+
>
140+
<Item key="1">
141+
<Counter label="foo" />
142+
</Item>
143+
<Item key="2">
144+
<Counter label="bar" />
145+
</Item>
146+
</DHCTabPanels>
147+
</Tabs>
148+
</Provider>
149+
);
150+
151+
expect(screen.getByLabelText('panels')).toHaveStyle(
152+
'background-color: red'
153+
);
154+
});
155+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React, { type Key, useMemo, useRef } from 'react';
2+
import {
3+
createHtmlPortalNode,
4+
type HtmlPortalNode,
5+
InPortal,
6+
OutPortal,
7+
} from 'react-reverse-portal';
8+
import {
9+
Item,
10+
TabPanels,
11+
type SpectrumTabPanelsProps,
12+
} from '@adobe/react-spectrum';
13+
import { type CollectionChildren } from '@react-types/shared';
14+
15+
export interface DHCTabPanelsProps<T> extends SpectrumTabPanelsProps<T> {
16+
/**
17+
* If static panels with keys should stay mounted when not visible.
18+
* This will not apply to dynamic panels created with a render function.
19+
* Defaults to false.
20+
*/
21+
keepMounted?: boolean;
22+
}
23+
24+
/**
25+
* Wrapper for react-spectrum TabPanels that adds support for keeping panels mounted
26+
* when not visible using the `keepMounted` prop.
27+
* Panels created with a render function will not be kept mounted.
28+
*/
29+
export function DHCTabPanels<T extends object>(
30+
props: DHCTabPanelsProps<T>
31+
): JSX.Element {
32+
const { children, keepMounted: keepMountedProp = false, ...rest } = props;
33+
const keepMounted = keepMountedProp && typeof children !== 'function';
34+
35+
const portalNodeMap = useRef(new Map<Key, HtmlPortalNode>());
36+
37+
const portalNodes = useMemo(() => {
38+
const nodes: JSX.Element[] = [];
39+
if (!keepMounted) {
40+
return nodes;
41+
}
42+
React.Children.forEach(children, child => {
43+
// Spectrum would ignore these anyway because it uses Item key to determine if the panel mounts
44+
if (child == null || child.key == null) {
45+
return;
46+
}
47+
48+
let portal = portalNodeMap.current.get(child.key);
49+
if (portal == null) {
50+
portal = createHtmlPortalNode({
51+
attributes: {
52+
// Should make the placeholder div not affect layout and act as if children are mounted directly to the parent
53+
style: 'display: contents',
54+
},
55+
});
56+
portalNodeMap.current.set(child.key, portal);
57+
}
58+
nodes.push(
59+
<InPortal node={portal} key={child.key}>
60+
{child.props.children}
61+
</InPortal>
62+
);
63+
});
64+
65+
return nodes;
66+
}, [children, keepMounted]);
67+
68+
const mappedChildren: CollectionChildren<T> = useMemo(() => {
69+
const newChildren: CollectionChildren<T> = [];
70+
if (!keepMounted) {
71+
return newChildren;
72+
}
73+
// Need to use forEach instead of map because map always changes the key of the returned elements
74+
React.Children.forEach(children, child => {
75+
if (child == null || child.key == null) {
76+
newChildren.push(child);
77+
return;
78+
}
79+
80+
const portal = portalNodeMap.current.get(child.key);
81+
if (portal == null) {
82+
newChildren.push(child);
83+
return;
84+
}
85+
86+
newChildren.push(
87+
<Item key={child.key}>
88+
<OutPortal node={portal} />
89+
</Item>
90+
);
91+
});
92+
93+
return newChildren;
94+
}, [children, keepMounted]);
95+
96+
return (
97+
<>
98+
{keepMounted && portalNodes}
99+
<TabPanels
100+
// eslint-disable-next-line react/jsx-props-no-spreading
101+
{...rest}
102+
>
103+
{keepMounted ? mappedChildren : children}
104+
</TabPanels>
105+
</>
106+
);
107+
}

packages/components/src/spectrum/navigation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ export {
1313
type SpectrumLinkProps as LinkProps,
1414
TabList,
1515
type SpectrumTabListProps as TabListProps,
16-
TabPanels,
17-
type SpectrumTabPanelsProps as TabPanelsProps,
1816
Tabs,
1917
type SpectrumTabsProps as TabsProps,
2018
} from '@adobe/react-spectrum';
19+
export {
20+
DHCTabPanels as TabPanels,
21+
type DHCTabPanelsProps as TabPanelsProps,
22+
} from './TabPanels';

0 commit comments

Comments
 (0)