From 76a9538a2bc844c6f74eae74470be45ff2afc0a8 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 18 Jan 2024 12:16:02 -0600 Subject: [PATCH] test(react): convert Combobox tests to react-testing-library --- .../src/components/Combobox/index.js | 969 ------------------ packages/react/package.json | 9 +- .../src/components/Combobox/Combobox.test.tsx | 914 +++++++++++++++++ .../src/components/Combobox/Combobox.tsx | 4 +- packages/react/yarn.lock | 12 + 5 files changed, 934 insertions(+), 974 deletions(-) delete mode 100644 packages/react/__tests__/src/components/Combobox/index.js create mode 100644 packages/react/src/components/Combobox/Combobox.test.tsx diff --git a/packages/react/__tests__/src/components/Combobox/index.js b/packages/react/__tests__/src/components/Combobox/index.js deleted file mode 100644 index 0ea3c6782..000000000 --- a/packages/react/__tests__/src/components/Combobox/index.js +++ /dev/null @@ -1,969 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mount } from 'enzyme'; -import { spy } from 'sinon'; -import { - default as Combobox, - ComboboxGroup, - ComboboxOption -} from 'src/components/Combobox'; - -const simulateKeyDown = - (wrapper, key) => - (event = {}) => { - wrapper.simulate('keydown', { key, ...event }); - wrapper.update(); - }; - -// Utility function for checking if the ComboboxListbox is currently open -const listboxIsOpen = (wrapper) => (isOpen) => { - const Listbox = wrapper.find('ul[role="listbox"]'); - const input = wrapper.find('[role="combobox"]'); - expect(input.prop('aria-expanded')).toEqual(isOpen); - if (isOpen) { - expect(Listbox.prop('className')).toContain('Combobox__listbox--open'); - } else { - expect(Listbox.prop('className')).not.toContain('Combobox__listbox--open'); - } -}; - -// Utility function for checking for active element for the given Combobox Option of a Combobox component -const optionIsActive = (wrapper) => (index) => { - const combobox = wrapper.find('input[role="combobox"]'); - const options = wrapper.find( - '[role="listbox"] .ComboboxOption[role="option"]' - ); - const activeOption = options.at(index); - expect(combobox.prop('aria-activedescendant')).toBeTruthy(); - expect(combobox.prop('aria-activedescendant')).toEqual( - activeOption.prop('id') - ); - expect(activeOption.hasClass('ComboboxOption--active')).toBeTruthy(); - options.forEach( - (option, index) => - index !== index && - expect(option.hasClass('ComboboxOption--active')).toBeFalsy() - ); -}; - -// Utility function for checking for active element for the given Combobox Option of a Combobox component -const optionIsSelected = (wrapper) => (index) => { - const options = wrapper.find( - '[role="listbox"] .ComboboxOption[role="option"]' - ); - const selectedOption = options.at(index); - expect(selectedOption.prop('aria-selected')).toEqual(true); - options.forEach( - (option, index) => - index !== index && expect(option.prop('aria-selected')).toEqual(false) - ); -}; - -test('should render combobox with options', () => { - const options = [ - { value: 'Apple', label: 'Apple' }, - { value: 'Banana', label: 'Banana' }, - { value: 'Cantaloupe', label: 'Cantaloupe' } - ]; - const wrapper = mount(); - expect(wrapper.find(Combobox).exists()).toBeTruthy(); - expect(wrapper.find(ComboboxOption).at(0).text()).toEqual('Apple'); - expect(wrapper.find(ComboboxOption).at(1).text()).toEqual('Banana'); - expect(wrapper.find(ComboboxOption).at(2).text()).toEqual('Cantaloupe'); -}); - -test('should render combobox with children', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - expect(wrapper.find(Combobox).exists()).toBeTruthy(); - expect(wrapper.find(ComboboxOption).at(0).text()).toEqual('Apple'); - expect(wrapper.find(ComboboxOption).at(1).text()).toEqual('Banana'); - expect(wrapper.find(ComboboxOption).at(2).text()).toEqual('Cantaloupe'); -}); - -test('should render combobox options with description', () => { - const options = [ - { - value: 'Apple', - label: 'Apple', - description: - 'A crispy orb of deliciousness that comes in colors from "stoplight red" to "alien green".' - }, - { - value: 'Banana', - label: 'Banana', - description: - 'This curvy wonder brings the tropics to your taste buds with its sunny disposition.' - }, - { - value: 'Cantaloupe', - label: 'Cantaloupe', - description: 'A bumpy, juicy, orb with moist deliciousness inside.' - } - ]; - const wrapper = mount(); - expect(wrapper.find(Combobox).exists()).toBeTruthy(); - expect(wrapper.find('.ComboboxOption__description').at(0).text()).toEqual( - options[0].description - ); - expect(wrapper.find('.ComboboxOption__description').at(1).text()).toEqual( - options[1].description - ); - expect(wrapper.find('.ComboboxOption__description').at(2).text()).toEqual( - options[2].description - ); -}); - -test('should render combobox children with description', () => { - const options = [ - { - value: 'Apple', - label: 'Apple', - description: - 'A crispy orb of deliciousness that comes in colors from "stoplight red" to "alien green".' - }, - { - value: 'Banana', - label: 'Banana', - description: - 'This curvy wonder brings the tropics to your taste buds with its sunny disposition.' - }, - { - value: 'Cantaloupe', - label: 'Cantaloupe', - description: 'A bumpy, juicy, orb with moist deliciousness inside.' - } - ]; - const children = options.map(({ value, label, description }, index) => ( - - {label} - - )); - const wrapper = mount({children}); - expect(wrapper.find(Combobox).exists()).toBeTruthy(); - expect(wrapper.find('.ComboboxOption__description').at(0).text()).toEqual( - options[0].description - ); - expect(wrapper.find('.ComboboxOption__description').at(1).text()).toEqual( - options[1].description - ); - expect(wrapper.find('.ComboboxOption__description').at(2).text()).toEqual( - options[2].description - ); -}); - -test('should render combobox with groups', () => { - const wrapper = mount( - - - Apple - Banana - Cantaloupe - - - Artichoke - Broccoli - Carrots - - - ); - - const group1 = wrapper.find(ComboboxGroup).at(0); - const group2 = wrapper.find(ComboboxGroup).at(1); - - expect(wrapper.find(Combobox).exists()).toBeTruthy(); - expect(group1.exists()).toBeTruthy(); - expect(group2.exists()).toBeTruthy(); - expect(group1.find('ul').prop('role')).toEqual('group'); - expect(group2.find('ul').prop('role')).toEqual('group'); - expect(group1.find('ul').prop('aria-labelledby')).toBeTruthy(); - expect(group2.find('ul').prop('aria-labelledby')).toBeTruthy(); - expect(group1.find(ComboboxOption).at(0).text()).toEqual('Apple'); - expect(group1.find(ComboboxOption).at(1).text()).toEqual('Banana'); - expect(group1.find(ComboboxOption).at(2).text()).toEqual('Cantaloupe'); - expect(group2.find(ComboboxOption).at(0).text()).toEqual('Artichoke'); - expect(group2.find(ComboboxOption).at(1).text()).toEqual('Broccoli'); - expect(group2.find(ComboboxOption).at(2).text()).toEqual('Carrots'); -}); - -test('should render required combobox', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - expect(wrapper.find('[role="combobox"]').prop('required')).toEqual(true); - expect(wrapper.find('.Field__required-text').exists()).toBeTruthy(); - expect(wrapper.find('.Field__required-text').text()).toEqual('Required'); -}); - -test('should render combobox with error', () => { - const errorId = 'combo-error'; - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - expect(wrapper.find(`#${errorId}`).exists()).toBeTruthy(); - expect(wrapper.find(`#${errorId}`).text()).toEqual( - 'You forgot to choose a value.' - ); - expect( - wrapper.find('input').getDOMNode().getAttribute('aria-describedby') - ).toBe(`other-id ${errorId}`); -}); - -test('should open combobox listbox on click', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('click'); - assertListboxIsOpen(true); -}); - -test('should focus combobox input on click', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - const onFocus = spy( - wrapper.find('[role="combobox"]').getElement().ref.current, - 'focus' - ); - expect(onFocus.notCalled).toBeTruthy(); - wrapper - .find('[role="combobox"]') - .simulate('click', { target: document.body }); - assertListboxIsOpen(true); - expect(onFocus.calledOnce).toBeTruthy(); -}); - -test('should allow an input ref to be passed to the combobox', () => { - const inputRef = React.createRef(); - const wrapper = mount( - - Apple - - ); - - expect(inputRef.current).toBeTruthy(); - expect(inputRef.current).toEqual(wrapper.find('input').getDOMNode()); -}); - -test('should open combobox listbox on focus', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('focus'); - assertListboxIsOpen(true); -}); - -test('should open combobox listbox on keypress', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('keydown', { key: 'ArrowDown' }); - assertListboxIsOpen(true); -}); - -test('should close combobox listbox on "esc" keypress', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('keydown', { key: 'ArrowDown' }); - wrapper.find('[role="combobox"]').simulate('keydown', { key: 'A' }); - assertListboxIsOpen(true); - wrapper.find('[role="combobox"]').simulate('keydown', { key: 'Escape' }); - assertListboxIsOpen(false); -}); - -test('should not open combobox listbox on "enter" keypress', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('keydown', { key: 'Enter' }); - assertListboxIsOpen(false); -}); - -test('should close combobox listbox on "blur"', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('focus'); - assertListboxIsOpen(true); - wrapper.find('[role="combobox"]').simulate('blur'); - assertListboxIsOpen(false); -}); - -test('should close combobox listbox when selecting option via click', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('focus'); - assertListboxIsOpen(true); - wrapper.find('li[role="option"]').at(0).simulate('click'); - assertListboxIsOpen(false); -}); - -test('should prevent default on combobox listbox option via mousedown', () => { - const preventDefault = spy(); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - - assertListboxIsOpen(false); - wrapper.find('[role="combobox"]').simulate('focus'); - assertListboxIsOpen(true); - expect(preventDefault.notCalled).toBeTruthy(); - wrapper - .find('li[role="option"]') - .at(0) - .simulate('mousedown', { preventDefault }); - expect(preventDefault.calledOnce).toBeTruthy(); -}); - -test('should close combobox listbox when selecting option via keypress', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - assertListboxIsOpen(false); - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('keydown', { key: 'ArrowDown' }); - act(() => { - // we need to manually fire active change for the listbox - wrapper - .find('Listbox') - .props() - .onActiveChange({ - element: { - getAttribute: () => 'id' - } - }); - }); - combobox.simulate('keydown', { key: 'Enter' }); - assertListboxIsOpen(false); -}); - -test('should set aria-activedescendent for active combobox options', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't - // work correctly within jsdom/enzyme so we fire the events directly on listbox - const simulateArrowDownKeypress = simulateKeyDown( - wrapper.find('ul[role="listbox"]'), - 'ArrowDown' - ); - const assertOptionIsActive = optionIsActive(wrapper); - - expect(combobox.prop('aria-activedescendant')).toBeFalsy(); - combobox.simulate('focus'); - assertListboxIsOpen(true); - simulateArrowDownKeypress(); - assertOptionIsActive(0); - simulateArrowDownKeypress(); - assertOptionIsActive(1); - simulateArrowDownKeypress(); - assertOptionIsActive(2); -}); - -test('should prevent default event on home/end keypress', () => { - const preventDefault = spy(); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const combobox = wrapper.find('[role="combobox"]'); - expect(preventDefault.notCalled).toBeTruthy(); - combobox.simulate('keydown', { key: 'Home', preventDefault }); - expect(preventDefault.callCount).toEqual(1); - combobox.simulate('keydown', { key: 'End', preventDefault }); - expect(preventDefault.callCount).toEqual(2); - combobox.simulate('keydown', { key: 'ArrowDown', preventDefault }); - expect(preventDefault.callCount).toEqual(2); -}); - -test('should call onActiveChange when active option changes', () => { - const onActiveChange = spy(); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't - // work correctly within jsdom/enzyme so we fire the events directly on listbox - const simulateArrowDownKeypress = simulateKeyDown( - wrapper.find('ul[role="listbox"]'), - 'ArrowDown' - ); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - expect(onActiveChange.notCalled).toBeTruthy(); - simulateArrowDownKeypress(); - expect(onActiveChange.callCount).toEqual(1); - expect(onActiveChange.getCall(0).firstArg.value).toEqual('Apple'); - simulateArrowDownKeypress(); - expect(onActiveChange.callCount).toEqual(2); - expect(onActiveChange.getCall(1).firstArg.value).toEqual('Banana'); - simulateArrowDownKeypress(); - expect(onActiveChange.callCount).toEqual(3); - expect(onActiveChange.getCall(2).firstArg.value).toEqual('Cantaloupe'); -}); - -test('should set input value to empty string on open with selected option', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - wrapper.find('[role="option"]').at(1).simulate('click'); - combobox.simulate('blur'); - assertListboxIsOpen(false); - combobox.simulate('focus'); - expect(wrapper.find('input[role="combobox"]').prop('value')).toEqual(''); -}); - -test('should restore input value to selected value on close with selected option', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - wrapper.find('[role="option"]').at(1).simulate('click'); - combobox.simulate('blur'); - assertListboxIsOpen(false); - combobox.simulate('focus'); - combobox.simulate('blur'); - expect(wrapper.find('input[role="combobox"]').prop('value')).toEqual( - 'Banana' - ); -}); - -test('should handle selection with "click" event', () => { - const onSelectionChange = spy(); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const assertOptionIsSelected = optionIsSelected(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - expect(onSelectionChange.notCalled).toBeTruthy(); - wrapper.find('[role="option"]').at(1).simulate('click'); - wrapper.update(); - expect(onSelectionChange.callCount).toEqual(1); - expect(onSelectionChange.firstCall.firstArg.value).toEqual('Banana'); - assertOptionIsSelected(0); - combobox.simulate('click'); - wrapper.find('[role="option"]').at(2).simulate('click'); - expect(onSelectionChange.secondCall.firstArg.value).toEqual('Cantaloupe'); - assertOptionIsSelected(0); -}); - -test('should handle selection with "enter" keydown event', () => { - const onSelectionChange = spy(); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const assertOptionIsSelected = optionIsSelected(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't - // work correctly within jsdom/enzyme so we fire the events directly on listbox - const simulateArrowDownKeypress = simulateKeyDown( - wrapper.find('ul[role="listbox"]'), - 'ArrowDown' - ); - const simulateEnterKeypress = simulateKeyDown( - wrapper.find('ul[role="listbox"]'), - 'Enter' - ); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - expect(onSelectionChange.notCalled).toBeTruthy(); - simulateArrowDownKeypress(); - simulateEnterKeypress(); - expect(onSelectionChange.callCount).toEqual(1); - expect(onSelectionChange.firstCall.firstArg.value).toEqual('Apple'); - combobox.simulate('click'); - assertOptionIsSelected(0); - simulateArrowDownKeypress(); - simulateEnterKeypress(); - expect(onSelectionChange.secondCall.firstArg.value).toEqual('Banana'); - combobox.simulate('click'); - assertOptionIsSelected(1); - simulateArrowDownKeypress(); - simulateEnterKeypress(); -}); - -test('should always render all options when autocomplete="none"', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'a' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(3); - combobox.simulate('change', { target: { value: 'ap' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(3); - combobox.simulate('change', { target: { value: 'apple' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(3); -}); - -test('should render matching options when autocomplete="manual"', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'a' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(3); - combobox.simulate('change', { target: { value: 'ap' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(1); - expect(wrapper.find('.ComboboxOption[role="option"]').text()).toEqual( - 'Apple' - ); -}); - -test('should render results not found when no options match when autocomplete="manual"', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'x' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(0); - expect(wrapper.find('.ComboboxListbox__empty').text()).toEqual( - 'No results found.' - ); -}); - -test('should render results not found render function when no options match when autocomplete="manual"', () => { - const wrapper = mount( - ( - Yo, no results found here. - )} - > - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'x' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(0); - expect(wrapper.find('.no-results').text()).toEqual( - 'Yo, no results found here.' - ); -}); - -test('should render results not found render component when no options match when autocomplete="manual"', () => { - const wrapper = mount( - Yo, no results found here. - } - > - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'x' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(0); - expect(wrapper.find('.no-results').text()).toEqual( - 'Yo, no results found here.' - ); -}); - -test('should render matching options when autocomplete="automatic"', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'a' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(3); - combobox.simulate('change', { target: { value: 'ap' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(1); - expect(wrapper.find('.ComboboxOption[role="option"]').text()).toEqual( - 'Apple' - ); -}); - -test('should render results not found when no options match when autocomplete="automatic"', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - combobox.simulate('change', { target: { value: 'x' } }); - wrapper.update(); - expect(wrapper.find('.ComboboxOption[role="option"]').length).toEqual(0); - expect(wrapper.find('.ComboboxListbox__empty').text()).toEqual( - 'No results found.' - ); -}); - -test('should set selected value to active descendent when autocomplete="automatic" loses focus', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't - // work correctly within jsdom/enzyme so we fire the events directly on listbox - const simulateArrowDownKeypress = simulateKeyDown( - wrapper.find('ul[role="listbox"]'), - 'ArrowDown' - ); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - // Note: first active item should already be "Apple", but since dispatchEvent is - // used it's not triggered correctly in enzyme so we fire an initial event to - // make the first value active - simulateArrowDownKeypress(); - simulateArrowDownKeypress(); - combobox.simulate('blur'); - expect(wrapper.find('[role="combobox"]').prop('value')).toEqual('Banana'); -}); - -test('should use id from props when set', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - expect(wrapper.find('.Combobox').prop('id')).toEqual('this-is-a-combobox'); -}); - -test('should set selected value with "defaultValue" prop', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - const assertOptionIsSelected = optionIsSelected(wrapper); - - expect(wrapper.find('input[role="combobox"]').prop('value')).toEqual( - 'Banana' - ); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - assertOptionIsSelected(1); -}); - -test('should set selected value with "value" prop', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - const assertListboxIsOpen = listboxIsOpen(wrapper); - const combobox = wrapper.find('[role="combobox"]'); - const assertOptionIsSelected = optionIsSelected(wrapper); - - expect(wrapper.find('input[role="combobox"]').prop('value')).toEqual( - 'Banana' - ); - - combobox.simulate('focus'); - assertListboxIsOpen(true); - assertOptionIsSelected(1); -}); - -test('should not render hidden input when name is not provided', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - expect(wrapper.find('input[type="hidden"]').exists()).toBeFalsy(); -}); - -test('should render hidden input with value from text contents of ComboboxOption', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - expect(wrapper.find('input[type="hidden"]').prop('value')).toEqual('Banana'); -}); - -test('should render hidden input with value from value from ComboboxOption', () => { - const wrapper = mount( - - 🍎 - 🍌 - 🍈 - - ); - - expect(wrapper.find('input[type="hidden"]').prop('value')).toEqual('Banana'); -}); - -test('should render hidden input with value from formValue from ComboboxOption', () => { - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - - expect(wrapper.find('input[type="hidden"]').prop('value')).toEqual('2'); -}); - -test('should support portal element for combobox listbos', () => { - const element = document.createElement('div'); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - expect(wrapper.find('Portal').exists()).toBeTruthy(); - expect(element.querySelector('ul[role="listbox"]')).toBeTruthy(); -}); - -test('should support portal element ref for combobox listbos', () => { - const element = document.createElement('div'); - const wrapper = mount( - - Apple - Banana - Cantaloupe - - ); - expect(wrapper.find('Portal').exists()).toBeTruthy(); - expect(element.querySelector('ul[role="listbox"]')).toBeTruthy(); -}); diff --git a/packages/react/package.json b/packages/react/package.json index 2803aa814..03cf2fcbf 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -43,16 +43,17 @@ "@rollup/plugin-dynamic-import-vars": "^1.4.2", "@rollup/plugin-typescript": "^11.1.2", "@svgr/rollup": "^6.1.2", - "@testing-library/react": "11.1.2", "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "11.1.2", "@types/classnames": "^2.2.10", + "@types/enzyme-adapter-react-16": "^1.0.9", + "@types/jest": "^24.7.1", + "@types/jest-axe": "^3.5.4", "@types/node": "^17.0.42", "@types/react": "^18.0.12", "@types/react-dom": "^18.0.5", - "@types/jest-axe": "^3.5.4", - "@types/jest": "^24.7.1", "@types/react-syntax-highlighter": "^15.5.2", - "@types/enzyme-adapter-react-16": "^1.0.9", + "@types/sinon": "^10", "autoprefixer": "^9.7.6", "babel-plugin-module-resolver": "^4.0.0", "babel-plugin-transform-export-extensions": "^6.22.0", diff --git a/packages/react/src/components/Combobox/Combobox.test.tsx b/packages/react/src/components/Combobox/Combobox.test.tsx new file mode 100644 index 000000000..9938dbeb7 --- /dev/null +++ b/packages/react/src/components/Combobox/Combobox.test.tsx @@ -0,0 +1,914 @@ +import React from 'react'; +import { + render, + screen, + waitFor, + fireEvent, + createEvent +} from '@testing-library/react'; +import { within } from '@testing-library/dom'; +import { spy } from 'sinon'; +import Combobox from './Combobox'; +import ComboboxGroup from './ComboboxGroup'; +import ComboboxOption from './ComboboxOption'; + +// Utility function for checking if the ComboboxListbox is currently open +const assertListboxIsOpen = (isOpen: boolean) => { + const Listbox = screen.getByRole('listbox'); + const input = screen.getByRole('combobox'); + expect(JSON.parse(input.getAttribute('aria-expanded') + '')).toEqual(isOpen); + if (isOpen) { + expect(Array.from(Listbox.classList)).toContain('Combobox__listbox--open'); + } else { + expect(Array.from(Listbox.classList)).not.toContain( + 'Combobox__listbox--open' + ); + } +}; + +// Utility function for checking for active element for the given Combobox Option of a Combobox component +const assertOptionIsActive = (index: number) => { + const combobox = screen.getByRole('combobox'); + const options = screen.queryAllByRole('option'); + const activeOption = options.at(index); + expect(combobox.getAttribute('aria-activedescendant')).toBeTruthy(); + expect(combobox.getAttribute('aria-activedescendant')).toEqual( + activeOption?.getAttribute('id') + ); + expect( + activeOption?.classList.contains('ComboboxOption--active') + ).toBeTruthy(); + options.forEach( + (option, index) => + index !== index && + expect(option.classList.contains('ComboboxOption--active')).toBeFalsy() + ); +}; + +// Utility function for checking for active element for the given Combobox Option of a Combobox component +const assertOptionIsSelected = (index: number) => { + const options = screen.queryAllByRole('option'); + const selectedOption = options.at(index); + expect( + JSON.parse(selectedOption?.getAttribute('aria-selected') + '') + ).toEqual(true); + options.forEach( + (option, index) => + index !== index && + expect(option.getAttribute('aria-selected')).toEqual(false) + ); +}; + +test('should render combobox with options', () => { + const options = [ + { value: 'Apple', label: 'Apple' }, + { value: 'Banana', label: 'Banana' }, + { value: 'Cantaloupe', label: 'Cantaloupe' } + ]; + render(); + expect(screen.queryByRole('combobox')).toBeTruthy(); + expect(screen.queryAllByRole('option').at(0)?.innerText).toEqual('Apple'); + expect(screen.queryAllByRole('option').at(1)?.innerText).toEqual('Banana'); + expect(screen.queryAllByRole('option').at(2)?.innerText).toEqual( + 'Cantaloupe' + ); +}); + +test('should render combobox with children', () => { + render( + + Apple + Banana + Cantaloupe + + ); + expect(screen.queryByRole('combobox')).toBeTruthy(); + expect(screen.queryAllByRole('option').at(0)?.innerText).toEqual('Apple'); + expect(screen.queryAllByRole('option').at(1)?.innerText).toEqual('Banana'); + expect(screen.queryAllByRole('option').at(2)?.innerText).toEqual( + 'Cantaloupe' + ); +}); + +test('should render combobox options with description', () => { + const options = [ + { + value: 'Apple', + label: 'Apple', + description: + 'A crispy orb of deliciousness that comes in colors from "stoplight red" to "alien green".' + }, + { + value: 'Banana', + label: 'Banana', + description: + 'This curvy wonder brings the tropics to your taste buds with its sunny disposition.' + }, + { + value: 'Cantaloupe', + label: 'Cantaloupe', + description: 'A bumpy, juicy, orb with moist deliciousness inside.' + } + ]; + render(); + expect(screen.queryByRole('combobox')).toBeTruthy(); + expect( + screen + .queryAllByRole('option') + .at(0) + ?.querySelector('.ComboboxOption__description')?.textContent + ).toEqual(options[0].description); + expect( + screen + .queryAllByRole('option') + .at(1) + ?.querySelector('.ComboboxOption__description')?.textContent + ).toEqual(options[1].description); + expect( + screen + .queryAllByRole('option') + .at(2) + ?.querySelector('.ComboboxOption__description')?.textContent + ).toEqual(options[2].description); +}); + +test('should render combobox children with description', () => { + const options = [ + { + value: 'Apple', + label: 'Apple', + description: + 'A crispy orb of deliciousness that comes in colors from "stoplight red" to "alien green".' + }, + { + value: 'Banana', + label: 'Banana', + description: + 'This curvy wonder brings the tropics to your taste buds with its sunny disposition.' + }, + { + value: 'Cantaloupe', + label: 'Cantaloupe', + description: 'A bumpy, juicy, orb with moist deliciousness inside.' + } + ]; + const children = options.map(({ value, label, description }, index) => ( + + {label} + + )); + render({children}); + expect(screen.queryByRole('combobox')).toBeTruthy(); + expect( + screen + .queryAllByRole('option') + .at(0) + ?.querySelector('.ComboboxOption__description')?.textContent + ).toEqual(options[0].description); + expect( + screen + .queryAllByRole('option') + .at(1) + ?.querySelector('.ComboboxOption__description')?.textContent + ).toEqual(options[1].description); + expect( + screen + .queryAllByRole('option') + .at(2) + ?.querySelector('.ComboboxOption__description')?.textContent + ).toEqual(options[2].description); +}); + +test('should render combobox with groups', () => { + render( + + + Apple + Banana + Cantaloupe + + + Artichoke + Broccoli + Carrots + + + ); + + const group1 = screen.queryAllByRole('group').at(0) as HTMLElement; + const group2 = screen.queryAllByRole('group').at(1) as HTMLElement; + + expect(screen.queryByRole('combobox')).toBeTruthy(); + expect(group1).toBeTruthy(); + expect(group2).toBeTruthy(); + expect(group1.tagName).toEqual('UL'); + expect(group2.tagName).toEqual('UL'); + expect(group1.getAttribute('aria-labelledby')).toBeTruthy(); + expect(group2.getAttribute('aria-labelledby')).toBeTruthy(); + expect(within(group1).queryAllByRole('option').at(0)?.innerText).toEqual( + 'Apple' + ); + expect(within(group1).queryAllByRole('option').at(1)?.innerText).toEqual( + 'Banana' + ); + expect(within(group1).queryAllByRole('option').at(2)?.innerText).toEqual( + 'Cantaloupe' + ); + expect(within(group2).queryAllByRole('option').at(0)?.innerText).toEqual( + 'Artichoke' + ); + expect(within(group2).queryAllByRole('option').at(1)?.innerText).toEqual( + 'Broccoli' + ); + expect(within(group2).queryAllByRole('option').at(2)?.innerText).toEqual( + 'Carrots' + ); +}); + +test('should render required combobox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + expect(screen.getByRole('combobox').hasAttribute('required')).toEqual(true); + expect(screen.queryByText('Required')).toBeTruthy(); +}); + +test('should render combobox with error', () => { + const errorId = 'combo-error'; + render( + + Apple + Banana + Cantaloupe + + ); + + expect(screen.queryByText('You forgot to choose a value.')).toBeTruthy(); + expect(screen.getByRole('combobox').getAttribute('aria-describedby')).toBe( + `other-id ${errorId}` + ); +}); + +test('should open combobox listbox on click', async () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + fireEvent.click(screen.getByRole('combobox')); + assertListboxIsOpen(true); +}); + +test('should maintain focus on combobox input on click', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + expect(screen.getByRole('combobox')).not.toHaveFocus(); + screen + .getByRole('combobox') + .addEventListener('click', (event: MouseEvent) => { + // simulate a focus from a click + (event.target as HTMLInputElement)?.focus(); + }); + fireEvent.click(screen.getByRole('combobox')); + assertListboxIsOpen(true); + expect(screen.getByRole('combobox')).toHaveFocus(); +}); + +test('should allow an input ref to be passed to the combobox', () => { + const inputRef = React.createRef(); + render( + + Apple + + ); + + expect(inputRef.current).toBeTruthy(); + expect(inputRef.current).toEqual(screen.getByRole('combobox')); +}); + +test('should open combobox listbox on focus', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + fireEvent.focus(screen.getByRole('combobox')); + assertListboxIsOpen(true); +}); + +test('should open combobox listbox on keypress', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + assertListboxIsOpen(true); +}); + +test('should close combobox listbox on "esc" keypress', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'A' }); + assertListboxIsOpen(true); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }); + assertListboxIsOpen(false); +}); + +test('should not open combobox listbox on "enter" keypress', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }); + assertListboxIsOpen(false); +}); + +test('should close combobox listbox on "blur"', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + fireEvent.focus(screen.getByRole('combobox')); + assertListboxIsOpen(true); + fireEvent.blur(screen.getByRole('combobox')); + assertListboxIsOpen(false); +}); + +test('should close combobox listbox when selecting option via click', async () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListboxIsOpen(false); + screen.getByRole('combobox').focus(); + assertListboxIsOpen(true); + fireEvent.click(screen.getAllByRole('option')[0]); + assertListboxIsOpen(false); +}); + +test('should prevent default on combobox listbox option via mousedown', () => { + const preventDefault = spy(); + render( + + Apple + Banana + Cantaloupe + + ); + + const option = screen.getAllByRole('option')[0]; + const fireMouseDownEvent = () => { + const event = createEvent.mouseDown(option); + // rtl doesn't let us mock preventDefault + // see: https://github.com/testing-library/react-testing-library/issues/572 + event.preventDefault = preventDefault; + fireEvent(option, event); + }; + + assertListboxIsOpen(false); + fireEvent.focus(screen.getByRole('combobox')); + assertListboxIsOpen(true); + expect(preventDefault.notCalled).toBeTruthy(); + fireMouseDownEvent(); + expect(preventDefault.calledOnce).toBeTruthy(); +}); + +test('should close combobox listbox when selecting option via keypress', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + assertListboxIsOpen(false); + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.keyDown(combobox, { key: 'ArrowDown' }); + fireEvent.keyDown(combobox, { key: 'Enter' }); + assertListboxIsOpen(false); +}); + +test('should set aria-activedescendent for active combobox options', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't + // work correctly within jsdom so we fire the events directly on listbox + const fireArrowDownKeyPress = () => + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); + + expect(combobox.hasAttribute('aria-activedescendant')).toBeFalsy(); + fireEvent.focus(combobox); + assertListboxIsOpen(true); + + fireArrowDownKeyPress(); + assertOptionIsActive(0); + fireArrowDownKeyPress(); + assertOptionIsActive(1); + fireArrowDownKeyPress(); + assertOptionIsActive(2); +}); + +test('should prevent default event on home/end keypress', () => { + const preventDefault = spy(); + render( + + Apple + Banana + Cantaloupe + + ); + + const combobox = screen.getByRole('combobox'); + const fireKeydownEvent = (key: string) => { + const event = createEvent.keyDown(combobox, { key }); + // rtl doesn't let us mock preventDefault + // see: https://github.com/testing-library/react-testing-library/issues/572 + event.preventDefault = preventDefault; + fireEvent(combobox, event); + }; + expect(preventDefault.notCalled).toBeTruthy(); + fireKeydownEvent('Home'); + expect(preventDefault.callCount).toEqual(1); + fireKeydownEvent('End'); + expect(preventDefault.callCount).toEqual(2); + fireKeydownEvent('ArrowDown'); + expect(preventDefault.callCount).toEqual(2); +}); + +test('should call onActiveChange when active option changes', () => { + const onActiveChange = spy(); + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't + // work correctly within jsdom so we fire the events directly on listbox + const fireArrowDownKeyPress = () => + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + expect(onActiveChange.notCalled).toBeTruthy(); + fireArrowDownKeyPress(); + expect(onActiveChange.callCount).toEqual(1); + expect(onActiveChange.getCall(0).firstArg.value).toEqual('Apple'); + fireArrowDownKeyPress(); + expect(onActiveChange.callCount).toEqual(2); + expect(onActiveChange.getCall(1).firstArg.value).toEqual('Banana'); + fireArrowDownKeyPress(); + expect(onActiveChange.callCount).toEqual(3); + expect(onActiveChange.getCall(2).firstArg.value).toEqual('Cantaloupe'); +}); + +test('should set input value to empty string on open with selected option', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.click(screen.getAllByRole('option')[1]); + fireEvent.blur(combobox); + assertListboxIsOpen(false); + fireEvent.focus(combobox); + expect(screen.getByRole('combobox').getAttribute('value')).toEqual(''); +}); + +test('should restore input value to selected value on close with selected option', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.click(screen.getAllByRole('option')[1]); + fireEvent.blur(combobox); + assertListboxIsOpen(false); + fireEvent.focus(combobox); + fireEvent.blur(combobox); + expect(screen.getByRole('combobox').getAttribute('value')).toEqual('Banana'); +}); + +test('should handle selection with "click" event', () => { + const onSelectionChange = spy(); + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + expect(onSelectionChange.notCalled).toBeTruthy(); + fireEvent.click(screen.getAllByRole('option')[1]); + expect(onSelectionChange.callCount).toEqual(1); + expect(onSelectionChange.firstCall.firstArg.value).toEqual('Banana'); + assertOptionIsSelected(0); + fireEvent.click(combobox); + fireEvent.click(screen.getAllByRole('option')[2]); + expect(onSelectionChange.secondCall.firstArg.value).toEqual('Cantaloupe'); + assertOptionIsSelected(0); +}); + +test('should handle selection with "enter" keydown event', () => { + const onSelectionChange = spy(); + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't + // work correctly within jsdom/enzyme so we fire the events directly on listbox + const fireArrowDownKeyPress = () => + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); + const fireEnterKeyPress = () => + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Enter' }); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + expect(onSelectionChange.notCalled).toBeTruthy(); + fireArrowDownKeyPress(); + fireEnterKeyPress(); + expect(onSelectionChange.callCount).toEqual(1); + expect(onSelectionChange.firstCall.firstArg.value).toEqual('Apple'); + fireEvent.click(combobox); + assertOptionIsSelected(0); + fireArrowDownKeyPress(); + fireEnterKeyPress(); + expect(onSelectionChange.secondCall.firstArg.value).toEqual('Banana'); + fireEvent.click(combobox); + assertOptionIsSelected(1); + fireArrowDownKeyPress(); + fireEnterKeyPress(); +}); + +test('should always render all options when autocomplete="none"', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'a' } }); + expect(screen.getAllByRole('option').length).toEqual(3); + fireEvent.change(combobox, { target: { value: 'ap' } }); + expect(screen.getAllByRole('option').length).toEqual(3); + fireEvent.change(combobox, { target: { value: 'apple' } }); + expect(screen.getAllByRole('option').length).toEqual(3); +}); + +test('should render matching options when autocomplete="manual"', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'a' } }); + expect(screen.getAllByRole('option').length).toEqual(3); + fireEvent.change(combobox, { target: { value: 'ap' } }); + expect(screen.getAllByRole('option').length).toEqual(1); + expect(screen.getByRole('option').innerText).toEqual('Apple'); +}); + +test('should render results not found when no options match when autocomplete="manual"', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'x' } }); + expect(screen.queryAllByRole('option').length).toEqual(0); + expect(screen.queryByText('No results found.')).toBeTruthy(); +}); + +test('should render results not found render function when no options match when autocomplete="manual"', () => { + render( + ( + Yo, no results found here. + )} + > + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'x' } }); + expect(screen.queryAllByRole('option').length).toEqual(0); + expect(screen.queryByText('Yo, no results found here.')).toBeTruthy(); +}); + +test('should render results not found render component when no options match when autocomplete="manual"', () => { + render( + Yo, no results found here. + } + > + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'x' } }); + expect(screen.queryAllByRole('option').length).toEqual(0); + expect(screen.queryByText('Yo, no results found here.')).toBeTruthy(); +}); + +test('should render matching options when autocomplete="automatic"', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'a' } }); + expect(screen.getAllByRole('option').length).toEqual(3); + fireEvent.change(combobox, { target: { value: 'ap' } }); + expect(screen.getAllByRole('option').length).toEqual(1); + expect(screen.getByRole('option').innerText).toEqual('Apple'); +}); + +test('should render results not found when no options match when autocomplete="automatic"', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireEvent.change(combobox, { target: { value: 'x' } }); + expect(screen.queryAllByRole('option').length).toEqual(0); + expect(screen.queryByText('No results found.')).toBeTruthy(); +}); + +test('should set selected value to active descendent when autocomplete="automatic" loses focus', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + // Note: Combobox forwards events to Listbox via dispatchEvent, but this doesn't + // work correctly within jsdom so we fire the events directly on listbox + const fireArrowDownKeyPress = () => + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + fireArrowDownKeyPress(); + fireEvent.blur(combobox); + expect(screen.getByRole('combobox').getAttribute('value')).toEqual('Banana'); +}); + +test('should use id from props when set', () => { + render( + + Apple + Banana + Cantaloupe + + ); + expect(document.querySelector('.Combobox')?.getAttribute('id')).toEqual( + 'this-is-a-combobox' + ); +}); + +test('should set selected value with "defaultValue" prop', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + expect(screen.getByRole('combobox').getAttribute('value')).toEqual('Banana'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + assertOptionIsSelected(1); +}); + +test('should set selected value with "value" prop', () => { + render( + + Apple + Banana + Cantaloupe + + ); + const combobox = screen.getByRole('combobox'); + + expect(screen.getByRole('combobox').getAttribute('value')).toEqual('Banana'); + + fireEvent.focus(combobox); + assertListboxIsOpen(true); + assertOptionIsSelected(1); +}); + +test('should not render hidden input when name is not provided', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + expect(document.querySelector('input[type="hidden"]')).toBeFalsy(); +}); + +test('should render hidden input with value from text contents of ComboboxOption', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + expect( + document.querySelector('input[type="hidden"]')?.getAttribute('value') + ).toEqual('Banana'); +}); + +test('should render hidden input with value from value from ComboboxOption', () => { + render( + + 🍎 + 🍌 + 🍈 + + ); + + expect( + document.querySelector('input[type="hidden"]')?.getAttribute('value') + ).toEqual('Banana'); +}); + +test('should render hidden input with value from formValue from ComboboxOption', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + expect( + document.querySelector('input[type="hidden"]')?.getAttribute('value') + ).toEqual('2'); +}); + +test('should support portal element for combobox listbox', () => { + const element = document.createElement('div'); + render( + + Apple + Banana + Cantaloupe + + ); + expect( + element.contains(element.querySelector('[role="listbox"]')) + ).toBeTruthy(); +}); + +test('should support portal element ref for combobox listbox', () => { + const element = document.createElement('div'); + render( + + Apple + Banana + Cantaloupe + + ); + expect( + element.contains(element.querySelector('[role="listbox"]')) + ).toBeTruthy(); +}); diff --git a/packages/react/src/components/Combobox/Combobox.tsx b/packages/react/src/components/Combobox/Combobox.tsx index d4e780bfd..abba6b71d 100644 --- a/packages/react/src/components/Combobox/Combobox.tsx +++ b/packages/react/src/components/Combobox/Combobox.tsx @@ -219,7 +219,9 @@ const Combobox = forwardRef( const handleComboboxOptionClick = useCallback(() => { // maintain focus on the input - inputRef.current?.focus(); + if (inputRef.current !== document.activeElement) { + inputRef.current?.focus(); + } }, []); const handleBlur = useCallback( diff --git a/packages/react/yarn.lock b/packages/react/yarn.lock index 5be1814f5..ebfecf6c6 100644 --- a/packages/react/yarn.lock +++ b/packages/react/yarn.lock @@ -1790,6 +1790,18 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== +"@types/sinon@^10": + version "10.0.20" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.20.tgz#f1585debf4c0d99f9938f4111e5479fb74865146" + integrity sha512-2APKKruFNCAZgx3daAyACGzWuJ028VVCUDk6o2rw/Z4PXT0ogwdV4KUegW0MwVs0Zu59auPXbbuBJHF12Sx1Eg== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz"