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 = ' ',