Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- `ToggleGroupControl`: Fix arrow key navigation in RTL ([#65735](https://github.com/WordPress/gutenberg/pull/65735)).
- `ToggleGroupControl`: indicator doesn't jump around when the layout around it changes ([#65175](https://github.com/WordPress/gutenberg/pull/65175)).
- `Composite`: fix legacy support for the store prop ([#65821](https://github.com/WordPress/gutenberg/pull/65821)).
- `Composite`: make items tabbable if active element gets removed ([#65720](https://github.com/WordPress/gutenberg/pull/65720)).

### Deprecations

Expand Down
20 changes: 19 additions & 1 deletion packages/components/src/composite/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,23 @@ export const CompositeItem = forwardRef<
// obfuscated to discourage its use outside of the component's internals.
const store = ( props.store ?? context.store ) as Ariakit.CompositeStore;

return <Ariakit.CompositeItem store={ store } { ...props } ref={ ref } />;
// If the active item is not connected, Composite may end up in a state
// where none of the items are tabbable. In this case, we force all items to
// be tabbable, so that as soon as an item received focus, it becomes active
// and Composite goes back to working as expected.
const tabbable = Ariakit.useStoreState( store, ( state ) => {
return (
state?.activeId !== null &&
! store?.item( state?.activeId )?.element?.isConnected
);
} );

return (
<Ariakit.CompositeItem
store={ store }
tabbable={ tabbable }
{ ...props }
ref={ ref }
/>
);
} );
123 changes: 123 additions & 0 deletions packages/components/src/composite/test/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* External dependencies
*/
import { queryByAttribute, render, screen } from '@testing-library/react';
import { click, press, waitFor } from '@ariakit/test';
import type { ComponentProps } from 'react';

/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import { Composite } from '..';

// This is necessary because of how Ariakit calculates page up and
// page down. Without this, nothing has a height, and so paging up
// and down doesn't behave as expected in tests.

let clientHeightSpy: jest.SpiedGetter<
typeof HTMLElement.prototype.clientHeight
>;

beforeAll( () => {
clientHeightSpy = jest
.spyOn( HTMLElement.prototype, 'clientHeight', 'get' )
.mockImplementation( function getClientHeight( this: HTMLElement ) {
if ( this.tagName === 'BODY' ) {
return window.outerHeight;
}
return 50;
} );
} );

afterAll( () => {
clientHeightSpy?.mockRestore();
} );

async function renderAndValidate( ...args: Parameters< typeof render > ) {
const view = render( ...args );
await waitFor( () => {
const activeButton = queryByAttribute(
'data-active-item',
view.baseElement,
'true'
);
expect( activeButton ).not.toBeNull();
} );
return view;
}

function RemoveItemTest( props: ComponentProps< typeof Composite > ) {
const [ showThirdItem, setShowThirdItem ] = useState( true );
return (
<>
<button>Focus trap before composite</button>
<Composite { ...props }>
<Composite.Item>Item 1</Composite.Item>
<Composite.Item>Item 2</Composite.Item>
{ showThirdItem && <Composite.Item>Item 3</Composite.Item> }
</Composite>
<button onClick={ () => setShowThirdItem( ( value ) => ! value ) }>
Toggle third item
</button>
</>
);
}

describe( 'Composite', () => {
it( 'should remain focusable even when there are no elements in the DOM associated with the currently active ID', async () => {
await renderAndValidate( <RemoveItemTest /> );

const toggleButton = screen.getByRole( 'button', {
name: 'Toggle third item',
} );

await press.Tab();
await press.Tab();

expect(
screen.getByRole( 'button', { name: 'Item 1' } )
).toHaveFocus();

await press.ArrowRight();
await press.ArrowRight();

expect(
screen.getByRole( 'button', { name: 'Item 3' } )
).toHaveFocus();

await click( toggleButton );

expect(
screen.queryByRole( 'button', { name: 'Item 3' } )
).not.toBeInTheDocument();

await press.ShiftTab();

expect(
screen.getByRole( 'button', { name: 'Item 2' } )
).toHaveFocus();

await click( toggleButton );

expect(
screen.getByRole( 'button', { name: 'Item 3' } )
).toBeVisible();

await press.ShiftTab();

expect(
screen.getByRole( 'button', { name: 'Item 2' } )
).toHaveFocus();

await press.ArrowRight();

expect(
screen.getByRole( 'button', { name: 'Item 3' } )
).toHaveFocus();
} );
} );