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

TabPanel: add tabName prop (controlled component) #46704

Closed
wants to merge 2 commits into from
Closed
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
2 changes: 2 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- `TabPanel`: add tabName prop for tab panel ([#46704](https://github.com/WordPress/gutenberg/pull/46704)).

### Enhancements

- `SearchControl`: polish metrics for `compact` size variant ([#54663](https://github.com/WordPress/gutenberg/pull/54663)).
Expand Down
8 changes: 8 additions & 0 deletions packages/components/src/tab-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ The name of the tab to be selected upon mounting of component. If this prop is n
- Required: No
- Default: none

#### tabName

The name of the tab to be selected.

- Type: `String`
- Required: No
- Default: none

#### selectOnMove

When `true`, the tab will be selected when receiving focus (automatic tab activation). When `false`, the tab will be selected only when clicked (manual tab activation). See the [official W3C docs](https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/) for more info.
Expand Down
21 changes: 15 additions & 6 deletions packages/components/src/tab-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const UnforwardedTabPanel = (
tabs,
selectOnMove = true,
initialTabName,
tabName,
orientation = 'horizontal',
activeClass = 'is-active',
onSelect,
Expand All @@ -87,11 +88,11 @@ const UnforwardedTabPanel = (
const instanceId = useInstanceId( TabPanel, 'tab-panel' );

const prependInstanceId = useCallback(
( tabName: string | undefined ) => {
if ( typeof tabName === 'undefined' ) {
( tab: string | undefined ) => {
if ( typeof tab === 'undefined' ) {
return;
}
return `${ instanceId }-${ tabName }`;
return `${ instanceId }-${ tab }`;
},
[ instanceId ]
);
Expand All @@ -118,14 +119,14 @@ const UnforwardedTabPanel = (
},
orientation,
selectOnMove,
defaultSelectedId: prependInstanceId( initialTabName ),
defaultSelectedId: prependInstanceId( tabName || initialTabName ),
} );

const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) );

const setTabStoreSelectedId = useCallback(
( tabName: string ) => {
tabStore.setState( 'selectedId', prependInstanceId( tabName ) );
( tab: string ) => {
tabStore.setState( 'selectedId', prependInstanceId( tab ) );
},
[ prependInstanceId, tabStore ]
);
Expand All @@ -145,6 +146,13 @@ const UnforwardedTabPanel = (
}
}, [ selectedTabName, initialTabName, onSelect, previousSelectedTabName ] );

// handle selection of tabName
useEffect( () => {
if ( tabName ) {
setTabStoreSelectedId( tabName );
}
}, [ tabName, setTabStoreSelectedId ] );

// Handle selecting the initial tab.
useLayoutEffect( () => {
// If there's a selected tab, don't override it.
Expand Down Expand Up @@ -190,6 +198,7 @@ const UnforwardedTabPanel = (
setTabStoreSelectedId( firstEnabledTab.name );
}
}, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] );

return (
<div className={ className } ref={ ref }>
<Ariakit.TabList
Expand Down
20 changes: 20 additions & 0 deletions packages/components/src/tab-panel/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ Default.args = {
],
};

export const SelectedTab = Template.bind( {} );
SelectedTab.args = {
children: ( tab ) => <p>Selected tab: { tab.title }</p>,
tabs: [
{
name: 'tab1',
title: 'Tab 1',
},
{
name: 'tab2',
title: 'Tab 2',
},
{
name: 'tab3',
title: 'Tab 3',
},
],
tabName: 'tab2',
};

export const DisabledTab = Template.bind( {} );
DisabledTab.args = {
children: ( tab ) => <p>Selected tab: { tab.title }</p>,
Expand Down
126 changes: 120 additions & 6 deletions packages/components/src/tab-panel/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import userEvent from '@testing-library/user-event';
* WordPress dependencies
*/
import { wordpress, category, media } from '@wordpress/icons';
import { useState, useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import TabPanel from '..';
import cleanupTooltip from '../../tooltip/test/utils';
import type { TabPanelProps } from '../types';

const TABS = [
{
Expand All @@ -33,16 +35,44 @@ const TABS = [
},
];

const UncontrolledTabPanel = ( { tabName, ...props }: TabPanelProps ) => {
return <TabPanel { ...props } />;
};

const ControlledTabPanel = ( {
tabName,
onSelect,
...props
}: TabPanelProps ) => {
const [ value, setValue ] = useState( tabName );
const handleOnSelect: TabPanelProps[ 'onSelect' ] = ( newValue ) => {
setValue( newValue );
onSelect?.( newValue );
};

useEffect( () => {
setValue( tabName );
}, [ tabName ] );

return (
<>
<TabPanel
{ ...props }
tabName={ value }
onSelect={ handleOnSelect }
/>
</>
);
};

const getSelectedTab = async () =>
await screen.findByRole( 'tab', { selected: true } );

let originalGetClientRects: () => DOMRectList;

describe.each( [
[ 'uncontrolled', TabPanel ],
// The controlled component tests will be added once we certify the
// uncontrolled component's behaviour on trunk.
// [ 'controlled', TabPanel ],
[ 'uncontrolled', UncontrolledTabPanel ],
[ 'controlled', ControlledTabPanel ],
] )( 'TabPanel %s', ( ...modeAndComponent ) => {
const [ , Component ] = modeAndComponent;

Expand Down Expand Up @@ -384,6 +414,85 @@ describe.each( [
} );
} );

describe( 'With `tabName`', () => {
it( 'should render the tab set by tabName prop', async () => {
render(
<Component
tabName="beta"
tabs={ TABS }
children={ () => undefined }
/>
);

let expectedTab = 'Alpha';
if ( Component === ControlledTabPanel ) {
expectedTab = 'Beta';
}
expect( await getSelectedTab() ).toHaveTextContent( expectedTab );
} );

it( 'should render the tab set by tabName prop when tabName and initialTabName are set', async () => {
render(
<Component
initialTabName="gamma"
tabName="beta"
tabs={ TABS }
children={ () => undefined }
/>
);

let expectedTab = 'Gamma';
if ( Component === ControlledTabPanel ) {
expectedTab = 'Beta';
}
expect( await getSelectedTab() ).toHaveTextContent( expectedTab );
} );

it( 'should not select a tab when `tabName` does not match any known tab', () => {
render(
<Component
initialTabName="does-not-exist"
tabName="does-not-exist"
tabs={ TABS }
children={ () => undefined }
/>
);

// No tab should be selected i.e. it doesn't fall back to first tab.
expect(
screen.queryByRole( 'tab', { selected: true } )
).not.toBeInTheDocument();

// No tabpanel should be rendered either
expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument();
} );

it( 'should change tabs when tabName is changed', async () => {
const mockOnSelect = jest.fn();

const { rerender } = render(
<Component
tabName="beta"
tabs={ TABS }
onSelect={ mockOnSelect }
children={ () => undefined }
/>
);

rerender(
<Component
tabName="alpha"
tabs={ TABS }
onSelect={ mockOnSelect }
children={ () => undefined }
/>
);

expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( 'Disabled Tab', () => {
it( 'should disable the tab when `disabled` is `true`', async () => {
const user = userEvent.setup();
Expand Down Expand Up @@ -453,7 +562,7 @@ describe.each( [
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
} );

it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', async () => {
it( 'should select first enabled tab when the tab associated to `initialTabName` / `tabName` is disabled', async () => {
const mockOnSelect = jest.fn();

const { rerender } = render(
Expand All @@ -465,6 +574,7 @@ describe.each( [
return { ...tab, disabled: true };
} ) }
initialTabName="beta"
tabName="beta"
children={ () => undefined }
onSelect={ mockOnSelect }
/>
Expand All @@ -479,6 +589,7 @@ describe.each( [
<Component
tabs={ TABS }
initialTabName="beta"
tabName="beta"
children={ () => undefined }
onSelect={ mockOnSelect }
/>
Expand Down Expand Up @@ -533,12 +644,13 @@ describe.each( [
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
} );

it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', async () => {
it( 'should select the first enabled tab when the tab associated to `initialTabName` / `tabName` becomes disabled while being the active tab', async () => {
const mockOnSelect = jest.fn();

const { rerender } = render(
<Component
initialTabName="gamma"
tabName="gamma"
tabs={ TABS }
children={ () => undefined }
onSelect={ mockOnSelect }
Expand All @@ -552,6 +664,7 @@ describe.each( [
rerender(
<Component
initialTabName="gamma"
tabName="gamma"
tabs={ [
TABS[ 0 ],
TABS[ 1 ],
Expand All @@ -569,6 +682,7 @@ describe.each( [
rerender(
<Component
initialTabName="gamma"
tabName="gamma"
tabs={ TABS }
children={ () => undefined }
onSelect={ mockOnSelect }
Expand Down
4 changes: 4 additions & 0 deletions packages/components/src/tab-panel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export type TabPanelProps = {
* If this prop is not set, the first tab will be selected by default.
*/
initialTabName?: string;
/**
* The name of the tab to be selected.
*/
tabName?: string;
/**
* The function called when a tab has been selected.
* It is passed the `tabName` as an argument.
Expand Down