diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index b6e033ab24ab74..51f820251b8d7c 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -6,7 +6,7 @@ import { createPrivateSlotFill } from './slot-fill'; import { DropdownMenuV2 } from './dropdown-menu-v2'; import { ComponentsContext } from './context/context-system-provider'; import Theme from './theme'; -import Tabs from './tabs'; +import { Tabs } from './tabs'; import { kebabCase } from './utils/strings'; import { lock } from './lock-unlock'; diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx index 09225c0cce9b8d..37367e4642e152 100644 --- a/packages/components/src/tabs/index.tsx +++ b/packages/components/src/tabs/index.tsx @@ -25,192 +25,209 @@ import { Tab } from './tab'; import { TabList } from './tablist'; import { TabPanel } from './tabpanel'; -function Tabs( { - selectOnMove = true, - defaultTabId, - orientation = 'horizontal', - onSelect, - children, - selectedTabId, -}: 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 ); - }, - selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`, - 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; - } +/** + * Display one panel of content at a time with a tabbed interface, based on the + * WAI-ARIA Tabs Patternā . + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ + * ``` + */ +export const Tabs = Object.assign( + function Tabs( { + selectOnMove = true, + defaultTabId, + orientation = 'horizontal', + onSelect, + children, + selectedTabId, + }: 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 ); + }, + selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`, + rtl: isRTL(), + } ); - 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; - } + const isControlled = selectedTabId !== undefined; + + const { items, selectedId, activeId } = useStoreState( store ); + const { setSelectedId, setActiveId } = store; - // 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; + // 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; } - // 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 ); + 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; } - if ( firstEnabledTab ) { - setSelectedId( firstEnabledTab.id ); - } else if ( tabsHavePopulatedRef.current ) { - setSelectedId( null ); + // 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; } - } - }, [ - 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 ] ); + // 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; + } - 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 ] ); + // In controlled mode, we trust that disabling tabs is done + // intentionally, and don't select a new tab automatically. + if ( isControlled ) { + setSelectedId( null ); + return; + } - useEffect( () => { - if ( ! isControlled ) { - 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; + } - requestAnimationFrame( () => { - const focusedElement = - items?.[ 0 ]?.element?.ownerDocument.activeElement; + 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 ( - ! focusedElement || - ! items.some( ( item ) => focusedElement === item.element ) + tabsHavePopulatedRef.current && + !! selectedTabId && + ! selectedTab ) { - return; // Return early if no tabs are focused. + setSelectedId( null ); } + }, [ isControlled, selectedTab, selectedTabId, setSelectedId ] ); - // If, after ariakit re-computes the active tab, that tab doesn't match - // the currently focused tab, then we force an update to ariakit to avoid - // any mismatches, especially when navigating to previous/next tab with - // arrow keys. - if ( activeId !== focusedElement.id ) { - setActiveId( focusedElement.id ); + 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; } - } ); - }, [ activeId, isControlled, items, setActiveId ] ); - const contextValue = useMemo( - () => ( { - store, - instanceId, + requestAnimationFrame( () => { + const focusedElement = + items?.[ 0 ]?.element?.ownerDocument.activeElement; + + if ( + ! focusedElement || + ! items.some( ( item ) => focusedElement === item.element ) + ) { + return; // Return early if no tabs are focused. + } + + // If, after ariakit re-computes the active tab, that tab doesn't match + // the currently focused tab, then we force an update to ariakit to avoid + // any mismatches, especially when navigating to previous/next tab with + // arrow keys. + if ( activeId !== focusedElement.id ) { + setActiveId( focusedElement.id ); + } + } ); + }, [ activeId, isControlled, items, setActiveId ] ); + + const contextValue = useMemo( + () => ( { + store, + instanceId, + } ), + [ store, instanceId ] + ); + + return ( + + { children } + + ); + }, + { + Tab: Object.assign( Tab, { + displayName: 'Tabs.Tab', + } ), + TabList: Object.assign( TabList, { + displayName: 'Tabs.TabList', } ), - [ store, instanceId ] - ); - - return ( - - { children } - - ); -} - -Tabs.TabList = TabList; -Tabs.Tab = Tab; -Tabs.TabPanel = TabPanel; -Tabs.Context = TabsContext; - -export default Tabs; + TabPanel: Object.assign( TabPanel, { + displayName: 'Tabs.TabPanel', + } ), + Context: Object.assign( TabsContext, { + displayName: 'Tabs.Context', + } ), + } +); diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index 5141a48a899502..5b2fd621bbb436 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -12,7 +12,7 @@ import { useState } from '@wordpress/element'; /** * Internal dependencies */ -import Tabs from '..'; +import { Tabs } from '..'; import { Slot, Fill, Provider as SlotFillProvider } from '../../slot-fill'; import DropdownMenu from '../../dropdown-menu'; import Button from '../../button'; @@ -30,6 +30,8 @@ const meta: Meta< typeof Tabs > = { 'Tabs.Tab': Tabs.Tab, // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 'Tabs.TabPanel': Tabs.TabPanel, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Tabs.Context': Tabs.Context, }, tags: [ 'status-private' ], parameters: { diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index 0563b0227d106e..82c75a3f16b253 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -13,7 +13,7 @@ import { useEffect, useState } from '@wordpress/element'; /** * Internal dependencies */ -import Tabs from '..'; +import { Tabs } from '..'; import type { TabsProps } from '../types'; type Tab = {