diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 1db9d8cca26e08..d0acdbd43c6e93 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -89,6 +89,10 @@
- `Slot`: add `style` prop to `bubblesVirtually` version ([#56428](https://github.com/WordPress/gutenberg/pull/56428))
- Introduce experimental new version of `CustomSelectControl` based on `ariakit` ([#55790](https://github.com/WordPress/gutenberg/pull/55790))
+### Code Quality
+
+- `Composite`: add unit tests for `useCompositeState` ([#56645](https://github.com/WordPress/gutenberg/pull/56645)).
+
## 25.12.0 (2023-11-16)
### Bug Fix
diff --git a/packages/components/src/composite/test/index.tsx b/packages/components/src/composite/test/index.tsx
new file mode 100644
index 00000000000000..02fe6c3d1d60ab
--- /dev/null
+++ b/packages/components/src/composite/test/index.tsx
@@ -0,0 +1,576 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Internal dependencies
+ */
+import {
+ Composite as ReakitComposite,
+ CompositeGroup as ReakitCompositeGroup,
+ CompositeItem as ReakitCompositeItem,
+ useCompositeState as ReakitUseCompositeState,
+} from '..';
+
+const COMPOSITE_SUITES = {
+ reakit: {
+ Composite: ReakitComposite,
+ CompositeGroup: ReakitCompositeGroup,
+ CompositeItem: ReakitCompositeItem,
+ useCompositeState: ReakitUseCompositeState,
+ },
+};
+
+type InitialState = Parameters< typeof ReakitUseCompositeState >[ 0 ];
+
+// It was decided not to test the full API, instead opting
+// to cover basic usage, with a view to adding broader support
+// for the original API should the need arise. As such we are
+// only testing here for standard usage.
+// See https://github.com/WordPress/gutenberg/pull/56645
+
+describe.each( Object.entries( COMPOSITE_SUITES ) )(
+ 'Validate %s implementation',
+ ( _, { Composite, CompositeGroup, CompositeItem, useCompositeState } ) => {
+ function useSpreadProps( initialState?: InitialState ) {
+ return useCompositeState( initialState );
+ }
+
+ function useStateProps( initialState?: InitialState ) {
+ return {
+ state: useCompositeState( initialState ),
+ };
+ }
+
+ function OneDimensionalTest( { ...props } ) {
+ return (
+
+ Item 1
+ Item 2
+ Item 3
+
+ );
+ }
+
+ function getOneDimensionalItems() {
+ return {
+ item1: screen.getByText( 'Item 1' ),
+ item2: screen.getByText( 'Item 2' ),
+ item3: screen.getByText( 'Item 3' ),
+ };
+ }
+
+ function TwoDimensionalTest( { ...props } ) {
+ return (
+
+
+ Item A1
+ Item A2
+ Item A3
+
+
+ Item B1
+ Item B2
+ Item B3
+
+
+ Item C1
+ Item C2
+ Item C3
+
+
+ );
+ }
+
+ function getTwoDimensionalItems() {
+ return {
+ itemA1: screen.getByText( 'Item A1' ),
+ itemA2: screen.getByText( 'Item A2' ),
+ itemA3: screen.getByText( 'Item A3' ),
+ itemB1: screen.getByText( 'Item B1' ),
+ itemB2: screen.getByText( 'Item B2' ),
+ itemB3: screen.getByText( 'Item B3' ),
+ itemC1: screen.getByText( 'Item C1' ),
+ itemC2: screen.getByText( 'Item C2' ),
+ itemC3: screen.getByText( 'Item C3' ),
+ };
+ }
+
+ function ShiftTest( { ...props } ) {
+ return (
+
+
+ Item A1
+
+
+ Item B1
+ Item B2
+
+
+ Item C1
+
+ Item C2
+
+
+
+ );
+ }
+
+ function getShiftTestItems() {
+ return {
+ itemA1: screen.getByText( 'Item A1' ),
+ itemB1: screen.getByText( 'Item B1' ),
+ itemB2: screen.getByText( 'Item B2' ),
+ itemC1: screen.getByText( 'Item C1' ),
+ itemC2: screen.getByText( 'Item C2' ),
+ };
+ }
+
+ describe.each( [
+ [ 'With spread state', useSpreadProps ],
+ [ 'With `state` prop', useStateProps ],
+ ] )( '%s', ( __, useProps ) => {
+ function useOneDimensionalTest( initialState?: InitialState ) {
+ const Test = () => (
+
+ );
+ render( );
+ return getOneDimensionalItems();
+ }
+
+ test( 'Renders as a single tab stop', async () => {
+ const user = userEvent.setup();
+ const Test = () => (
+ <>
+
+
+
+ >
+ );
+ render( );
+
+ await user.tab();
+ expect( screen.getByText( 'Before' ) ).toHaveFocus();
+ await user.tab();
+ expect( screen.getByText( 'Item 1' ) ).toHaveFocus();
+ await user.tab();
+ expect( screen.getByText( 'After' ) ).toHaveFocus();
+ await user.tab( { shift: true } );
+ expect( screen.getByText( 'Item 1' ) ).toHaveFocus();
+ } );
+
+ test( 'Excludes disabled items', async () => {
+ const user = userEvent.setup();
+ const Test = () => {
+ const props = useProps();
+ return (
+
+ Item 1
+
+ Item 2
+
+ Item 3
+
+ );
+ };
+ render( );
+
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ expect( item2 ).toBeDisabled();
+
+ await user.tab();
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item2 ).not.toHaveFocus();
+ expect( item3 ).toHaveFocus();
+ } );
+
+ test( 'Includes focusable disabled items', async () => {
+ const user = userEvent.setup();
+ const Test = () => {
+ const props = useProps();
+ return (
+
+ Item 1
+
+ Item 2
+
+ Item 3
+
+ );
+ };
+ render( );
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ expect( item2 ).toBeEnabled();
+ expect( item2 ).toHaveAttribute( 'aria-disabled', 'true' );
+
+ await user.tab();
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item2 ).toHaveFocus();
+ expect( item3 ).not.toHaveFocus();
+ } );
+
+ test( 'Supports `baseId`', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ baseId: 'test-id',
+ } );
+
+ expect( item1.id ).toMatch( 'test-id-1' );
+ expect( item2.id ).toMatch( 'test-id-2' );
+ expect( item3.id ).toMatch( 'test-id-3' );
+ } );
+
+ test( 'Supports `currentId`', async () => {
+ const user = userEvent.setup();
+ const { item2 } = useOneDimensionalTest( {
+ baseId: 'test-id',
+ currentId: 'test-id-2',
+ } );
+
+ await user.tab();
+ expect( item2 ).toHaveFocus();
+ } );
+ } );
+
+ describe.each( [
+ [
+ 'When LTR',
+ false,
+ { previous: 'ArrowLeft', next: 'ArrowRight' },
+ ],
+ [ 'When RTL', true, { previous: 'ArrowRight', next: 'ArrowLeft' } ],
+ ] )( '%s', ( _when, rtl, { previous, next } ) => {
+ function useOneDimensionalTest( initialState?: InitialState ) {
+ const Test = () => (
+
+ );
+ render( );
+ return getOneDimensionalItems();
+ }
+
+ function useTwoDimensionalTest( initialState?: InitialState ) {
+ const Test = () => (
+
+ );
+ render( );
+ return getTwoDimensionalItems();
+ }
+
+ function useShiftTest( shift: boolean ) {
+ const Test = () => (
+
+ );
+ render( );
+ return getShiftTestItems();
+ }
+
+ describe( 'In one dimension', () => {
+ test( 'All directions work with no orientation', async () => {
+ const user = userEvent.setup();
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ rtl,
+ } );
+
+ await user.tab();
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[End]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[Home]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[PageDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[PageUp]' );
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Only left/right work with horizontal orientation', async () => {
+ const user = userEvent.setup();
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ rtl,
+ orientation: 'horizontal',
+ } );
+
+ await user.tab();
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[End]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[Home]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[PageDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[PageUp]' );
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Only up/down work with vertical orientation', async () => {
+ const user = userEvent.setup();
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ rtl,
+ orientation: 'vertical',
+ } );
+
+ await user.tab();
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[End]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[Home]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[PageDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[PageUp]' );
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps with loop enabled', async () => {
+ const user = userEvent.setup();
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ rtl,
+ loop: true,
+ } );
+
+ await user.tab();
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item2 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( item3 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( item1 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( item3 ).toHaveFocus();
+ } );
+ } );
+
+ describe( 'In two dimensions', () => {
+ test( 'All directions work as standard', async () => {
+ const user = userEvent.setup();
+ const {
+ itemA1,
+ itemA2,
+ itemA3,
+ itemB1,
+ itemB2,
+ itemC1,
+ itemC3,
+ } = useTwoDimensionalTest( { rtl } );
+
+ await user.tab();
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemB1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemB2 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( itemA2 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[End]' );
+ expect( itemA3 ).toHaveFocus();
+ await user.keyboard( '[PageDown]' );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( '[Home]' );
+ expect( itemC1 ).toHaveFocus();
+ await user.keyboard( '[PageUp]' );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '{Control>}[End]{/Control}' );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( '{Control>}[Home]{/Control}' );
+ expect( itemA1 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps around rows/columns with loop enabled', async () => {
+ const user = userEvent.setup();
+ const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
+ useTwoDimensionalTest( { rtl, loop: true } );
+
+ await user.tab();
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemA2 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemA3 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemB1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemC1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( itemA3 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( itemC3 ).toHaveFocus();
+ } );
+
+ test( 'Focus moves between rows/columns with wrap enabled', async () => {
+ const user = userEvent.setup();
+ const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
+ useTwoDimensionalTest( { rtl, wrap: true } );
+
+ await user.tab();
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemA2 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemA3 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemB1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemC1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemA2 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '{Control>}[End]{/Control}' );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemC3 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps around start/end with loop and wrap enabled', async () => {
+ const user = userEvent.setup();
+ const { itemA1, itemC3 } = useTwoDimensionalTest( {
+ rtl,
+ loop: true,
+ wrap: true,
+ } );
+
+ await user.tab();
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( `[${ previous }]` );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ expect( itemC3 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemA1 ).toHaveFocus();
+ } );
+
+ test( 'Focus shifts if vertical neighbour unavailable when shift enabled', async () => {
+ const user = userEvent.setup();
+ const { itemA1, itemB1, itemB2, itemC1 } =
+ useShiftTest( true );
+
+ await user.tab();
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemB1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemB2 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ // A2 doesn't exist
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemB1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemB2 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ // C2 is disabled
+ expect( itemC1 ).toHaveFocus();
+ } );
+
+ test( 'Focus does not shift if vertical neighbour unavailable when shift not enabled', async () => {
+ const user = userEvent.setup();
+ const { itemA1, itemB1, itemB2 } = useShiftTest( false );
+
+ await user.tab();
+ expect( itemA1 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ expect( itemB1 ).toHaveFocus();
+ await user.keyboard( `[${ next }]` );
+ expect( itemB2 ).toHaveFocus();
+ await user.keyboard( '[ArrowUp]' );
+ // A2 doesn't exist
+ expect( itemB2 ).toHaveFocus();
+ await user.keyboard( '[ArrowDown]' );
+ // C2 is disabled
+ expect( itemB2 ).toHaveFocus();
+ } );
+ } );
+ } );
+ }
+);