diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 6080498d5e92b..1a5fe64aafe3e 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -17,6 +17,7 @@
- `Guide`, `Modal`: Restore accent color themability ([#58098](https://github.com/WordPress/gutenberg/pull/58098)).
- `DropdownMenuV2`: Restore accent color themability ([#58130](https://github.com/WordPress/gutenberg/pull/58130)).
+- `Tabs`: improve controlled mode focus handling and keyboard navigation ([#57696](https://github.com/WordPress/gutenberg/pull/57696)).
- Expand theming support in the `COLORS` variable object ([#58097](https://github.com/WordPress/gutenberg/pull/58097)).
## 25.16.0 (2024-01-24)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 7f738cb9f08a9..e8e9bff76b3e9 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -8,7 +8,12 @@ import * as Ariakit from '@ariakit/react';
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
-import { useLayoutEffect, useMemo, useRef } from '@wordpress/element';
+import {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from '@wordpress/element';
/**
* Internal dependencies
@@ -44,8 +49,8 @@ function Tabs( {
const isControlled = selectedTabId !== undefined;
- const { items, selectedId } = store.useState();
- const { setSelectedId, move } = store;
+ const { items, selectedId, activeId } = store.useState();
+ 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
@@ -154,26 +159,29 @@ function Tabs( {
setSelectedId,
] );
- // In controlled mode, make sure browser focus follows the selected tab if
- // the selection is changed while a tab is already being focused.
- useLayoutEffect( () => {
- if ( ! isControlled || ! selectOnMove ) {
+ useEffect( () => {
+ if ( ! isControlled ) {
return;
}
- const currentItem = items.find( ( item ) => item.id === selectedId );
- const activeElement = currentItem?.element?.ownerDocument.activeElement;
- const tabsHasFocus = items.some( ( item ) => {
- return activeElement && activeElement === item.element;
- } );
+
+ const focusedElement =
+ items?.[ 0 ]?.element?.ownerDocument.activeElement;
if (
- activeElement &&
- tabsHasFocus &&
- selectedId !== activeElement.id
+ ! focusedElement ||
+ ! items.some( ( item ) => focusedElement === item.element )
) {
- move( selectedId );
+ 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 );
}
- }, [ isControlled, items, move, selectOnMove, selectedId ] );
+ }, [ activeId, isControlled, items, setActiveId ] );
const contextValue = useMemo(
() => ( {
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
index 7a53115910796..afa2d8283e6d6 100644
--- a/packages/components/src/tabs/tablist.tsx
+++ b/packages/components/src/tabs/tablist.tsx
@@ -28,11 +28,30 @@ export const TabList = forwardRef<
return null;
}
const { store } = context;
+
+ const { selectedId, activeId, selectOnMove } = store.useState();
+ const { setActiveId } = store;
+
+ const onBlur = () => {
+ if ( ! selectOnMove ) {
+ return;
+ }
+
+ // When automatic tab selection is on, make sure that the active tab is up
+ // to date with the selected tab when leaving the tablist. This makes sure
+ // that the selected tab will receive keyboard focus when tabbing back into
+ // the tablist.
+ if ( selectedId !== activeId ) {
+ setActiveId( selectedId );
+ }
+ };
+
return (
}
+ onBlur={ onBlur }
{ ...otherProps }
>
{ children }
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 8c2c2d0fd2fa2..ca9a01928e599 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -1172,6 +1172,111 @@ describe( 'Tabs', () => {
).not.toBeInTheDocument();
} );
} );
+ describe( 'When `selectedId` is changed by the controlling component', () => {
+ describe.each( [ true, false ] )(
+ 'and `selectOnMove` is %s',
+ ( selectOnMove ) => {
+ it( 'should continue to handle arrow key navigation properly', async () => {
+ const { rerender } = render(
+
+ );
+
+ // Tab key should focus the currently selected tab, which is Beta.
+ await press.Tab();
+ expect( await getSelectedTab() ).toHaveTextContent(
+ 'Beta'
+ );
+ expect( await getSelectedTab() ).toHaveFocus();
+
+ rerender(
+
+ );
+
+ // When the selected tab is changed, it should not automatically receive focus.
+ expect( await getSelectedTab() ).toHaveTextContent(
+ 'Gamma'
+ );
+ expect(
+ screen.getByRole( 'tab', { name: 'Beta' } )
+ ).toHaveFocus();
+
+ // Arrow keys should move focus to the next tab, which is Gamma
+ await press.ArrowRight();
+ expect(
+ screen.getByRole( 'tab', { name: 'Gamma' } )
+ ).toHaveFocus();
+ } );
+
+ it( 'should focus the correct tab when tabbing out and back into the tablist', async () => {
+ const { rerender } = render(
+ <>
+
+
+ >
+ );
+
+ // Tab key should focus the currently selected tab, which is Beta.
+ await press.Tab();
+ await press.Tab();
+ expect( await getSelectedTab() ).toHaveTextContent(
+ 'Beta'
+ );
+ expect( await getSelectedTab() ).toHaveFocus();
+
+ rerender(
+ <>
+
+
+ >
+ );
+
+ // When the selected tab is changed, it should not automatically receive focus.
+ expect( await getSelectedTab() ).toHaveTextContent(
+ 'Gamma'
+ );
+ expect(
+ screen.getByRole( 'tab', { name: 'Beta' } )
+ ).toHaveFocus();
+
+ // Press shift+tab, move focus to the button before Tabs
+ await press.ShiftTab();
+ expect(
+ screen.getByRole( 'button', { name: 'Focus me' } )
+ ).toHaveFocus();
+
+ // Press tab, move focus back to the tablist
+ await press.Tab();
+
+ const betaTab = screen.getByRole( 'tab', {
+ name: 'Beta',
+ } );
+ const gammaTab = screen.getByRole( 'tab', {
+ name: 'Gamma',
+ } );
+
+ expect(
+ selectOnMove ? gammaTab : betaTab
+ ).toHaveFocus();
+ } );
+ }
+ );
+ } );
describe( 'When `selectOnMove` is `true`', () => {
it( 'should automatically select a newly focused tab', async () => {
@@ -1188,24 +1293,6 @@ describe( 'Tabs', () => {
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
expect( await getSelectedTab() ).toHaveFocus();
} );
- it( 'should automatically update focus when the selected tab is changed by the controlling component', async () => {
- const { rerender } = render(
-
- );
-
- // Tab key should focus the currently selected tab, which is Beta.
- await press.Tab();
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect( await getSelectedTab() ).toHaveFocus();
-
- rerender(
-
- );
-
- // When the selected tab is changed, it should automatically receive focus.
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- expect( await getSelectedTab() ).toHaveFocus();
- } );
} );
describe( 'When `selectOnMove` is `false`', () => {
it( 'should apply focus without automatically changing the selected tab', async () => {
@@ -1247,37 +1334,6 @@ describe( 'Tabs', () => {
await press.Enter();
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
} );
- it( 'should not automatically update focus when the selected tab is changed by the controlling component', async () => {
- const { rerender } = render(
-
- );
-
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
-
- // Tab key should focus the currently selected tab, which is Beta.
- await press.Tab();
- await waitFor( async () =>
- expect( await getSelectedTab() ).toHaveFocus()
- );
-
- rerender(
-
- );
-
- // When the selected tab is changed, it should not automatically receive focus.
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- expect(
- screen.getByRole( 'tab', { name: 'Beta' } )
- ).toHaveFocus();
- } );
} );
} );
it( 'should associate each `Tab` with the correct `TabPanel`, even if they are not rendered in the same order', async () => {
diff --git a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js
index 84536c88227ce..6b55f68897cbd 100644
--- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js
+++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js
@@ -75,7 +75,7 @@ test.describe( 'Order of block keyboard navigation', () => {
);
await page.keyboard.press( 'Tab' );
- await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Post' );
+ await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block' );
} );
test( 'allows tabbing in navigation mode if no block is selected (reverse)', async ( {