Skip to content

Commit

Permalink
TabPanel: support manual tab activation (#46004)
Browse files Browse the repository at this point in the history
* TabPanel: support manual tab activation

* Mock `getClientRects` in TabPanel s unit tests to make sure that `NavigableMenu` works as expected

* Add tab activation unit tests

* Update docs

* CHANGELOG

* Rename prop to `selectOnMove`

* Typo

* Move `originalGetClientRects` declaration inside `beforeAll`

* Apply suggestions from code review

Co-authored-by: Marin Atanasov <[email protected]>

Co-authored-by: Marin Atanasov <[email protected]>
  • Loading branch information
ciampo and tyxla authored Nov 28, 2022
1 parent cc49790 commit 03a278e
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 2 deletions.
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;
};

0 comments on commit 03a278e

Please sign in to comment.