diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 8f0a224b7c7ec0..1571c7b2b617f7 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -7,6 +7,7 @@
- `ConfirmDialog`: Add `__next40pxDefaultSize` to buttons ([#58421](https://github.com/WordPress/gutenberg/pull/58421)).
- `SnackbarList`: Allow limiting the number of maximum visible Snackbars ([#58559](https://github.com/WordPress/gutenberg/pull/58559)).
- `Snackbar`: Update the warning message ([#58591](https://github.com/WordPress/gutenberg/pull/58591)).
+- `Composite`: Implementing `useCompositeState` with Ariakit ([#57304](https://github.com/WordPress/gutenberg/pull/57304))
### Bug Fix
diff --git a/packages/components/src/composite/current/index.ts b/packages/components/src/composite/current/index.ts
new file mode 100644
index 00000000000000..ec844b25a3a9d9
--- /dev/null
+++ b/packages/components/src/composite/current/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Composite is a component that may contain navigable items represented by
+ * CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements
+ * all the keyboard navigation mechanisms to ensure that there's only one
+ * tab stop for the whole Composite element. This means that it can behave as
+ * a roving tabindex or aria-activedescendant container.
+ *
+ * @see https://ariakit.org/components/composite
+ */
+
+/* eslint-disable-next-line no-restricted-imports */
+export {
+ Composite,
+ CompositeGroup,
+ CompositeGroupLabel,
+ CompositeItem,
+ CompositeRow,
+ useCompositeStore,
+} from '@ariakit/react';
+
+/* eslint-disable-next-line no-restricted-imports */
+export type { CompositeStore, CompositeStoreProps } from '@ariakit/react';
diff --git a/packages/components/src/composite/current/stories/index.story.tsx b/packages/components/src/composite/current/stories/index.story.tsx
new file mode 100644
index 00000000000000..441af6941c614a
--- /dev/null
+++ b/packages/components/src/composite/current/stories/index.story.tsx
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryFn } from '@storybook/react';
+
+/**
+ * WordPress dependencies
+ */
+import { isRTL } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import {
+ Composite,
+ CompositeGroup,
+ CompositeRow,
+ CompositeItem,
+ useCompositeStore,
+} from '..';
+import { UseCompositeStorePlaceholder, transform } from './utils';
+
+const meta: Meta< typeof UseCompositeStorePlaceholder > = {
+ title: 'Components/Composite (V2)',
+ component: UseCompositeStorePlaceholder,
+ subcomponents: {
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ Composite,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ CompositeGroup,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ CompositeRow,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ CompositeItem,
+ },
+ parameters: {
+ badges: [ 'private' ],
+ docs: {
+ canvas: { sourceState: 'shown' },
+ source: { transform },
+ extractArgTypes: ( component: React.FunctionComponent ) => {
+ const name = component.displayName;
+ const path = name
+ ?.replace(
+ /([a-z])([A-Z])/g,
+ ( _, a, b ) => `${ a }-${ b.toLowerCase() }`
+ )
+ .toLowerCase();
+ const url = `https://ariakit.org/reference/${ path }`;
+ return {
+ props: {
+ name: 'Props',
+ description: `See Ariakit docs for ${ name }
`,
+ table: { type: { summary: undefined } },
+ },
+ };
+ },
+ },
+ },
+};
+export default meta;
+
+export const Default: StoryFn< typeof Composite > = ( { ...initialState } ) => {
+ const rtl = isRTL();
+ const store = useCompositeStore( { rtl, ...initialState } );
+
+ return (
+
+
+ Item A1
+ Item A2
+ Item A3
+
+
+ Item B1
+ Item B2
+ Item B3
+
+
+ Item C1
+ Item C2
+ Item C3
+
+
+ );
+};
diff --git a/packages/components/src/composite/current/stories/utils.tsx b/packages/components/src/composite/current/stories/utils.tsx
new file mode 100644
index 00000000000000..4b2d1bba4b312b
--- /dev/null
+++ b/packages/components/src/composite/current/stories/utils.tsx
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import type { StoryContext } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import type { CompositeStoreProps } from '..';
+
+export function UseCompositeStorePlaceholder( props: CompositeStoreProps ) {
+ return (
+
+ { Object.entries( props ).map( ( [ name, value ] ) => (
+ <>
+ - { name }
+ - { JSON.stringify( value ) }
+ >
+ ) ) }
+
+ );
+}
+UseCompositeStorePlaceholder.displayName = 'useCompositeStore';
+
+export function transform( code: string, context: StoryContext ) {
+ // The output generated by Storybook for these components is
+ // messy, so we apply this transform to make it more useful
+ // for anyone reading the docs.
+ const config = ` ${ JSON.stringify( context.args, null, 2 ) } `;
+ const state = config.replace( ' {} ', '' );
+ return [
+ // Include a setup line, showing how to make use of
+ // `useCompositeStore` to convert store options into
+ // a composite store prop.
+ `const store = useCompositeStore(${ state });`,
+ '',
+ 'return (',
+ ' ' +
+ code
+ // The generated output includes a full dump of everything
+ // in the store; the reader probably isn't interested in
+ // what that looks like, so instead we drop all of that
+ // in favor of the store generated above.
+ .replaceAll( /store=\{\{[\s\S]*?\}\}/g, 'store={ store }' )
+ // Now we tidy the output by removing any unnecessary
+ // whitespace...
+ .replaceAll( //g, ( match ) =>
+ match.replaceAll( /\s+\s/g, ' ' )
+ )
+ // ...including around children...
+ .replaceAll(
+ />\s*(\w[\w ]*?)\s*<\//g,
+ ( _, value ) => `>${ value }`
+ )
+ // ...and inside JSX definitions.
+ .replaceAll( '} >', '}>' )
+ // Finally we indent everything to make it more readable.
+ .replaceAll( /\n/g, '\n ' ),
+ ');',
+ ].join( '\n' );
+}
diff --git a/packages/components/src/composite/index.ts b/packages/components/src/composite/index.ts
index 2a4e3655426908..f0dd4ef8995b57 100644
--- a/packages/components/src/composite/index.ts
+++ b/packages/components/src/composite/index.ts
@@ -1,22 +1,4 @@
-/**
- * Composite is a component that may contain navigable items represented by
- * CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements
- * all the keyboard navigation mechanisms to ensure that there's only one
- * tab stop for the whole Composite element. This means that it can behave as
- * a roving tabindex or aria-activedescendant container.
- *
- * @see https://reakit.io/docs/composite/
- *
- * The plan is to build own API that accounts for future breaking changes
- * in Reakit (https://github.com/WordPress/gutenberg/pull/28085).
- */
-/* eslint-disable-next-line no-restricted-imports */
-export {
- Composite,
- CompositeGroup,
- CompositeItem,
- useCompositeState,
-} from 'reakit';
+// Until we migrate away from Reakit, the 'unstable'
+// implementation remains the default.
-/* eslint-disable-next-line no-restricted-imports */
-export type { CompositeStateReturn as CompositeState } from 'reakit';
+export * from './unstable';
diff --git a/packages/components/src/composite/legacy/index.tsx b/packages/components/src/composite/legacy/index.tsx
new file mode 100644
index 00000000000000..06a82461be9a1b
--- /dev/null
+++ b/packages/components/src/composite/legacy/index.tsx
@@ -0,0 +1,204 @@
+/**
+ * Composite is a component that may contain navigable items represented by
+ * CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements
+ * all the keyboard navigation mechanisms to ensure that there's only one
+ * tab stop for the whole Composite element. This means that it can behave as
+ * a roving tabindex or aria-activedescendant container.
+ *
+ * @see https://ariakit.org/components/composite
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { forwardRef } from '@wordpress/element';
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * Internal dependencies
+ */
+import * as Current from '../current';
+import { useInstanceId } from '@wordpress/compose';
+
+type Orientation = 'horizontal' | 'vertical';
+
+export interface LegacyStateOptions {
+ /**
+ * ID that will serve as a base for all the items IDs.
+ */
+ baseId?: string;
+ /**
+ * Determines how next and previous functions will behave. If `rtl` is set
+ * to `true`, they will be inverted. This only affects the composite widget
+ * behavior. You still need to set `dir="rtl"` on HTML/CSS.
+ *
+ * @default false
+ */
+ rtl?: boolean;
+ /**
+ * Defines the orientation of the composite widget. If the composite has a
+ * single row or column (one-dimensional), the orientation value determines
+ * which arrow keys can be used to move focus.
+ */
+ orientation?: Orientation;
+ /**
+ * The current focused item `id`.
+ */
+ currentId?: string;
+ /**
+ * Determines how focus moves from the start and end of rows and columns.
+ *
+ * @default false
+ */
+ loop?: boolean | Orientation;
+ /**
+ * If enabled, moving to the next item from the last one in a row or column
+ * will focus the first item in the next row or column and vice-versa.
+ *
+ * ** Has effect only on two-dimensional composites. **
+ *
+ * @default false
+ */
+ wrap?: boolean | Orientation;
+ /**
+ * If enabled, moving up or down when there's no next item or the next item
+ * is disabled will shift to the item right before it.
+ *
+ * ** Has effect only on two-dimensional composites. **
+ *
+ * @default false
+ */
+ shift?: boolean;
+ unstable_virtual?: boolean;
+}
+
+type Component = React.FunctionComponent< any >;
+
+type CompositeStore = ReturnType< typeof Current.useCompositeStore >;
+type CompositeStoreState = { store: CompositeStore };
+export type CompositeState = CompositeStoreState &
+ Required< Pick< LegacyStateOptions, 'baseId' > >;
+
+// Legacy composite components can either provide state through a
+// single `state` prop, or via individual props, usually through
+// spreading the state generated by `useCompositeState`.
+// That is, ``.
+export type CompositeStateProps =
+ | { state: CompositeState }
+ | ( CompositeState & { state?: never } );
+type ComponentProps< C extends Component > = React.ComponentPropsWithRef< C >;
+export type CompositeProps< C extends Component > = ComponentProps< C > &
+ CompositeStateProps;
+type CompositeComponent< C extends Component > = (
+ props: CompositeProps< C >
+) => React.ReactElement;
+type CompositeComponentProps = CompositeState &
+ (
+ | ComponentProps< typeof Current.CompositeGroup >
+ | ComponentProps< typeof Current.CompositeItem >
+ | ComponentProps< typeof Current.CompositeRow >
+ );
+
+function showDeprecationMessage( previous?: string, next?: string ) {
+ if ( previous ) {
+ deprecated( `wp.components.__unstable${ previous }`, {
+ alternative: `wp.components.${ next || previous }`,
+ } );
+ }
+}
+
+function mapLegacyStatePropsToComponentProps(
+ legacyProps: CompositeStateProps
+): CompositeComponentProps {
+ // If a `state` prop is provided, we unpack that; otherwise,
+ // the necessary props are provided directly in `legacyProps`.
+ if ( legacyProps.state ) {
+ const { state, ...rest } = legacyProps;
+ const { store, ...props } =
+ mapLegacyStatePropsToComponentProps( state );
+ return { ...rest, ...props, store };
+ }
+
+ return legacyProps;
+}
+
+function proxyComposite< C extends Component >(
+ ProxiedComponent: C | React.ForwardRefExoticComponent< C >,
+ propMap: Record< string, string > = {}
+): CompositeComponent< C > {
+ const displayName = ProxiedComponent.displayName;
+ const Component = ( legacyProps: CompositeStateProps ) => {
+ showDeprecationMessage( displayName );
+
+ const { store, ...rest } =
+ mapLegacyStatePropsToComponentProps( legacyProps );
+ const props = rest as ComponentProps< C >;
+ props.id = useInstanceId( store, props.baseId, props.id );
+
+ Object.entries( propMap ).forEach( ( [ from, to ] ) => {
+ if ( props.hasOwnProperty( from ) ) {
+ Object.assign( props, { [ to ]: props[ from ] } );
+ delete props[ from ];
+ }
+ } );
+
+ delete props.baseId;
+
+ return ;
+ };
+ Component.displayName = displayName;
+ return Component;
+}
+
+// The old `CompositeGroup` used to behave more like the current
+// `CompositeRow`, but this has been split into two different
+// components. We handle that difference by checking on the
+// provided role, and returning the appropriate component.
+const unproxiedCompositeGroup = forwardRef<
+ any,
+ React.ComponentPropsWithoutRef<
+ typeof Current.CompositeGroup | typeof Current.CompositeRow
+ >
+>( ( { role, ...props }, ref ) => {
+ const Component =
+ role === 'row' ? Current.CompositeRow : Current.CompositeGroup;
+ return ;
+} );
+unproxiedCompositeGroup.displayName = 'CompositeGroup';
+
+export const Composite = proxyComposite( Current.Composite, { baseId: 'id' } );
+export const CompositeGroup = proxyComposite( unproxiedCompositeGroup );
+export const CompositeItem = proxyComposite( Current.CompositeItem, {
+ focusable: 'accessibleWhenDisabled',
+} );
+
+export function useCompositeState(
+ legacyStateOptions: LegacyStateOptions = {}
+): CompositeState {
+ showDeprecationMessage( 'UseCompositeState', 'useCompositeStore' );
+
+ const {
+ baseId,
+ currentId: defaultActiveId,
+ orientation,
+ rtl = false,
+ loop: focusLoop = false,
+ wrap: focusWrap = false,
+ shift: focusShift = false,
+ // eslint-disable-next-line camelcase
+ unstable_virtual: virtualFocus,
+ } = legacyStateOptions;
+
+ return {
+ baseId: useInstanceId( Composite, 'composite', baseId ),
+ store: Current.useCompositeStore( {
+ defaultActiveId,
+ rtl,
+ orientation,
+ focusLoop,
+ focusShift,
+ focusWrap,
+ virtualFocus,
+ } ),
+ };
+}
diff --git a/packages/components/src/composite/legacy/stories/index.story.tsx b/packages/components/src/composite/legacy/stories/index.story.tsx
new file mode 100644
index 00000000000000..a11e4838125d9d
--- /dev/null
+++ b/packages/components/src/composite/legacy/stories/index.story.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryFn } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import {
+ Composite,
+ CompositeGroup,
+ CompositeItem,
+ useCompositeState,
+} from '..';
+import { UseCompositeStatePlaceholder, transform } from './utils';
+
+const meta: Meta< typeof UseCompositeStatePlaceholder > = {
+ title: 'Components/Composite (Legacy)',
+ component: UseCompositeStatePlaceholder,
+ subcomponents: {
+ Composite,
+ CompositeGroup,
+ CompositeItem,
+ },
+ args: {},
+ parameters: {
+ controls: { exclude: /^unstable_/ },
+ docs: {
+ canvas: { sourceState: 'shown' },
+ source: { transform },
+ },
+ },
+ argTypes: {
+ orientation: { control: 'select' },
+ loop: {
+ control: 'select',
+ options: [ true, false, 'horizontal', 'vertical' ],
+ },
+ wrap: {
+ control: 'select',
+ options: [ true, false, 'horizontal', 'vertical' ],
+ },
+ },
+};
+export default meta;
+
+export const TwoDimensionsWithStateProp: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+
+
+ Item A1
+
+
+ Item A2
+
+
+ Item A3
+
+
+
+
+ Item B1
+
+
+ Item B2
+
+
+ Item B3
+
+
+
+
+ Item C1
+
+
+ Item C2
+
+
+ Item C3
+
+
+
+ );
+};
+TwoDimensionsWithStateProp.args = {};
+
+export const TwoDimensionsWithSpreadProps: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+
+
+ Item A1
+
+
+ Item A2
+
+
+ Item A3
+
+
+
+
+ Item B1
+
+
+ Item B2
+
+
+ Item B3
+
+
+
+
+ Item C1
+
+
+ Item C2
+
+
+ Item C3
+
+
+
+ );
+};
+TwoDimensionsWithSpreadProps.args = {};
+
+export const OneDimensionWithStateProp: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+
+ Item 4
+
+
+ Item 5
+
+
+ );
+};
+OneDimensionWithStateProp.args = {};
+
+export const OneDimensionWithSpreadProps: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+
+ Item 4
+
+
+ Item 5
+
+
+ );
+};
+OneDimensionWithSpreadProps.args = {};
diff --git a/packages/components/src/composite/legacy/stories/utils.tsx b/packages/components/src/composite/legacy/stories/utils.tsx
new file mode 100644
index 00000000000000..06edd348634695
--- /dev/null
+++ b/packages/components/src/composite/legacy/stories/utils.tsx
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import type { StoryContext } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import type { LegacyStateOptions } from '..';
+
+export function UseCompositeStatePlaceholder( props: LegacyStateOptions ) {
+ return (
+
+ { Object.entries( props ).map( ( [ name, value ] ) => (
+ <>
+ - { name }
+ - { JSON.stringify( value ) }
+ >
+ ) ) }
+
+ );
+}
+UseCompositeStatePlaceholder.displayName = 'useCompositeState';
+
+export function transform( code: string, context: StoryContext ) {
+ // The output generated by Storybook for these components is
+ // messy, so we apply this transform to make it more useful
+ // for anyone reading the docs.
+ const config = ` ${ JSON.stringify( context.args, null, 2 ) } `;
+ const state = config.replace( ' {} ', '' );
+ return [
+ // Include a setup line, showing how to make use of
+ // `useCompositeState` to convert state options into
+ // a composite state option.
+ `const state = useCompositeState(${ state });`,
+ '',
+ 'return (',
+ ' ' +
+ code
+ // The generated output includes a full dump of everything
+ // in the state; the reader probably isn't interested in
+ // what that looks like, so instead we drop all of that
+ // in favor of the state generated above.
+ .replaceAll( /state=\{\{[\s\S]*?\}\}/g, 'state={ state }' )
+ // The previous line only works for `state={ state }`, and
+ // doesn't replace spread props, so we do that separately.
+ .replaceAll( '=>', '' )
+ .replaceAll( /baseId=[^>]+?(\s*>)/g, ( _, close ) => {
+ return `{ ...state }${ close }`;
+ } )
+ // Now we tidy the output by removing any unnecessary
+ // whitespace...
+ .replaceAll( //g, ( match ) =>
+ match.replaceAll( /\s+\s/g, ' ' )
+ )
+ // ...including around children...
+ .replaceAll(
+ / >\s+([\w\s]*?)\s+<\//g,
+ ( _, value ) => `>${ value }`
+ )
+ // ...and inside JSX definitions.
+ .replaceAll( '} >', '}>' )
+ // Finally we indent everything to make it more readable.
+ .replaceAll( /\n/g, '\n ' ),
+ ');',
+ ].join( '\n' );
+}
diff --git a/packages/components/src/composite/legacy/test/index.tsx b/packages/components/src/composite/legacy/test/index.tsx
new file mode 100644
index 00000000000000..dd57d6373b2bfb
--- /dev/null
+++ b/packages/components/src/composite/legacy/test/index.tsx
@@ -0,0 +1,609 @@
+/**
+ * External dependencies
+ */
+import { queryByAttribute, render, screen } from '@testing-library/react';
+import { press, waitFor } from '@ariakit/test';
+
+/**
+ * Internal dependencies
+ */
+import {
+ Composite,
+ CompositeGroup,
+ CompositeItem,
+ useCompositeState,
+} 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();
+} );
+
+type InitialState = Parameters< typeof useCompositeState >[ 0 ];
+type CompositeState = ReturnType< typeof useCompositeState >;
+type CompositeStateProps = CompositeState | { state: CompositeState };
+
+const warningsIssued = new Map();
+
+async function renderAndValidate( ...args: Parameters< typeof render > ) {
+ const view = render( ...args );
+ await waitFor( () => {
+ const activeButton = queryByAttribute(
+ 'data-active-item',
+ view.baseElement,
+ ''
+ );
+ expect( activeButton ).not.toBeNull();
+ } );
+ return view;
+}
+
+function getKeys( rtl: boolean ) {
+ return {
+ previous: rtl ? 'ArrowRight' : 'ArrowLeft',
+ next: rtl ? 'ArrowLeft' : 'ArrowRight',
+ first: rtl ? 'End' : 'Home',
+ last: rtl ? 'Home' : 'End',
+ };
+}
+
+function OneDimensionalTest( props: CompositeStateProps ) {
+ 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: CompositeStateProps ) {
+ 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: CompositeStateProps ) {
+ 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',
+ ( initialState?: InitialState ) => useCompositeState( initialState ),
+ ],
+ [
+ 'With `state` prop',
+ ( initialState?: InitialState ) => ( {
+ state: useCompositeState( initialState ),
+ } ),
+ ],
+] )( '%s', ( __, useProps ) => {
+ test( 'Renders as a single tab stop', async () => {
+ const Test = () => (
+ <>
+
+
+
+ >
+ );
+ renderAndValidate( );
+
+ // Using the legacy composite components issues a deprecation
+ // warning, but only on the first usage. As such, we only
+ // expect `console` to warn once; any further rendering
+ // should not warn.
+ const warningKey = 'single tab stop';
+ if ( warningsIssued.get( warningKey ) ) {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect( console ).not.toHaveWarned();
+ } else {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect( console ).toHaveWarned();
+ warningsIssued.set( warningKey, true );
+ }
+
+ await press.Tab();
+ expect( screen.getByText( 'Before' ) ).toHaveFocus();
+ await press.Tab();
+ expect( screen.getByText( 'Item 1' ) ).toHaveFocus();
+ await press.Tab();
+ expect( screen.getByText( 'After' ) ).toHaveFocus();
+ await press.ShiftTab();
+ expect( screen.getByText( 'Item 1' ) ).toHaveFocus();
+ } );
+
+ test( 'Excludes disabled items', async () => {
+ const Test = () => {
+ const props = useProps();
+ return (
+
+ Item 1
+
+ Item 2
+
+ Item 3
+
+ );
+ };
+ renderAndValidate( );
+
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ expect( item2 ).toBeDisabled();
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).not.toHaveFocus();
+ expect( item3 ).toHaveFocus();
+ } );
+
+ test( 'Includes focusable disabled items', async () => {
+ const Test = () => {
+ const props = useProps();
+ return (
+
+ Item 1
+
+ Item 2
+
+ Item 3
+
+ );
+ };
+ renderAndValidate( );
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ expect( item2 ).toBeEnabled();
+ expect( item2 ).toHaveAttribute( 'aria-disabled', 'true' );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ expect( item3 ).not.toHaveFocus();
+ } );
+
+ test( 'Supports `baseId`', async () => {
+ const Test = () => (
+
+ );
+ renderAndValidate( );
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ 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 Test = () => (
+
+ );
+ renderAndValidate( );
+ const { item2 } = getOneDimensionalItems();
+
+ await press.Tab();
+ expect( item2 ).toHaveFocus();
+ } );
+} );
+
+describe.each( [
+ [ 'When LTR', false ],
+ [ 'When RTL', true ],
+] )( '%s', ( _when, rtl ) => {
+ const { previous, next, first, last } = getKeys( rtl );
+
+ function useOneDimensionalTest( initialState?: InitialState ) {
+ const Test = () => (
+
+ );
+ renderAndValidate( );
+ return getOneDimensionalItems();
+ }
+
+ function useTwoDimensionalTest( initialState?: InitialState ) {
+ const Test = () => (
+
+ );
+ renderAndValidate( );
+ return getTwoDimensionalItems();
+ }
+
+ function useShiftTest( shift: boolean ) {
+ const Test = () => (
+
+ );
+ renderAndValidate( );
+ return getShiftTestItems();
+ }
+
+ describe( 'In one dimension', () => {
+ test( 'All directions work with no orientation', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest();
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item1 ).toHaveFocus();
+ await press( next );
+ expect( item2 ).toHaveFocus();
+ await press( next );
+ expect( item3 ).toHaveFocus();
+ await press( previous );
+ expect( item2 ).toHaveFocus();
+ await press( previous );
+ expect( item1 ).toHaveFocus();
+ await press.End();
+ expect( item3 ).toHaveFocus();
+ await press.Home();
+ expect( item1 ).toHaveFocus();
+ await press.PageDown();
+ expect( item3 ).toHaveFocus();
+ await press.PageUp();
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Only left/right work with horizontal orientation', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ orientation: 'horizontal',
+ } );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item1 ).toHaveFocus();
+ await press( next );
+ expect( item2 ).toHaveFocus();
+ await press( next );
+ expect( item3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item3 ).toHaveFocus();
+ await press( previous );
+ expect( item2 ).toHaveFocus();
+ await press( previous );
+ expect( item1 ).toHaveFocus();
+ await press.End();
+ expect( item3 ).toHaveFocus();
+ await press.Home();
+ expect( item1 ).toHaveFocus();
+ await press.PageDown();
+ expect( item3 ).toHaveFocus();
+ await press.PageUp();
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Only up/down work with vertical orientation', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ orientation: 'vertical',
+ } );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press( next );
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press( previous );
+ expect( item3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item1 ).toHaveFocus();
+ await press.End();
+ expect( item3 ).toHaveFocus();
+ await press.Home();
+ expect( item1 ).toHaveFocus();
+ await press.PageDown();
+ expect( item3 ).toHaveFocus();
+ await press.PageUp();
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps with loop enabled', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ loop: true,
+ } );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item3 ).toHaveFocus();
+ await press( next );
+ expect( item1 ).toHaveFocus();
+ await press( previous );
+ expect( item3 ).toHaveFocus();
+ } );
+ } );
+
+ describe( 'In two dimensions', () => {
+ test( 'All directions work as standard', async () => {
+ const { itemA1, itemA2, itemA3, itemB1, itemB2, itemC1, itemC3 } =
+ useTwoDimensionalTest();
+
+ // Using the legacy composite components issues a deprecation
+ // warning, but only on the first usage. As such, we only
+ // expect `console` to warn once; any further rendering
+ // should not warn.
+ const warningKey = 'directions';
+ if ( warningsIssued.get( warningKey ) ) {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect( console ).not.toHaveWarned();
+ } else {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect( console ).toHaveWarned();
+ warningsIssued.set( warningKey, true );
+ }
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemA2 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press( last );
+ expect( itemA3 ).toHaveFocus();
+ await press.PageDown();
+ expect( itemC3 ).toHaveFocus();
+ await press( next );
+ expect( itemC3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC3 ).toHaveFocus();
+ await press( first );
+ expect( itemC1 ).toHaveFocus();
+ await press.PageUp();
+ expect( itemA1 ).toHaveFocus();
+ await press.End( null, { ctrlKey: true } );
+ expect( itemC3 ).toHaveFocus();
+ await press.Home( null, { ctrlKey: true } );
+ expect( itemA1 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps around rows/columns with loop enabled', async () => {
+ const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
+ useTwoDimensionalTest( { loop: true } );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press( next );
+ expect( itemA2 ).toHaveFocus();
+ await press( next );
+ expect( itemA3 ).toHaveFocus();
+ await press( next );
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemA3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemC3 ).toHaveFocus();
+ } );
+
+ test( 'Focus moves between rows/columns with wrap enabled', async () => {
+ const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
+ useTwoDimensionalTest( { wrap: true } );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press( next );
+ expect( itemA2 ).toHaveFocus();
+ await press( next );
+ expect( itemA3 ).toHaveFocus();
+ await press( next );
+ expect( itemB1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemA2 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemA1 ).toHaveFocus();
+ await press.End( itemA1, { ctrlKey: true } );
+ expect( itemC3 ).toHaveFocus();
+ await press( next );
+ expect( itemC3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC3 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps around start/end with loop and wrap enabled', async () => {
+ const { itemA1, itemC3 } = useTwoDimensionalTest( {
+ loop: true,
+ wrap: true,
+ } );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemC3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemC3 ).toHaveFocus();
+ await press( next );
+ expect( itemA1 ).toHaveFocus();
+ } );
+
+ test( 'Focus shifts if vertical neighbour unavailable when shift enabled', async () => {
+ const { itemA1, itemB1, itemB2, itemC1 } = useShiftTest( true );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowUp();
+ // A2 doesn't exist
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowDown();
+ // C2 is disabled
+ expect( itemC1 ).toHaveFocus();
+ } );
+
+ test( 'Focus does not shift if vertical neighbour unavailable when shift not enabled', async () => {
+ const { itemA1, itemB1, itemB2 } = useShiftTest( false );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowUp();
+ // A2 doesn't exist
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowDown();
+ // C2 is disabled
+ expect( itemB2 ).toHaveFocus();
+ } );
+ } );
+} );
diff --git a/packages/components/src/composite/test/index.tsx b/packages/components/src/composite/test/index.tsx
deleted file mode 100644
index 02fe6c3d1d60ab..00000000000000
--- a/packages/components/src/composite/test/index.tsx
+++ /dev/null
@@ -1,576 +0,0 @@
-/**
- * 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();
- } );
- } );
- } );
- }
-);
diff --git a/packages/components/src/composite/unstable/index.ts b/packages/components/src/composite/unstable/index.ts
new file mode 100644
index 00000000000000..f2a195091dff92
--- /dev/null
+++ b/packages/components/src/composite/unstable/index.ts
@@ -0,0 +1,23 @@
+/**
+ * Composite is a component that may contain navigable items represented by
+ * CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements
+ * all the keyboard navigation mechanisms to ensure that there's only one
+ * tab stop for the whole Composite element. This means that it can behave as
+ * a roving tabindex or aria-activedescendant container.
+ *
+ * @see https://reakit.io/docs/composite/
+ *
+ * This is now entirely deprecated in favor of [current](../current), which is
+ * based on Ariakit. A [legacy](../legacy) implementation of this API has been
+ * created for backwards-compatibility, which will eventually replace this.
+ */
+/* eslint-disable-next-line no-restricted-imports */
+export {
+ Composite,
+ CompositeGroup,
+ CompositeItem,
+ useCompositeState,
+} from 'reakit';
+
+/* eslint-disable-next-line no-restricted-imports */
+export type { CompositeStateReturn as CompositeState } from 'reakit';
diff --git a/packages/components/src/composite/unstable/stories/index.story.tsx b/packages/components/src/composite/unstable/stories/index.story.tsx
new file mode 100644
index 00000000000000..54543e80fc480d
--- /dev/null
+++ b/packages/components/src/composite/unstable/stories/index.story.tsx
@@ -0,0 +1,151 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryFn } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import {
+ Composite,
+ CompositeGroup,
+ CompositeItem,
+ useCompositeState,
+} from '..';
+
+import Legacy from '../../legacy/stories/index.story';
+import { UseCompositeStatePlaceholder, transform } from './utils';
+
+Composite.displayName = 'Composite';
+CompositeGroup.displayName = 'CompositeGroup';
+CompositeItem.displayName = 'CompositeItem';
+
+const meta: Meta< typeof UseCompositeStatePlaceholder > = {
+ title: 'Components/Composite',
+ component: UseCompositeStatePlaceholder,
+ subcomponents: {
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ Composite,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ CompositeGroup,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ CompositeItem,
+ },
+ argTypes: { ...Legacy.argTypes },
+ parameters: {
+ docs: {
+ canvas: { sourceState: 'shown' },
+ source: { transform },
+ },
+ },
+ decorators: [
+ // Because of the way Reakit caches state, this is a hack to make sure
+ // stories update when config is changed.
+ ( Story ) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+
+export const TwoDimensionsWithStateProp: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+
+ Item A1
+ Item A2
+ Item A3
+
+
+ Item B1
+ Item B2
+ Item B3
+
+
+ Item C1
+ Item C2
+ Item C3
+
+
+ );
+};
+TwoDimensionsWithStateProp.args = {};
+
+export const TwoDimensionsWithSpreadProps: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+
+ Item A1
+ Item A2
+ Item A3
+
+
+ Item B1
+ Item B2
+ Item B3
+
+
+ Item C1
+ Item C2
+ Item C3
+
+
+ );
+};
+TwoDimensionsWithSpreadProps.args = {};
+
+export const OneDimensionWithStateProp: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+
+ );
+};
+OneDimensionWithStateProp.args = {};
+
+export const OneDimensionWithSpreadProps: StoryFn<
+ typeof UseCompositeStatePlaceholder
+> = ( initialState ) => {
+ const state = useCompositeState( initialState );
+
+ return (
+
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+
+ );
+};
+OneDimensionWithSpreadProps.args = {};
diff --git a/packages/components/src/composite/unstable/stories/utils.tsx b/packages/components/src/composite/unstable/stories/utils.tsx
new file mode 100644
index 00000000000000..ed79e5ae850446
--- /dev/null
+++ b/packages/components/src/composite/unstable/stories/utils.tsx
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+import type { StoryContext } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import type { CompositeState as FullCompositeState } from '..';
+
+type CompositeState = Pick<
+ FullCompositeState,
+ 'baseId' | 'rtl' | 'orientation' | 'currentId' | 'loop' | 'wrap' | 'shift'
+>;
+
+export function UseCompositeStatePlaceholder( props: CompositeState ) {
+ return (
+
+ { Object.entries( props ).map( ( [ name, value ] ) => (
+ <>
+ - { name }
+ - { JSON.stringify( value ) }
+ >
+ ) ) }
+
+ );
+}
+UseCompositeStatePlaceholder.displayName = 'useCompositeState';
+
+export function transform( code: string, context: StoryContext ) {
+ // The output generated by Storybook for these components is
+ // messy, so we apply this transform to make it more useful
+ // for anyone reading the docs.
+ const config = ` ${ JSON.stringify( context.args, null, 2 ) } `;
+ const state = config.replace( ' {} ', '' );
+ return [
+ // Include a setup line, showing how to make use of
+ // `useCompositeState` to convert state options into
+ // a composite state option.
+ `const state = useCompositeState(${ state });`,
+ '',
+ 'return (',
+ ' ' +
+ code
+ // The generated output includes a full dump of everything
+ // in the state; the reader probably isn't interested in
+ // what that looks like, so instead we drop all of that
+ // in favor of the state generated above.
+ .replaceAll( /state=\{\{[\s\S]*?\}\}/g, 'state={ state }' )
+ // The previous line only works for `state={ state }`, and
+ // doesn't replace spread props, so we do that separately.
+ .replaceAll( '=>', '' )
+ .replaceAll( /baseId=[^>]+?(\s*>)/g, ( _, close ) => {
+ return `{ ...state }${ close }`;
+ } )
+ // Now we tidy the output by removing any unnecessary
+ // whitespace...
+ .replaceAll( //g, ( match ) =>
+ match.replaceAll( /\s+\s/g, ' ' )
+ )
+ // ...including around children...
+ .replaceAll(
+ / >\s+([\w\s]*?)\s+<\//g,
+ ( _, value ) => `>${ value }`
+ )
+ // ...and inside JSX definitions.
+ .replaceAll( '} >', '}>' )
+ // Finally we indent everything to make it more readable.
+ .replaceAll( /\n/g, '\n ' ),
+ ');',
+ ].join( '\n' );
+}
diff --git a/packages/components/src/composite/unstable/test/index.tsx b/packages/components/src/composite/unstable/test/index.tsx
new file mode 100644
index 00000000000000..bb565f63f0cda4
--- /dev/null
+++ b/packages/components/src/composite/unstable/test/index.tsx
@@ -0,0 +1,543 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { press } from '@ariakit/test';
+
+/**
+ * Internal dependencies
+ */
+import {
+ Composite,
+ CompositeGroup,
+ CompositeItem,
+ useCompositeState,
+} from '..';
+
+type InitialState = Parameters< typeof useCompositeState >[ 0 ];
+type CompositeState = ReturnType< typeof useCompositeState >;
+type CompositeStateProps = CompositeState | { state: CompositeState };
+
+function getKeys( rtl: boolean ) {
+ return {
+ previous: rtl ? 'ArrowRight' : 'ArrowLeft',
+ next: rtl ? 'ArrowLeft' : 'ArrowRight',
+ first: 'Home',
+ last: 'End',
+ };
+}
+
+function OneDimensionalTest( props: CompositeStateProps ) {
+ 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: CompositeStateProps ) {
+ 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: CompositeStateProps ) {
+ 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',
+ ( initialState?: InitialState ) => useCompositeState( initialState ),
+ ],
+ [
+ 'With `state` prop',
+ ( initialState?: InitialState ) => ( {
+ state: useCompositeState( initialState ),
+ } ),
+ ],
+] )( '%s', ( __, useProps ) => {
+ test( 'Renders as a single tab stop', async () => {
+ const Test = () => (
+ <>
+
+
+
+ >
+ );
+ render( );
+
+ await press.Tab();
+ expect( screen.getByText( 'Before' ) ).toHaveFocus();
+ await press.Tab();
+ expect( screen.getByText( 'Item 1' ) ).toHaveFocus();
+ await press.Tab();
+ expect( screen.getByText( 'After' ) ).toHaveFocus();
+ await press.ShiftTab();
+ expect( screen.getByText( 'Item 1' ) ).toHaveFocus();
+ } );
+
+ test( 'Excludes disabled items', async () => {
+ const Test = () => {
+ const props = useProps();
+ return (
+
+ Item 1
+
+ Item 2
+
+ Item 3
+
+ );
+ };
+ render( );
+
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ expect( item2 ).toBeDisabled();
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).not.toHaveFocus();
+ expect( item3 ).toHaveFocus();
+ } );
+
+ test( 'Includes focusable disabled items', async () => {
+ 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 press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ expect( item3 ).not.toHaveFocus();
+ } );
+
+ test( 'Supports `baseId`', async () => {
+ const Test = () => (
+
+ );
+ render( );
+ const { item1, item2, item3 } = getOneDimensionalItems();
+
+ 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 Test = () => (
+
+ );
+ render( );
+ const { item2 } = getOneDimensionalItems();
+
+ await press.Tab();
+ expect( item2 ).toHaveFocus();
+ } );
+} );
+
+describe.each( [
+ [ 'When LTR', false ],
+ [ 'When RTL', true ],
+] )( '%s', ( _when, rtl ) => {
+ const { previous, next, first, last } = getKeys( rtl );
+
+ 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 { item1, item2, item3 } = useOneDimensionalTest();
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item1 ).toHaveFocus();
+ await press( next );
+ expect( item2 ).toHaveFocus();
+ await press( next );
+ expect( item3 ).toHaveFocus();
+ await press( previous );
+ expect( item2 ).toHaveFocus();
+ await press( previous );
+ expect( item1 ).toHaveFocus();
+ await press.End();
+ expect( item3 ).toHaveFocus();
+ await press.Home();
+ expect( item1 ).toHaveFocus();
+ await press.PageDown();
+ expect( item3 ).toHaveFocus();
+ await press.PageUp();
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Only left/right work with horizontal orientation', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ orientation: 'horizontal',
+ } );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item1 ).toHaveFocus();
+ await press( next );
+ expect( item2 ).toHaveFocus();
+ await press( next );
+ expect( item3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item3 ).toHaveFocus();
+ await press( previous );
+ expect( item2 ).toHaveFocus();
+ await press( previous );
+ expect( item1 ).toHaveFocus();
+ await press.End();
+ expect( item3 ).toHaveFocus();
+ await press.Home();
+ expect( item1 ).toHaveFocus();
+ await press.PageDown();
+ expect( item3 ).toHaveFocus();
+ await press.PageUp();
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Only up/down work with vertical orientation', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ orientation: 'vertical',
+ } );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press( next );
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press( previous );
+ expect( item3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item1 ).toHaveFocus();
+ await press.End();
+ expect( item3 ).toHaveFocus();
+ await press.Home();
+ expect( item1 ).toHaveFocus();
+ await press.PageDown();
+ expect( item3 ).toHaveFocus();
+ await press.PageUp();
+ expect( item1 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps with loop enabled', async () => {
+ const { item1, item2, item3 } = useOneDimensionalTest( {
+ loop: true,
+ } );
+
+ await press.Tab();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item2 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( item1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( item3 ).toHaveFocus();
+ await press( next );
+ expect( item1 ).toHaveFocus();
+ await press( previous );
+ expect( item3 ).toHaveFocus();
+ } );
+ } );
+
+ describe( 'In two dimensions', () => {
+ test( 'All directions work as standard', async () => {
+ const { itemA1, itemA2, itemA3, itemB1, itemB2, itemC1, itemC3 } =
+ useTwoDimensionalTest();
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemA2 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press( last );
+ expect( itemA3 ).toHaveFocus();
+ await press.PageDown();
+ expect( itemC3 ).toHaveFocus();
+ await press( next );
+ expect( itemC3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC3 ).toHaveFocus();
+ await press( first );
+ expect( itemC1 ).toHaveFocus();
+ await press.PageUp();
+ expect( itemA1 ).toHaveFocus();
+ await press.End( null, { ctrlKey: true } );
+ expect( itemC3 ).toHaveFocus();
+ await press.Home( null, { ctrlKey: true } );
+ expect( itemA1 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps around rows/columns with loop enabled', async () => {
+ const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
+ useTwoDimensionalTest( { loop: true } );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press( next );
+ expect( itemA2 ).toHaveFocus();
+ await press( next );
+ expect( itemA3 ).toHaveFocus();
+ await press( next );
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemA3 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemC3 ).toHaveFocus();
+ } );
+
+ test( 'Focus moves between rows/columns with wrap enabled', async () => {
+ const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } =
+ useTwoDimensionalTest( { wrap: true } );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press( next );
+ expect( itemA2 ).toHaveFocus();
+ await press( next );
+ expect( itemA3 ).toHaveFocus();
+ await press( next );
+ expect( itemB1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemA2 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemA1 ).toHaveFocus();
+ await press.End( itemA1, { ctrlKey: true } );
+ expect( itemC3 ).toHaveFocus();
+ await press( next );
+ expect( itemC3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemC3 ).toHaveFocus();
+ } );
+
+ test( 'Focus wraps around start/end with loop and wrap enabled', async () => {
+ const { itemA1, itemC3 } = useTwoDimensionalTest( {
+ loop: true,
+ wrap: true,
+ } );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press( previous );
+ expect( itemC3 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowUp();
+ expect( itemC3 ).toHaveFocus();
+ await press( next );
+ expect( itemA1 ).toHaveFocus();
+ } );
+
+ test( 'Focus shifts if vertical neighbour unavailable when shift enabled', async () => {
+ const { itemA1, itemB1, itemB2, itemC1 } = useShiftTest( true );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowUp();
+ // A2 doesn't exist
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowDown();
+ // C2 is disabled
+ expect( itemC1 ).toHaveFocus();
+ } );
+
+ test( 'Focus does not shift if vertical neighbour unavailable when shift not enabled', async () => {
+ const { itemA1, itemB1, itemB2 } = useShiftTest( false );
+
+ await press.Tab();
+ expect( itemA1 ).toHaveFocus();
+ await press.ArrowDown();
+ expect( itemB1 ).toHaveFocus();
+ await press( next );
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowUp();
+ // A2 doesn't exist
+ expect( itemB2 ).toHaveFocus();
+ await press.ArrowDown();
+ // C2 is disabled
+ expect( itemB2 ).toHaveFocus();
+ } );
+ } );
+} );
diff --git a/packages/components/src/composite/v2.ts b/packages/components/src/composite/v2.ts
index d329fd3fd11dfb..5e3e8c13fd05e7 100644
--- a/packages/components/src/composite/v2.ts
+++ b/packages/components/src/composite/v2.ts
@@ -1,22 +1,4 @@
-/**
- * Composite is a component that may contain navigable items represented by
- * CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements
- * all the keyboard navigation mechanisms to ensure that there's only one
- * tab stop for the whole Composite element. This means that it can behave as
- * a roving tabindex or aria-activedescendant container.
- *
- * @see https://ariakit.org/components/composite
- */
+// Until we migrate away from Reakit, the 'current'
+// Ariakit implementation is considered a v2.
-/* eslint-disable-next-line no-restricted-imports */
-export {
- Composite,
- CompositeGroup,
- CompositeGroupLabel,
- CompositeItem,
- CompositeRow,
- useCompositeStore,
-} from '@ariakit/react';
-
-/* eslint-disable-next-line no-restricted-imports */
-export type { CompositeStore } from '@ariakit/react';
+export * from './current';