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 }', '}>' ) + // 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 }', '}>' ) + // 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 }', '}>' ) + // 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';