diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 0cdcb59841f40f..d0889b460dbd05 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -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)). diff --git a/packages/components/src/tab-panel/README.md b/packages/components/src/tab-panel/README.md index 67b00c37679eca..bde377cdd26092 100644 --- a/packages/components/src/tab-panel/README.md +++ b/packages/components/src/tab-panel/README.md @@ -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. diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index 4fff7dc306b023..431ae52ce3e9ad 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -78,6 +78,7 @@ const UnforwardedTabPanel = ( tabs, selectOnMove = true, initialTabName, + tabName, orientation = 'horizontal', activeClass = 'is-active', onSelect, @@ -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 ] ); @@ -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 ] ); @@ -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. @@ -190,6 +198,7 @@ const UnforwardedTabPanel = ( setTabStoreSelectedId( firstEnabledTab.name ); } }, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); + return (

Selected tab: { tab.title }

, + 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 ) =>

Selected tab: { tab.title }

, diff --git a/packages/components/src/tab-panel/test/index.tsx b/packages/components/src/tab-panel/test/index.tsx index c07685935ce162..eb0d5f411f27e6 100644 --- a/packages/components/src/tab-panel/test/index.tsx +++ b/packages/components/src/tab-panel/test/index.tsx @@ -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 = [ { @@ -33,16 +35,44 @@ const TABS = [ }, ]; +const UncontrolledTabPanel = ( { tabName, ...props }: TabPanelProps ) => { + return ; +}; + +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 ( + <> + + + ); +}; + 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; @@ -384,6 +414,85 @@ describe.each( [ } ); } ); + describe( 'With `tabName`', () => { + it( 'should render the tab set by tabName prop', async () => { + render( + 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( + 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( + 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( + undefined } + /> + ); + + rerender( + 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(); @@ -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( @@ -465,6 +574,7 @@ describe.each( [ return { ...tab, disabled: true }; } ) } initialTabName="beta" + tabName="beta" children={ () => undefined } onSelect={ mockOnSelect } /> @@ -479,6 +589,7 @@ describe.each( [ undefined } onSelect={ mockOnSelect } /> @@ -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( undefined } onSelect={ mockOnSelect } @@ -552,6 +664,7 @@ describe.each( [ rerender( undefined } onSelect={ mockOnSelect } diff --git a/packages/components/src/tab-panel/types.ts b/packages/components/src/tab-panel/types.ts index 1f4dc7c677483a..b201fb89d8a2d5 100644 --- a/packages/components/src/tab-panel/types.ts +++ b/packages/components/src/tab-panel/types.ts @@ -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.