diff --git a/packages/components/src/custom-select-control/test/index.js b/packages/components/src/custom-select-control/test/index.js index 150afe4aa75f51..52bb841a4f953e 100644 --- a/packages/components/src/custom-select-control/test/index.js +++ b/packages/components/src/custom-select-control/test/index.js @@ -4,54 +4,205 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + /** * Internal dependencies */ import CustomSelectControl from '..'; -describe( 'CustomSelectControl', () => { - it( 'Captures the keypress event and does not let it propagate', async () => { - const user = userEvent.setup(); - const onKeyDown = jest.fn(); - const options = [ - { - key: 'one', - name: 'Option one', - }, - { - key: 'two', - name: 'Option two', - }, - { - key: 'three', - name: 'Option three', +const customClass = 'amber-skies'; + +const props = { + label: 'label!', + options: [ + { + key: 'flower1', + name: 'violets', + }, + { + key: 'flower2', + name: 'crimson clover', + className: customClass, + }, + { + key: 'flower3', + name: 'poppy', + }, + { + key: 'color1', + name: 'amber', + className: customClass, + }, + { + key: 'color2', + name: 'aquamarine', + style: { + backgroundColor: 'rgb(127, 255, 212)', + rotate: '13deg', }, - ]; + }, + ], + __nextUnconstrainedWidth: true, +}; - render( -
- -
+const ControlledCustomSelectControl = ( { options } ) => { + const [ value, setValue ] = useState( options[ 0 ] ); + return ( + setValue( selectedItem ) } + value={ options.find( ( option ) => option.key === value.key ) } + /> + ); +}; + +describe.each( [ + [ 'uncontrolled', CustomSelectControl ], + [ 'controlled', ControlledCustomSelectControl ], +] )( 'CustomSelectControl %s', ( ...modeAndComponent ) => { + const [ , Component ] = modeAndComponent; + + it( 'Should replace the initial selection when a new item is selected', async () => { + const user = userEvent.setup(); + + render( ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.click( currentSelectedItem ); + + await user.click( + screen.getByRole( 'option', { + name: 'crimson clover', + } ) + ); + + expect( currentSelectedItem ).toHaveTextContent( 'crimson clover' ); + + await user.click( currentSelectedItem ); + + await user.click( + screen.getByRole( 'option', { + name: 'poppy', + } ) + ); + + expect( currentSelectedItem ).toHaveTextContent( 'poppy' ); + } ); + + it( 'Should keep current selection if dropdown is closed without changing selection', async () => { + const user = userEvent.setup(); + + render( ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + await user.keyboard( '{enter}' ); + expect( + screen.getByRole( 'listbox', { + name: 'label!', + } ) + ).toBeVisible(); + + await user.keyboard( '{escape}' ); + expect( + screen.queryByRole( 'listbox', { + name: 'label!', + } ) + ).not.toBeInTheDocument(); + + expect( currentSelectedItem ).toHaveTextContent( + props.options[ 0 ].name + ); + } ); + + it( 'Should apply class only to options that have a className defined', async () => { + const user = userEvent.setup(); + + render( ); + + await user.click( + screen.getByRole( 'button', { + expanded: false, + } ) + ); + + // return an array of items _with_ a className added + const itemsWithClass = props.options.filter( + ( option ) => option.className !== undefined + ); + + // assert against filtered array + itemsWithClass.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).toHaveClass( + customClass + ) + ); + + // return an array of items _without_ a className added + const itemsWithoutClass = props.options.filter( + ( option ) => option.className === undefined + ); + + // assert against filtered array + itemsWithoutClass.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).not.toHaveClass( + customClass + ) + ); + } ); + + it( 'Should apply styles only to options that have styles defined', async () => { + const user = userEvent.setup(); + const customStyles = + 'background-color: rgb(127, 255, 212); rotate: 13deg;'; + + render( ); + + await user.click( + screen.getByRole( 'button', { + expanded: false, + } ) + ); + + // return an array of items _with_ styles added + const styledItems = props.options.filter( + ( option ) => option.style !== undefined ); - const toggleButton = screen.getByRole( 'button' ); - await user.click( toggleButton ); - const customSelect = screen.getByRole( 'listbox' ); - await user.type( customSelect, '{enter}' ); + // assert against filtered array + styledItems.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).toHaveStyle( + customStyles + ) + ); + + // return an array of items _without_ styles added + const unstyledItems = props.options.filter( + ( option ) => option.style === undefined + ); - expect( onKeyDown ).toHaveBeenCalledTimes( 0 ); + // assert against filtered array + unstyledItems.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).not.toHaveStyle( + customStyles + ) + ); } ); it( 'does not show selected hint by default', () => { render( { __experimentalHint: 'Hint', }, ] } - __nextUnconstrainedWidth /> ); expect( @@ -71,6 +221,7 @@ describe( 'CustomSelectControl', () => { it( 'shows selected hint when __experimentalShowSelectedHint is set', () => { render( { }, ] } __experimentalShowSelectedHint - __nextUnconstrainedWidth /> ); expect( screen.getByRole( 'button', { name: 'Custom select' } ) ).toHaveTextContent( 'Hint' ); } ); + + describe( 'Keyboard behavior and accessibility', () => { + it( 'Captures the keypress event and does not let it propagate', async () => { + const user = userEvent.setup(); + const onKeyDown = jest.fn(); + + render( +
+ +
+ ); + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + await user.click( currentSelectedItem ); + + const customSelect = screen.getByRole( 'listbox', { + name: 'label!', + } ); + await user.type( customSelect, '{enter}' ); + + expect( onKeyDown ).toHaveBeenCalledTimes( 0 ); + } ); + + it( 'Should be able to change selection using keyboard', async () => { + const user = userEvent.setup(); + + render( ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + expect( currentSelectedItem ).toHaveFocus(); + + await user.keyboard( '{enter}' ); + expect( + screen.getByRole( 'listbox', { + name: 'label!', + } ) + ).toHaveFocus(); + + await user.keyboard( '{arrowdown}' ); + await user.keyboard( '{enter}' ); + + expect( currentSelectedItem ).toHaveTextContent( 'crimson clover' ); + } ); + + it( 'Should be able to type characters to select matching options', async () => { + const user = userEvent.setup(); + + render( ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + await user.keyboard( '{enter}' ); + expect( + screen.getByRole( 'listbox', { + name: 'label!', + } ) + ).toHaveFocus(); + + await user.keyboard( '{a}' ); + await user.keyboard( '{enter}' ); + expect( currentSelectedItem ).toHaveTextContent( 'amber' ); + } ); + + it( 'Can change selection with a focused input and closed dropdown if typed characters match an option', async () => { + const user = userEvent.setup(); + + render( ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + expect( currentSelectedItem ).toHaveFocus(); + + await user.keyboard( '{a}' ); + await user.keyboard( '{q}' ); + + expect( + screen.queryByRole( 'listbox', { + name: 'label!', + hidden: true, + } ) + ).not.toBeInTheDocument(); + + await user.keyboard( '{enter}' ); + expect( currentSelectedItem ).toHaveTextContent( 'aquamarine' ); + } ); + + it( 'Should have correct aria-selected value for selections', async () => { + const user = userEvent.setup(); + + render( ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.click( currentSelectedItem ); + + // get all items except for first option + const unselectedItems = props.options.filter( + ( { name } ) => name !== props.options[ 0 ].name + ); + + // assert that all other items have aria-selected="false" + unselectedItems.map( ( { name } ) => + expect( + screen.getByRole( 'option', { name, selected: false } ) + ).toBeVisible() + ); + + // assert that first item has aria-selected="true" + expect( + screen.getByRole( 'option', { + name: props.options[ 0 ].name, + selected: true, + } ) + ).toBeVisible(); + + // change the current selection + await user.click( screen.getByRole( 'option', { name: 'poppy' } ) ); + + // click button to mount listbox with options again + await user.click( currentSelectedItem ); + + // check that first item is has aria-selected="false" after new selection + expect( + screen.getByRole( 'option', { + name: props.options[ 0 ].name, + selected: false, + } ) + ).toBeVisible(); + + // check that new selected item now has aria-selected="true" + expect( + screen.getByRole( 'option', { + name: 'poppy', + selected: true, + } ) + ).toBeVisible(); + } ); + + it( 'Should call custom event handlers', async () => { + const user = userEvent.setup(); + const onFocusMock = jest.fn(); + const onBlurMock = jest.fn(); + + render( + + ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + + expect( currentSelectedItem ).toHaveFocus(); + expect( onFocusMock ).toHaveBeenCalledTimes( 1 ); + + await user.tab(); + expect( currentSelectedItem ).not.toHaveFocus(); + expect( onBlurMock ).toHaveBeenCalledTimes( 1 ); + } ); + } ); } );