diff --git a/packages/tabs/src/Tab/Tab.tsx b/packages/tabs/src/Tab/Tab.tsx index 5409e388c5..2bac929073 100644 --- a/packages/tabs/src/Tab/Tab.tsx +++ b/packages/tabs/src/Tab/Tab.tsx @@ -20,14 +20,16 @@ import { TabProps } from './Tab.types'; * @param props.to Destination when name is rendered as `Link` tag. * */ -function Tab({ selected, children, ...rest }: TabProps) { +function Tab({ children, _shouldRender, ...rest }: TabProps) { // default and name are not an HTML properties // onClick applies to TabTitle component, not Tab component delete rest.default, delete rest.name, delete rest.onClick, delete rest.href; + if (!_shouldRender) return null; + return (
- {selected ? children : null} + {children}
); } diff --git a/packages/tabs/src/Tab/Tab.types.ts b/packages/tabs/src/Tab/Tab.types.ts index c5976d011e..fcdf25dbf8 100644 --- a/packages/tabs/src/Tab/Tab.types.ts +++ b/packages/tabs/src/Tab/Tab.types.ts @@ -42,6 +42,12 @@ export interface TabProps extends HTMLElementProps<'div'> { */ selected?: boolean; + /** + * Boolean that determines if tab panel should render in DOM + * @private + */ + _shouldRender?: boolean; + // Done in order to support any Router system, such that TabTitle component can accept any URL destination prop. [key: string]: any; } diff --git a/packages/tabs/src/TabPanel/TabPanel.styles.ts b/packages/tabs/src/TabPanel/TabPanel.styles.ts new file mode 100644 index 0000000000..60beae1802 --- /dev/null +++ b/packages/tabs/src/TabPanel/TabPanel.styles.ts @@ -0,0 +1,5 @@ +import { css } from '@leafygreen-ui/emotion'; + +export const hiddenTabPanelStyle = css` + display: none; +`; diff --git a/packages/tabs/src/TabPanel/TabPanel.tsx b/packages/tabs/src/TabPanel/TabPanel.tsx index af43582cae..3cae8a53f0 100644 --- a/packages/tabs/src/TabPanel/TabPanel.tsx +++ b/packages/tabs/src/TabPanel/TabPanel.tsx @@ -1,15 +1,17 @@ import React, { useMemo } from 'react'; import { useDescendant } from '@leafygreen-ui/descendants'; +import { cx } from '@leafygreen-ui/emotion'; import { TabPanelDescendantsContext, useTabDescendantsContext, } from '../context'; +import { hiddenTabPanelStyle } from './TabPanel.styles'; import { TabPanelProps } from './TabPanel.types'; -const TabPanel = ({ child, selectedIndex }: TabPanelProps) => { +const TabPanel = ({ child, forceRender, selectedIndex }: TabPanelProps) => { const { id, index, ref } = useDescendant(TabPanelDescendantsContext); const { tabDescendants } = useTabDescendantsContext(); @@ -17,11 +19,17 @@ const TabPanel = ({ child, selectedIndex }: TabPanelProps) => { return tabDescendants.find(tabDescendant => tabDescendant.index === index); }, [tabDescendants, index]); + const selected = index === selectedIndex; + return (
{React.cloneElement(child, { id, - selected: !child.props.disabled && index === selectedIndex, + selected, + _shouldRender: !child.props.disabled && (forceRender || selected), + className: cx({ + [hiddenTabPanelStyle]: !selected, + }), ['aria-labelledby']: relatedTab?.id, })}
diff --git a/packages/tabs/src/TabPanel/TabPanel.types.ts b/packages/tabs/src/TabPanel/TabPanel.types.ts index 1ccbaaa0ae..bfaa402028 100644 --- a/packages/tabs/src/TabPanel/TabPanel.types.ts +++ b/packages/tabs/src/TabPanel/TabPanel.types.ts @@ -1,4 +1,5 @@ export interface TabPanelProps { child: React.ReactElement; + forceRender: boolean; selectedIndex: number; } diff --git a/packages/tabs/src/Tabs.spec.tsx b/packages/tabs/src/Tabs.spec.tsx index 90a911a09c..ad66316790 100644 --- a/packages/tabs/src/Tabs.spec.tsx +++ b/packages/tabs/src/Tabs.spec.tsx @@ -74,6 +74,35 @@ describe('packages/tabs', () => { expect(getByTestId('inline-children')).toBeInTheDocument(); }); + + describe('forceRenderAllTabPanels', () => { + test('renders only selected panel when prop is false', () => { + const { getAllTabsInTabList, getAllTabPanelsInDOM } = renderTabs({ + forceRenderAllTabPanels: false, + }); + const tabs = getAllTabsInTabList(); + const tabPanels = getAllTabPanelsInDOM(); + expect(tabs.length).not.toEqual(tabPanels.length); + }); + + test('renders all tab panels in DOM but only selected panel is visible when prop is true', () => { + const { getAllTabsInTabList, getAllTabPanelsInDOM, getSelectedPanel } = + renderTabs({ + forceRenderAllTabPanels: true, + }); + const tabs = getAllTabsInTabList(); + const tabPanels = getAllTabPanelsInDOM(); + expect(tabs.length).toEqual(tabPanels.length); + + const selectedPanel = getSelectedPanel(); + const hiddenPanels = tabPanels.filter( + panel => panel.id !== selectedPanel?.id, + ); + + expect(selectedPanel).toBeVisible(); + hiddenPanels.forEach(panel => expect(panel).not.toBeVisible()); + }); + }); }); describe('when controlled', () => { diff --git a/packages/tabs/src/Tabs.stories.tsx b/packages/tabs/src/Tabs.stories.tsx index 5ddd9ddc11..795ed246ec 100644 --- a/packages/tabs/src/Tabs.stories.tsx +++ b/packages/tabs/src/Tabs.stories.tsx @@ -105,6 +105,7 @@ export const LiveExample: StoryFn = ({ max-width: 66vw; `} aria-label="Tabs to demonstrate usage of Leafygreen UI Tab Components" + forceRenderAllTabPanels={true} {...props} /> diff --git a/packages/tabs/src/Tabs/Tabs.tsx b/packages/tabs/src/Tabs/Tabs.tsx index 9ecdf30d44..67f5a3c1fc 100644 --- a/packages/tabs/src/Tabs/Tabs.tsx +++ b/packages/tabs/src/Tabs/Tabs.tsx @@ -60,6 +60,7 @@ const Tabs = (props: AccessibleTabsProps) => { inlineChildren, selected: controlledSelected, setSelected: setControlledSelected, + forceRenderAllTabPanels = false, 'data-lgid': dataLgId = LGIDS_TABS.root, 'aria-labelledby': ariaLabelledby, 'aria-label': ariaLabel, @@ -155,7 +156,13 @@ const Tabs = (props: AccessibleTabsProps) => { return child; } - return ; + return ( + + ); }); return ( diff --git a/packages/tabs/src/Tabs/Tabs.types.ts b/packages/tabs/src/Tabs/Tabs.types.ts index 507d2b8239..b8e023756b 100644 --- a/packages/tabs/src/Tabs/Tabs.types.ts +++ b/packages/tabs/src/Tabs/Tabs.types.ts @@ -1,57 +1,66 @@ -import { Either, HTMLElementProps, LgIdProps } from '@leafygreen-ui/lib'; +import { + DarkModeProps, + Either, + HTMLElementProps, + LgIdProps, +} from '@leafygreen-ui/lib'; import { BaseFontSize } from '@leafygreen-ui/tokens'; -export interface TabsProps extends HTMLElementProps<'div'>, LgIdProps { +export interface TabsProps + extends HTMLElementProps<'div'>, + LgIdProps, + DarkModeProps { /** - * Content that will appear inside of Tabs component. - * Should be comprised of at least two `` components. + * Accessible label that describes the set of tabs */ - children: React.ReactNode; + ['aria-label']?: string; /** - * Content that will appear inline after the `` components. `inlineChildren` are wrapped in a flexbox container. + * References id of label external to the component that describes the set of tabs */ - inlineChildren?: React.ReactNode; + ['aria-labelledby']?: string; /** - * Callback to be executed when Tab is selected. Receives index of activated Tab as the first argument. + * HTML Element that wraps title in Tab List. * - * @type (index: number) => void + * @type HTMLElement | React.Component */ - setSelected?: React.Dispatch; + as?: React.ElementType; /** - * Index of the Tab that should appear active. If value passed to selected prop, component will be controlled by consumer. + * The base font size of the title and text rendered in children. */ - selected?: number; + baseFontSize?: BaseFontSize; /** - * determines if component will appear for Dark Mode - * @default false + * Content that will appear inside of Tabs component. + * Should be comprised of at least two `` components. */ - darkMode?: boolean; + children: React.ReactNode; /** - * HTML Element that wraps title in Tab List. - * - * @type HTMLElement | React.Component + * When set to true, all tab panels will forcibly render in DOM. + * Tab panels that are not selected will be hidden with `display: none;` + * This will not apply for disabled tabs */ - as?: React.ElementType; + forceRenderAllTabPanels?: boolean; /** - * Accessible label that describes the set of tabs + * Content that will appear inline after the `` components. `inlineChildren` are wrapped in a flexbox container. */ - ['aria-label']?: string; + inlineChildren?: React.ReactNode; /** - * References id of label external to the component that describes the set of tabs + * Index of the Tab that should appear active. If value passed to selected prop, component will be controlled by consumer. */ - ['aria-labelledby']?: string; + selected?: number; /** - * The base font size of the title and text rendered in children. + * Callback to be executed when Tab is selected. Receives index of activated Tab as the first argument. + * + * @type (index: number) => void */ - baseFontSize?: BaseFontSize; + setSelected?: React.Dispatch; } type AriaLabels = 'aria-label' | 'aria-labelledby'; diff --git a/packages/tabs/src/utils/getTestUtils/getTestUtils.spec.tsx b/packages/tabs/src/utils/getTestUtils/getTestUtils.spec.tsx index c9f50d3a25..ce43ec3251 100644 --- a/packages/tabs/src/utils/getTestUtils/getTestUtils.spec.tsx +++ b/packages/tabs/src/utils/getTestUtils/getTestUtils.spec.tsx @@ -120,6 +120,16 @@ describe('packages/tabs/getTestUtils', () => { }); }); + describe('getAllTabPanelsInDOM', () => { + test('returns all tab panels in DOM', () => { + renderTabs(); + const { getAllTabPanelsInDOM } = getTestUtils(); + const allTabPanels = getAllTabPanelsInDOM(); + + expect(allTabPanels).toHaveLength(1); + }); + }); + describe('getSelectedPanel', () => { test('is in the document', () => { renderTabs(); diff --git a/packages/tabs/src/utils/getTestUtils/getTestUtils.ts b/packages/tabs/src/utils/getTestUtils/getTestUtils.ts index d9d964a674..80543f951b 100644 --- a/packages/tabs/src/utils/getTestUtils/getTestUtils.ts +++ b/packages/tabs/src/utils/getTestUtils/getTestUtils.ts @@ -15,7 +15,6 @@ export const getTestUtils = ( /** * Queries the `element` for the tab list element. Will throw if no element is found. - * * Then, finds and returns all elements with role=tab. Will throw if no tabs are found. */ const getAllTabsInTabList = (): Array => { @@ -56,18 +55,50 @@ export const getTestUtils = ( }; /** - * Queries the `element` for the selected panel. Returns null if selected panel is not found. + * Queries the `element` for the tab panels element. Will throw if no element is found. + * Then, finds and returns all elements with role=tabpanel. Will throw if no tab panels are found. */ - const getSelectedPanel = () => { - return queryBySelector( + const getAllTabPanelsInDOM = () => { + const tabPanels = queryBySelector( element, - '[role="tabpanel"]:not(:empty)', + `[data-lgid=${LGIDS_TABS.tabPanels}]`, + ); + + if (!tabPanels) { + throw new Error('Unable to find tab panels container'); + } + + const allTabPanels = + tabPanels.querySelectorAll('[role="tabpanel"]'); + + if (allTabPanels.length === 0) { + throw new Error( + 'Unable to find any tabpanel elements in tab panels container', + ); + } + + return Array.from(allTabPanels); + }; + + /** + * Gets all tab panels and filters for the displayed tab panel. Returns null if selected panel is not found. + */ + const getSelectedPanel = () => { + const allTabPanels = getAllTabPanelsInDOM(); + + const visibleTabPanel = allTabPanels.find( + tabPanel => getComputedStyle(tabPanel).display !== 'none', ); + + if (!visibleTabPanel) return null; + + return visibleTabPanel; }; return { getAllTabsInTabList: () => getAllTabsInTabList(), getTabUtilsByName: (name: string) => getTabUtilsByName(name), + getAllTabPanelsInDOM: () => getAllTabPanelsInDOM(), getSelectedPanel: () => getSelectedPanel(), }; }; diff --git a/packages/tabs/src/utils/getTestUtils/getTestUtils.types.ts b/packages/tabs/src/utils/getTestUtils/getTestUtils.types.ts index 9ac451deaa..522be24fc3 100644 --- a/packages/tabs/src/utils/getTestUtils/getTestUtils.types.ts +++ b/packages/tabs/src/utils/getTestUtils/getTestUtils.types.ts @@ -26,6 +26,11 @@ export interface TestUtilsReturnType { */ getTabUtilsByName: (name: string) => TabUtils | null; + /** + * Returns an array of tab panels in the tab panels container. + */ + getAllTabPanelsInDOM: () => Array; + /** * Returns selected tab panel or null if a selected tab panel is not found */