Skip to content
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
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"popper.js": "^1.16.1",
"prop-types": "^15.7.2",
"react-beautiful-dnd": "^13.1.0",
"react-reverse-portal": "^2.3.0",
"react-transition-group": "^4.4.2",
"react-virtualized-auto-sizer": "1.0.6",
"react-window": "^1.8.6"
Expand Down
218 changes: 218 additions & 0 deletions packages/components/src/spectrum/TabPanels.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import React, { useEffect, useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import {
defaultTheme,
Item,
Provider,
TabList,
Tabs,
} from '@adobe/react-spectrum';
import { DHCTabPanels } from './TabPanels';

function Counter({ label }: { label: string }) {
const [count, setCount] = useState(0);
return (
<div>
<button type="button" onClick={() => setCount(count + 1)}>
{label}: {count}
</button>
</div>
);
}

function OnMountUnmount({
onMount,
onUnmount,
}: {
onMount: () => void;
onUnmount: () => void;
}) {
useEffect(() => {
onMount();
return () => {
onUnmount();
};
}, [onMount, onUnmount]);
return null;
}

describe('TabPanels', () => {
it('should not persist panel state by default when switching tabs', () => {
render(
<Provider theme={defaultTheme}>
<Tabs aria-label="test">
<TabList>
<Item key="1">Tab 1</Item>
<Item key="2">Tab 2</Item>
</TabList>
<DHCTabPanels>
<Item key="1">
<Counter label="foo" />
</Item>
<Item key="2">
<Counter label="bar" />
</Item>
</DHCTabPanels>
</Tabs>
</Provider>
);

screen.getByRole('button', { name: /foo/ }).click();
expect(screen.getByText('foo: 1')).toBeInTheDocument();
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();

screen.getByText('Tab 2', { selector: 'span' }).click();
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
expect(screen.queryByText('bar: 0')).toBeInTheDocument();

screen.getByText('Tab 1', { selector: 'span' }).click();
expect(screen.getByText('foo: 0')).toBeInTheDocument();
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
});

it('should persist panel state when keepMounted is true', () => {
render(
<Provider theme={defaultTheme}>
<Tabs aria-label="test">
<TabList>
<Item key="1">Tab 1</Item>
<Item key="2">Tab 2</Item>
</TabList>
<DHCTabPanels keepMounted>
<Item key="1">
<Counter label="foo" />
</Item>
<Item key="2">
<Counter label="bar" />
</Item>
</DHCTabPanels>
</Tabs>
</Provider>
);

screen.getByRole('button', { name: /foo/ }).click();
expect(screen.getByText('foo: 1')).toBeInTheDocument();
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();

screen.getByText('Tab 2', { selector: 'span' }).click();
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
expect(screen.queryByText('bar: 0')).toBeInTheDocument();

screen.getByText('Tab 1', { selector: 'span' }).click();
expect(screen.getByText('foo: 1')).toBeInTheDocument();
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
});

it('should not persist panel state when using a render function', () => {
const tabs = [
{
id: '1',
label: 'Tab 1',
content: <Counter label="foo" />,
},
{
id: '2',
label: 'Tab 2',
content: <Counter label="bar" />,
},
];
type Tab = (typeof tabs)[0];
render(
<Provider theme={defaultTheme}>
<Tabs items={tabs} aria-label="test">
<TabList>{(tab: Tab) => <Item>{tab.label}</Item>}</TabList>
<DHCTabPanels keepMounted>
{(tab: Tab) => <Item>{tab.content}</Item>}
</DHCTabPanels>
</Tabs>
</Provider>
);

screen.getByRole('button', { name: /foo/ }).click();
expect(screen.getByText('foo: 1')).toBeInTheDocument();
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();

screen.getByText('Tab 2', { selector: 'span' }).click();
expect(screen.queryByText(/foo/)).not.toBeInTheDocument();
expect(screen.queryByText('bar: 0')).toBeInTheDocument();

screen.getByText('Tab 1', { selector: 'span' }).click();
expect(screen.getByText('foo: 0')).toBeInTheDocument();
expect(screen.queryByText(/bar/)).not.toBeInTheDocument();
});

it('should pass through style props', () => {
render(
<Provider theme={defaultTheme}>
<Tabs aria-label="test">
<TabList>
<Item key="1">Tab 1</Item>
<Item key="2">Tab 2</Item>
</TabList>
<DHCTabPanels
aria-label="panels"
UNSAFE_style={{ backgroundColor: 'red' }}
>
<Item key="1">
<Counter label="foo" />
</Item>
<Item key="2">
<Counter label="bar" />
</Item>
</DHCTabPanels>
</Tabs>
</Provider>
);

expect(screen.getByLabelText('panels')).toHaveStyle(
'background-color: red'
);
});

it('should still unmount a panel that is not in the tree when using keepMounted', () => {
const onMount = jest.fn();
const onUnmount = jest.fn();
const { rerender } = render(
<Provider theme={defaultTheme}>
<Tabs aria-label="test">
<TabList>
<Item key="1">Tab 1</Item>
<Item key="2">Tab 2</Item>
</TabList>
<DHCTabPanels>
<Item key="1">
<Counter label="foo" />
</Item>
<Item key="2">
<OnMountUnmount onMount={onMount} onUnmount={onUnmount} />
</Item>
</DHCTabPanels>
</Tabs>
</Provider>
);

waitFor(() => expect(onMount).toHaveBeenCalledTimes(1));
expect(onUnmount).toHaveBeenCalledTimes(0);

rerender(
<Provider theme={defaultTheme}>
<Tabs aria-label="test">
<TabList>
<Item key="1">Tab 1</Item>
<Item key="2">Tab 2</Item>
</TabList>
<DHCTabPanels>
<Item key="1">
<Counter label="foo" />
</Item>
<Item key="2">
<Counter label="bar" />
</Item>
</DHCTabPanels>
</Tabs>
</Provider>
);

waitFor(() => expect(onUnmount).toHaveBeenCalledTimes(1));
});
});
111 changes: 111 additions & 0 deletions packages/components/src/spectrum/TabPanels.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { type Key, useMemo, useRef } from 'react';
import {
createHtmlPortalNode,
type HtmlPortalNode,
InPortal,
OutPortal,
} from 'react-reverse-portal';
import {
Item,
TabPanels,
type SpectrumTabPanelsProps,
} from '@adobe/react-spectrum';
import { type CollectionChildren } from '@react-types/shared';

export interface DHCTabPanelsProps<T> extends SpectrumTabPanelsProps<T> {
/**
* If static panels with keys should stay mounted when not visible.
* This will not apply to dynamic panels created with a render function.
* Defaults to false.
*/
keepMounted?: boolean;
}

/**
* Wrapper for react-spectrum TabPanels that adds support for keeping panels mounted
* when not visible using the `keepMounted` prop.
* Panels created with a render function will not be kept mounted.
*/
export function DHCTabPanels<T extends object>(
props: DHCTabPanelsProps<T>
): JSX.Element {
const { children, keepMounted: keepMountedProp = false, ...rest } = props;
const keepMounted = keepMountedProp && typeof children !== 'function';

const portalNodeMap = useRef(new Map<Key, HtmlPortalNode>());

const portalNodes = useMemo(() => {
const nodes: JSX.Element[] = [];
const nextNodeMap = new Map<Key, HtmlPortalNode>(); // Keep track of the portals we use so we can clean up stale portals
if (!keepMounted) {
Comment thread
mattrunyon marked this conversation as resolved.
portalNodeMap.current = nextNodeMap;
return nodes;
}
React.Children.forEach(children, child => {
// Spectrum would ignore these anyway because it uses Item key to determine if the panel mounts
if (child == null || child.key == null) {
return;
}

let portal = portalNodeMap.current.get(child.key);
if (portal == null) {
portal = createHtmlPortalNode({
attributes: {
// Should make the placeholder div not affect layout and act as if children are mounted directly to the parent
style: 'display: contents',
},
});
}
nextNodeMap.set(child.key, portal);
nodes.push(
<InPortal node={portal} key={child.key}>
{child.props.children}
</InPortal>
);
});

portalNodeMap.current = nextNodeMap;

return nodes;
}, [children, keepMounted]);

const mappedChildren: CollectionChildren<T> = useMemo(() => {
const newChildren: CollectionChildren<T> = [];
if (!keepMounted) {
return newChildren;
}
// Need to use forEach instead of map because map always changes the key of the returned elements
React.Children.forEach(children, child => {
if (child == null || child.key == null) {
newChildren.push(child);
return;
}

const portal = portalNodeMap.current.get(child.key);
if (portal == null) {
newChildren.push(child);
return;
}

newChildren.push(
<Item key={child.key}>
<OutPortal node={portal} />
</Item>
);
});

return newChildren;
}, [children, keepMounted]);

return (
<>
{keepMounted && portalNodes}
<TabPanels
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
>
{keepMounted ? mappedChildren : children}
</TabPanels>
</>
);
}
6 changes: 4 additions & 2 deletions packages/components/src/spectrum/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export {
type SpectrumLinkProps as LinkProps,
TabList,
type SpectrumTabListProps as TabListProps,
TabPanels,
type SpectrumTabPanelsProps as TabPanelsProps,
Tabs,
type SpectrumTabsProps as TabsProps,
} from '@adobe/react-spectrum';
export {
DHCTabPanels as TabPanels,
type DHCTabPanelsProps as TabPanelsProps,
} from './TabPanels';