diff --git a/src/components/forms/ComboBox/ComboBox.test.tsx b/src/components/forms/ComboBox/ComboBox.test.tsx index 4902821ae9..1e7ace1ec6 100644 --- a/src/components/forms/ComboBox/ComboBox.test.tsx +++ b/src/components/forms/ComboBox/ComboBox.test.tsx @@ -170,12 +170,14 @@ describe('ComboBox component', () => { ) const firstItem = getByTestId('combo-box-option-list').children[0] + const inputEl = getByTestId('combo-box-input') userEvent.click(getByTestId('combo-box-toggle')) expect(firstItem).toBeVisible() expect(firstItem).not.toHaveFocus() expect(firstItem).toHaveClass('usa-combo-box__list-option--focused') + expect(inputEl).toHaveAttribute('aria-activedescendant', firstItem.id) }) it('highlights the default value when opening the menu, when one exists', () => { @@ -249,144 +251,148 @@ describe('ComboBox component', () => { expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) - it('renders select with custom props if passed in', () => { - const { getByTestId } = render( - - ) - const comboBoxSelect = getByTestId('combo-box-select') - expect(comboBoxSelect).toHaveAttribute('required') - expect(comboBoxSelect).toHaveAttribute('role', 'testing') - }) + describe('with custom props', () => { + it('renders select with custom props if passed in', () => { + const { getByTestId } = render( + + ) + const comboBoxSelect = getByTestId('combo-box-select') + expect(comboBoxSelect).toHaveAttribute('required') + expect(comboBoxSelect).toHaveAttribute('role', 'testing') + }) - it('renders input with custom props if passed in', () => { - const { getByTestId } = render( - - ) + it('renders input with custom props if passed in', () => { + const { getByTestId } = render( + + ) - const comboBoxInput = getByTestId('combo-box-input') - expect(comboBoxInput).toHaveAttribute('required') - expect(comboBoxInput).toHaveAttribute('type', 'url') - }) + const comboBoxInput = getByTestId('combo-box-input') + expect(comboBoxInput).toHaveAttribute('required') + expect(comboBoxInput).toHaveAttribute('type', 'url') + }) - it('allows a custom input onChange handler to be called', () => { - const mockOnInputChange = jest.fn() + it('allows a custom input onChange handler to be called', () => { + const mockOnInputChange = jest.fn() - const { getByTestId } = render( - - ) + const { getByTestId } = render( + + ) - const input = getByTestId('combo-box-input') - userEvent.type(input, 'x') - expect(mockOnInputChange).toHaveBeenCalled() - }) + const input = getByTestId('combo-box-input') + userEvent.type(input, 'x') + expect(mockOnInputChange).toHaveBeenCalled() + }) - it('renders list with custom props if passed in', () => { - const { getByTestId } = render( - - ) + it('renders list with custom props if passed in', () => { + const { getByTestId } = render( + + ) - const comboBoxOptionList = getByTestId('combo-box-option-list') - expect(comboBoxOptionList).toHaveAttribute( - 'aria-labelledby', - 'test-label-id' - ) + const comboBoxOptionList = getByTestId('combo-box-option-list') + expect(comboBoxOptionList).toHaveAttribute( + 'aria-labelledby', + 'test-label-id' + ) + }) }) - it('scrolls options list to the very top when the menu opens if nothing is selected', () => { - const { getByTestId } = render( - - ) + describe('scrolling to a focused option', () => { + it('scrolls options list to the very top when the menu opens if nothing is selected', () => { + const { getByTestId } = render( + + ) - const listEl = getByTestId('combo-box-option-list') - jest.spyOn(listEl, 'offsetHeight', 'get').mockReturnValue(205) - listEl.scrollTop = 2000 // Scroll list 2000px down + const listEl = getByTestId('combo-box-option-list') + jest.spyOn(listEl, 'offsetHeight', 'get').mockReturnValue(205) + listEl.scrollTop = 2000 // Scroll list 2000px down - userEvent.click(getByTestId('combo-box-toggle')) + userEvent.click(getByTestId('combo-box-toggle')) - expect(listEl.scrollTop).toEqual(0) - }) + expect(listEl.scrollTop).toEqual(0) + }) - it('scrolls down to the selected option when the list is opened', () => { - const { getByTestId } = render( - - ) + it('scrolls down to the selected option when the list is opened', () => { + const { getByTestId } = render( + + ) - const mango = getByTestId('combo-box-option-mango') - const listEl = getByTestId('combo-box-option-list') + const mango = getByTestId('combo-box-option-mango') + const listEl = getByTestId('combo-box-option-list') - jest.spyOn(mango, 'offsetTop', 'get').mockReturnValue(1365) - jest.spyOn(mango, 'offsetHeight', 'get').mockReturnValue(39) - jest.spyOn(listEl, 'offsetHeight', 'get').mockReturnValue(205) - listEl.scrollTop = 0 // Scroll list to the top + jest.spyOn(mango, 'offsetTop', 'get').mockReturnValue(1365) + jest.spyOn(mango, 'offsetHeight', 'get').mockReturnValue(39) + jest.spyOn(listEl, 'offsetHeight', 'get').mockReturnValue(205) + listEl.scrollTop = 0 // Scroll list to the top - userEvent.click(getByTestId('combo-box-toggle')) - expect(mango).toHaveClass( - 'usa-combo-box__list-option--focused usa-combo-box__list-option--selected' - ) + userEvent.click(getByTestId('combo-box-toggle')) + expect(mango).toHaveClass( + 'usa-combo-box__list-option--focused usa-combo-box__list-option--selected' + ) - expect(listEl.scrollTop).toEqual(1199) - }) + expect(listEl.scrollTop).toEqual(1199) + }) - it('scrolls up to the selected option when the list is opened', () => { - const { getByTestId } = render( - - ) + it('scrolls up to the selected option when the list is opened', () => { + const { getByTestId } = render( + + ) - const mango = getByTestId('combo-box-option-mango') - const listEl = getByTestId('combo-box-option-list') + const mango = getByTestId('combo-box-option-mango') + const listEl = getByTestId('combo-box-option-list') - jest.spyOn(mango, 'offsetTop', 'get').mockReturnValue(1365) - jest.spyOn(mango, 'offsetHeight', 'get').mockReturnValue(39) - jest.spyOn(listEl, 'offsetHeight', 'get').mockReturnValue(205) - listEl.scrollTop = 2292 // Scroll list 2292px down + jest.spyOn(mango, 'offsetTop', 'get').mockReturnValue(1365) + jest.spyOn(mango, 'offsetHeight', 'get').mockReturnValue(39) + jest.spyOn(listEl, 'offsetHeight', 'get').mockReturnValue(205) + listEl.scrollTop = 2292 // Scroll list 2292px down - userEvent.click(getByTestId('combo-box-toggle')) - expect(mango).toHaveClass( - 'usa-combo-box__list-option--focused usa-combo-box__list-option--selected' - ) + userEvent.click(getByTestId('combo-box-toggle')) + expect(mango).toHaveClass( + 'usa-combo-box__list-option--focused usa-combo-box__list-option--selected' + ) - expect(listEl.scrollTop).toEqual(1365) + expect(listEl.scrollTop).toEqual(1365) + }) }) describe('filtering', () => { @@ -749,6 +755,11 @@ describe('ComboBox component', () => { fireEvent.blur(getByTestId('combo-box-input')) expect(getByTestId('combo-box-input')).toHaveValue('') + expect(getByTestId('combo-box-input')).toHaveAttribute( + 'aria-activedescendant', + '' + ) + expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) describe('keyboard actions', () => { @@ -942,7 +953,12 @@ describe('ComboBox component', () => { key: 'ArrowDown', }) - expect(getByTestId('combo-box-option-apricot')).toHaveFocus() + const focusedOption = getByTestId('combo-box-option-apricot') + expect(focusedOption).toHaveFocus() + expect(getByTestId('combo-box-input')).toHaveAttribute( + 'aria-activedescendant', + focusedOption.id + ) }) it('focuses the previous option when up arrow is pressed', () => { @@ -967,7 +983,38 @@ describe('ComboBox component', () => { key: 'ArrowUp', }) - expect(getByTestId('combo-box-option-apricot')).toHaveFocus() + const focusedOption = getByTestId('combo-box-option-apricot') + expect(focusedOption).toHaveFocus() + expect(getByTestId('combo-box-input')).toHaveAttribute( + 'aria-activedescendant', + focusedOption.id + ) + }) + + it('opens the menu and focuses on the selected option when down arrow is pressed in the input', () => { + const { getByTestId } = render( + + ) + + const comboBoxInput = getByTestId('combo-box-input') + userEvent.click(comboBoxInput) + fireEvent.keyDown(comboBoxInput, { + key: 'ArrowDown', + }) + + expect(getByTestId('combo-box-option-list')).toBeVisible() + const focusedOption = getByTestId('combo-box-option-watermelon') + expect(focusedOption).toHaveFocus() + expect(getByTestId('combo-box-input')).toHaveAttribute( + 'aria-activedescendant', + focusedOption.id + ) }) it('opens the menu when down arrow is pressed in the input', () => { @@ -987,7 +1034,12 @@ describe('ComboBox component', () => { }) expect(getByTestId('combo-box-option-list')).toBeVisible() - expect(getByTestId('combo-box-option-apple')).toHaveFocus() + const focusedOption = getByTestId('combo-box-option-apple') + expect(focusedOption).toHaveFocus() + expect(getByTestId('combo-box-input')).toHaveAttribute( + 'aria-activedescendant', + focusedOption.id + ) }) it('closes the menu when the first option is focused and arrow up is pressed', () => { @@ -1011,6 +1063,7 @@ describe('ComboBox component', () => { expect(getByTestId('combo-box-option-list')).not.toBeVisible() expect(comboBoxInput).toHaveFocus() + expect(comboBoxInput).toHaveAttribute('aria-activedescendant', '') }) it('does not change focus when last option is focused and down arrow is pressed', () => { @@ -1090,7 +1143,7 @@ describe('ComboBox component', () => { expect(getByTestId('combo-box-option-mangosteen')).toHaveFocus() }) - it('does not close menu when an option is selected and the first option is focused and up arrow is pressed', () => { + it('closes the menu when an option is selected and the first option is focused and up arrow is pressed', () => { const { getByTestId } = render( { key: 'ArrowUp', }) - expect(appleOption).toHaveFocus() + expect(getByTestId('combo-box-input')).toHaveFocus() expect(getByTestId('combo-box-input')).toHaveAttribute( 'aria-expanded', - 'true' + 'false' ) - expect(getByTestId('combo-box-option-list')).toBeVisible() + expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) it('hides the clear button when typing in the input, but does not clear the selection', () => { @@ -1202,7 +1255,8 @@ describe('ComboBox component', () => { expect(comboBoxInput).toHaveValue('Apple') }) - xit('focuses the input when an option is focused and shift-tab is pressed', () => { + // TODO + it.skip('focuses the input when an option is focused and shift-tab is pressed', () => { const { getByTestId } = render( { fireEvent.blur(input) expect(input).toHaveAttribute('aria-expanded', 'false') + expect(input).toHaveAttribute('aria-activedescendant', '') expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) @@ -1302,6 +1357,7 @@ describe('ComboBox component', () => { fireEvent.blur(blackberryOption) expect(input).toHaveAttribute('aria-expanded', 'false') + expect(input).toHaveAttribute('aria-activedescendant', '') expect(getByTestId('combo-box-option-list')).not.toBeVisible() }) @@ -1325,6 +1381,7 @@ describe('ComboBox component', () => { fireEvent.click(getByTestId('combo-box-toggle')) expect(input).toHaveAttribute('aria-expanded', 'false') + expect(input).toHaveAttribute('aria-activedescendant', '') expect(optionList).not.toBeVisible() }) @@ -1424,6 +1481,7 @@ describe('ComboBox component', () => { const yuzuOption = getByTestId('combo-box-option-yuzu') const blackberryOption = getByTestId('combo-box-option-blackberry') + const inputEl = getByTestId('combo-box-input') userEvent.click(getByTestId('combo-box-toggle')) userEvent.hover(blackberryOption) @@ -1431,12 +1489,19 @@ describe('ComboBox component', () => { expect(blackberryOption).toHaveClass( 'usa-combo-box__list-option--focused' ) + expect(blackberryOption).toHaveFocus() + expect(inputEl).toHaveAttribute( + 'aria-activedescendant', + blackberryOption.id + ) userEvent.hover(yuzuOption) expect(blackberryOption).not.toHaveClass( 'usa-combo-box__list-option--focused' ) expect(yuzuOption).toHaveClass('usa-combo-box__list-option--focused') + expect(yuzuOption).toHaveFocus() + expect(inputEl).toHaveAttribute('aria-activedescendant', yuzuOption.id) }) it('clears focus when clicking outside of the component', () => { @@ -1458,6 +1523,10 @@ describe('ComboBox component', () => { userEvent.click(getByTestId('outside')) expect(getByTestId('combo-box-input')).not.toHaveFocus() + expect(getByTestId('combo-box-input')).toHaveAttribute( + 'aria-activedescendant', + '' + ) }) }) @@ -1514,6 +1583,108 @@ describe('ComboBox component', () => { }) }) + it('updates the status text when the list is opened with options', () => { + const { getByTestId } = render( + + ) + + const status = screen.getByRole('status') + + // open options list + fireEvent.click(getByTestId('combo-box-input')) + + expect(status).toHaveTextContent( + `${fruitOptions.length} results available.` + ) + }) + + it('updates the status text when the list is opened with filtered options', () => { + const { getByTestId } = render( + + ) + + const status = screen.getByRole('status') + + const input = getByTestId('combo-box-input') + userEvent.type(input, 'a') + + expect(status).toHaveTextContent(`43 results available.`) + }) + + it('updates the status text when the list is opened with no options', () => { + const { getByTestId } = render( + + ) + + const status = screen.getByRole('status') + + // open options list + fireEvent.click(getByTestId('combo-box-input')) + + expect(status).toHaveTextContent(`No results.`) + }) + + it('updates the status text when the list is closed', () => { + const { getByTestId } = render( + + ) + + const status = screen.getByRole('status') + + // open options list + fireEvent.click(getByTestId('combo-box-input')) + + expect(status).toHaveTextContent( + `${fruitOptions.length} results available.` + ) + + fireEvent.blur(getByTestId('combo-box-input')) + + expect(getByTestId('combo-box-option-list')).not.toBeVisible() + expect(status).toBeEmptyDOMElement() + }) + + it('updates the status text when an option is selected', () => { + const { getByTestId } = render( + + ) + + const status = screen.getByRole('status') + + userEvent.type(getByTestId('combo-box-input'), 'Banana') + + expect(status).toHaveTextContent(`1 result available.`) + + userEvent.type(getByTestId('combo-box-input'), '{enter}') + + expect(status).toBeEmptyDOMElement() + }) + it('allows the assistive hint to be customized', () => { const { getByTestId } = render( = state.filteredOptions.length) { dispatch({ type: ActionTypes.FOCUS_OPTION, option: lastOption }) @@ -358,6 +357,12 @@ export const ComboBox = forwardRef( const listID = `${id}--list` const assistiveHintID = `${id}--assistiveHint` + const focusedItemIndex = state.focusedOption + ? state.filteredOptions.findIndex((i) => i === state.focusedOption) + : -1 + const focusedItemId = + focusedItemIndex > -1 && `${listID}--option-${focusedItemIndex}` + return (
@@ -483,7 +489,9 @@ export const ComboBox = forwardRef( ) : null} -
+
+ {state.statusText} +
1 ? 's' : '' + } available.`, + } + + if (optionsToDisplay.length < 1) { + newState.statusText = 'No results.' } if (disableFiltering || !state.selectedOption) { @@ -123,20 +132,29 @@ export const useComboBox = ( return newState } - case ActionTypes.OPEN_LIST: + case ActionTypes.OPEN_LIST: { + const statusText = state.filteredOptions.length + ? `${state.filteredOptions.length} result${ + state.filteredOptions.length > 1 ? 's' : '' + } available.` + : 'No results.' + return { ...state, isOpen: true, focusMode: FocusMode.Input, focusedOption: state.selectedOption || state.focusedOption || optionsList[0], + statusText, } + } case ActionTypes.CLOSE_LIST: { const newState = { ...state, isOpen: false, focusMode: FocusMode.Input, focusedOption: undefined, + statusText: '', } if (state.filteredOptions.length === 0) { @@ -151,13 +169,21 @@ export const useComboBox = ( return newState } - case ActionTypes.FOCUS_OPTION: + case ActionTypes.FOCUS_OPTION: { + const statusText = state.filteredOptions.length + ? `${state.filteredOptions.length} result${ + state.filteredOptions.length > 1 ? 's' : '' + } available.` + : 'No results.' + return { ...state, isOpen: true, focusedOption: action.option, focusMode: FocusMode.Item, + statusText, } + } case ActionTypes.CLEAR: return { ...state, @@ -167,6 +193,7 @@ export const useComboBox = ( selectedOption: undefined, filteredOptions: optionsList, focusedOption: optionsList[0], + statusText: '', } case ActionTypes.BLUR: { const newState = { @@ -174,6 +201,7 @@ export const useComboBox = ( isOpen: false, focusMode: FocusMode.None, filteredOptions: optionsList, + statusText: '', } if (!state.selectedOption) { @@ -195,6 +223,7 @@ export const useComboBox = ( selectedOption: undefined, filteredOptions: optionsList, focusedOption: undefined, + statusText: '', } } default: