Skip to content
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

Components: Tabs: Improve Controlled Mode Focus Handling #57696

Merged
merged 14 commits into from
Feb 1, 2024
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 25 additions & 17 deletions packages/components/src/tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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( () => {
chad1008 marked this conversation as resolved.
Show resolved Hide resolved
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(
() => ( {
Expand Down
19 changes: 19 additions & 0 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Ariakit.TabList
ref={ ref }
store={ store }
render={ <TabListWrapper /> }
onBlur={ onBlur }
{ ...otherProps }
>
{ children }
Expand Down
154 changes: 105 additions & 49 deletions packages/components/src/tabs/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,111 @@ describe( 'Tabs', () => {
).not.toBeInTheDocument();
} );
} );
describe( 'When `selectedId` is changed by the controlling component', () => {
ciampo marked this conversation as resolved.
Show resolved Hide resolved
describe.each( [ true, false ] )(
'and `selectOnMove` is %s',
( selectOnMove ) => {
it( 'should continue to handle arrow key navigation properly', async () => {
const { rerender } = render(
<ControlledTabs
tabs={ TABS }
selectedTabId="beta"
selectOnMove={ selectOnMove }
/>
);

// Tab key should focus the currently selected tab, which is Beta.
await press.Tab();
expect( await getSelectedTab() ).toHaveTextContent(
'Beta'
);
expect( await getSelectedTab() ).toHaveFocus();

rerender(
<ControlledTabs
tabs={ TABS }
selectedTabId="gamma"
selectOnMove={ selectOnMove }
/>
);

// 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(
<>
<button>Focus me</button>
<ControlledTabs
tabs={ TABS }
selectedTabId="beta"
selectOnMove={ selectOnMove }
/>
</>
);

// 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(
<>
<button>Focus me</button>
<ControlledTabs
tabs={ TABS }
selectedTabId="gamma"
selectOnMove={ selectOnMove }
/>
</>
);

// 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 () => {
Expand All @@ -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(
<ControlledTabs tabs={ TABS } selectedTabId="beta" />
);

// Tab key should focus the currently selected tab, which is Beta.
await press.Tab();
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
expect( await getSelectedTab() ).toHaveFocus();

rerender(
<ControlledTabs tabs={ TABS } selectedTabId="gamma" />
);

// 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 () => {
Expand Down Expand Up @@ -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(
<ControlledTabs
tabs={ TABS }
selectedTabId="beta"
selectOnMove={ false }
/>
);

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(
<ControlledTabs
tabs={ TABS }
selectedTabId="gamma"
selectOnMove={ false }
/>
);

// 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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ( {
Expand Down
Loading