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,
}