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,
+ })
}
}