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 = {