diff --git a/jest.config.js b/jest.config.js index 7a7a2b50..8d869c49 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,7 @@ const { } = require('kcd-scripts/jest') module.exports = { + resetMocks: true, collectCoverageFrom, coveragePathIgnorePatterns: [ ...coveragePathIgnorePatterns, diff --git a/package.json b/package.json index 18cc9185..b4f93502 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "jest-watch-select-projects": "^2.0.0", "jsdom": "^16.2.2", "kcd-scripts": "^6.2.0", + "redent": "^3.0.0", "typescript": "^3.9.5" }, "eslintConfig": { @@ -59,7 +60,8 @@ "import/prefer-default-export": "off", "import/no-unassigned-import": "off", "import/no-useless-path-segments": "off", - "no-console": "off" + "no-console": "off", + "no-func-assign": "off" } }, "eslintIgnore": [ diff --git a/src/event-map.js b/src/event-map.js index 98d78e66..0eba4a28 100644 --- a/src/event-map.js +++ b/src/event-map.js @@ -1,350 +1,360 @@ export const eventMap = { - // Clipboard Events - copy: { - EventType: 'ClipboardEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - cut: { - EventType: 'ClipboardEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - paste: { - EventType: 'ClipboardEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Composition Events - compositionEnd: { - EventType: 'CompositionEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - compositionStart: { - EventType: 'CompositionEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - compositionUpdate: { - EventType: 'CompositionEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Keyboard Events - keyDown: { - EventType: 'KeyboardEvent', - defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, - }, - keyPress: { - EventType: 'KeyboardEvent', - defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, - }, - keyUp: { - EventType: 'KeyboardEvent', - defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, - }, - // Focus Events - focus: { - EventType: 'FocusEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - blur: { - EventType: 'FocusEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - focusIn: { - EventType: 'FocusEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - focusOut: { - EventType: 'FocusEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - // Form Events - change: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: false}, - }, - input: { - EventType: 'InputEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - invalid: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: true}, - }, - submit: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: true}, - }, - reset: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: true}, - }, - // Mouse Events - click: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, button: 0, composed: true}, - }, - contextMenu: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dblClick: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - drag: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dragEnd: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - dragEnter: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dragExit: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - dragLeave: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - dragOver: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - dragStart: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - drop: { - EventType: 'DragEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseDown: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseEnter: { - EventType: 'MouseEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - mouseLeave: { - EventType: 'MouseEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - mouseMove: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseOut: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseOver: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - mouseUp: { - EventType: 'MouseEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Selection Events - select: { - EventType: 'Event', - defaultInit: {bubbles: true, cancelable: false}, - }, - // Touch Events - touchCancel: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - touchEnd: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - touchMove: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - touchStart: { - EventType: 'TouchEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // UI Events - scroll: { - EventType: 'UIEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - // Wheel Events - wheel: { - EventType: 'WheelEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - // Media Events - abort: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - canPlay: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - canPlayThrough: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - durationChange: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - emptied: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - encrypted: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - ended: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - loadedData: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - loadedMetadata: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - loadStart: { - EventType: 'ProgressEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - pause: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - play: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - playing: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - progress: { - EventType: 'ProgressEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - rateChange: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - seeked: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - seeking: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - stalled: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - suspend: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - timeUpdate: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - volumeChange: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - waiting: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - // Image Events - load: { - EventType: 'UIEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - error: { - EventType: 'Event', - defaultInit: {bubbles: false, cancelable: false}, - }, - // Animation Events - animationStart: { - EventType: 'AnimationEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - animationEnd: { - EventType: 'AnimationEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - animationIteration: { - EventType: 'AnimationEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - // Transition Events - transitionEnd: { - EventType: 'TransitionEvent', - defaultInit: {bubbles: true, cancelable: true}, - }, - // pointer events - pointerOver: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerEnter: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - pointerDown: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerMove: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerUp: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerCancel: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: false, composed: true}, - }, - pointerOut: { - EventType: 'PointerEvent', - defaultInit: {bubbles: true, cancelable: true, composed: true}, - }, - pointerLeave: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false}, - }, - gotPointerCapture: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - lostPointerCapture: { - EventType: 'PointerEvent', - defaultInit: {bubbles: false, cancelable: false, composed: true}, - }, - // history events - popState: { - EventType: 'PopStateEvent', - defaultInit: {bubbles: true, cancelable: false}, - }, - } - - export const eventAliasMap = { - doubleClick: 'dblClick', - } + // Clipboard Events + copy: { + EventType: 'ClipboardEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + cut: { + EventType: 'ClipboardEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + paste: { + EventType: 'ClipboardEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Composition Events + compositionEnd: { + EventType: 'CompositionEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + compositionStart: { + EventType: 'CompositionEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + compositionUpdate: { + EventType: 'CompositionEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Keyboard Events + keyDown: { + EventType: 'KeyboardEvent', + defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, + }, + keyPress: { + EventType: 'KeyboardEvent', + defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, + }, + keyUp: { + EventType: 'KeyboardEvent', + defaultInit: {bubbles: true, cancelable: true, charCode: 0, composed: true}, + }, + // Focus Events + focus: { + EventType: 'FocusEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + blur: { + EventType: 'FocusEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + focusIn: { + EventType: 'FocusEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + focusOut: { + EventType: 'FocusEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + // Form Events + change: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: false}, + }, + input: { + EventType: 'InputEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + invalid: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: true}, + }, + submit: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: true}, + }, + reset: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: true}, + }, + // Mouse Events + click: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, button: 0, composed: true}, + }, + contextMenu: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dblClick: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + drag: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dragEnd: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + dragEnter: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dragExit: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + dragLeave: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + dragOver: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + dragStart: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + drop: { + EventType: 'DragEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseDown: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseEnter: { + EventType: 'MouseEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + mouseLeave: { + EventType: 'MouseEvent', + defaultInit: {bubbles: false, cancelable: false, composed: true}, + }, + mouseMove: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseOut: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseOver: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + mouseUp: { + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Selection Events + select: { + EventType: 'Event', + defaultInit: {bubbles: true, cancelable: false}, + }, + // Touch Events + touchCancel: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true}, + }, + touchEnd: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + touchMove: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + touchStart: { + EventType: 'TouchEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // UI Events + scroll: { + EventType: 'UIEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + // Wheel Events + wheel: { + EventType: 'WheelEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, + // Media Events + abort: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + canPlay: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + canPlayThrough: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + durationChange: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + emptied: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + encrypted: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + ended: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + loadedData: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + loadedMetadata: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + loadStart: { + EventType: 'ProgressEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + pause: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + play: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + playing: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + progress: { + EventType: 'ProgressEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + rateChange: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + seeked: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + seeking: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + stalled: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + suspend: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + timeUpdate: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + volumeChange: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + waiting: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + // Image Events + load: { + EventType: 'UIEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, + error: { + EventType: 'Event', + defaultInit: {bubbles: false, cancelable: false}, + }, + // Animation Events + animationStart: { + EventType: 'AnimationEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, + animationEnd: { + EventType: 'AnimationEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, + animationIteration: { + EventType: 'AnimationEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, + // Transition Events + transitionEnd: { + EventType: 'TransitionEvent', + defaultInit: {bubbles: true, cancelable: true}, + }, + // pointer events + pointerOver: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerEnter: { + EventType: 'PointerEvent', + defaultInit: {bubbles: false, cancelable: false, button: -1}, + }, + pointerDown: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerMove: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerUp: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerCancel: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: false, composed: true, button: -1}, + }, + pointerOut: { + EventType: 'PointerEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true, button: -1}, + }, + pointerLeave: { + EventType: 'PointerEvent', + defaultInit: {bubbles: false, cancelable: false, button: -1}, + }, + gotPointerCapture: { + EventType: 'PointerEvent', + defaultInit: { + bubbles: false, + cancelable: false, + composed: true, + button: -1, + }, + }, + lostPointerCapture: { + EventType: 'PointerEvent', + defaultInit: { + bubbles: false, + cancelable: false, + composed: true, + button: -1, + }, + }, + // history events + popState: { + EventType: 'PopStateEvent', + defaultInit: {bubbles: true, cancelable: false}, + }, +} + +export const eventAliasMap = { + doubleClick: 'dblClick', +} diff --git a/src/events.js b/src/events.js index 58da1f7c..08dde7c9 100644 --- a/src/events.js +++ b/src/events.js @@ -18,72 +18,78 @@ function fireEvent(element, event) { }) } -const createEvent = {} +function createEvent( + eventName, + node, + init, + {EventType = 'Event', defaultInit = {}} = {}, +) { + if (!node) { + throw new Error( + `Unable to fire a "${eventName}" event - please provide a DOM element.`, + ) + } + const eventInit = {...defaultInit, ...init} + const {target: {value, files, ...targetProperties} = {}} = eventInit + if (value !== undefined) { + setNativeValue(node, value) + } + if (files !== undefined) { + // input.files is a read-only property so this is not allowed: + // input.files = [file] + // so we have to use this workaround to set the property + Object.defineProperty(node, 'files', { + configurable: true, + enumerable: true, + writable: true, + value: files, + }) + } + Object.assign(node, targetProperties) + const window = getWindowFromNode(node) + const EventConstructor = window[EventType] || window.Event + let event + /* istanbul ignore else */ + if (typeof EventConstructor === 'function') { + event = new EventConstructor(eventName, eventInit) + } else { + // IE11 polyfill from https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill + event = window.document.createEvent(EventType) + const {bubbles, cancelable, detail, ...otherInit} = eventInit + event.initEvent(eventName, bubbles, cancelable, detail) + Object.keys(otherInit).forEach(eventKey => { + event[eventKey] = otherInit[eventKey] + }) + } + + // DataTransfer is not supported in jsdom: https://github.com/jsdom/jsdom/issues/1568 + const dataTransferProperties = ['dataTransfer', 'clipboardData'] + dataTransferProperties.forEach(dataTransferKey => { + const dataTransferValue = eventInit[dataTransferKey] + + if (typeof dataTransferValue === 'object') { + /* istanbul ignore if */ + if (typeof window.DataTransfer === 'function') { + Object.defineProperty(event, dataTransferKey, { + value: Object.assign(new window.DataTransfer(), dataTransferValue), + }) + } else { + Object.defineProperty(event, dataTransferKey, { + value: dataTransferValue, + }) + } + } + }) + + return event +} Object.keys(eventMap).forEach(key => { const {EventType, defaultInit} = eventMap[key] const eventName = key.toLowerCase() - createEvent[key] = (node, init) => { - if (!node) { - throw new Error( - `Unable to fire a "${key}" event - please provide a DOM element.`, - ) - } - const eventInit = {...defaultInit, ...init} - const {target: {value, files, ...targetProperties} = {}} = eventInit - if (value !== undefined) { - setNativeValue(node, value) - } - if (files !== undefined) { - // input.files is a read-only property so this is not allowed: - // input.files = [file] - // so we have to use this workaround to set the property - Object.defineProperty(node, 'files', { - configurable: true, - enumerable: true, - writable: true, - value: files, - }) - } - Object.assign(node, targetProperties) - const window = getWindowFromNode(node) - const EventConstructor = window[EventType] || window.Event - let event - /* istanbul ignore else */ - if (typeof EventConstructor === 'function') { - event = new EventConstructor(eventName, eventInit) - } else { - // IE11 polyfill from https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill - event = window.document.createEvent(EventType) - const {bubbles, cancelable, detail, ...otherInit} = eventInit - event.initEvent(eventName, bubbles, cancelable, detail) - Object.keys(otherInit).forEach(eventKey => { - event[eventKey] = otherInit[eventKey] - }) - } - - // DataTransfer is not supported in jsdom: https://github.com/jsdom/jsdom/issues/1568 - ['dataTransfer', 'clipboardData'].forEach(dataTransferKey => { - const dataTransferValue = eventInit[dataTransferKey]; - - if (typeof dataTransferValue === 'object') { - /* istanbul ignore if */ - if (typeof window.DataTransfer === 'function') { - Object.defineProperty(event, dataTransferKey, { - value: Object.assign(new window.DataTransfer(), dataTransferValue) - }) - } else { - Object.defineProperty(event, dataTransferKey, { - value: dataTransferValue - }) - } - } - }) - - return event - } - + createEvent[key] = (node, init) => + createEvent(eventName, node, init, {EventType, defaultInit}) fireEvent[key] = (node, init) => fireEvent(node, createEvent[key](node, init)) }) diff --git a/src/index.js b/src/index.js index 2a279383..6a2fb404 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import {getQueriesForElement} from './get-queries-for-element' import * as queries from './queries' import * as queryHelpers from './query-helpers' +import * as userEvent from './user-event' export * from './queries' export * from './wait-for' @@ -26,4 +27,5 @@ export { // export query utils under a namespace for convenience: queries, queryHelpers, + userEvent, } diff --git a/src/user-event/.eslintrc b/src/user-event/.eslintrc new file mode 100644 index 00000000..b7d78581 --- /dev/null +++ b/src/user-event/.eslintrc @@ -0,0 +1,8 @@ +{ + "rules": { + // everything in this directory is intentionally running in series, not parallel + // because user's cannot fire multiple events at the same time and we need + // all events fired in a predictable order. + "no-await-in-loop": "off" + } +} diff --git a/src/user-event/__mocks__/utils.js b/src/user-event/__mocks__/utils.js new file mode 100644 index 00000000..30ff2456 --- /dev/null +++ b/src/user-event/__mocks__/utils.js @@ -0,0 +1,50 @@ +// this helps us track what the state is before and after an event is fired +// this is needed for determining the snapshot values +const actual = jest.requireActual('../utils') + +function getTrackedElementValues(element) { + return { + value: element.value, + checked: element.checked, + selectionStart: element.selectionStart, + selectionEnd: element.selectionEnd, + + // unfortunately, changing a select option doesn't happen within fireEvent + // but rather imperatively via `options.selected = newValue` + // because of this we don't (currently) have a way to track before/after + // in a given fireEvent call. + } +} + +function wrapWithTestData(fn) { + return async (element, init) => { + const before = getTrackedElementValues(element) + const testData = {before} + + // put it on the element so the event handler can grab it + element.testData = testData + const result = await fn(element, init) + + const after = getTrackedElementValues(element) + Object.assign(testData, {after}) + + // elete the testData for the next event + delete element.testData + return result + } +} + +const mockFireEvent = wrapWithTestData(actual.fireEvent) + +for (const key of Object.keys(actual.fireEvent)) { + if (typeof actual.fireEvent[key] === 'function') { + mockFireEvent[key] = wrapWithTestData(actual.fireEvent[key], key) + } else { + mockFireEvent[key] = actual.fireEvent[key] + } +} + +module.exports = { + ...actual, + fireEvent: mockFireEvent, +} diff --git a/src/user-event/__tests__/blur.js b/src/user-event/__tests__/blur.js new file mode 100644 index 00000000..197b036c --- /dev/null +++ b/src/user-event/__tests__/blur.js @@ -0,0 +1,65 @@ +import {userEvent} from '../../' +import {setup} from './helpers/utils' + +test('blur a button', async () => { + const {element, getEventSnapshot, clearEventCalls} = setup(``) + const input = element.children[0] + const button = element.children[1] + + addEventListener(button, 'click', () => input.focus()) + + expect(input).not.toHaveFocus() + + await userEvent.click(button) + expect(input).toHaveFocus() + + await userEvent.click(button) + expect(input).toHaveFocus() +}) + +test('gives focus to the form control when clicking the label', async () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const input = element.children[1] + + await userEvent.click(label) + expect(input).toHaveFocus() +}) + +test('gives focus to the form control when clicking within a label', async () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const span = label.firstChild + const input = element.children[1] + + await userEvent.click(span) + expect(input).toHaveFocus() +}) + +test('fires no events when clicking a label with a nested control that is disabled', async () => { + const {element, getEventSnapshot} = setup(``) + await userEvent.click(element) + expect(getEventSnapshot()).toMatchInlineSnapshot( + `No events were fired on: label`, + ) +}) + +test('does not crash if the label has no control', async () => { + const {element} = setup(``) + await userEvent.click(element) +}) + +test('clicking a label checks the checkbox', async () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const input = element.children[1] + + await userEvent.click(label) + expect(input).toHaveFocus() + expect(input).toBeChecked() +}) + +test('clicking a label checks the radio', async () => { + const {element} = setup(` +
+ + +
+ `) + const label = element.children[0] + const input = element.children[1] + + await userEvent.click(label) + expect(input).toHaveFocus() + expect(input).toBeChecked() +}) + +test('submits a form when clicking on a `) + await userEvent.click(element.children[0]) + expect(eventWasFired('submit')).toBe(true) +}) + +test('does not submit a form when clicking on a + + `) + await userEvent.click(element.children[0]) + expect(getEventSnapshot()).not.toContain('submit') +}) + +test('does not fire blur on current element if is the same as previous', async () => { + const {element, getEventSnapshot, clearEventCalls} = setup(' + + + `) + + const [one, five] = [ + document.querySelector('[data-testid="one"]'), + document.querySelector('[data-testid="five"]'), + ] + + await userEvent.tab() + expect(one).toHaveFocus() + + await userEvent.tab() + expect(five).toHaveFocus() +}) + +test('should keep focus on the document if there are no enabled, focusable elements', async () => { + setup(``) + await userEvent.tab() + expect(document.body).toHaveFocus() + + await userEvent.tab({shift: true}) + expect(document.body).toHaveFocus() +}) + +test('should respect radio groups', async () => { + setup(` +
+ + + + +
`) + + const [firstLeft, firstRight, , secondRight] = document.querySelectorAll( + '[data-testid="element"]', + ) + + await userEvent.tab() + + expect(firstLeft).toHaveFocus() + + await userEvent.tab() + + expect(secondRight).toHaveFocus() + + await userEvent.tab({shift: true}) + + expect(firstRight).toHaveFocus() +}) diff --git a/src/user-event/__tests__/type-modifiers.js b/src/user-event/__tests__/type-modifiers.js new file mode 100644 index 00000000..e437d9f5 --- /dev/null +++ b/src/user-event/__tests__/type-modifiers.js @@ -0,0 +1,795 @@ +import {userEvent} from '../../' +import {setup} from './helpers/utils' + +// Note, use the setup function at the bottom of the file... +// but don't hurt yourself trying to read it 😅 + +// keep in mind that we do not handle modifier interactions. This is primarily +// because modifiers behave differently on different operating systems. +// For example: {alt}{backspace}{/alt} will remove everything from the current +// cursor position to the beginning of the word on Mac, but you need to use +// {ctrl}{backspace}{/ctrl} to do that on Windows. And that doesn't appear to +// be consistent within an OS either 🙃 +// So we're not going to even try. + +// This also means that '{shift}a' will fire an input event with the shiftKey, +// but will not capitalize "a". + +test('{esc} triggers typing the escape character', async () => { + const {element, getEventSnapshot} = setup('') + + await userEvent.type(element, '{esc}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + 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: Escape (27) + input[value=""] - keyup: Escape (27) + `) +}) + +test('a{backspace}', async () => { + const {element, getEventSnapshot} = setup('') + await userEvent.type(element, 'a{backspace}') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + 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: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) + input[value="a"] - keydown: Backspace (8) + input[value=""] - input + "a{CURSOR}" -> "{CURSOR}" + input[value=""] - keyup: Backspace (8) + `) +}) + +test('{backspace}a', async () => { + const {element, getEventSnapshot} = setup('') + await userEvent.type(element, '{backspace}a') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + 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: Backspace (8) + input[value=""] - keyup: Backspace (8) + input[value=""] - keydown: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) + `) +}) + +test('{backspace} triggers typing the backspace character and deletes the character behind the cursor', async () => { + const {element, getEventSnapshot} = setup('') + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="o"] + + input[value="yo"] - select + input[value="yo"] - pointerover + input[value="yo"] - pointerenter + input[value="yo"] - mouseover: Left (0) + input[value="yo"] - mouseenter: Left (0) + input[value="yo"] - pointermove + input[value="yo"] - mousemove: Left (0) + input[value="yo"] - pointerdown + input[value="yo"] - mousedown: Left (0) + input[value="yo"] - focus + input[value="yo"] - focusin + input[value="yo"] - pointerup + input[value="yo"] - mouseup: Left (0) + input[value="yo"] - click: Left (0) + input[value="yo"] - keydown: Backspace (8) + input[value="o"] - input + "y{CURSOR}o" -> "o{CURSOR}" + input[value="o"] - select + input[value="o"] - keyup: Backspace (8) + `) +}) + +test('{backspace} on a readOnly input', async () => { + const {element, getEventSnapshot} = setup('') + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="yo"] + + input[value="yo"] - select + input[value="yo"] - pointerover + input[value="yo"] - pointerenter + input[value="yo"] - mouseover: Left (0) + input[value="yo"] - mouseenter: Left (0) + input[value="yo"] - pointermove + input[value="yo"] - mousemove: Left (0) + input[value="yo"] - pointerdown + input[value="yo"] - mousedown: Left (0) + input[value="yo"] - focus + input[value="yo"] - focusin + input[value="yo"] - pointerup + input[value="yo"] - mouseup: Left (0) + input[value="yo"] - click: Left (0) + input[value="yo"] - keydown: Backspace (8) + input[value="yo"] - keyup: Backspace (8) + `) +}) + +test('{backspace} does not fire input if keydown prevents default', async () => { + const {element, getEventSnapshot} = setup('', { + eventHandlers: {keyDown: e => e.preventDefault()}, + }) + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="yo"] + + input[value="yo"] - select + input[value="yo"] - pointerover + input[value="yo"] - pointerenter + input[value="yo"] - mouseover: Left (0) + input[value="yo"] - mouseenter: Left (0) + input[value="yo"] - pointermove + input[value="yo"] - mousemove: Left (0) + input[value="yo"] - pointerdown + input[value="yo"] - mousedown: Left (0) + input[value="yo"] - focus + input[value="yo"] - focusin + input[value="yo"] - pointerup + input[value="yo"] - mouseup: Left (0) + input[value="yo"] - click: Left (0) + input[value="yo"] - keydown: Backspace (8) + input[value="yo"] - keyup: Backspace (8) + `) +}) + +test('{backspace} deletes the selected range', async () => { + const {element, getEventSnapshot} = setup('') + element.setSelectionRange(1, 5) + + await userEvent.type(element, '{backspace}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="Here"] + + input[value="Hi there"] - select + input[value="Hi there"] - pointerover + input[value="Hi there"] - pointerenter + input[value="Hi there"] - mouseover: Left (0) + input[value="Hi there"] - mouseenter: Left (0) + input[value="Hi there"] - pointermove + input[value="Hi there"] - mousemove: Left (0) + input[value="Hi there"] - pointerdown + input[value="Hi there"] - mousedown: Left (0) + input[value="Hi there"] - focus + input[value="Hi there"] - focusin + input[value="Hi there"] - pointerup + input[value="Hi there"] - mouseup: Left (0) + input[value="Hi there"] - click: Left (0) + input[value="Hi there"] - keydown: Backspace (8) + input[value="Here"] - input + "H{SELECTION}i th{/SELECTION}ere" -> "Here{CURSOR}" + input[value="Here"] - select + input[value="Here"] - keyup: Backspace (8) + `) +}) + +test('{backspace} on an input type that does not support selection ranges', async () => { + const {element} = setup('') + // note: you cannot even call setSelectionRange on these kinds of elements... + await userEvent.type(element, '{backspace}{backspace}a') + // removed "m" then "o" then add "a" + expect(element).toHaveValue('yo@example.ca') +}) + +test('{alt}a{/alt}', async () => { + const {element, getEventSnapshot} = setup('') + + await userEvent.type(element, '{alt}a{/alt}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + 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: Alt (18) {alt} + input[value=""] - keydown: a (97) {alt} + input[value=""] - keypress: a (97) {alt} + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) {alt} + input[value="a"] - keyup: Alt (18) + `) +}) + +test('{meta}a{/meta}', async () => { + const {element, getEventSnapshot} = setup('') + + await userEvent.type(element, '{meta}a{/meta}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + 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: Meta (93) {meta} + input[value=""] - keydown: a (97) {meta} + input[value=""] - keypress: a (97) {meta} + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) {meta} + input[value="a"] - keyup: Meta (93) + `) +}) + +test('{ctrl}a{/ctrl}', async () => { + const {element, getEventSnapshot} = setup('') + + await userEvent.type(element, '{ctrl}a{/ctrl}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + 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: Control (17) {ctrl} + input[value=""] - keydown: a (97) {ctrl} + input[value=""] - keypress: a (97) {ctrl} + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) {ctrl} + input[value="a"] - keyup: Control (17) + `) +}) + +test('{shift}a{/shift}', async () => { + const {element, getEventSnapshot} = setup('') + + await userEvent.type(element, '{shift}a{/shift}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + 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: Shift (16) {shift} + input[value=""] - keydown: a (97) {shift} + input[value=""] - keypress: a (97) {shift} + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) {shift} + input[value="a"] - keyup: Shift (16) + `) +}) + +test('a{enter}', async () => { + const {element, getEventSnapshot} = setup('') + + await userEvent.type(element, 'a{enter}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="a"] + + 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: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + "{CURSOR}" -> "a{CURSOR}" + input[value="a"] - keyup: a (97) + input[value="a"] - keydown: Enter (13) + input[value="a"] - keypress: Enter (13) + input[value="a"] - keyup: Enter (13) + `) +}) + +test('{enter} with preventDefault keydown', async () => { + const {element, getEventSnapshot} = setup('', { + eventHandlers: { + keyDown: e => e.preventDefault(), + }, + }) + + await userEvent.type(element, '{enter}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + 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: Enter (13) + input[value=""] - keyup: Enter (13) + `) +}) + +test('{enter} on a button', async () => { + const {element, getEventSnapshot} = setup('