diff --git a/.all-contributorsrc b/.all-contributorsrc
index 8040dc4b..88c453f8 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -725,6 +725,62 @@
"contributions": [
"code"
]
+ },
+ {
+ "login": "benadamstyles",
+ "name": "Ben Styles",
+ "avatar_url": "https://avatars.githubusercontent.com/u/4380655?v=4",
+ "profile": "https://benadamstyles.com",
+ "contributions": [
+ "code",
+ "test"
+ ]
+ },
+ {
+ "login": "LauraBeatris",
+ "name": "Laura Beatris",
+ "avatar_url": "https://avatars.githubusercontent.com/u/48022589?v=4",
+ "profile": "http://laurabeatris.com",
+ "contributions": [
+ "code",
+ "test"
+ ]
+ },
+ {
+ "login": "just-boris",
+ "name": "Boris Serdiuk",
+ "avatar_url": "https://avatars.githubusercontent.com/u/812240?v=4",
+ "profile": "https://twitter.com/boriscoder",
+ "contributions": [
+ "bug"
+ ]
+ },
+ {
+ "login": "bozdoz",
+ "name": "bozdoz",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1410985?v=4",
+ "profile": "https://bozdoz.com",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "jKatt",
+ "name": "Jan Kattelans",
+ "avatar_url": "https://avatars.githubusercontent.com/u/5550790?v=4",
+ "profile": "https://github.com/jKatt",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "schoeneu",
+ "name": "schoeneu",
+ "avatar_url": "https://avatars.githubusercontent.com/u/3261341?v=4",
+ "profile": "https://github.com/schoeneu",
+ "contributions": [
+ "bug"
+ ]
}
],
"commitConvention": "none",
diff --git a/README.md b/README.md
index b4d0fc64..3abeee53 100644
--- a/README.md
+++ b/README.md
@@ -271,12 +271,15 @@ test('types into the input', () => {
})
```
-### `upload(element, file, [{ clickInit, changeInit }])`
+### `upload(element, file, [{ clickInit, changeInit }], [options])`
Uploads file to an ``. For uploading multiple files use `` with
`multiple` attribute and the second `upload` argument must be array then. Also
it's possible to initialize click or change event with using third argument.
+If `options.applyAccept` is set to `true` and there is an `accept` attribute on
+the element, files that don't match will be discarded.
+
```jsx
import React from 'react'
import {render, screen} from '@testing-library/react'
@@ -533,6 +536,8 @@ method.
| arrowRight | `{arrowright}` |
| arrowDown | `{arrowdown}` |
| arrowUp | `{arrowup}` |
+| home | `{home}` |
+| end | `{end}` |
| enter | `{enter}` |
| escape | `{esc}` |
| delete | `{del}` |
@@ -683,6 +688,14 @@ Thanks goes to these people ([emoji key][emojis]):
Vasilii Kovalev 💻 📖 |
Dale Seo 📖 |
Alex Boyce 💻 |
+ Ben Styles 💻 ⚠️ |
+ Laura Beatris 💻 ⚠️ |
+ Boris Serdiuk 🐛 |
+ bozdoz 📖 |
+
+
+ Jan Kattelans 💻 |
+ schoeneu 🐛 |
diff --git a/src/__tests__/click.js b/src/__tests__/click.js
index 3c19daa2..8a0e784e 100644
--- a/src/__tests__/click.js
+++ b/src/__tests__/click.js
@@ -442,3 +442,20 @@ test('calls FocusEvents with relatedTarget', () => {
element0,
)
})
+
+test('move focus to closest focusable element', () => {
+ const {element} = setup(`
+
+
this is not focusable
+
+
+ `)
+
+ document.body.focus()
+ userEvent.click(element.children[1])
+ expect(element.children[1]).toHaveFocus()
+
+ document.body.focus()
+ userEvent.click(element.children[0])
+ expect(element).toHaveFocus()
+})
diff --git a/src/__tests__/type.js b/src/__tests__/type.js
index 18fb97d5..9940e78a 100644
--- a/src/__tests__/type.js
+++ b/src/__tests__/type.js
@@ -1121,43 +1121,43 @@ test('can type into an input with type `time`', () => {
const {element, getEventSnapshot} = setup('')
userEvent.type(element, '01:05')
expect(getEventSnapshot()).toMatchInlineSnapshot(`
- Events fired on: input[value="01:05"]
-
- input[value=""] - pointerover
- input[value=""] - pointerenter
- input[value=""] - mouseover: Left (0)
- input[value=""] - mouseenter: Left (0)
- input[value=""] - pointermove
- input[value=""] - mousemove: Left (0)
- input[value=""] - pointerdown
- input[value=""] - mousedown: Left (0)
- input[value=""] - focus
- input[value=""] - focusin
- input[value=""] - pointerup
- input[value=""] - mouseup: Left (0)
- input[value=""] - click: Left (0)
- input[value=""] - keydown: 0 (48)
- input[value=""] - keypress: 0 (48)
- input[value=""] - keyup: 0 (48)
- input[value=""] - keydown: 1 (49)
- input[value=""] - keypress: 1 (49)
- input[value=""] - keyup: 1 (49)
- input[value=""] - keydown: : (58)
- input[value=""] - keypress: : (58)
- input[value=""] - keyup: : (58)
- input[value=""] - keydown: 0 (48)
- input[value=""] - keypress: 0 (48)
- input[value="01:00"] - input
- "{CURSOR}" -> "{CURSOR}01:00"
- input[value="01:00"] - change
- input[value="01:00"] - keyup: 0 (48)
- input[value="01:00"] - keydown: 5 (53)
- input[value="01:00"] - keypress: 5 (53)
- input[value="01:05"] - input
- "{CURSOR}01:00" -> "{CURSOR}01:05"
- input[value="01:05"] - change
- input[value="01:05"] - keyup: 5 (53)
- `)
+ Events fired on: input[value="01:05"]
+
+ input[value=""] - pointerover
+ input[value=""] - pointerenter
+ input[value=""] - mouseover: Left (0)
+ input[value=""] - mouseenter: Left (0)
+ input[value=""] - pointermove
+ input[value=""] - mousemove: Left (0)
+ input[value=""] - pointerdown
+ input[value=""] - mousedown: Left (0)
+ input[value=""] - focus
+ input[value=""] - focusin
+ input[value=""] - pointerup
+ input[value=""] - mouseup: Left (0)
+ input[value=""] - click: Left (0)
+ input[value=""] - keydown: 0 (48)
+ input[value=""] - keypress: 0 (48)
+ input[value=""] - keyup: 0 (48)
+ input[value=""] - keydown: 1 (49)
+ input[value=""] - keypress: 1 (49)
+ input[value=""] - keyup: 1 (49)
+ input[value=""] - keydown: : (58)
+ input[value=""] - keypress: : (58)
+ input[value=""] - keyup: : (58)
+ input[value=""] - keydown: 0 (48)
+ input[value=""] - keypress: 0 (48)
+ input[value="01:00"] - input
+ "{CURSOR}" -> "{CURSOR}01:00"
+ input[value="01:00"] - change
+ input[value="01:00"] - keyup: 0 (48)
+ input[value="01:00"] - keydown: 5 (53)
+ input[value="01:00"] - keypress: 5 (53)
+ input[value="01:05"] - input
+ "{CURSOR}01:00" -> "{CURSOR}01:05"
+ input[value="01:05"] - change
+ input[value="01:05"] - keyup: 5 (53)
+ `)
expect(element).toHaveValue('01:05')
})
@@ -1165,40 +1165,40 @@ test('can type into an input with type `time` without ":"', () => {
const {element, getEventSnapshot} = setup('')
userEvent.type(element, '0105')
expect(getEventSnapshot()).toMatchInlineSnapshot(`
- Events fired on: input[value="01:05"]
-
- input[value=""] - pointerover
- input[value=""] - pointerenter
- input[value=""] - mouseover: Left (0)
- input[value=""] - mouseenter: Left (0)
- input[value=""] - pointermove
- input[value=""] - mousemove: Left (0)
- input[value=""] - pointerdown
- input[value=""] - mousedown: Left (0)
- input[value=""] - focus
- input[value=""] - focusin
- input[value=""] - pointerup
- input[value=""] - mouseup: Left (0)
- input[value=""] - click: Left (0)
- input[value=""] - keydown: 0 (48)
- input[value=""] - keypress: 0 (48)
- input[value=""] - keyup: 0 (48)
- input[value=""] - keydown: 1 (49)
- input[value=""] - keypress: 1 (49)
- input[value=""] - keyup: 1 (49)
- input[value=""] - keydown: 0 (48)
- input[value=""] - keypress: 0 (48)
- input[value="01:00"] - input
- "{CURSOR}" -> "{CURSOR}01:00"
- input[value="01:00"] - change
- input[value="01:00"] - keyup: 0 (48)
- input[value="01:00"] - keydown: 5 (53)
- input[value="01:00"] - keypress: 5 (53)
- input[value="01:05"] - input
- "{CURSOR}01:00" -> "{CURSOR}01:05"
- input[value="01:05"] - change
- input[value="01:05"] - keyup: 5 (53)
- `)
+ Events fired on: input[value="01:05"]
+
+ input[value=""] - pointerover
+ input[value=""] - pointerenter
+ input[value=""] - mouseover: Left (0)
+ input[value=""] - mouseenter: Left (0)
+ input[value=""] - pointermove
+ input[value=""] - mousemove: Left (0)
+ input[value=""] - pointerdown
+ input[value=""] - mousedown: Left (0)
+ input[value=""] - focus
+ input[value=""] - focusin
+ input[value=""] - pointerup
+ input[value=""] - mouseup: Left (0)
+ input[value=""] - click: Left (0)
+ input[value=""] - keydown: 0 (48)
+ input[value=""] - keypress: 0 (48)
+ input[value=""] - keyup: 0 (48)
+ input[value=""] - keydown: 1 (49)
+ input[value=""] - keypress: 1 (49)
+ input[value=""] - keyup: 1 (49)
+ input[value=""] - keydown: 0 (48)
+ input[value=""] - keypress: 0 (48)
+ input[value="01:00"] - input
+ "{CURSOR}" -> "{CURSOR}01:00"
+ input[value="01:00"] - change
+ input[value="01:00"] - keyup: 0 (48)
+ input[value="01:00"] - keydown: 5 (53)
+ input[value="01:00"] - keypress: 5 (53)
+ input[value="01:05"] - input
+ "{CURSOR}01:00" -> "{CURSOR}01:05"
+ input[value="01:05"] - change
+ input[value="01:05"] - keyup: 5 (53)
+ `)
expect(element).toHaveValue('01:05')
})
@@ -1208,7 +1208,7 @@ test('can type more a number higher than 60 minutes into an input `time` and the
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value="23:59"]
-
+
input[value=""] - pointerover
input[value=""] - pointerenter
input[value=""] - mouseover: Left (0)
@@ -1254,7 +1254,7 @@ test('can type letters into an input `time` and they are ignored', () => {
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value="16:36"]
-
+
input[value=""] - pointerover
input[value=""] - pointerenter
input[value=""] - mouseover: Left (0)
@@ -1495,3 +1495,22 @@ test('{arrowup} fires keyup/keydown events', () => {
input[value=""] - keyup: ArrowUp (38)
`)
})
+
+test('{enter} fires click on links', () => {
+ const {element, getEventSnapshot} = setup('link')
+
+ element?.focus()
+
+ userEvent.type(element, '{enter}', {skipClick: true})
+
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+ Events fired on: a
+
+ a - focus
+ a - focusin
+ a - keydown: Enter (13)
+ a - keypress: Enter (13)
+ a - click: Left (0)
+ a - keyup: Enter (13)
+ `)
+})
diff --git a/src/__tests__/upload.js b/src/__tests__/upload.js
index f09a6ae2..f83c5a57 100644
--- a/src/__tests__/upload.js
+++ b/src/__tests__/upload.js
@@ -163,3 +163,59 @@ test('should call onChange/input bubbling up the event when a file is selected',
expect(onInputInput).toHaveBeenCalledTimes(1)
expect(onInputForm).toHaveBeenCalledTimes(1)
})
+
+test.each([
+ [true, 'video/*,audio/*', 2],
+ [true, '.png', 1],
+ [true, 'text/csv', 1],
+ [true, '', 4],
+ [false, 'video/*', 4],
+])(
+ 'should filter according to accept attribute applyAccept=%s, acceptAttribute=%s',
+ (applyAccept, acceptAttribute, expectedLength) => {
+ const files = [
+ new File(['hello'], 'hello.png', {type: 'image/png'}),
+ new File(['there'], 'there.jpg', {type: 'audio/mp3'}),
+ new File(['there'], 'there.csv', {type: 'text/csv'}),
+ new File(['there'], 'there.jpg', {type: 'video/mp4'}),
+ ]
+ const {element} = setup(`
+
+ `)
+
+ userEvent.upload(element, files, undefined, {applyAccept})
+
+ expect(element.files).toHaveLength(expectedLength)
+ },
+)
+
+test('should not trigger input event when selected files are the same', () => {
+ const {element, eventWasFired, clearEventCalls} = setup(
+ '',
+ )
+ const files = [
+ new File(['hello'], 'hello.png', {type: 'image/png'}),
+ new File(['there'], 'there.png', {type: 'image/png'}),
+ ]
+
+ userEvent.upload(element, [])
+ expect(eventWasFired('input')).toBe(false)
+ expect(element.files).toHaveLength(0)
+
+ userEvent.upload(element, files)
+ expect(eventWasFired('input')).toBe(true)
+ expect(element.files).toHaveLength(2)
+
+ clearEventCalls()
+
+ userEvent.upload(element, files)
+ expect(eventWasFired('input')).toBe(false)
+ expect(element.files).toHaveLength(2)
+
+ userEvent.upload(element, [])
+ expect(eventWasFired('input')).toBe(true)
+ expect(element.files).toHaveLength(0)
+})
diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js
index bdd0d0e8..680f5cfa 100644
--- a/src/__tests__/utils.js
+++ b/src/__tests__/utils.js
@@ -1,4 +1,5 @@
-import {isInstanceOfElement} from '../utils'
+import { screen } from '@testing-library/dom'
+import {isInstanceOfElement, isVisible} 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
@@ -71,3 +72,19 @@ describe('check element type per isInstanceOfElement', () => {
expect(() => isInstanceOfElement(element, 'HTMLSpanElement')).toThrow()
})
})
+
+test('check if element is visible', () => {
+ setup(`
+
+
+
+
+
+ `)
+
+ expect(isVisible(screen.getByTestId('visibleInput'))).toBe(true)
+ expect(isVisible(screen.getByTestId('styledDisplayedInput'))).toBe(true)
+ expect(isVisible(screen.getByTestId('styledHiddenInput'))).toBe(false)
+ expect(isVisible(screen.getByTestId('childInput'))).toBe(false)
+ expect(isVisible(screen.getByTestId('hiddenInput'))).toBe(false)
+})
diff --git a/src/click.js b/src/click.js
index 5c40fda7..e8fe535f 100644
--- a/src/click.js
+++ b/src/click.js
@@ -61,14 +61,12 @@ function clickElement(element, init, {clickCount}) {
element,
getMouseEventOptions('mousedown', init, clickCount),
)
- if (
- continueDefaultHandling &&
- element !== element.ownerDocument.activeElement
- ) {
- if (previousElement && !isFocusable(element)) {
+ if (continueDefaultHandling) {
+ const closestFocusable = findClosest(element, isFocusable)
+ if (previousElement && !closestFocusable) {
blur(previousElement, init)
- } else {
- focus(element, init)
+ } else if (closestFocusable) {
+ focus(closestFocusable, init)
}
}
}
@@ -84,6 +82,16 @@ function clickElement(element, init, {clickCount}) {
}
}
+function findClosest(el, callback) {
+ do {
+ if (callback(el)) {
+ return el
+ }
+ el = el.parentElement
+ } while (el && el !== document.body)
+ return undefined
+}
+
function click(element, init, {skipHover = false, clickCount = 0} = {}) {
if (!skipHover) hover(element, init)
switch (element.tagName) {
diff --git a/src/tab.js b/src/tab.js
index 5205684b..975e99ca 100644
--- a/src/tab.js
+++ b/src/tab.js
@@ -1,5 +1,5 @@
import {fireEvent} from '@testing-library/dom'
-import {getActiveElement, FOCUSABLE_SELECTOR} from './utils'
+import {getActiveElement, FOCUSABLE_SELECTOR, isVisible} from './utils'
import {focus} from './focus'
import {blur} from './blur'
@@ -33,9 +33,9 @@ function tab({shift = false, focusTrap} = {}) {
el === previousElement ||
(el.getAttribute('tabindex') !== '-1' &&
!el.disabled &&
- // Hidden elements are taken out of the
- // document, along with all their children.
- !el.closest('[hidden]')),
+ // Hidden elements are not tabable
+ isVisible(el)
+ ),
)
if (enabledElements.length === 0) return
diff --git a/src/type.js b/src/type.js
index 71fdf1cd..125c1968 100644
--- a/src/type.js
+++ b/src/type.js
@@ -8,13 +8,14 @@ import {
getActiveElement,
calculateNewValue,
setSelectionRangeIfNecessary,
- isClickable,
+ isClickableInput,
isValidDateValue,
getSelectionRange,
getValue,
isContentEditable,
isValidInputTimeValue,
buildTimeValue,
+ isInstanceOfElement,
} from './utils'
import {click} from './click'
import {navigationKey} from './keys/navigation-key'
@@ -321,7 +322,7 @@ function fireInputEventIfNeeded({
const prevValue = getValue(currentElement())
if (
!currentElement().readOnly &&
- !isClickable(currentElement()) &&
+ !isClickableInput(currentElement()) &&
newValue !== prevValue
) {
if (isContentEditable(currentElement())) {
@@ -583,7 +584,12 @@ function handleEnter({currentElement, eventOverrides}) {
})
if (keyPressDefaultNotPrevented) {
- if (isClickable(currentElement())) {
+ if (
+ isClickableInput(currentElement()) ||
+ // Links with href defined should handle Enter the same as a click
+ (isInstanceOfElement(currentElement(), 'HTMLAnchorElement') &&
+ currentElement().href)
+ ) {
fireEvent.click(currentElement(), {
...eventOverrides,
})
@@ -712,7 +718,7 @@ function handleSelectall({currentElement}) {
}
function handleSpace(context) {
- if (isClickable(context.currentElement())) {
+ if (isClickableInput(context.currentElement())) {
handleSpaceOnClickable(context)
return
}
diff --git a/src/upload.js b/src/upload.js
index a252b438..0d067c44 100644
--- a/src/upload.js
+++ b/src/upload.js
@@ -3,23 +3,30 @@ import {click} from './click'
import {blur} from './blur'
import {focus} from './focus'
-function upload(element, fileOrFiles, init) {
+function upload(element, fileOrFiles, init, {applyAccept = false} = {}) {
if (element.disabled) return
click(element, init)
const input = element.tagName === 'LABEL' ? element.control : element
- const files = (Array.isArray(fileOrFiles)
- ? fileOrFiles
- : [fileOrFiles]
- ).slice(0, input.multiple ? undefined : 1)
+ const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles])
+ .filter(file => !applyAccept || isAcceptableFile(file, element.accept))
+ .slice(0, input.multiple ? undefined : 1)
// blur fires when the file selector pops up
blur(element, init)
// focus fires when they make their selection
focus(element, init)
+ // do not fire an input event if the file selection does not change
+ if (
+ files.length === input.files.length &&
+ files.every((f, i) => f === input.files.item(i))
+ ) {
+ return
+ }
+
// the event fired in the browser isn't actually an "input" or "change" event
// but a new Event with a type set to "input" and "change"
// Kinda odd...
@@ -46,4 +53,22 @@ function upload(element, fileOrFiles, init) {
})
}
+function isAcceptableFile(file, accept) {
+ if (!accept) {
+ return true
+ }
+
+ const wildcards = ['audio/*', 'image/*', 'video/*']
+
+ return accept.split(',').some(acceptToken => {
+ if (acceptToken[0] === '.') {
+ // tokens starting with a dot represent a file extension
+ return file.name.endsWith(acceptToken)
+ } else if (wildcards.includes(acceptToken)) {
+ return file.type.startsWith(acceptToken.substr(0, acceptToken.length - 1))
+ }
+ return file.type === acceptToken
+ })
+}
+
export {upload}
diff --git a/src/utils.js b/src/utils.js
index 96578020..b4825948 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -5,8 +5,8 @@ import {getWindowFromNode} from '@testing-library/dom/dist/helpers'
/**
* Check if an element is of a given type.
*
- * @param Element The element to test
- * @param string Constructor name. E.g. 'HTMLSelectElement'
+ * @param {Element} element The element to test
+ * @param {string} elementType Constructor name. E.g. 'HTMLSelectElement'
*/
function isInstanceOfElement(element, elementType) {
try {
@@ -285,7 +285,7 @@ const CLICKABLE_INPUT_TYPES = [
'submit',
]
-function isClickable(element) {
+function isClickableInput(element) {
return (
element.tagName === 'BUTTON' ||
(isInstanceOfElement(element, 'HTMLInputElement') &&
@@ -293,6 +293,19 @@ function isClickable(element) {
)
}
+function isVisible(element) {
+ const getComputedStyle = getWindowFromNode(element).getComputedStyle
+
+ for(; element && element.ownerDocument; element = element.parentNode) {
+ const display = getComputedStyle(element).display
+ if (display === 'none') {
+ return false
+ }
+ }
+
+ return true
+}
+
function eventWrapper(cb) {
let result
getConfig().eventWrapper(() => {
@@ -353,7 +366,7 @@ function isValidInputTimeValue(element, timeValue) {
export {
FOCUSABLE_SELECTOR,
isFocusable,
- isClickable,
+ isClickableInput,
getMouseEventOptions,
isLabelWithInternallyDisabledControl,
getActiveElement,
@@ -367,4 +380,5 @@ export {
getSelectionRange,
isContentEditable,
isInstanceOfElement,
+ isVisible,
}
diff --git a/typings/index.d.ts b/typings/index.d.ts
index 56211bfc..eae80cde 100644
--- a/typings/index.d.ts
+++ b/typings/index.d.ts
@@ -26,6 +26,10 @@ export interface IClickOptions {
clickCount?: number
}
+export interface IUploadOptions {
+ applyAccept?: boolean
+}
+
declare const userEvent: {
clear: (element: TargetElement) => void
click: (
@@ -52,6 +56,7 @@ declare const userEvent: {
element: TargetElement,
files: FilesArgument,
init?: UploadInitArgument,
+ options?: IUploadOptions,
) => void
type: (
element: TargetElement,
@@ -84,6 +89,8 @@ export enum specialChars {
escape = '{esc}',
delete = '{del}',
backspace = '{backspace}',
+ home = '{home}',
+ end = '{end}',
selectAll = '{selectall}',
space = '{space}',
whitespace = ' ',