diff --git a/package.json b/package.json index eaf33416..e2e23e7b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "rules": { "jsx-a11y/click-events-have-key-events": "off", "jsx-a11y/tabindex-no-positive": "off", - "no-return-assign": "off" + "no-return-assign": "off", + "react/prop-types": "off" }, "overrides": [ { diff --git a/src/__tests__/helpers/utils.js b/src/__tests__/helpers/utils.js index 6dbfd1c8..50f5d68d 100644 --- a/src/__tests__/helpers/utils.js +++ b/src/__tests__/helpers/utils.js @@ -43,8 +43,8 @@ function setup(ui) { } = render(ui) element.previousTestData = getTestData(element) - const getEventCalls = addListeners(element) - return {element, getEventCalls} + const {getEventCalls, clearEventCalls} = addListeners(element) + return {element, getEventCalls, clearEventCalls} } function addListeners(element) { @@ -102,7 +102,8 @@ function addListeners(element) { }) .join('\n') } - return getEventCalls + const clearEventCalls = () => generalListener.mockClear() + return {getEventCalls, clearEventCalls} } // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button diff --git a/src/__tests__/toggleselectoptions.js b/src/__tests__/toggleselectoptions.js index bb88507d..e4a3d8d9 100644 --- a/src/__tests__/toggleselectoptions.js +++ b/src/__tests__/toggleselectoptions.js @@ -46,7 +46,7 @@ test('should fire the correct events for multiple select when focus is in other const $otherBtn = screen.getByRole('button') - const getButtonEvents = addListeners($otherBtn) + const {getEventCalls: getButtonEvents} = addListeners($otherBtn) $otherBtn.focus() diff --git a/src/__tests__/type-modifiers.js b/src/__tests__/type-modifiers.js index c27138ca..f398ea99 100644 --- a/src/__tests__/type-modifiers.js +++ b/src/__tests__/type-modifiers.js @@ -28,9 +28,37 @@ test('{esc} triggers typing the escape character', async () => { `) }) +test('a{backspace}', async () => { + const {element, getEventCalls} = setup() + await userEvent.type(element, 'a{backspace}') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: a (97) + keypress: a (97) + input: "{CURSOR}" -> "a" + keyup: a (97) + keydown: Backspace (8) + input: "a{CURSOR}" -> "" + keyup: Backspace (8) + `) +}) + +test('{backspace}a', async () => { + const {element, getEventCalls} = setup() + await userEvent.type(element, '{backspace}a') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: Backspace (8) + keyup: Backspace (8) + keydown: a (97) + keypress: a (97) + input: "{CURSOR}" -> "a" + keyup: a (97) + `) +}) + test('{backspace} triggers typing the backspace character and deletes the character behind the cursor', async () => { - const {element: input, getEventCalls} = setup() - input.value = 'yo' + const {element: input, getEventCalls} = setup() input.setSelectionRange(1, 1) await userEvent.type(input, '{backspace}') @@ -44,9 +72,24 @@ test('{backspace} triggers typing the backspace character and deletes the charac }) test('{backspace} on a readOnly input', async () => { - const {element: input, getEventCalls} = setup() - input.readOnly = true - input.value = 'yo' + const {element: input, getEventCalls} = setup( + , + ) + input.setSelectionRange(1, 1) + + await userEvent.type(input, '{backspace}') + + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: Backspace (8) + keyup: Backspace (8) + `) +}) + +test('{backspace} does not fire input if keydown prevents default', async () => { + const {element: input, getEventCalls} = setup( + e.preventDefault()} />, + ) input.setSelectionRange(1, 1) await userEvent.type(input, '{backspace}') @@ -59,8 +102,9 @@ test('{backspace} on a readOnly input', async () => { }) test('{backspace} deletes the selected range', async () => { - const {element: input, getEventCalls} = setup() - input.value = 'Hi there' + const {element: input, getEventCalls} = setup( + , + ) input.setSelectionRange(1, 5) await userEvent.type(input, '{backspace}') diff --git a/src/__tests__/type.js b/src/__tests__/type.js index 6912c477..71ab097b 100644 --- a/src/__tests__/type.js +++ b/src/__tests__/type.js @@ -52,6 +52,31 @@ test('should append text all at once', async () => { `) }) +test('does not fire input event when keypress calls prevent default', async () => { + const {element, getEventCalls} = setup( + e.preventDefault()} />, + ) + await userEvent.type(element, 'a') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: a (97) + keypress: a (97) + keyup: a (97) + `) +}) + +test('does not fire keypress or input events when keydown calls prevent default', async () => { + const {element, getEventCalls} = setup( + e.preventDefault()} />, + ) + await userEvent.type(element, 'a') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: a (97) + keyup: a (97) + `) +}) + // TODO: Let's migrate these tests to use the setup util test('should not type when event.preventDefault() is called', async () => { const onChange = jest.fn() @@ -396,3 +421,77 @@ test('does not continue firing events when disabled during typing', async () => await userEvent.type(input, 'hi there') expect(input).toHaveValue('h') }) + +function DollarInput({initialValue = ''}) { + const [value, setValue] = React.useState(initialValue) + function handleChange(event) { + const val = event.target.value + const withoutDollar = val.replace(/\$/g, '') + const num = Number(withoutDollar) + if (Number.isNaN(num)) return + setValue(`$${withoutDollar}`) + } + return +} + +test('typing into a controlled input works', async () => { + const {element, getEventCalls} = setup() + await userEvent.type(element, '23') + expect(element.value).toBe('$23') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: 2 (50) + keypress: 2 (50) + input: "{CURSOR}" -> "2" + keyup: 2 (50) + keydown: 3 (51) + keypress: 3 (51) + input: "$2{CURSOR}" -> "$23" + keyup: 3 (51) + `) +}) + +test('typing in the middle of a controlled input works', async () => { + const {element, getEventCalls} = setup() + element.setSelectionRange(2, 2) + + await userEvent.type(element, '1') + + expect(element.value).toBe('$213') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: 1 (49) + keypress: 1 (49) + input: "$2{CURSOR}3" -> "$213" + keyup: 1 (49) + `) +}) + +test('ignored {backspace} in controlled input', async () => { + const {element, getEventCalls} = setup() + element.setSelectionRange(1, 1) + + await userEvent.type(element, '{backspace}') + // this is the same behavior in the browser. + // in our case, when you try to backspace the "$", our event handler + // will ignore that change and React resets the value to what it was + // before. When the value is set programmatically to something different + // from what was expected based on the input event, the browser sets + // the selection start and end to the end of the input + expect(element.selectionStart).toBe(element.value.length) + expect(element.selectionEnd).toBe(element.value.length) + await userEvent.type(element, '4') + + expect(element.value).toBe('$234') + // the backslash in the inline snapshot is to escape the $ before {CURSOR} + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + keydown: Backspace (8) + input: "\${CURSOR}23" -> "23" + keyup: Backspace (8) + keydown: 4 (52) + keypress: 4 (52) + input: "$23{CURSOR}" -> "$234" + keyup: 4 (52) + `) +}) diff --git a/src/type.js b/src/type.js index de87434f..b5885d8a 100644 --- a/src/type.js +++ b/src/type.js @@ -25,12 +25,24 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) { // The focused element could change between each event, so get the currently active element each time const currentElement = () => element.ownerDocument.activeElement const currentValue = () => element.ownerDocument.activeElement.value + const setSelectionRange = newSelectionStart => { + // if the actual selection start is different from the one we expected + // then we set it to the end of the input + if (currentElement().selectionStart !== newSelectionStart) { + currentElement().setSelectionRange?.( + currentValue().length, + currentValue().length, + ) + } + } if (allAtOnce) { if (!element.readOnly) { + const {newValue, newSelectionStart} = calculateNewValue(text) fireEvent.input(element, { - target: {value: calculateNewValue(text)}, + target: {value: newValue}, }) + setSelectionRange(newSelectionStart) } } else { const eventCallbackMap = { @@ -89,11 +101,13 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) { if (currentElement().tagName === 'TEXTAREA') { await tick() + const {newValue, newSelectionStart} = calculateNewValue('\n') fireEvent.input(currentElement(), { - target: {value: calculateNewValue('\n')}, + target: {value: newValue}, inputType: 'insertLineBreak', ...eventOverrides, }) + setSelectionRange(newSelectionStart) } await tick() @@ -131,25 +145,24 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) { const key = 'Backspace' const keyCode = 8 - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (!currentElement().readOnly) { - await tick() - - const {selectionStart} = currentElement() - - fireEvent.input(currentElement(), { - target: {value: calculateNewValue('')}, - inputType: 'deleteContentBackward', + const keyPressDefaultNotPrevented = fireEvent.keyDown( + currentElement(), + { + key, + keyCode, + which: keyCode, ...eventOverrides, + }, + ) + + if (keyPressDefaultNotPrevented) { + await fireInputEventIfNeeded({ + ...calculateNewBackspaceValue(), + eventOverrides: { + inputType: 'deleteContentBackward', + ...eventOverrides, + }, }) - - element.setSelectionRange?.(selectionStart, selectionStart) } await tick() @@ -187,13 +200,64 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) { } } + async function fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides, + }) { + if (!currentElement().readOnly && newValue !== currentValue()) { + await tick() + + fireEvent.input(currentElement(), { + target: {value: newValue}, + ...eventOverrides, + }) + + setSelectionRange(newSelectionStart) + } + } + + // yes, calculateNewBackspaceValue and calculateNewValue look extremely similar + // and you may be tempted to create a shared abstraction. + // If you, brave soul, decide to so endevor, please increment this count + // when you inevitably fail: 1 + function calculateNewBackspaceValue() { + const {selectionStart, selectionEnd} = currentElement() + const value = currentValue() + let newValue, newSelectionStart + + if (selectionStart === selectionEnd) { + if (selectionStart === 0) { + // at the beginning of the input + newValue = value + } else if (selectionStart === value.length) { + // at the end of the input + newValue = value.slice(0, value.length - 1) + newSelectionStart = selectionStart - 1 + } else { + // in the middle of the input + newValue = + value.slice(0, selectionStart - 1) + value.slice(selectionEnd) + newSelectionStart = selectionStart - 1 + } + } else { + // we have something selected + const firstPart = value.slice(0, selectionStart) + newValue = firstPart + value.slice(selectionEnd) + newSelectionStart = firstPart.length + } + + return {newValue, newSelectionStart} + } + function calculateNewValue(newEntry) { const {selectionStart, selectionEnd} = currentElement() // can't use .maxLength property because of a jsdom bug: // https://github.com/jsdom/jsdom/issues/2927 const maxLength = Number(currentElement().getAttribute('maxlength') ?? -1) const value = currentValue() - let newValue + let newValue, newSelectionStart + if (selectionStart === selectionEnd) { if (selectionStart === 0) { // at the beginning of the input @@ -204,20 +268,24 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) { } else { // in the middle of the input newValue = - value.slice(0, selectionStart - 1) + - newEntry + - value.slice(selectionEnd) + value.slice(0, selectionStart) + newEntry + value.slice(selectionEnd) } + newSelectionStart = selectionStart + newEntry.length } else { // we have something selected - newValue = - value.slice(0, selectionStart) + newEntry + value.slice(selectionEnd) + const firstPart = value.slice(0, selectionStart) + newEntry + newValue = firstPart + value.slice(selectionEnd) + newSelectionStart = firstPart.length } if (maxLength < 0) { - return newValue + return {newValue, newSelectionStart} } else { - return newValue.slice(0, maxLength) + return { + newValue: newValue.slice(0, maxLength), + newSelectionStart: + newSelectionStart > maxLength ? maxLength : newSelectionStart, + } } } @@ -242,22 +310,11 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) { ...eventOverrides, }) - const newValue = calculateNewValue(key) - - if (keyPressDefaultNotPrevented && newValue !== currentValue()) { - if (!currentElement().readOnly) { - await tick() - - const {selectionStart} = currentElement() - - fireEvent.input(currentElement(), { - target: { - value: newValue, - }, - }) - - element.setSelectionRange?.(selectionStart + 1, selectionStart + 1) - } + if (keyPressDefaultNotPrevented) { + await fireInputEventIfNeeded({ + ...calculateNewValue(key), + eventOverrides, + }) } }