/** * External dependencies */ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; /** * WordPress dependencies */ import { useMemo, useState } from '@wordpress/element'; /** * Internal dependencies */ import DataViewsPicker from '../index'; import { LAYOUT_PICKER_GRID } from '../../constants'; import type { ActionButton, View, ViewPickerGrid } from '../../types'; import filterSortAndPaginate from '../../utils/filter-sort-and-paginate'; type Data = { id: number; title: string; author?: number; order?: number; }; const onChangeSelection = jest.fn(); const data: Data[] = [ { id: 1, title: 'Hello World', author: 1, order: 1, }, { id: 2, title: 'Homepage', author: 2, order: 1, }, { id: 3, title: 'Posts', author: 2, order: 1, }, ]; const singleSelectCallback = jest.fn(); const singleSelectActions: ActionButton< Data >[] = [ { id: 'confirm', label: 'Confirm', supportsBulk: false, isPrimary: true, callback: singleSelectCallback, }, ]; const multiSelectCallback = jest.fn(); const multiSelectActions: ActionButton< Data >[] = [ { id: 'confirm', label: 'Confirm', supportsBulk: true, isPrimary: true, icon: 'check', callback: multiSelectCallback, }, ]; function Picker( { view: additionalView, actions, label, multiselect, ...props }: { actions?: ActionButton< Data >[]; view?: Partial< View >; label?: string; multiselect?: boolean; } ) { const [ view, setView ] = useState< View >( { type: LAYOUT_PICKER_GRID, fields: [], titleField: 'title', mediaField: 'image', search: '', page: 1, perPage: 10, filters: [], ...additionalView, } as ViewPickerGrid ); const [ selection, setSelection ] = useState< string[] >( [] ); const { data: shownData, paginationInfo } = useMemo( () => { return filterSortAndPaginate( data, view, [] ); }, [ view ] ); const dataViewProps = { actions, picker: true, getItemId: ( item: Data ) => item.id.toString(), paginationInfo, data: shownData, view, defaultLayouts: { [ LAYOUT_PICKER_GRID ]: {} }, fields: [], onChangeView: setView, multiselect, selection, itemListLabel: label, onChangeSelection: ( newSelection: string[] ) => { onChangeSelection( newSelection ); setSelection( newSelection ); }, ...props, }; return ; } describe( 'DataViews Picker', () => { describe( 'Grid layout', () => { it( 'renders the grid as a `listbox` role, with items as `option` roles', () => { render( ); // Grid should have listbox role expect( screen.getByRole( 'listbox' ) ).toBeInTheDocument(); // Each data item should have option role const options = screen.getAllByRole( 'option' ); expect( options ).toHaveLength( data.length ); } ); it( 'supports specifying a `label` which is rendered as an aria-label', () => { const testLabel = 'Select an item from the grid'; render( ); // Grid should have the specified aria-label expect( screen.getByRole( 'listbox', { name: testLabel } ) ).toBeInTheDocument(); } ); it( 'implements single tab-stop composite pattern with aria-activedescendant', async () => { render( ); // Grid should be tabbable as the main composite widget const grid = screen.getByRole( 'listbox' ); expect( grid ).toHaveAttribute( 'tabindex', '0' ); // Individual options exist but are managed by composite pattern const options = screen.getAllByRole( 'option' ); expect( options.length ).toBeGreaterThan( 0 ); const user = userEvent.setup(); const viewOptionsButton = screen.getByRole( 'button', { name: 'View options', } ); // Focus the viewOptions button, which is just before the grid. viewOptionsButton.focus(); expect( viewOptionsButton ).toHaveFocus(); // Tab to the grid (single tab-stop for the entire grid) await user.keyboard( '{Tab}' ); expect( grid ).toHaveFocus(); // Test aria-activedescendant behavior // Trigger navigation to establish aria-activedescendant await user.keyboard( '{ArrowRight}' ); await user.keyboard( '{ArrowLeft}' ); const firstActiveDescendant = grid.getAttribute( 'aria-activedescendant' ); expect( firstActiveDescendant ).toBeTruthy(); expect( firstActiveDescendant ).toBe( options[ 0 ].id ); // Navigate with arrow keys to test aria-activedescendant changes await user.keyboard( '{ArrowRight}' ); expect( grid ).toHaveFocus(); // Check that aria-activedescendant changed after navigation const secondActiveDescendant = grid.getAttribute( 'aria-activedescendant' ); expect( secondActiveDescendant ).toBeTruthy(); expect( secondActiveDescendant ).toBe( options[ 1 ].id ); expect( secondActiveDescendant ).not.toBe( firstActiveDescendant ); // Navigate to third option await user.keyboard( '{ArrowRight}' ); expect( grid ).toHaveFocus(); const thirdActiveDescendant = grid.getAttribute( 'aria-activedescendant' ); expect( thirdActiveDescendant ).toBeTruthy(); expect( thirdActiveDescendant ).toBe( options[ 2 ].id ); expect( thirdActiveDescendant ).not.toBe( firstActiveDescendant ); expect( thirdActiveDescendant ).not.toBe( secondActiveDescendant ); // Navigate back to first option await user.keyboard( '{ArrowLeft}' ); expect( grid ).toHaveFocus(); await user.keyboard( '{ArrowLeft}' ); expect( grid ).toHaveFocus(); // Verify aria-activedescendant is back to first option const backToFirstActiveDescendant = grid.getAttribute( 'aria-activedescendant' ); expect( backToFirstActiveDescendant ).toBe( firstActiveDescendant ); expect( backToFirstActiveDescendant ).toBe( options[ 0 ].id ); // Tab should move focus away from the grid entirely await user.keyboard( '{Tab}' ); expect( grid ).not.toHaveFocus(); // Shift+Tab should move back to the grid await user.keyboard( '{Shift>}{Tab}{/Shift}' ); expect( grid ).toHaveFocus(); // aria-activedescendant should be maintained when returning to grid expect( grid.getAttribute( 'aria-activedescendant' ) ).toBeTruthy(); } ); describe( 'Single selection', () => { it( 'maintains only a single selected item and calls the `onChangeSelection` callback when the selection changes', async () => { render( ); const user = userEvent.setup(); const listbox = screen.getByRole( 'listbox' ); const options = within( listbox ).getAllByRole( 'option' ); // Click first item await user.click( options[ 0 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( options[ 1 ] ).toHaveAttribute( 'aria-selected', 'false' ); expect( options[ 2 ] ).toHaveAttribute( 'aria-selected', 'false' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 0 ].id.toString(), ] ); // Click second item - should deselect first await user.click( options[ 1 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'false' ); expect( options[ 1 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( options[ 2 ] ).toHaveAttribute( 'aria-selected', 'false' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 1 ].id.toString(), ] ); } ); it( 'calls the action callback when the action button is clicked', async () => { render( ); const user = userEvent.setup(); const options = screen.getAllByRole( 'option' ); // Select first item await user.click( options[ 0 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); // Find the action button with correct label within the action buttons container const confirmButton = screen.getByRole( 'button', { name: 'Confirm', } ); expect( confirmButton ).toBeInTheDocument(); // Clear any previous calls and click the action button singleSelectCallback.mockClear(); await user.click( confirmButton ); // Verify the callback was called with correct parameters expect( singleSelectCallback ).toHaveBeenCalledTimes( 1 ); } ); } ); describe( 'Multi selection', () => { it( 'adds the `aria-multiselectable` attribute to the listbox', () => { render( ); const listbox = screen.getByRole( 'listbox' ); expect( listbox ).toHaveAttribute( 'aria-multiselectable', 'true' ); } ); it( 'supports multiple selected items and calls the `onChangeSelection` callback when the selection changes', async () => { // Test multi-selection by clicking multiple items render( ); const user = userEvent.setup(); const listbox = screen.getByRole( 'listbox' ); const options = within( listbox ).getAllByRole( 'option' ); // Click first item await user.click( options[ 0 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( options[ 1 ] ).toHaveAttribute( 'aria-selected', 'false' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 0 ].id.toString(), ] ); // Click second item - both should remain selected in multi-select mode await user.click( options[ 1 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( options[ 1 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 0 ].id.toString(), data[ 1 ].id.toString(), ] ); // Click first item again to deselect it await user.click( options[ 0 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'false' ); expect( options[ 1 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 1 ].id.toString(), ] ); } ); it( 'calls the action callback when the action button is clicked', async () => { render( ); const user = userEvent.setup(); const options = screen.getAllByRole( 'option' ); // Select multiple items await user.click( options[ 0 ] ); await user.click( options[ 1 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( options[ 1 ] ).toHaveAttribute( 'aria-selected', 'true' ); // Third item should remain unselected expect( options[ 2 ] ).toHaveAttribute( 'aria-selected', 'false' ); // Find the action button with correct label within the action buttons container const confirmButton = screen.getByRole( 'button', { name: 'Confirm', } ); expect( confirmButton ).toBeInTheDocument(); // Clear any previous calls and click the action button multiSelectCallback.mockClear(); await user.click( confirmButton ); // Verify the callback was called with correct parameters for multi-selection expect( multiSelectCallback ).toHaveBeenCalledTimes( 1 ); } ); it( 'maintains the selected items when navigating between pages for a paginated view', async () => { // Create a component with pagination (2 items per page) render( ); const user = userEvent.setup(); const listbox = screen.getByRole( 'listbox' ); // Page 1: Select first item let options = within( listbox ).getAllByRole( 'option' ); expect( options ).toHaveLength( 2 ); // Should show 2 items per page await user.click( options[ 0 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 0 ].id.toString(), ] ); // Navigate to page 2 const nextButton = screen.getByRole( 'button', { name: /next/i, } ); await user.click( nextButton ); // Page 2: Select another item options = within( listbox ).getAllByRole( 'option' ); expect( options ).toHaveLength( 1 ); // Page 2 should have 1 item (item 3) await user.click( options[ 0 ] ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); expect( onChangeSelection ).toHaveBeenCalledWith( [ data[ 0 ].id.toString(), data[ 2 ].id.toString(), ] ); // Go back to page 1 const prevButton = screen.getByRole( 'button', { name: /previous/i, } ); await user.click( prevButton ); // Verify first item is still selected options = within( listbox ).getAllByRole( 'option' ); expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); } ); } ); } ); } );