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

TabPanel: support manual tab activation #46004

Merged
merged 10 commits into from
Nov 28, 2022
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New Feature

- `TabPanel`: support manual tab activation ([#46004](https://github.com/WordPress/gutenberg/pull/46004)).

### Bug Fix

- `ColorPalette`: show "Clear" button even when colors array is empty ([#46001](https://github.com/WordPress/gutenberg/pull/46001)).
Expand Down
8 changes: 8 additions & 0 deletions packages/components/src/tab-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ The name of the tab to be selected upon mounting of component. If this prop is n
- Required: No
- Default: none

#### selectOnMove

When `true`, the tab will be selected when receiving focus (automatic tab activation). When `false`, the tab will be selected only when clicked (manual tab activation). See the [official W3C docs](https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/) for more info.

- Type: `boolean`
- Required: No
- Default: `true`

#### children

A function which renders the tabviews given the selected tab. The function is passed the active tab object as an argument as defined the tabs prop.
Expand Down
12 changes: 10 additions & 2 deletions packages/components/src/tab-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function TabPanel( {
className,
children,
tabs,
selectOnMove = true,
initialTabName,
orientation = 'horizontal',
activeClass = 'is-active',
Expand All @@ -93,7 +94,12 @@ export function TabPanel( {
[ onSelect ]
);

const onNavigate = ( _childIndex: number, child: HTMLButtonElement ) => {
// Simulate a click on the newly focused tab, which causes the component
// to show the `tab-panel` associated with the clicked tab.
const activateTabAutomatically = (
_childIndex: number,
child: HTMLButtonElement
) => {
child.click();
};
const selectedTab = find( tabs, { name: selected } );
Expand All @@ -110,7 +116,9 @@ export function TabPanel( {
<NavigableMenu
role="tablist"
orientation={ orientation }
onNavigate={ onNavigate }
onNavigate={
selectOnMove ? activateTabAutomatically : undefined
}
className="components-tab-panel__tabs"
>
{ tabs.map( ( tab ) => (
Expand Down
111 changes: 111 additions & 0 deletions packages/components/src/tab-panel/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,24 @@ const TABS = [

const getSelectedTab = () => screen.getByRole( 'tab', { selected: true } );

let originalGetClientRects: () => DOMRectList;

describe( 'TabPanel', () => {
beforeAll( () => {
originalGetClientRects = window.HTMLElement.prototype.getClientRects;
// Mocking `getClientRects()` is necessary to pass a check performed by
// the `focus.tabbable.find()` and by the `focus.focusable.find()` functions
// from the `@wordpress/dom` package.
// @ts-expect-error We're not trying to comply to the DOM spec, only mocking
window.HTMLElement.prototype.getClientRects = function () {
return [ 'trick-jsdom-into-having-size-for-element-rect' ];
};
} );

afterAll( () => {
window.HTMLElement.prototype.getClientRects = originalGetClientRects;
} );

it( 'should render a tabpanel, and clicking should change tabs', async () => {
const user = setupUser();
const panelRenderFunction = jest.fn();
Expand Down Expand Up @@ -194,4 +211,98 @@ describe( 'TabPanel', () => {
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
} );
} );

describe( 'tab activation', () => {
it( 'defaults to automatic tab activation', async () => {
const user = setupUser();
const mockOnSelect = jest.fn();

render(
<TabPanel
tabs={ TABS }
children={ () => undefined }
onSelect={ mockOnSelect }
/>
);

// onSelect gets called on the initial render.
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );

// Click on Alpha, make sure Alpha is selected
await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) );
expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );

// Navigate forward with arrow keys,
// make sure Beta is selected automatically.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );

// Navigate forward with arrow keys,
// make sure Gamma (last tab) is selected automatically.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 4 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );

// Navigate forward with arrow keys,
// make sure Alpha (first tab) is selected automatically.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 5 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );

// Navigate backwards with arrow keys,
// make sure Gamma (last tab) is selected automatically
await user.keyboard( '[ArrowLeft]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 6 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );
} );

it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => {
const user = setupUser();
const mockOnSelect = jest.fn();

render(
<TabPanel
tabs={ TABS }
children={ () => undefined }
onSelect={ mockOnSelect }
selectOnMove={ false }
/>
);

// onSelect gets called on the initial render.
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );

// Click on Alpha, make sure Alpha is selected
await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) );
expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );

// Navigate forward with arrow keys.
// Make sure Beta is focused, but that the tab selection happens only when
// pressing the spacebar or the enter key.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus();
await user.keyboard( '[Enter]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );

// Navigate forward with arrow keys.
// Make sure Gamma (last tab) is focused, but that the tab selection
// happens only when pressing the spacebar or the enter key.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
expect(
screen.getByRole( 'tab', { name: 'Gamma' } )
).toHaveFocus();
await user.keyboard( '[Space]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 4 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );

// No need to test the "wrap-around" behavior, as it's being tested in the
// "automatic tab activation" test above.
} );
} );
} );
11 changes: 11 additions & 0 deletions packages/components/src/tab-panel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,15 @@ export type TabPanelProps = {
* Array of tab objects. Each tab object should contain at least a `name` and a `title`.
*/
tabs: Tab[];
/**
* When `true`, the tab will be selected when receiving focus (automatic tab
* activation). When `false`, the tab will be selected only when clicked
* (manual tab activation). See the official W3C docs for more info.
* .
*
* @default true
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/
*/
selectOnMove?: boolean;
};