Skip to content

Commit

Permalink
forceRenderAllTabPanels prop
Browse files Browse the repository at this point in the history
  • Loading branch information
stephl3 committed May 28, 2024
1 parent c0b6b76 commit f93c7c4
Show file tree
Hide file tree
Showing 12 changed files with 149 additions and 35 deletions.
6 changes: 4 additions & 2 deletions packages/tabs/src/Tab/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div {...rest} role="tabpanel">
{selected ? children : null}
{children}
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/tabs/src/Tab/Tab.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions packages/tabs/src/TabPanel/TabPanel.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { css } from '@leafygreen-ui/emotion';

export const hiddenTabPanelStyle = css`
display: none;
`;
12 changes: 10 additions & 2 deletions packages/tabs/src/TabPanel/TabPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
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();

const relatedTab = useMemo(() => {
return tabDescendants.find(tabDescendant => tabDescendant.index === index);
}, [tabDescendants, index]);

const selected = index === selectedIndex;

return (
<div ref={ref}>
{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,
})}
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/tabs/src/TabPanel/TabPanel.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface TabPanelProps {
child: React.ReactElement;
forceRender: boolean;
selectedIndex: number;
}
29 changes: 29 additions & 0 deletions packages/tabs/src/Tabs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/tabs/src/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const LiveExample: StoryFn<TabsProps> = ({
max-width: 66vw;
`}
aria-label="Tabs to demonstrate usage of Leafygreen UI Tab Components"
forceRenderAllTabPanels={true}
{...props}
/>
</LeafyGreenProvider>
Expand Down
9 changes: 8 additions & 1 deletion packages/tabs/src/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -155,7 +156,13 @@ const Tabs = (props: AccessibleTabsProps) => {
return child;
}

return <TabPanel child={child} selectedIndex={selected} />;
return (
<TabPanel
child={child}
selectedIndex={selected}
forceRender={forceRenderAllTabPanels}
/>
);
});

return (
Expand Down
59 changes: 34 additions & 25 deletions packages/tabs/src/Tabs/Tabs.types.ts
Original file line number Diff line number Diff line change
@@ -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 `<Tab />` components.
* Accessible label that describes the set of tabs
*/
children: React.ReactNode;
['aria-label']?: string;

/**
* Content that will appear inline after the `<Tab />` 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<number>;
as?: React.ElementType<any>;

/**
* 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 `<Tab />` 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<any>;
forceRenderAllTabPanels?: boolean;

/**
* Accessible label that describes the set of tabs
* Content that will appear inline after the `<Tab />` 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<number>;
}

type AriaLabels = 'aria-label' | 'aria-labelledby';
Expand Down
10 changes: 10 additions & 0 deletions packages/tabs/src/utils/getTestUtils/getTestUtils.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
41 changes: 36 additions & 5 deletions packages/tabs/src/utils/getTestUtils/getTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement> => {
Expand Down Expand Up @@ -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<HTMLElement>(
const getAllTabPanelsInDOM = () => {
const tabPanels = queryBySelector<HTMLElement>(
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<HTMLElement>('[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(),
};
};
5 changes: 5 additions & 0 deletions packages/tabs/src/utils/getTestUtils/getTestUtils.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>;

/**
* Returns selected tab panel or null if a selected tab panel is not found
*/
Expand Down

0 comments on commit f93c7c4

Please sign in to comment.