Skip to content

Commit 09077bf

Browse files
authored
feat: Add keepMounted param to TabPanels for persisting panels when they are not active (#2434)
Part of DH-18349. Will need a small update in dh.ui to enable support there which is what the ticket wants. Does not change default behavior of `TabPanels`, just adds an extra param to keep static panels mounted (in React, but not actually mounted in the DOM). There are unit tests, but can also test with local plugins from this PR deephaven/deephaven-plugins#1177
1 parent e6ed762 commit 09077bf

5 files changed

Lines changed: 351 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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { render, screen, waitFor } 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+
function OnMountUnmount({
24+
onMount,
25+
onUnmount,
26+
}: {
27+
onMount: () => void;
28+
onUnmount: () => void;
29+
}) {
30+
useEffect(() => {
31+
onMount();
32+
return () => {
33+
onUnmount();
34+
};
35+
}, [onMount, onUnmount]);
36+
return null;
37+
}
38+
39+
describe('TabPanels', () => {
40+
it('should not persist panel state by default when switching tabs', () => {
41+
render(
42+
<Provider theme={defaultTheme}>
43+
<Tabs aria-label="test">
44+
<TabList>
45+
<Item key="1">Tab 1</Item>
46+
<Item key="2">Tab 2</Item>
47+
</TabList>
48+
<DHCTabPanels>
49+
<Item key="1">
50+
<Counter label="foo" />
51+
</Item>
52+
<Item key="2">
53+
<Counter label="bar" />
54+
</Item>
55+
</DHCTabPanels>
56+
</Tabs>
57+
</Provider>
58+
);
59+
60+
screen.getByRole('button', { name: /foo/ }).click();
61+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
62+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
63+
64+
screen.getByText('Tab 2', { selector: 'span' }).click();
65+
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
66+
expect(screen.queryByText('bar: 0')).toBeInTheDocument();
67+
68+
screen.getByText('Tab 1', { selector: 'span' }).click();
69+
expect(screen.getByText('foo: 0')).toBeInTheDocument();
70+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
71+
});
72+
73+
it('should persist panel state when keepMounted is true', () => {
74+
render(
75+
<Provider theme={defaultTheme}>
76+
<Tabs aria-label="test">
77+
<TabList>
78+
<Item key="1">Tab 1</Item>
79+
<Item key="2">Tab 2</Item>
80+
</TabList>
81+
<DHCTabPanels keepMounted>
82+
<Item key="1">
83+
<Counter label="foo" />
84+
</Item>
85+
<Item key="2">
86+
<Counter label="bar" />
87+
</Item>
88+
</DHCTabPanels>
89+
</Tabs>
90+
</Provider>
91+
);
92+
93+
screen.getByRole('button', { name: /foo/ }).click();
94+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
95+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
96+
97+
screen.getByText('Tab 2', { selector: 'span' }).click();
98+
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
99+
expect(screen.queryByText('bar: 0')).toBeInTheDocument();
100+
101+
screen.getByText('Tab 1', { selector: 'span' }).click();
102+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
103+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
104+
});
105+
106+
it('should not persist panel state when using a render function', () => {
107+
const tabs = [
108+
{
109+
id: '1',
110+
label: 'Tab 1',
111+
content: <Counter label="foo" />,
112+
},
113+
{
114+
id: '2',
115+
label: 'Tab 2',
116+
content: <Counter label="bar" />,
117+
},
118+
];
119+
type Tab = (typeof tabs)[0];
120+
render(
121+
<Provider theme={defaultTheme}>
122+
<Tabs items={tabs} aria-label="test">
123+
<TabList>{(tab: Tab) => <Item>{tab.label}</Item>}</TabList>
124+
<DHCTabPanels keepMounted>
125+
{(tab: Tab) => <Item>{tab.content}</Item>}
126+
</DHCTabPanels>
127+
</Tabs>
128+
</Provider>
129+
);
130+
131+
screen.getByRole('button', { name: /foo/ }).click();
132+
expect(screen.getByText('foo: 1')).toBeInTheDocument();
133+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
134+
135+
screen.getByText('Tab 2', { selector: 'span' }).click();
136+
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
137+
expect(screen.queryByText('bar: 0')).toBeInTheDocument();
138+
139+
screen.getByText('Tab 1', { selector: 'span' }).click();
140+
expect(screen.getByText('foo: 0')).toBeInTheDocument();
141+
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
142+
});
143+
144+
it('should pass through style props', () => {
145+
render(
146+
<Provider theme={defaultTheme}>
147+
<Tabs aria-label="test">
148+
<TabList>
149+
<Item key="1">Tab 1</Item>
150+
<Item key="2">Tab 2</Item>
151+
</TabList>
152+
<DHCTabPanels
153+
aria-label="panels"
154+
UNSAFE_style={{ backgroundColor: 'red' }}
155+
>
156+
<Item key="1">
157+
<Counter label="foo" />
158+
</Item>
159+
<Item key="2">
160+
<Counter label="bar" />
161+
</Item>
162+
</DHCTabPanels>
163+
</Tabs>
164+
</Provider>
165+
);
166+
167+
expect(screen.getByLabelText('panels')).toHaveStyle(
168+
'background-color: red'
169+
);
170+
});
171+
172+
it('should still unmount a panel that is not in the tree when using keepMounted', () => {
173+
const onMount = jest.fn();
174+
const onUnmount = jest.fn();
175+
const { rerender } = render(
176+
<Provider theme={defaultTheme}>
177+
<Tabs aria-label="test">
178+
<TabList>
179+
<Item key="1">Tab 1</Item>
180+
<Item key="2">Tab 2</Item>
181+
</TabList>
182+
<DHCTabPanels>
183+
<Item key="1">
184+
<Counter label="foo" />
185+
</Item>
186+
<Item key="2">
187+
<OnMountUnmount onMount={onMount} onUnmount={onUnmount} />
188+
</Item>
189+
</DHCTabPanels>
190+
</Tabs>
191+
</Provider>
192+
);
193+
194+
waitFor(() => expect(onMount).toHaveBeenCalledTimes(1));
195+
expect(onUnmount).toHaveBeenCalledTimes(0);
196+
197+
rerender(
198+
<Provider theme={defaultTheme}>
199+
<Tabs aria-label="test">
200+
<TabList>
201+
<Item key="1">Tab 1</Item>
202+
<Item key="2">Tab 2</Item>
203+
</TabList>
204+
<DHCTabPanels>
205+
<Item key="1">
206+
<Counter label="foo" />
207+
</Item>
208+
<Item key="2">
209+
<Counter label="bar" />
210+
</Item>
211+
</DHCTabPanels>
212+
</Tabs>
213+
</Provider>
214+
);
215+
216+
waitFor(() => expect(onUnmount).toHaveBeenCalledTimes(1));
217+
});
218+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
const nextNodeMap = new Map<Key, HtmlPortalNode>(); // Keep track of the portals we use so we can clean up stale portals
40+
if (!keepMounted) {
41+
portalNodeMap.current = nextNodeMap;
42+
return nodes;
43+
}
44+
React.Children.forEach(children, child => {
45+
// Spectrum would ignore these anyway because it uses Item key to determine if the panel mounts
46+
if (child == null || child.key == null) {
47+
return;
48+
}
49+
50+
let portal = portalNodeMap.current.get(child.key);
51+
if (portal == null) {
52+
portal = createHtmlPortalNode({
53+
attributes: {
54+
// Should make the placeholder div not affect layout and act as if children are mounted directly to the parent
55+
style: 'display: contents',
56+
},
57+
});
58+
}
59+
nextNodeMap.set(child.key, portal);
60+
nodes.push(
61+
<InPortal node={portal} key={child.key}>
62+
{child.props.children}
63+
</InPortal>
64+
);
65+
});
66+
67+
portalNodeMap.current = nextNodeMap;
68+
69+
return nodes;
70+
}, [children, keepMounted]);
71+
72+
const mappedChildren: CollectionChildren<T> = useMemo(() => {
73+
const newChildren: CollectionChildren<T> = [];
74+
if (!keepMounted) {
75+
return newChildren;
76+
}
77+
// Need to use forEach instead of map because map always changes the key of the returned elements
78+
React.Children.forEach(children, child => {
79+
if (child == null || child.key == null) {
80+
newChildren.push(child);
81+
return;
82+
}
83+
84+
const portal = portalNodeMap.current.get(child.key);
85+
if (portal == null) {
86+
newChildren.push(child);
87+
return;
88+
}
89+
90+
newChildren.push(
91+
<Item key={child.key}>
92+
<OutPortal node={portal} />
93+
</Item>
94+
);
95+
});
96+
97+
return newChildren;
98+
}, [children, keepMounted]);
99+
100+
return (
101+
<>
102+
{keepMounted && portalNodes}
103+
<TabPanels
104+
// eslint-disable-next-line react/jsx-props-no-spreading
105+
{...rest}
106+
>
107+
{keepMounted ? mappedChildren : children}
108+
</TabPanels>
109+
</>
110+
);
111+
}

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)