diff --git a/src/__tests__/helpers/utils.js b/src/__tests__/helpers/utils.js index 9d5e7c59c..69946df96 100644 --- a/src/__tests__/helpers/utils.js +++ b/src/__tests__/helpers/utils.js @@ -74,6 +74,17 @@ function setupListbox() { document.body.append(wrapper) const listbox = wrapper.querySelector('[role="listbox"]') const options = Array.from(wrapper.querySelectorAll('[role="option"]')) + + // the user is responsible for handling aria-selected on listbox options + options.forEach(el => + el.addEventListener('click', e => + e.target.setAttribute( + 'aria-selected', + JSON.stringify(!JSON.parse(e.target.getAttribute('aria-selected'))), + ), + ), + ) + return { ...addListeners(listbox), listbox, diff --git a/src/__tests__/select-options.js b/src/__tests__/select-options.js index 24259ba0f..9be6ef22b 100644 --- a/src/__tests__/select-options.js +++ b/src/__tests__/select-options.js @@ -1,5 +1,5 @@ import userEvent from '../' -import {setupSelect, addListeners, setupListbox} from './helpers/utils' +import {setupSelect, addListeners, setupListbox, setup} from './helpers/utils' test('fires correct events', () => { const {select, options, getEventSnapshot} = setupSelect() @@ -22,6 +22,13 @@ test('fires correct events', () => { select[name="select"][value="1"] - click: Left (0) select[name="select"][value="2"] - input select[name="select"][value="2"] - change + select[name="select"][value="2"] - pointerover + select[name="select"][value="2"] - pointerenter + select[name="select"][value="2"] - mouseover: Left (0) + select[name="select"][value="2"] - mouseenter: Left (0) + select[name="select"][value="2"] - pointerup + select[name="select"][value="2"] - mouseup: Left (0) + select[name="select"][value="2"] - click: Left (0) `) const [o1, o2, o3] = options expect(o1.selected).toBe(false) @@ -35,33 +42,22 @@ test('fires correct events on listBox select', () => { expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: ul[value="2"] - ul - pointerover + li#2[value="2"][aria-selected=false] - pointerover ul - pointerenter - ul - mouseover: Left (0) + li#2[value="2"][aria-selected=false] - mouseover: Left (0) ul - mouseenter: Left (0) - ul - pointermove - ul - mousemove: Left (0) - ul - pointerdown - ul - mousedown: Left (0) - ul - pointerup - ul - mouseup: Left (0) - ul - click: Left (0) - li#2[value="2"][aria-selected=true] - pointerover - ul[value="2"] - pointerenter - li#2[value="2"][aria-selected=true] - mouseover: Left (0) - ul[value="2"] - mouseenter: Left (0) - li#2[value="2"][aria-selected=true] - pointermove - li#2[value="2"][aria-selected=true] - mousemove: Left (0) - li#2[value="2"][aria-selected=true] - pointerover - ul[value="2"] - pointerenter - li#2[value="2"][aria-selected=true] - mouseover: Left (0) - ul[value="2"] - mouseenter: Left (0) - li#2[value="2"][aria-selected=true] - pointermove - li#2[value="2"][aria-selected=true] - mousemove: Left (0) - li#2[value="2"][aria-selected=true] - pointerdown - li#2[value="2"][aria-selected=true] - mousedown: Left (0) - li#2[value="2"][aria-selected=true] - pointerup - li#2[value="2"][aria-selected=true] - mouseup: Left (0) + li#2[value="2"][aria-selected=false] - pointermove + li#2[value="2"][aria-selected=false] - mousemove: Left (0) + li#2[value="2"][aria-selected=false] - pointerover + ul - pointerenter + li#2[value="2"][aria-selected=false] - mouseover: Left (0) + ul - mouseenter: Left (0) + li#2[value="2"][aria-selected=false] - pointermove + li#2[value="2"][aria-selected=false] - mousemove: Left (0) + li#2[value="2"][aria-selected=false] - pointerdown + li#2[value="2"][aria-selected=false] - mousedown: Left (0) + li#2[value="2"][aria-selected=false] - pointerup + li#2[value="2"][aria-selected=false] - mouseup: Left (0) li#2[value="2"][aria-selected=true] - click: Left (0) li#2[value="2"][aria-selected=true] - pointermove li#2[value="2"][aria-selected=true] - mousemove: Left (0) @@ -150,6 +146,13 @@ test('a previously focused input gets blurred', () => { `) }) +test('throws an error if elements is neither select nor listbox', () => { + const {element} = setup(``) + expect(() => userEvent.selectOptions(element, ['foo'])).toThrowError( + /neither select nor listbox/i, + ) +}) + test('throws an error one selected option does not match', () => { const {select} = setupSelect({multiple: true}) expect(() => diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js new file mode 100644 index 000000000..bdd0d0e8b --- /dev/null +++ b/src/__tests__/utils.js @@ -0,0 +1,73 @@ +import {isInstanceOfElement} from '../utils' +import {setup} from './helpers/utils' + +// isInstanceOfElement can be removed once the peerDependency for @testing-library/dom is bumped to a version that includes https://github.com/testing-library/dom-testing-library/pull/885 +describe('check element type per isInstanceOfElement', () => { + let defaultViewDescriptor, spanDescriptor + beforeAll(() => { + defaultViewDescriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(global.document), + 'defaultView', + ) + spanDescriptor = Object.getOwnPropertyDescriptor( + global.window, + 'HTMLSpanElement', + ) + }) + afterEach(() => { + Object.defineProperty( + Object.getPrototypeOf(global.document), + 'defaultView', + defaultViewDescriptor, + ) + Object.defineProperty(global.window, 'HTMLSpanElement', spanDescriptor) + }) + + test('check in regular jest environment', () => { + const {element} = setup(``) + + expect(element.ownerDocument.defaultView).toEqual( + expect.objectContaining({ + HTMLSpanElement: expect.any(Function), + }), + ) + + expect(isInstanceOfElement(element, 'HTMLSpanElement')).toBe(true) + expect(isInstanceOfElement(element, 'HTMLDivElement')).toBe(false) + }) + + test('check in detached document', () => { + const {element} = setup(``) + + Object.defineProperty( + Object.getPrototypeOf(element.ownerDocument), + 'defaultView', + {value: null}, + ) + + expect(element.ownerDocument.defaultView).toBe(null) + + expect(isInstanceOfElement(element, 'HTMLSpanElement')).toBe(true) + expect(isInstanceOfElement(element, 'HTMLDivElement')).toBe(false) + }) + + test('check in environment not providing constructors on window', () => { + const {element} = setup(``) + + delete global.window.HTMLSpanElement + + expect(element.ownerDocument.defaultView.HTMLSpanElement).toBe(undefined) + + expect(isInstanceOfElement(element, 'HTMLSpanElement')).toBe(true) + expect(isInstanceOfElement(element, 'HTMLDivElement')).toBe(false) + }) + + test('throw error if element is not created by HTML*Element constructor', () => { + const doc = new Document() + + // constructor is global.Element + const element = doc.createElement('span') + + expect(() => isInstanceOfElement(element, 'HTMLSpanElement')).toThrow() + }) +}) diff --git a/src/select-options.js b/src/select-options.js index 967a34b34..9620814e7 100644 --- a/src/select-options.js +++ b/src/select-options.js @@ -1,4 +1,5 @@ import {createEvent, getConfig, fireEvent} from '@testing-library/dom' +import {isInstanceOfElement} from './utils' import {click} from './click' import {focus} from './focus' import {hover, unhover} from './hover' @@ -36,53 +37,70 @@ function selectOptionsBase(newValue, select, values, init) { if (select.disabled || !selectedOptions.length) return - if (select.multiple) { - for (const option of selectedOptions) { - // events fired for multiple select are weird. Can't use hover... - fireEvent.pointerOver(option, init) + if (isInstanceOfElement(select, 'HTMLSelectElement')) { + if (select.multiple) { + for (const option of selectedOptions) { + // events fired for multiple select are weird. Can't use hover... + fireEvent.pointerOver(option, init) + fireEvent.pointerEnter(select, init) + fireEvent.mouseOver(option) + fireEvent.mouseEnter(select) + fireEvent.pointerMove(option, init) + fireEvent.mouseMove(option, init) + fireEvent.pointerDown(option, init) + fireEvent.mouseDown(option, init) + focus(select, init) + fireEvent.pointerUp(option, init) + fireEvent.mouseUp(option, init) + selectOption(option) + fireEvent.click(option, init) + } + } else if (selectedOptions.length === 1) { + // the click to open the select options + click(select, init) + + selectOption(selectedOptions[0]) + + // the browser triggers another click event on the select for the click on the option + // this second click has no 'down' phase + fireEvent.pointerOver(select, init) fireEvent.pointerEnter(select, init) - fireEvent.mouseOver(option) + fireEvent.mouseOver(select) fireEvent.mouseEnter(select) - fireEvent.pointerMove(option, init) - fireEvent.mouseMove(option, init) - fireEvent.pointerDown(option, init) - fireEvent.mouseDown(option, init) - focus(select, init) - fireEvent.pointerUp(option, init) - fireEvent.mouseUp(option, init) - selectOption(option) - fireEvent.click(option, init) + fireEvent.pointerUp(select, init) + fireEvent.mouseUp(select, init) + fireEvent.click(select, init) + } else { + throw getConfig().getElementError( + `Cannot select multiple options on a non-multiple select`, + select, + ) } - } else if (selectedOptions.length === 1) { - click(select, init) - selectOption(selectedOptions[0]) + } else if (select.getAttribute('role') === 'listbox') { + selectedOptions.forEach(option => { + hover(option, init) + click(option, init) + unhover(option, init) + }) } else { throw getConfig().getElementError( - `Cannot select multiple options on a non-multiple select`, + `Cannot select options on elements that are neither select nor listbox elements`, select, ) } function selectOption(option) { - if (option.getAttribute('role') === 'option') { - option?.setAttribute?.('aria-selected', newValue) - - hover(option, init) - click(option, init) - unhover(option, init) - } else { - option.selected = newValue - fireEvent( - select, - createEvent('input', select, { - bubbles: true, - cancelable: false, - composed: true, - ...init, - }), - ) - fireEvent.change(select, init) - } + option.selected = newValue + fireEvent( + select, + createEvent('input', select, { + bubbles: true, + cancelable: false, + composed: true, + ...init, + }), + ) + fireEvent.change(select, init) } } diff --git a/src/upload.js b/src/upload.js index 87bd447f5..a252b438b 100644 --- a/src/upload.js +++ b/src/upload.js @@ -6,16 +6,14 @@ import {focus} from './focus' function upload(element, fileOrFiles, init) { if (element.disabled) return - let files - let input = element - click(element, init) - if (element.tagName === 'LABEL') { - files = element.control.multiple ? fileOrFiles : [fileOrFiles] - input = element.control - } else { - files = element.multiple ? fileOrFiles : [fileOrFiles] - } + + const input = element.tagName === 'LABEL' ? element.control : element + + const files = (Array.isArray(fileOrFiles) + ? fileOrFiles + : [fileOrFiles] + ).slice(0, input.multiple ? undefined : 1) // blur fires when the file selector pops up blur(element, init) diff --git a/src/utils.js b/src/utils.js index 8c284082b..fc46985a7 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,36 @@ import {getConfig} from '@testing-library/dom' +import {getWindowFromNode} from '@testing-library/dom/dist/helpers' + +// isInstanceOfElement can be removed once the peerDependency for @testing-library/dom is bumped to a version that includes https://github.com/testing-library/dom-testing-library/pull/885 +/** + * Check if an element is of a given type. + * + * @param Element The element to test + * @param string Constructor name. E.g. 'HTMLSelectElement' + */ +function isInstanceOfElement(element, elementType) { + try { + const window = getWindowFromNode(element) + // Window usually has the element constructors as properties but is not required to do so per specs + if (typeof window[elementType] === 'function') { + return element instanceof window[elementType] + } + } catch (e) { + // The document might not be associated with a window + } + + // Fall back to the constructor name as workaround for test environments that + // a) not associate the document with a window + // b) not provide the constructor as property of window + if (/^HTML(\w+)Element$/.test(element.constructor.name)) { + return element.constructor.name === elementType + } + + // The user passed some node that is not created in a browser-like environment + throw new Error( + `Unable to verify if element is instance of ${elementType}. Please file an issue describing your test environment: https://github.com/testing-library/dom-testing-library/issues/new`, + ) +} function isMousePressEvent(event) { return ( @@ -257,7 +289,7 @@ function isClickable(element) { return ( element.tagName === 'BUTTON' || (element.tagName === 'A' && element.href) || - (element instanceof element.ownerDocument.defaultView.HTMLInputElement && + (isInstanceOfElement(element, 'HTMLInputElement') && CLICKABLE_INPUT_TYPES.includes(element.type)) ) } @@ -335,4 +367,5 @@ export { getValue, getSelectionRange, isContentEditable, + isInstanceOfElement, }