Skip to content
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

feat: inactive tab persistence for Tabs #2975

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
47 changes: 47 additions & 0 deletions pages/tabs/inactive-tab-persistence.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import { Button } from '~components';
import Tabs from '~components/tabs';

import { IframeWrapper } from '../utils/iframe-wrapper';

function Counter() {
const [count, setCount] = useState(0);

return <Button onClick={() => setCount(count + 1)}>This button has been clicked {count} times</Button>;
}

export default function InactiveTabPersistenceDemoPage() {
return (
<div id="test" style={{ padding: 10 }}>
<h1>Tabs inactive tab persistence test page</h1>
<h2>Tabs with persistence</h2>
<Tabs tabs={tabsWithPersistence} preserveInactiveTabs={true} />
<hr />
<h2>Tabs without persistence</h2>
<Tabs tabs={tabsWithoutPersistence} preserveInactiveTabs={false} />
</div>
);
}

const tabsWithPersistence = createTestTabs('iframe-with-persist');
const tabsWithoutPersistence = createTestTabs('iframe-without-persist');

function createTestTabs(iframeId: string) {
return [
{
id: 'first',
label: 'Tab one',
content: <span>This tab contains static content.</span>,
},
{
id: 'second',
label: 'Tab two',
content: <IframeWrapper id={iframeId} AppComponent={() => <Counter />} />,
},
];
}
126 changes: 124 additions & 2 deletions src/tabs/__tests__/tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import React, { useEffect, useState } from 'react';
import { act, fireEvent, render } from '@testing-library/react';

import { KeyCode } from '@cloudscape-design/test-utils-core/utils';

Expand Down Expand Up @@ -1229,4 +1229,126 @@ describe('Tabs', () => {
verifyTabContentLabelledBy(secondTabId);
});
});

describe('Optional inactive tab persistence', () => {
/**
* Finds all tab content and returns them.
*/
function findTabContents(wrapper: TabsWrapper): ElementWrapper<HTMLDivElement>[] {
return wrapper.findAll(`.${styles['tabs-content']}`);
}

/**
* Finds the tab content by using the tab index
* @param index 1-based index of the tab content element to return
*/
function findContentByTabIndex(wrapper: TabsWrapper, index: number): ElementWrapper | null {
return wrapper.find(`.${styles['tabs-content']}:nth-child(${index})`);
}

it('only renders content of active tab by default', () => {
const { wrapper } = renderTabs(<Tabs tabs={defaultTabs} activeTabId="first" onChange={() => void 0} />);

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('First content');

wrapper
.findTabContents()
.slice(1)
.forEach(tab => {
expect(tab.getElement()!).toBeEmptyDOMElement();
});
});

it('renders all tab contents given truthy `preserveInactiveTabs` flag', () => {
const { wrapper } = renderTabs(
<Tabs tabs={defaultTabs} activeTabId="first" preserveInactiveTabs={true} onChange={() => void 0} />
);

const tabContents = findTabContents(wrapper);

expect(tabContents[0].getElement()!).toHaveTextContent('First content');
expect(tabContents[1].getElement()!).toHaveTextContent('Second content');
});

it('hides inactive tabs given truthy `preserveInactiveTabs` flag', () => {
const { wrapper } = renderTabs(
<Tabs tabs={defaultTabs} activeTabId="first" preserveInactiveTabs={true} onChange={() => void 0} />
);

const tabContents = findTabContents(wrapper);
expect(wrapper.findTabContent()!.getElement()!).toBeVisible();

tabContents.slice(1).forEach(tab => {
expect(tab.getElement()!).not.toHaveClass(styles['tabs-content-active']);
expect(tab.getElement()!).not.toBeVisible();
});
});

function TimedMessage({ message }: { message: string }) {
const [loading, setLoading] = useState(true);

useEffect(() => {
setTimeout(() => setLoading(false), 4000);
}, []);

return <>{loading ? 'Loading...' : message}</>;
}

const statefulTabs: Array<TabsProps.Tab> = [
{
id: 'first',
label: 'First tab',
content: <TimedMessage message="First content" />,
href: '#first',
},
{
id: 'second',
label: 'Second tab',
content: 'Second content',
href: '#second',
},
];

it('preserves deselected tab DOM state given truthy `preserveInactiveTabs` flag', () => {
jest.useFakeTimers();

const { wrapper, rerender } = renderTabs(
<Tabs tabs={statefulTabs} activeTabId="first" preserveInactiveTabs={true} onChange={() => void 0} />
);

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('Loading...');

act(() => {
jest.advanceTimersByTime(4000);
});

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('First content');

rerender(<Tabs tabs={statefulTabs} activeTabId="second" preserveInactiveTabs={true} onChange={() => void 0} />);

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('Second content');
expect(findContentByTabIndex(wrapper, 1)!.getElement()).toHaveTextContent('First content');
});

it('does not preserve deselected tab DOM state by default', () => {
jest.useFakeTimers();

const { wrapper, rerender } = renderTabs(
<Tabs tabs={statefulTabs} activeTabId="first" preserveInactiveTabs={false} onChange={() => void 0} />
);

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('Loading...');

act(() => {
jest.advanceTimersByTime(4000);
});

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('First content');

rerender(<Tabs tabs={statefulTabs} activeTabId="second" preserveInactiveTabs={false} onChange={() => void 0} />);
rerender(<Tabs tabs={statefulTabs} activeTabId="first" preserveInactiveTabs={false} onChange={() => void 0} />);

expect(wrapper.findTabContent()!.getElement()).toHaveTextContent('Loading...');
});
});
});
10 changes: 8 additions & 2 deletions src/tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default function Tabs({
disableContentPaddings = false,
i18nStrings,
fitHeight,
preserveInactiveTabs = false,
...rest
}: TabsProps) {
for (const tab of tabs) {
Expand Down Expand Up @@ -86,17 +87,22 @@ export default function Tabs({
[styles['tabs-content-active']]: isTabSelected,
});

const isContentShown = isTabSelected && !selectedTab.disabled;
const isContentPersisted = preserveInactiveTabs || isContentShown;

const contentAttributes: JSX.IntrinsicElements['div'] = {
className: classes,
role: 'tabpanel',
id: `${idNamespace}-${tab.id}-panel`,
key: `${idNamespace}-${tab.id}-panel`,
tabIndex: 0,
'aria-labelledby': getTabElementId({ namespace: idNamespace, tabId: tab.id }),
style: {
display: isTabSelected ? 'block' : 'none',
},
};

const isContentShown = isTabSelected && !selectedTab.disabled;
return <div {...contentAttributes}>{isContentShown && selectedTab.content}</div>;
return <div {...contentAttributes}>{isContentPersisted && tab.content}</div>;
};

return (
Expand Down
6 changes: 4 additions & 2 deletions src/tabs/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ export interface TabsProps extends BaseComponentProps {
*/
i18nStrings?: TabsProps.I18nStrings;
/**
* Enabling this property makes the tab content fit to the available height.
* If the tab content is too short, it will stretch. If the tab content is too long, a vertical scrollbar will be shown.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've removed the description for fitHeight

* Preserve tabs in DOM tree when they're not active
* Might be useful to keep the state of individual tabs between openings
* By default, inactive tabs are removed from the DOM
*/
preserveInactiveTabs?: boolean;
fitHeight?: boolean;
}
export namespace TabsProps {
Expand Down
Loading