diff --git a/packages/components/src/composite/test/index.tsx b/packages/components/src/composite/test/index.tsx new file mode 100644 index 0000000000000..eaeffda502fd7 --- /dev/null +++ b/packages/components/src/composite/test/index.tsx @@ -0,0 +1,444 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable testing-library/render-result-naming-convention */ + +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { createRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useCompositeState as reakit } from '..'; +import type { CompositeInitialState, CompositeStateReturn } from '../types'; + +type InitialState = Partial< CompositeInitialState >; + +const DEFAULT_BASE_ID = 'base'; + +const DEFAULT_INITIAL_STATE: CompositeInitialState = { + baseId: DEFAULT_BASE_ID, + currentId: undefined, + loop: false, + orientation: undefined, + rtl: false, + shift: false, + unstable_virtual: false, + wrap: false, +}; + +const DEFAULT_PROPS = { + ...DEFAULT_INITIAL_STATE, + groups: [], + items: [], + unstable_hasActiveWidget: false, + unstable_idCountRef: { current: 0 }, + unstable_includesBaseElement: false, + unstable_moves: 0, +}; + +const DEFAULT_ACTIONS = { + down: expect.any( Function ), + first: expect.any( Function ), + last: expect.any( Function ), + move: expect.any( Function ), + next: expect.any( Function ), + previous: expect.any( Function ), + registerGroup: expect.any( Function ), + registerItem: expect.any( Function ), + reset: expect.any( Function ), + setBaseId: expect.any( Function ), + setCurrentId: expect.any( Function ), + setLoop: expect.any( Function ), + setOrientation: expect.any( Function ), + setRTL: expect.any( Function ), + setShift: expect.any( Function ), + setWrap: expect.any( Function ), + sort: expect.any( Function ), + unregisterGroup: expect.any( Function ), + unregisterItem: expect.any( Function ), + unstable_setHasActiveWidget: expect.any( Function ), + unstable_setIncludesBaseElement: expect.any( Function ), + unstable_setVirtual: expect.any( Function ), + up: expect.any( Function ), +}; + +const DEFAULT_STATE = { + ...DEFAULT_PROPS, + ...DEFAULT_ACTIONS, +}; + +function createItemRef( index: number ) { + const node = document.createElement( 'div' ); + node.id = '' + index; + node.compareDocumentPosition = ( other ): number => { + return ( other as HTMLElement ).id < node.id + ? node.DOCUMENT_POSITION_PRECEDING + : node.DOCUMENT_POSITION_FOLLOWING; + }; + const ref: React.MutableRefObject< HTMLElement | null > = createRef(); + ref.current = node; + return ref; +} + +function createItem( stem: string, index: number, groupId?: string ) { + return { + id: `${ stem }-${ index }`, + ref: createItemRef( index ), + groupId, + }; +} + +function initialiseItems( + context: CompositeStateReturn, + stem = 'test', + count = 3 +) { + act( () => { + for ( let index = 1; index <= count; index++ ) { + context.registerItem( createItem( stem, index ) ); + } + } ); +} + +function initialiseGroups( + context: CompositeStateReturn, + stem = 'test', + count = 3 +) { + act( () => { + for ( let index = 1; index <= count; index++ ) { + const id = `${ stem }-group-${ index }`; + context.registerGroup( { id, ref: createRef() } ); + context.registerItem( createItem( stem, index, id ) ); + } + } ); +} + +describe.each( [ + [ 'reakit', reakit ], + // [ 'ariakit', ariakit ] to be implemented +] )( 'Validate %s implementation', ( _, useCompositeState ) => { + function renderState( { + baseId = DEFAULT_BASE_ID, + ...additionalState + }: InitialState = {} ) { + const initialState = { baseId, ...additionalState }; + return renderHook( () => useCompositeState( initialState ) ).result; + } + + describe( 'State', () => { + test( 'No initial state', () => { + const state = renderState(); + expect( state.current ).toEqual( DEFAULT_STATE ); + } ); + + test.each( [ + [ '`baseId`', { baseId: 'test' } ], + [ '`currentId`', { currentId: 'test' } ], + [ '`loop` [boolean]', { loop: true } ], + [ '`loop` [horizontal]', { loop: 'horizontal' } ], + [ '`loop` [vertical]', { loop: 'vertical' } ], + [ '`orientation` [horizontal]', { orientation: 'horizontal' } ], + [ '`orientation` [vertical]', { orientation: 'vertical' } ], + [ '`rtl`', { rtl: true } ], + [ '`shift`', { shift: true } ], + [ '`unstable_virtual`', { unstable_virtual: true } ], + [ '`wrap` [boolean]', { wrap: true } ], + [ '`wrap` [horizontal]', { wrap: 'horizontal' } ], + [ '`wrap` [vertical]', { wrap: 'vertical' } ], + ] )( '%s', ( __, initialState ) => { + const state = renderState( initialState as InitialState ); + + expect( state.current ).toEqual( { + ...DEFAULT_STATE, + ...initialState, + } ); + } ); + } ); + + describe( 'Actions', () => { + describe( 'Basic setters', () => { + test( '`setBaseId`', () => { + const state = renderState(); + act( () => state.current.setBaseId( 'test' ) ); + expect( state.current.baseId ).toBe( 'test' ); + } ); + + test( '`setCurrentId`', () => { + const state = renderState(); + act( () => state.current.setCurrentId( 'test' ) ); + expect( state.current.currentId ).toBe( 'test' ); + } ); + + test( '`setLoop`', () => { + const state = renderState(); + act( () => state.current.setLoop( true ) ); + expect( state.current.loop ).toBe( true ); + } ); + + test( '`setOrientation`', () => { + const state = renderState(); + act( () => state.current.setOrientation( 'horizontal' ) ); + expect( state.current.orientation ).toBe( 'horizontal' ); + } ); + + test( '`setRTL`', () => { + const state = renderState(); + act( () => state.current.setRTL( true ) ); + expect( state.current.rtl ).toBe( true ); + } ); + + test( '`setShift`', () => { + const state = renderState(); + act( () => state.current.setShift( true ) ); + expect( state.current.shift ).toBe( true ); + } ); + + test( '`setWrap`', () => { + const state = renderState(); + act( () => state.current.setWrap( true ) ); + expect( state.current.wrap ).toBe( true ); + } ); + } ); + + describe( 'Unstable setters', () => { + test( '`unstable_setHasActiveWidget`', () => { + const state = renderState(); + + act( () => state.current.unstable_setHasActiveWidget( true ) ); + expect( state.current.unstable_hasActiveWidget ).toBe( true ); + + act( () => state.current.unstable_setHasActiveWidget( false ) ); + expect( state.current.unstable_hasActiveWidget ).toBe( false ); + } ); + + test( '`unstable_setIncludesBaseElement`', () => { + const state = renderState(); + + act( () => + state.current.unstable_setIncludesBaseElement( true ) + ); + expect( state.current.unstable_includesBaseElement ).toBe( + true + ); + + act( () => + state.current.unstable_setIncludesBaseElement( false ) + ); + expect( state.current.unstable_includesBaseElement ).toBe( + false + ); + } ); + + test( '`unstable_setVirtual`', () => { + const state = renderState(); + + act( () => state.current.unstable_setVirtual( true ) ); + expect( state.current.unstable_virtual ).toBe( true ); + + act( () => state.current.unstable_setVirtual( false ) ); + expect( state.current.unstable_virtual ).toBe( false ); + } ); + } ); + + describe( 'Registration handlers', () => { + test( '`registerItem`', () => { + const state = renderState(); + + act( () => + state.current.registerItem( { + id: 'test', + ref: createRef(), + } ) + ); + + expect( state.current.items ).toMatchObject( [ + { id: 'test' }, + ] ); + } ); + + test( '`registerGroup`', () => { + const state = renderState(); + act( () => + state.current.registerGroup( { + id: 'test', + ref: createRef(), + } ) + ); + + expect( state.current.groups ).toMatchObject( [ + { id: 'test' }, + ] ); + } ); + } ); + + describe( 'Movement', () => { + test( '`move` [specified]', () => { + const state = renderState(); + initialiseItems( state.current ); + act( () => state.current.move( 'test-2' ) ); + + expect( state.current.currentId ).toBe( 'test-2' ); + } ); + + test( '`move` [unspecified]', () => { + const state = renderState(); + initialiseItems( state.current ); + act( () => state.current.move( null ) ); + + expect( state.current.currentId ).toBe( null ); + } ); + + test( '`first`', () => { + const state = renderState(); + initialiseItems( state.current ); + act( () => state.current.first() ); + + expect( state.current.currentId ).toBe( 'test-1' ); + } ); + + test( '`last`', () => { + const state = renderState(); + initialiseItems( state.current ); + act( () => state.current.last() ); + + expect( state.current.currentId ).toBe( 'test-3' ); + } ); + + test( '`previous`', () => { + const state = renderState( { + currentId: 'test-3', + } ); + initialiseItems( state.current ); + act( () => state.current.previous() ); + + expect( state.current.currentId ).toBe( 'test-2' ); + } ); + + test( '`previous` [all the way]', () => { + const state = renderState( { + currentId: 'test-3', + } ); + initialiseItems( state.current ); + act( () => state.current.previous( true ) ); + + expect( state.current.currentId ).toBe( 'test-1' ); + } ); + + test( '`next`', () => { + const state = renderState( { + currentId: 'test-1', + } ); + initialiseItems( state.current ); + act( () => state.current.next() ); + + expect( state.current.currentId ).toBe( 'test-2' ); + } ); + + test( '`next` [all the way]', () => { + const state = renderState( { + currentId: 'test-1', + } ); + initialiseItems( state.current ); + act( () => state.current.next( true ) ); + + expect( state.current.currentId ).toBe( 'test-3' ); + } ); + + test( '`up`', () => { + const state = renderState( { + currentId: 'test-3', + } ); + initialiseGroups( state.current ); + act( () => state.current.up() ); + + expect( state.current.currentId ).toBe( 'test-2' ); + } ); + + test( '`up` [all the way]', () => { + const state = renderState( { + currentId: 'test-3', + } ); + initialiseGroups( state.current ); + act( () => state.current.up( true ) ); + + expect( state.current.currentId ).toBe( 'test-1' ); + } ); + + test( '`down`', () => { + const state = renderState( { + currentId: 'test-1', + } ); + initialiseGroups( state.current ); + act( () => state.current.down() ); + + expect( state.current.currentId ).toBe( 'test-2' ); + } ); + + test( '`down` [all the way]', () => { + const state = renderState( { + currentId: 'test-1', + } ); + initialiseGroups( state.current ); + act( () => state.current.down( true ) ); + + expect( state.current.currentId ).toBe( 'test-3' ); + } ); + } ); + + describe( 'Other', () => { + test( '`sort`', () => { + const state = renderState(); + initialiseItems( state.current ); + + expect( state.current.items ).toMatchObject( [ + { id: 'test-1' }, + { id: 'test-2' }, + { id: 'test-3' }, + ] ); + + for ( const item of state.current.items ) { + if ( item.ref.current ) { + item.ref.current.id = + '' + ( 4 - parseInt( item.ref.current.id, 10 ) ); + } + } + + act( () => state.current.sort() ); + + expect( state.current.items ).toMatchObject( [ + { id: 'test-3' }, + { id: 'test-2' }, + { id: 'test-1' }, + ] ); + } ); + + test( '`reset`', () => { + const state = renderState(); + + act( () => { + state.current.setLoop( true ); + state.current.setCurrentId( 'test' ); + } ); + + expect( state.current ).toMatchObject( { + loop: true, + currentId: 'test', + } ); + + act( () => state.current.reset() ); + + expect( state.current ).toMatchObject( { + loop: false, + currentId: undefined, + } ); + } ); + } ); + } ); +} ); diff --git a/packages/components/src/composite/types.ts b/packages/components/src/composite/types.ts new file mode 100644 index 0000000000000..c76675f80c3d0 --- /dev/null +++ b/packages/components/src/composite/types.ts @@ -0,0 +1,345 @@ +type IdActions = { + /** + * Sets `baseId`. + */ + setBaseId: React.Dispatch< React.SetStateAction< string > >; +}; + +type IdState = { + baseId: string; + /** + * @private + */ + unstable_idCountRef: React.MutableRefObject< number >; +}; + +type IdInitialState = Partial< Pick< IdState, 'baseId' > >; + +type IdStateReturn = IdState & IdActions; + +type Group = { + id: string; + ref: React.RefObject< HTMLElement >; +}; + +type Item = { + id: string | null; + ref: React.RefObject< HTMLElement >; + groupId?: Group[ 'id' ]; + disabled?: boolean; +}; + +type Orientation = 'horizontal' | 'vertical'; + +type CompositeState = IdState & { + /** + * If enabled, the composite element will act as an + * [aria-activedescendant](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_focus_activedescendant) + * container instead of + * [roving tabindex](https://www.w3.org/TR/wai-aria-practices/#kbd_roving_tabindex). + * DOM focus will remain on the composite while its items receive virtual focus. + * @default false + */ + unstable_virtual: boolean; + /** + * 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: + * - `undefined`: all arrow keys work. + * - `horizontal`: only left and right arrow keys work. + * - `vertical`: only up and down arrow keys work. + * + * It doesn't have any effect on two-dimensional composites. + * @default undefined + */ + orientation?: Orientation; + /** + * Lists all the composite items with their `id`, DOM `ref`, `disabled` state + * and `groupId` if any. This state is automatically updated when + * `registerItem` and `unregisterItem` are called. + * @example + * const composite = useCompositeState(); + * composite.items.forEach((item) => { + * const { id, ref, disabled, groupId } = item; + * ... + * }); + */ + items: Item[]; + /** + * Lists all the composite groups with their `id` and DOM `ref`. This state + * is automatically updated when `registerGroup` and `unregisterGroup` are + * called. + * @example + * const composite = useCompositeState(); + * composite.groups.forEach((group) => { + * const { id, ref } = group; + * ... + * }); + */ + groups: Group[]; + /** + * The current focused item `id`. + * - `undefined` will automatically focus the first enabled composite item. + * - `null` will focus the base composite element and users will be able to + * navigate out of it using arrow keys. + * - If `currentId` is initially set to `null`, the base composite element + * itself will have focus and users will be able to navigate to it using + * arrow keys. + * @default undefined + * @example + * // First enabled item has initial focus + * useCompositeState(); + * // Base composite element has initial focus + * useCompositeState({ currentId: null }); + * // Specific composite item element has initial focus + * useCompositeState({ currentId: "item-id" }); + */ + currentId?: string | null; + /** + * On one-dimensional composites: + * - `true` loops from the last item to the first item and vice-versa. + * - `horizontal` loops only if `orientation` is `horizontal` or not set. + * - `vertical` loops only if `orientation` is `vertical` or not set. + * - If `currentId` is initially set to `null`, the composite element will + * be focused in between the last and first items. + * + * On two-dimensional composites: + * - `true` loops from the last row/column item to the first item in the + * same row/column and vice-versa. If it's the last item in the last row, it + * moves to the first item in the first row and vice-versa. + * - `horizontal` loops only from the last row item to the first item in + * the same row. + * - `vertical` loops only from the last column item to the first item in + * the column row. + * - If `currentId` is initially set to `null`, vertical loop will have no + * effect as moving down from the last row or up from the first row will + * focus the composite element. + * - If `wrap` matches the value of `loop`, it'll wrap between the last + * item in the last row or column and the first item in the first row or + * column and vice-versa. + * @default false + */ + loop: boolean | Orientation; + /** + * **Has effect only on two-dimensional composites**. 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. + * - `true` wraps between rows and columns. + * - `horizontal` wraps only between rows. + * - `vertical` wraps only between columns. + * - If `loop` matches the value of `wrap`, it'll wrap between the last + * item in the last row or column and the first item in the first row or + * column and vice-versa. + * @default false + */ + wrap: boolean | Orientation; + /** + * **Has effect only on two-dimensional composites**. 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. + * @default false + */ + shift: boolean; + /** + * Stores the number of moves that have been performed by calling `move`, + * `next`, `previous`, `up`, `down`, `first` or `last`. + * @default 0 + */ + unstable_moves: number; + /** + * @default false + * @private + */ + unstable_hasActiveWidget: boolean; + /** + * @default false + * @private + */ + unstable_includesBaseElement: boolean; +}; + +export type CompositeInitialState = IdInitialState & + Partial< + Pick< + CompositeState, + | 'unstable_virtual' + | 'rtl' + | 'orientation' + | 'currentId' + | 'loop' + | 'wrap' + | 'shift' + | 'unstable_includesBaseElement' + > + >; + +type CompositeActions = IdActions & { + /** + * Registers a composite item. + * @example + * const ref = React.useRef(); + * const composite = useCompositeState(); + * React.useEffect(() => { + * composite.registerItem({ ref, id: "id" }); + * return () => composite.unregisterItem("id"); + * }, []); + */ + registerItem: ( item: Item ) => void; + /** + * Unregisters a composite item. + * @example + * const ref = React.useRef(); + * const composite = useCompositeState(); + * React.useEffect(() => { + * composite.registerItem({ ref, id: "id" }); + * return () => composite.unregisterItem("id"); + * }, []); + */ + unregisterItem: ( id: string ) => void; + /** + * Registers a composite group. + * @example + * const ref = React.useRef(); + * const composite = useCompositeState(); + * React.useEffect(() => { + * composite.registerGroup({ ref, id: "id" }); + * return () => composite.unregisterGroup("id"); + * }, []); + */ + registerGroup: ( group: Group ) => void; + /** + * Unregisters a composite group. + * @example + * const ref = React.useRef(); + * const composite = useCompositeState(); + * React.useEffect(() => { + * composite.registerGroup({ ref, id: "id" }); + * return () => composite.unregisterGroup("id"); + * }, []); + */ + unregisterGroup: ( id: string ) => void; + /** + * Moves focus to a given item ID. + * @example + * const composite = useCompositeState(); + * composite.move("item-2"); // focus item 2 + */ + move: ( id: string | null ) => void; + /** + * Moves focus to the next item. + */ + next: ( unstable_allTheWay?: boolean ) => void; + /** + * Moves focus to the previous item. + */ + previous: ( unstable_allTheWay?: boolean ) => void; + /** + * Moves focus to the item above. + */ + up: ( unstable_allTheWay?: boolean ) => void; + /** + * Moves focus to the item below. + */ + down: ( unstable_allTheWay?: boolean ) => void; + /** + * Moves focus to the first item. + */ + first: () => void; + /** + * Moves focus to the last item. + */ + last: () => void; + /** + * Sorts the `composite.items` based on the items position in the DOM. This + * is especially useful after modifying the composite items order in the DOM. + * Most of the time, though, you don't need to manually call this function as + * the re-ordering happens automatically. + */ + sort: () => void; + /** + * Sets `virtual`. + */ + unstable_setVirtual: React.Dispatch< + React.SetStateAction< CompositeState[ 'unstable_virtual' ] > + >; + /** + * Sets `rtl`. + * @example + * const composite = useCompositeState({ rtl: true }); + * composite.setRTL(false); + */ + setRTL: React.Dispatch< React.SetStateAction< CompositeState[ 'rtl' ] > >; + /** + * Sets `orientation`. + */ + setOrientation: React.Dispatch< + React.SetStateAction< CompositeState[ 'orientation' ] > + >; + /** + * Sets `currentId`. This is different from `composite.move` as this only + * updates the `currentId` state without moving focus. When the composite + * widget gets focused by the user, the item referred by the `currentId` + * state will get focus. + * @example + * const composite = useCompositeState({ currentId: "item-1" }); + * // Updates `composite.currentId` to `item-2` + * composite.setCurrentId("item-2"); + */ + setCurrentId: React.Dispatch< + React.SetStateAction< CompositeState[ 'currentId' ] > + >; + /** + * Sets `loop`. + */ + setLoop: React.Dispatch< React.SetStateAction< CompositeState[ 'loop' ] > >; + /** + * Sets `wrap`. + */ + setWrap: React.Dispatch< React.SetStateAction< CompositeState[ 'wrap' ] > >; + /** + * Sets `shift`. + */ + setShift: React.Dispatch< + React.SetStateAction< CompositeState[ 'shift' ] > + >; + /** + * Resets to initial state. + * @example + * // On initial render, currentId will be item-1 and loop will be true + * const composite = useCompositeState({ + * currentId: "item-1", + * loop: true, + * }); + * // On next render, currentId will be item-2 and loop will be false + * composite.setCurrentId("item-2"); + * composite.setLoop(false); + * // On next render, currentId will be item-1 and loop will be true + * composite.reset(); + */ + reset: () => void; + /** + * Sets `includesBaseElement`. + * @private + */ + unstable_setIncludesBaseElement: React.Dispatch< + React.SetStateAction< CompositeState[ 'unstable_includesBaseElement' ] > + >; + /** + * Sets `hasActiveWidget`. + * @private + */ + unstable_setHasActiveWidget: React.Dispatch< + React.SetStateAction< CompositeState[ 'unstable_hasActiveWidget' ] > + >; +}; + +export type CompositeStateReturn = IdStateReturn & + CompositeState & + CompositeActions;