-
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
Tabs: remove custom logic #66097
Tabs: remove custom logic #66097
Changes from all commits
d78b63d
1310753
d24002f
8bda7cd
259aeb4
24b0af9
abd08a4
98c9b52
cea4521
bba41b0
8fd95ca
149121f
7ec32f8
31522fa
30def74
c6dfd61
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 |
---|---|---|
|
@@ -2,18 +2,12 @@ | |
* External dependencies | ||
*/ | ||
import * as Ariakit from '@ariakit/react'; | ||
import { useStoreState } from '@ariakit/react'; | ||
|
||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { useInstanceId } from '@wordpress/compose'; | ||
import { | ||
useEffect, | ||
useLayoutEffect, | ||
useMemo, | ||
useRef, | ||
} from '@wordpress/element'; | ||
import { useEffect, useMemo } from '@wordpress/element'; | ||
import { isRTL } from '@wordpress/i18n'; | ||
|
||
/** | ||
|
@@ -25,6 +19,22 @@ import { Tab } from './tab'; | |
import { TabList } from './tablist'; | ||
import { TabPanel } from './tabpanel'; | ||
|
||
function externalToInternalTabId( | ||
externalId: string | undefined | null, | ||
instanceId: string | ||
) { | ||
return externalId && `${ instanceId }-${ externalId }`; | ||
} | ||
|
||
function internalToExternalTabId( | ||
internalId: string | undefined | null, | ||
instanceId: string | ||
) { | ||
return typeof internalId === 'string' | ||
? internalId.replace( `${ instanceId }-`, '' ) | ||
: internalId; | ||
} | ||
|
||
/** | ||
* Display one panel of content at a time with a tabbed interface, based on the | ||
* WAI-ARIA Tabs Pattern. | ||
|
@@ -40,147 +50,41 @@ export const Tabs = Object.assign( | |
onSelect, | ||
children, | ||
selectedTabId, | ||
activeTabId, | ||
defaultActiveTabId, | ||
onActiveTabIdChange, | ||
}: TabsProps ) { | ||
const instanceId = useInstanceId( Tabs, 'tabs' ); | ||
const store = Ariakit.useTabStore( { | ||
selectOnMove, | ||
orientation, | ||
defaultSelectedId: | ||
defaultTabId && `${ instanceId }-${ defaultTabId }`, | ||
setSelectedId: ( selectedId ) => { | ||
const strippedDownId = | ||
typeof selectedId === 'string' | ||
? selectedId.replace( `${ instanceId }-`, '' ) | ||
: selectedId; | ||
onSelect?.( strippedDownId ); | ||
defaultSelectedId: externalToInternalTabId( | ||
defaultTabId, | ||
instanceId | ||
), | ||
setSelectedId: ( newSelectedId ) => { | ||
onSelect?.( | ||
internalToExternalTabId( newSelectedId, instanceId ) | ||
); | ||
}, | ||
selectedId: externalToInternalTabId( selectedTabId, instanceId ), | ||
defaultActiveId: externalToInternalTabId( | ||
defaultActiveTabId, | ||
instanceId | ||
), | ||
setActiveId: ( newActiveId ) => { | ||
onActiveTabIdChange?.( | ||
internalToExternalTabId( newActiveId, instanceId ) | ||
); | ||
}, | ||
selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`, | ||
activeId: externalToInternalTabId( activeTabId, instanceId ), | ||
rtl: isRTL(), | ||
} ); | ||
|
||
const isControlled = selectedTabId !== undefined; | ||
|
||
const { items, selectedId, activeId } = useStoreState( store ); | ||
const { setSelectedId, setActiveId } = store; | ||
|
||
// Keep track of whether tabs have been populated. This is used to prevent | ||
// certain effects from firing too early while tab data and relevant | ||
// variables are undefined during the initial render. | ||
const tabsHavePopulatedRef = useRef( false ); | ||
if ( items.length > 0 ) { | ||
tabsHavePopulatedRef.current = true; | ||
} | ||
|
||
const selectedTab = items.find( ( item ) => item.id === selectedId ); | ||
const firstEnabledTab = items.find( ( item ) => { | ||
// Ariakit internally refers to disabled tabs as `dimmed`. | ||
return ! item.dimmed; | ||
} ); | ||
const initialTab = items.find( | ||
( item ) => item.id === `${ instanceId }-${ defaultTabId }` | ||
); | ||
|
||
// Handle selecting the initial tab. | ||
useLayoutEffect( () => { | ||
if ( isControlled ) { | ||
return; | ||
} | ||
|
||
// 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, as well as ensuring no tab is | ||
// selected if an invalid `defaultTabId` is provided. | ||
if ( defaultTabId && ! initialTab ) { | ||
return; | ||
} | ||
|
||
// If the currently selected tab is missing (i.e. removed from the DOM), | ||
// fall back to the initial tab or the first enabled tab if there is | ||
// one. Otherwise, no tab should be selected. | ||
if ( ! items.find( ( item ) => item.id === selectedId ) ) { | ||
if ( initialTab && ! initialTab.dimmed ) { | ||
setSelectedId( initialTab?.id ); | ||
return; | ||
} | ||
|
||
if ( firstEnabledTab ) { | ||
setSelectedId( firstEnabledTab.id ); | ||
} else if ( tabsHavePopulatedRef.current ) { | ||
setSelectedId( null ); | ||
} | ||
} | ||
}, [ | ||
firstEnabledTab, | ||
initialTab, | ||
defaultTabId, | ||
isControlled, | ||
items, | ||
selectedId, | ||
setSelectedId, | ||
] ); | ||
|
||
// Handle the currently selected tab becoming disabled. | ||
useLayoutEffect( () => { | ||
if ( ! selectedTab?.dimmed ) { | ||
return; | ||
} | ||
|
||
// In controlled mode, we trust that disabling tabs is done | ||
// intentionally, and don't select a new tab automatically. | ||
if ( isControlled ) { | ||
setSelectedId( null ); | ||
return; | ||
} | ||
|
||
// If the currently selected tab becomes disabled, fall back to the | ||
// `defaultTabId` if possible. Otherwise select the first | ||
// enabled tab (if there is one). | ||
if ( initialTab && ! initialTab.dimmed ) { | ||
setSelectedId( initialTab.id ); | ||
return; | ||
} | ||
|
||
if ( firstEnabledTab ) { | ||
setSelectedId( firstEnabledTab.id ); | ||
} | ||
}, [ | ||
firstEnabledTab, | ||
initialTab, | ||
isControlled, | ||
selectedTab?.dimmed, | ||
setSelectedId, | ||
] ); | ||
|
||
// Clear `selectedId` if the active tab is removed from the DOM in controlled mode. | ||
useLayoutEffect( () => { | ||
if ( ! isControlled ) { | ||
return; | ||
} | ||
|
||
// Once the tabs have populated, if the `selectedTabId` still can't be | ||
// found, clear the selection. | ||
if ( | ||
tabsHavePopulatedRef.current && | ||
!! selectedTabId && | ||
! selectedTab | ||
) { | ||
setSelectedId( null ); | ||
} | ||
}, [ isControlled, selectedTab, selectedTabId, setSelectedId ] ); | ||
const { items, activeId } = Ariakit.useStoreState( store ); | ||
const { setActiveId } = store; | ||
|
||
useEffect( () => { | ||
// If there is no active tab, fallback to place focus on the first enabled tab | ||
// so there is always an active element | ||
if ( selectedTabId === null && ! activeId && firstEnabledTab?.id ) { | ||
setActiveId( firstEnabledTab.id ); | ||
} | ||
}, [ selectedTabId, activeId, firstEnabledTab?.id, setActiveId ] ); | ||
|
||
useEffect( () => { | ||
if ( ! isControlled ) { | ||
return; | ||
} | ||
|
||
requestAnimationFrame( () => { | ||
const focusedElement = | ||
items?.[ 0 ]?.element?.ownerDocument.activeElement; | ||
|
@@ -200,7 +104,7 @@ export const Tabs = Object.assign( | |
setActiveId( focusedElement.id ); | ||
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 behaviour applied by this last custom hook is currently being discussed in ariakit/ariakit#4213 , to understand whether it will be even incorporated into ariakit, or if it's even a reasonable fix to implement on our side in the first place. It will be kept around for the time being. |
||
} | ||
} ); | ||
}, [ activeId, isControlled, items, setActiveId ] ); | ||
}, [ activeId, items, setActiveId ] ); | ||
|
||
const contextValue = useMemo( | ||
() => ( { | ||
|
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.
The custom logic added in #60681 is now applied directly where
Tabs
is consumed. The result should be the same. (cc @jeryj )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.
I tested on the Patterns tab of the inserter, and confirmed it focuses the first tab without activating it. It works the same as on trunk. Thanks!