-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
: implement Ariakit internally
#52133
Changes from all commits
f20e989
8b002ae
3f9b7e0
97a4edc
bbac641
3795477
5ede3dc
1bab116
75f090c
ea1e21e
dac69b7
e7b0aa2
6195305
68fcf7e
b9e019c
37c014b
c89aedd
582afa5
c97cbee
00d2eaa
4b7d7bb
aa1bfc2
defdb27
9731da4
9507f4f
d938bd3
60c70d5
6b98f0a
1782f26
4924d02
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import * as Ariakit from '@ariakit/react'; | ||
import classnames from 'classnames'; | ||
import type { ForwardedRef } from 'react'; | ||
|
||
|
@@ -9,38 +10,30 @@ import type { ForwardedRef } from 'react'; | |
*/ | ||
import { | ||
forwardRef, | ||
useState, | ||
useEffect, | ||
useLayoutEffect, | ||
useCallback, | ||
} from '@wordpress/element'; | ||
import { useInstanceId } from '@wordpress/compose'; | ||
import { useInstanceId, usePrevious } from '@wordpress/compose'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { NavigableMenu } from '../navigable-container'; | ||
|
||
import Button from '../button'; | ||
import type { TabButtonProps, TabPanelProps } from './types'; | ||
import type { TabPanelProps } from './types'; | ||
import type { WordPressComponentProps } from '../ui/context'; | ||
|
||
const TabButton = ( { | ||
tabId, | ||
children, | ||
selected, | ||
...rest | ||
}: TabButtonProps ) => ( | ||
<Button | ||
role="tab" | ||
tabIndex={ selected ? undefined : -1 } | ||
aria-selected={ selected } | ||
id={ tabId } | ||
__experimentalIsFocusable | ||
{ ...rest } | ||
> | ||
{ children } | ||
</Button> | ||
); | ||
// Separate the actual tab name from the instance ID. This is | ||
// necessary because Ariakit internally uses the element ID when | ||
// a new tab is selected, but our implementation looks specifically | ||
// for the tab name to be passed to the `onSelect` callback. | ||
const extractTabName = ( id: string | undefined | null ) => { | ||
if ( typeof id === 'undefined' || id === null ) { | ||
return; | ||
} | ||
return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; | ||
}; | ||
|
||
/** | ||
* TabPanel is an ARIA-compliant tabpanel. | ||
|
@@ -92,112 +85,156 @@ const UnforwardedTabPanel = ( | |
ref: ForwardedRef< any > | ||
) => { | ||
const instanceId = useInstanceId( TabPanel, 'tab-panel' ); | ||
const [ selected, setSelected ] = useState< string >(); | ||
|
||
const handleTabSelection = useCallback( | ||
( tabKey: string ) => { | ||
setSelected( tabKey ); | ||
onSelect?.( tabKey ); | ||
const prependInstanceId = useCallback( | ||
( tabName: string | undefined ) => { | ||
if ( typeof tabName === 'undefined' ) { | ||
return; | ||
} | ||
return `${ instanceId }-${ tabName }`; | ||
}, | ||
[ instanceId ] | ||
); | ||
|
||
const tabStore = Ariakit.useTabStore( { | ||
setSelectedId: ( newTabValue ) => { | ||
if ( typeof newTabValue === 'undefined' || newTabValue === null ) { | ||
return; | ||
} | ||
|
||
const newTab = tabs.find( | ||
( t ) => prependInstanceId( t.name ) === newTabValue | ||
); | ||
if ( newTab?.disabled || newTab === selectedTab ) { | ||
return; | ||
} | ||
|
||
const simplifiedTabName = extractTabName( newTabValue ); | ||
if ( typeof simplifiedTabName === 'undefined' ) { | ||
return; | ||
} | ||
|
||
onSelect?.( simplifiedTabName ); | ||
}, | ||
orientation, | ||
selectOnMove, | ||
defaultSelectedId: prependInstanceId( initialTabName ), | ||
} ); | ||
|
||
const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) ); | ||
|
||
const setTabStoreSelectedId = useCallback( | ||
( tabName: string ) => { | ||
tabStore.setState( 'selectedId', prependInstanceId( tabName ) ); | ||
}, | ||
[ onSelect ] | ||
[ prependInstanceId, tabStore ] | ||
); | ||
|
||
// Simulate a click on the newly focused tab, which causes the component | ||
// to show the `tab-panel` associated with the clicked tab. | ||
const activateTabAutomatically = ( | ||
_childIndex: number, | ||
child: HTMLElement | ||
) => { | ||
child.click(); | ||
}; | ||
const selectedTab = tabs.find( ( { name } ) => name === selected ); | ||
const selectedId = `${ instanceId }-${ selectedTab?.name ?? 'none' }`; | ||
const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); | ||
|
||
const previousSelectedTabName = usePrevious( selectedTabName ); | ||
|
||
// Ensure `onSelect` is called when the initial tab is selected. | ||
useEffect( () => { | ||
if ( | ||
previousSelectedTabName !== selectedTabName && | ||
selectedTabName === initialTabName && | ||
!! selectedTabName | ||
) { | ||
onSelect?.( selectedTabName ); | ||
} | ||
}, [ selectedTabName, initialTabName, onSelect, previousSelectedTabName ] ); | ||
|
||
// Handle selecting the initial tab. | ||
useLayoutEffect( () => { | ||
// If there's a selected tab, don't override it. | ||
if ( selectedTab ) { | ||
return; | ||
} | ||
|
||
const initialTab = tabs.find( ( tab ) => tab.name === initialTabName ); | ||
|
||
// Wait for the denoted initial tab to be declared before making a | ||
// selection. This ensures that if a tab is declared lazily it can | ||
// still receive initial selection. | ||
if ( initialTabName && ! initialTab ) { | ||
return; | ||
} | ||
|
||
if ( initialTab && ! initialTab.disabled ) { | ||
// Select the initial tab if it's not disabled. | ||
handleTabSelection( initialTab.name ); | ||
setTabStoreSelectedId( initialTab.name ); | ||
} else { | ||
// Fallback to the first enabled tab when the initial is disabled. | ||
// Fallback to the first enabled tab when the initial tab is | ||
// disabled or it can't be found. | ||
const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); | ||
if ( firstEnabledTab ) handleTabSelection( firstEnabledTab.name ); | ||
if ( firstEnabledTab ) { | ||
setTabStoreSelectedId( firstEnabledTab.name ); | ||
} | ||
} | ||
}, [ tabs, selectedTab, initialTabName, handleTabSelection ] ); | ||
}, [ | ||
tabs, | ||
selectedTab, | ||
initialTabName, | ||
instanceId, | ||
setTabStoreSelectedId, | ||
] ); | ||
|
||
// Handle the currently selected tab becoming disabled. | ||
useEffect( () => { | ||
// This effect only runs when the selected tab is defined and becomes disabled. | ||
if ( ! selectedTab?.disabled ) { | ||
return; | ||
} | ||
|
||
const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); | ||
|
||
// If the currently selected tab becomes disabled, select the first enabled tab. | ||
// (if there is one). | ||
if ( firstEnabledTab ) { | ||
handleTabSelection( firstEnabledTab.name ); | ||
setTabStoreSelectedId( firstEnabledTab.name ); | ||
} | ||
}, [ tabs, selectedTab?.disabled, handleTabSelection ] ); | ||
|
||
}, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); | ||
return ( | ||
<div className={ className } ref={ ref }> | ||
<NavigableMenu | ||
role="tablist" | ||
orientation={ orientation } | ||
onNavigate={ | ||
selectOnMove ? activateTabAutomatically : undefined | ||
} | ||
<Ariakit.TabList | ||
store={ tabStore } | ||
className="components-tab-panel__tabs" | ||
> | ||
{ tabs.map( ( tab ) => ( | ||
<TabButton | ||
className={ classnames( | ||
'components-tab-panel__tabs-item', | ||
tab.className, | ||
{ | ||
[ activeClass ]: tab.name === selected, | ||
{ tabs.map( ( tab ) => { | ||
return ( | ||
<Ariakit.Tab | ||
key={ tab.name } | ||
id={ prependInstanceId( tab.name ) } | ||
className={ classnames( | ||
'components-tab-panel__tabs-item', | ||
tab.className, | ||
{ | ||
[ activeClass ]: | ||
tab.name === selectedTabName, | ||
} | ||
) } | ||
disabled={ tab.disabled } | ||
aria-controls={ `${ prependInstanceId( | ||
tab.name | ||
) }-view` } | ||
Comment on lines
+213
to
+215
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current |
||
render={ | ||
<Button | ||
icon={ tab.icon } | ||
label={ tab.icon && tab.title } | ||
showTooltip={ !! tab.icon } | ||
/> | ||
} | ||
) } | ||
tabId={ `${ instanceId }-${ tab.name }` } | ||
aria-controls={ `${ instanceId }-${ tab.name }-view` } | ||
selected={ tab.name === selected } | ||
key={ tab.name } | ||
onClick={ () => handleTabSelection( tab.name ) } | ||
disabled={ tab.disabled } | ||
label={ tab.icon && tab.title } | ||
icon={ tab.icon } | ||
showTooltip={ !! tab.icon } | ||
> | ||
{ ! tab.icon && tab.title } | ||
</TabButton> | ||
) ) } | ||
</NavigableMenu> | ||
> | ||
{ ! tab.icon && tab.title } | ||
</Ariakit.Tab> | ||
); | ||
} ) } | ||
</Ariakit.TabList> | ||
{ selectedTab && ( | ||
<div | ||
key={ selectedId } | ||
aria-labelledby={ selectedId } | ||
role="tabpanel" | ||
id={ `${ selectedId }-view` } | ||
className="components-tab-panel__tab-content" | ||
<Ariakit.TabPanel | ||
id={ `${ prependInstanceId( selectedTab.name ) }-view` } | ||
store={ tabStore } | ||
tabId={ prependInstanceId( selectedTab.name ) } | ||
className={ 'components-tab-panel__tab-content' } | ||
> | ||
{ children( selectedTab ) } | ||
</div> | ||
</Ariakit.TabPanel> | ||
) } | ||
</div> | ||
); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just having a look at this PR — is there a reason why we imported
* from Ariakit
instead of importing only the needed components / functions?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, actually. Importing just the needed items would have been better. I'll plan a followup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just in case this is a factor, besides code style/aesthetics, there's no difference between importing the namespace and components individually. In the docs, we sometimes use the namespace import. The reasons are described in this section of the docs: https://ariakit.org/guide/coding-guidelines#import-namespace