From 1d57a79c9cccbf61e1b97d386fe8007072934fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Bayreuther?= Date: Thu, 23 Jul 2020 22:45:35 +0200 Subject: [PATCH] fix: button trigger space (#409) --- README.md | 4 +- package.json | 6 +- src/__tests__/click.js | 6 +- src/__tests__/deselect-options.js | 1 + src/__tests__/paste.js | 3 + src/__tests__/type-modifiers.js | 206 ++++++++ src/blur.js | 4 +- src/focus.js | 4 +- src/type.js | 752 +++++++++++++++++------------- src/utils.js | 18 + 10 files changed, 675 insertions(+), 329 deletions(-) diff --git a/README.md b/README.md index bcef77cd..034fd5d4 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ test('double click', () => { const checkbox = screen.getByTestId('checkbox') userEvent.dblClick(checkbox) expect(onChange).toHaveBeenCalledTimes(2) - expect(checkbox).toHaveProperty('checked', false) + expect(checkbox).not.toBeChecked() }) ``` @@ -185,6 +185,7 @@ The following special character strings are supported: | Text string | Key | Modifier | Notes | | ------------- | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `{enter}` | Enter | N/A | Will insert a newline character (`') diff --git a/src/blur.js b/src/blur.js index f8dee334..0f674300 100644 --- a/src/blur.js +++ b/src/blur.js @@ -1,14 +1,12 @@ -import {fireEvent} from '@testing-library/dom' import {getActiveElement, isFocusable, eventWrapper} from './utils' -function blur(element, init) { +function blur(element) { if (!isFocusable(element)) return const wasActive = getActiveElement(element.ownerDocument) === element if (!wasActive) return eventWrapper(() => element.blur()) - fireEvent.focusOut(element, init) } export {blur} diff --git a/src/focus.js b/src/focus.js index 1df8818f..ae1b8c89 100644 --- a/src/focus.js +++ b/src/focus.js @@ -1,14 +1,12 @@ -import {fireEvent} from '@testing-library/dom' import {getActiveElement, isFocusable, eventWrapper} from './utils' -function focus(element, init) { +function focus(element) { if (!isFocusable(element)) return const isAlreadyActive = getActiveElement(element.ownerDocument) === element if (isAlreadyActive) return eventWrapper(() => element.focus()) - fireEvent.focusIn(element, init) } export {focus} diff --git a/src/type.js b/src/type.js index 513fff17..2c7ba6c5 100644 --- a/src/type.js +++ b/src/type.js @@ -8,10 +8,48 @@ import { getActiveElement, calculateNewValue, setSelectionRangeIfNecessary, + isClickable, } from './utils' import {click} from './click' import {navigationKey} from './keys/navigation-key' +const modifierCallbackMap = { + ...createModifierCallbackEntries({ + name: 'shift', + key: 'Shift', + keyCode: 16, + modifierProperty: 'shiftKey', + }), + ...createModifierCallbackEntries({ + name: 'ctrl', + key: 'Control', + keyCode: 17, + modifierProperty: 'ctrlKey', + }), + ...createModifierCallbackEntries({ + name: 'alt', + key: 'Alt', + keyCode: 18, + modifierProperty: 'altKey', + }), + ...createModifierCallbackEntries({ + name: 'meta', + key: 'Meta', + keyCode: 93, + modifierProperty: 'metaKey', + }), +} + +const specialCharCallbackMap = { + '{enter}': handleEnter, + '{esc}': handleEsc, + '{del}': handleDel, + '{backspace}': handleBackspace, + '{selectall}': handleSelectall, + '{space}': handleSpace, + ' ': handleSpace, +} + function wait(time) { return new Promise(resolve => setTimeout(() => resolve(), time)) } @@ -49,12 +87,8 @@ async function typeImpl( if (!skipClick) click(element) // The focused element could change between each event, so get the currently active element each time - // This is why most of the utilities are within the type function itself. If - // they weren't, then we'd have to pass the "currentElement" function to them - // as an argument, which would be fine, but make sure that you pass the function - // and not just the element if the active element could change while the function - // is being run (for example, functions that are and/or fire events). const currentElement = () => getActiveElement(element.ownerDocument) + const currentValue = () => { const activeElement = currentElement() const value = activeElement.value @@ -65,28 +99,6 @@ async function typeImpl( } return value } - const setSelectionRange = ({newValue, newSelectionStart}) => { - // if we *can* change the selection start, then we will if the new value - // is the same as the current value (so it wasn't programatically changed - // when the fireEvent.input was triggered). - // The reason we have to do this at all is because it actually *is* - // programmatically changed by fireEvent.input, so we have to simulate the - // browser's default behavior - const value = currentValue() - - if (value === newValue) { - setSelectionRangeIfNecessary( - currentElement(), - newSelectionStart, - newSelectionStart, - ) - } else { - // If the currentValue is different than the expected newValue and we *can* - // change the selection range, than we should set it to the length of the - // currentValue to ensure that the browser behavior is mimicked. - setSelectionRangeIfNecessary(currentElement(), value.length, value.length) - } - } // by default, a new element has it's selection start and end at 0 // but most of the time when people call "type", they expect it to type @@ -107,46 +119,23 @@ async function typeImpl( ) } - const eventCallbackMap = getEventCallbackMap({ - currentElement, - currentValue, - fireInputEventIfNeeded, - setSelectionRange, - }) - const eventCallbacks = queueCallbacks() await runCallbacks(eventCallbacks) function queueCallbacks() { const callbacks = [] - const modifierClosers = [] let remainingString = text + while (remainingString) { - const eventKey = Object.keys(eventCallbackMap).find(key => - remainingString.startsWith(key), + const {callback, remainingString: newRemainingString} = getNextCallback( + remainingString, + skipAutoClose, ) - if (eventKey) { - const modifierCallback = eventCallbackMap[eventKey] - callbacks.push(modifierCallback) - - // if this modifier has an associated "close" callback and the developer - // doesn't close it themselves, then we close it for them automatically - // Effectively if they send in: '{alt}a' then we type: '{alt}a{/alt}' - if ( - !skipAutoClose && - modifierCallback.close && - !remainingString.includes(modifierCallback.close.name) - ) { - modifierClosers.push(modifierCallback.close.fn) - } - remainingString = remainingString.slice(eventKey.length) - } else { - const character = remainingString[0] - callbacks.push((...args) => typeCharacter(character, ...args)) - remainingString = remainingString.slice(1) - } + callbacks.push(callback) + remainingString = newRemainingString } - return [...callbacks, ...modifierClosers] + + return callbacks } async function runCallbacks(callbacks) { @@ -156,6 +145,8 @@ async function typeImpl( if (delay > 0) await wait(delay) if (!currentElement().disabled) { const returnValue = callback({ + currentElement, + currentValue, prevWasMinus, prevWasPeriod, prevValue, @@ -168,106 +159,215 @@ async function typeImpl( } } } +} - function fireInputEventIfNeeded({ - newValue, - newSelectionStart, - eventOverrides, - }) { - const prevValue = currentValue() - if (!currentElement().readOnly && newValue !== prevValue) { - fireEvent.input(currentElement(), { - target: {value: newValue}, - ...eventOverrides, - }) +function getNextCallback(remainingString, skipAutoClose) { + const modifierCallback = getModifierCallback(remainingString, skipAutoClose) + if (modifierCallback) { + return modifierCallback + } - setSelectionRange({newValue, newSelectionStart}) - } + const specialCharCallback = getSpecialCharCallback(remainingString) + if (specialCharCallback) { + return specialCharCallback + } + + return getTypeCallback(remainingString) +} - return {prevValue} +function getModifierCallback(remainingString, skipAutoClose) { + const modifierKey = Object.keys(modifierCallbackMap).find(key => + remainingString.startsWith(key), + ) + if (!modifierKey) { + return null } + const callback = modifierCallbackMap[modifierKey] - function typeCharacter( - char, - { - prevWasMinus = false, - prevWasPeriod = false, - prevValue = '', - eventOverrides, - }, + // if this modifier has an associated "close" callback and the developer + // doesn't close it themselves, then we close it for them automatically + // Effectively if they send in: '{alt}a' then we type: '{alt}a{/alt}' + if ( + !skipAutoClose && + callback.closeName && + !remainingString.includes(callback.closeName) ) { - const key = char // TODO: check if this also valid for characters with diacritic markers e.g. úé etc - const keyCode = char.charCodeAt(0) - let nextPrevWasMinus, nextPrevWasPeriod + remainingString += callback.closeName + } + remainingString = remainingString.slice(modifierKey.length) + return { + callback, + remainingString, + } +} - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, +function getSpecialCharCallback(remainingString) { + const specialChar = Object.keys(specialCharCallbackMap).find(key => + remainingString.startsWith(key), + ) + if (!specialChar) { + return null + } + return { + callback: specialCharCallbackMap[specialChar], + remainingString: remainingString.slice(specialChar.length), + } +} + +function getTypeCallback(remainingString) { + const character = remainingString[0] + const callback = createTypeCharacter(character) + return { + callback, + remainingString: remainingString.slice(1), + } +} + +function setSelectionRange({ + currentElement, + currentValue, + newValue, + newSelectionStart, +}) { + // if we *can* change the selection start, then we will if the new value + // is the same as the current value (so it wasn't programatically changed + // when the fireEvent.input was triggered). + // The reason we have to do this at all is because it actually *is* + // programmatically changed by fireEvent.input, so we have to simulate the + // browser's default behavior + const value = currentValue() + + if (value === newValue) { + setSelectionRangeIfNecessary( + currentElement(), + newSelectionStart, + newSelectionStart, + ) + } else { + // If the currentValue is different than the expected newValue and we *can* + // change the selection range, than we should set it to the length of the + // currentValue to ensure that the browser behavior is mimicked. + setSelectionRangeIfNecessary(currentElement(), value.length, value.length) + } +} + +function fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides, + currentValue, + currentElement, +}) { + const prevValue = currentValue() + if ( + !currentElement().readOnly && + !isClickable(currentElement()) && + newValue !== prevValue + ) { + fireEvent.input(currentElement(), { + target: {value: newValue}, ...eventOverrides, }) - if (keyDownDefaultNotPrevented) { - const keyPressDefaultNotPrevented = fireEvent.keyPress(currentElement(), { - key, - keyCode, - charCode: keyCode, - ...eventOverrides, - }) + setSelectionRange({ + currentElement, + currentValue, + newValue, + newSelectionStart, + }) + } - if (keyPressDefaultNotPrevented) { - let newEntry = char - if (prevWasMinus) { - newEntry = `-${char}` - } else if (prevWasPeriod) { - newEntry = `${prevValue}.${char}` - } + return {prevValue} +} - const inputEvent = fireInputEventIfNeeded({ - ...calculateNewValue(newEntry, currentElement(), currentValue()), - eventOverrides: { - data: key, - inputType: 'insertText', - ...eventOverrides, - }, - }) - prevValue = inputEvent.prevValue - - // typing "-" into a number input will not actually update the value - // so for the next character we type, the value should be set to - // `-${newEntry}` - // we also preserve the prevWasMinus when the value is unchanged due - // to typing an invalid character (typing "-a3" results in "-3") - // same applies for the decimal character. - if (currentElement().type === 'number') { - const newValue = currentValue() - if (newValue === prevValue && newEntry !== '-') { - nextPrevWasMinus = prevWasMinus - } else { - nextPrevWasMinus = newEntry === '-' - } - if (newValue === prevValue && newEntry !== '.') { - nextPrevWasPeriod = prevWasPeriod - } else { - nextPrevWasPeriod = newEntry === '.' - } - } - } - } +function createTypeCharacter(character) { + return context => typeCharacter(character, context) +} - fireEvent.keyUp(currentElement(), { +function typeCharacter( + char, + { + currentElement, + currentValue, + prevWasMinus = false, + prevWasPeriod = false, + prevValue = '', + eventOverrides, + }, +) { + const key = char // TODO: check if this also valid for characters with diacritic markers e.g. úé etc + const keyCode = char.charCodeAt(0) + let nextPrevWasMinus, nextPrevWasPeriod + + const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) + + if (keyDownDefaultNotPrevented) { + const keyPressDefaultNotPrevented = fireEvent.keyPress(currentElement(), { key, keyCode, - which: keyCode, + charCode: keyCode, ...eventOverrides, }) - return { - prevWasMinus: nextPrevWasMinus, - prevWasPeriod: nextPrevWasPeriod, - prevValue, + if (keyPressDefaultNotPrevented) { + let newEntry = char + if (prevWasMinus) { + newEntry = `-${char}` + } else if (prevWasPeriod) { + newEntry = `${prevValue}.${char}` + } + + const inputEvent = fireInputEventIfNeeded({ + ...calculateNewValue(newEntry, currentElement(), currentValue()), + eventOverrides: { + data: key, + inputType: 'insertText', + ...eventOverrides, + }, + currentValue, + currentElement, + }) + prevValue = inputEvent.prevValue + + // typing "-" into a number input will not actually update the value + // so for the next character we type, the value should be set to + // `-${newEntry}` + // we also preserve the prevWasMinus when the value is unchanged due + // to typing an invalid character (typing "-a3" results in "-3") + // same applies for the decimal character. + if (currentElement().type === 'number') { + const newValue = currentValue() + if (newValue === prevValue && newEntry !== '-') { + nextPrevWasMinus = prevWasMinus + } else { + nextPrevWasMinus = newEntry === '-' + } + if (newValue === prevValue && newEntry !== '.') { + nextPrevWasPeriod = prevWasPeriod + } else { + nextPrevWasPeriod = newEntry === '.' + } + } } } + + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) + + return { + prevWasMinus: nextPrevWasMinus, + prevWasPeriod: nextPrevWasPeriod, + prevValue, + } } // yes, calculateNewBackspaceValue and calculateNewValue look extremely similar @@ -335,202 +435,223 @@ function calculateNewDeleteValue(element, value) { return {newValue, newSelectionStart: selectionStart} } -function getEventCallbackMap({ - currentElement, - currentValue, - fireInputEventIfNeeded, - setSelectionRange, -}) { +function createModifierCallbackEntries({name, key, keyCode, modifierProperty}) { + const openName = `{${name}}` + const closeName = `{/${name}}` + + function open({currentElement, eventOverrides}) { + const newEventOverrides = {[modifierProperty]: true} + + fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + ...newEventOverrides, + }) + + return {eventOverrides: newEventOverrides} + } + open.closeName = closeName + function close({currentElement, eventOverrides}) { + const newEventOverrides = {[modifierProperty]: false} + + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + ...newEventOverrides, + }) + + return {eventOverrides: newEventOverrides} + } return { - ...modifier({ - name: 'shift', - key: 'Shift', - keyCode: 16, - modifierProperty: 'shiftKey', - }), - ...modifier({ - name: 'ctrl', - key: 'Control', - keyCode: 17, - modifierProperty: 'ctrlKey', - }), - ...modifier({ - name: 'alt', - key: 'Alt', - keyCode: 18, - modifierProperty: 'altKey', - }), - ...modifier({ - name: 'meta', - key: 'Meta', - keyCode: 93, - modifierProperty: 'metaKey', - }), - '{arrowleft}': navigationKey(currentElement, 'ArrowLeft'), - '{arrowright}': navigationKey(currentElement, 'ArrowRight'), - '{enter}': ({eventOverrides}) => { - const key = 'Enter' - const keyCode = 13 - - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) + [openName]: open, + [closeName]: close, + } +} - if (keyDownDefaultNotPrevented) { - fireEvent.keyPress(currentElement(), { - key, - keyCode, - charCode: keyCode, - ...eventOverrides, - }) - } +function handleEnter({currentElement, currentValue, eventOverrides}) { + const key = 'Enter' + const keyCode = 13 - if (currentElement().tagName === 'BUTTON') { - fireEvent.click(currentElement(), { - ...eventOverrides, - }) - } + const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) - if (currentElement().tagName === 'TEXTAREA') { - const {newValue, newSelectionStart} = calculateNewValue( - '\n', - currentElement(), - currentValue(), - ) - fireEvent.input(currentElement(), { - target: {value: newValue}, - inputType: 'insertLineBreak', - ...eventOverrides, - }) - setSelectionRange({newValue, newSelectionStart}) - } + if (keyDownDefaultNotPrevented) { + fireEvent.keyPress(currentElement(), { + key, + keyCode, + charCode: keyCode, + ...eventOverrides, + }) + } - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - }, - '{esc}': ({eventOverrides}) => { - const key = 'Escape' - const keyCode = 27 - - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) + if (isClickable(currentElement())) { + fireEvent.click(currentElement(), { + ...eventOverrides, + }) + } - // NOTE: Browsers do not fire a keypress on meta key presses + if (currentElement().tagName === 'TEXTAREA') { + const {newValue, newSelectionStart} = calculateNewValue( + '\n', + currentElement(), + currentValue(), + ) + fireEvent.input(currentElement(), { + target: {value: newValue}, + inputType: 'insertLineBreak', + ...eventOverrides, + }) + setSelectionRange({ + currentElement, + currentValue, + newValue, + newSelectionStart, + }) + } - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - }, - '{del}': ({eventOverrides}) => { - const key = 'Delete' - const keyCode = 46 - - const keyPressDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) +} - if (keyPressDefaultNotPrevented) { - fireInputEventIfNeeded({ - ...calculateNewDeleteValue(currentElement(), currentValue()), - eventOverrides: { - inputType: 'deleteContentForward', - ...eventOverrides, - }, - }) - } +function handleEsc({currentElement, eventOverrides}) { + const key = 'Escape' + const keyCode = 27 - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - }, - '{backspace}': ({eventOverrides}) => { - const key = 'Backspace' - const keyCode = 8 - - const keyPressDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) + fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) - if (keyPressDefaultNotPrevented) { - fireInputEventIfNeeded({ - ...calculateNewBackspaceValue(currentElement(), currentValue()), - eventOverrides: { - inputType: 'deleteContentBackward', - ...eventOverrides, - }, - }) - } + // NOTE: Browsers do not fire a keypress on meta key presses + + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) +} - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, +function handleDel({currentElement, currentValue, eventOverrides}) { + const key = 'Delete' + const keyCode = 46 + + const keyPressDefaultNotPrevented = fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) + + if (keyPressDefaultNotPrevented) { + fireInputEventIfNeeded({ + ...calculateNewDeleteValue(currentElement(), currentValue()), + eventOverrides: { + inputType: 'deleteContentForward', ...eventOverrides, - }) - }, - // the user can actually select in several different ways - // we're not going to choose, so we'll *only* set the selection range - '{selectall}': () => { - currentElement().setSelectionRange(0, currentValue().length) - }, + }, + currentElement, + currentValue, + }) } - function modifier({name, key, keyCode, modifierProperty}) { - function open({eventOverrides}) { - const newEventOverrides = {[modifierProperty]: true} + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) +} + +function handleBackspace({currentElement, currentValue, eventOverrides}) { + const key = 'Backspace' + const keyCode = 8 - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - ...newEventOverrides, - }) + const keyPressDefaultNotPrevented = fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) - return {eventOverrides: newEventOverrides} - } - open.close = {name: [`{/${name}}`], fn: close} - function close({eventOverrides}) { - const newEventOverrides = {[modifierProperty]: false} - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, + if (keyPressDefaultNotPrevented) { + fireInputEventIfNeeded({ + ...calculateNewBackspaceValue(currentElement(), currentValue()), + eventOverrides: { + inputType: 'deleteContentBackward', ...eventOverrides, - ...newEventOverrides, - }) + }, + currentElement, + currentValue, + }) + } - return {eventOverrides: newEventOverrides} - } - return { - [`{${name}}`]: open, - [`{/${name}}`]: close, - } + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) +} + +function handleSelectall({currentElement, currentValue}) { + // the user can actually select in several different ways + // we're not going to choose, so we'll *only* set the selection range + currentElement().setSelectionRange(0, currentValue().length) +} + +function handleSpace(context) { + if (isClickable(context.currentElement())) { + handleSpaceOnClickable(context) + return + } + typeCharacter(' ', context) +} + +function handleSpaceOnClickable({currentElement, eventOverrides}) { + const key = ' ' + const keyCode = 32 + + const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) + + if (keyDownDefaultNotPrevented) { + fireEvent.keyPress(currentElement(), { + key, + keyCode, + charCode: keyCode, + ...eventOverrides, + }) } + + fireEvent.keyUp(currentElement(), { + key, + keyCode, + which: keyCode, + ...eventOverrides, + }) + + fireEvent.click(currentElement(), { + ...eventOverrides, + }) } export {type} @@ -538,5 +659,4 @@ export {type} /* eslint no-loop-func: "off", - max-lines-per-function: "off", */ diff --git a/src/utils.js b/src/utils.js index bd1e1e6c..70c7112c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -171,6 +171,23 @@ function isFocusable(element) { ) } +const CLICKABLE_INPUT_TYPES = [ + 'button', + 'color', + 'file', + 'image', + 'reset', + 'submit', +] + +function isClickable(element) { + return ( + element.tagName === 'BUTTON' || + (element instanceof HTMLInputElement && + CLICKABLE_INPUT_TYPES.includes(element.type)) + ) +} + function eventWrapper(cb) { let result getConfig().eventWrapper(() => { @@ -182,6 +199,7 @@ function eventWrapper(cb) { export { FOCUSABLE_SELECTOR, isFocusable, + isClickable, getMouseEventOptions, isLabelWithInternallyDisabledControl, getActiveElement,