diff --git a/src/user-event/__tests__/paste.js b/src/user-event/__tests__/paste.js
new file mode 100644
index 00000000..4d1a0f75
--- /dev/null
+++ b/src/user-event/__tests__/paste.js
@@ -0,0 +1,96 @@
+import {userEvent} from '../../'
+import {setup} from './helpers/utils'
+
+test('should paste text in input', async () => {
+ const {element, getEventSnapshot} = setup('')
+
+ const text = 'Hello, world!'
+ await userEvent.paste(element, text)
+ expect(element).toHaveValue(text)
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+ Events fired on: input[value="Hello, world!"]
+
+ input[value=""] - focus
+ input[value=""] - select
+ input[value="Hello, world!"] - input
+ "{CURSOR}" -> "Hello, world!{CURSOR}"
+ input[value="Hello, world!"] - select
+ `)
+})
+
+test('should paste text in textarea', async () => {
+ const {element, getEventSnapshot} = setup('')
+
+ const text = 'Hello, world!'
+ await userEvent.paste(element, text)
+ expect(element).toHaveValue(text)
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+ Events fired on: textarea[value="Hello, world!"]
+
+ textarea[value=""] - focus
+ textarea[value=""] - select
+ textarea[value="Hello, world!"] - input
+ "{CURSOR}" -> "Hello, world!{CURSOR}"
+ textarea[value="Hello, world!"] - select
+ `)
+})
+
+test('does not paste when readOnly', async () => {
+ const {element, getEventSnapshot} = setup('')
+
+ await userEvent.paste(element, 'hi')
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+ Events fired on: input[value=""]
+
+ input[value=""] - focus
+ input[value=""] - select
+ `)
+})
+
+test('does not paste when disabled', async () => {
+ const {element, getEventSnapshot} = setup('')
+
+ await userEvent.paste(element, 'hi')
+ expect(getEventSnapshot()).toMatchInlineSnapshot(
+ `No events were fired on: input[value=""]`,
+ )
+})
+
+test.each(['input', 'textarea'])(
+ 'should paste text in <%s> up to maxLength if provided',
+ async type => {
+ const {element} = setup(`<${type} maxlength="10" />`)
+
+ await userEvent.type(element, 'superlongtext')
+ expect(element).toHaveValue('superlongt')
+
+ element.value = ''
+ await userEvent.paste(element, 'superlongtext')
+ expect(element).toHaveValue('superlongt')
+ },
+)
+
+test.each(['input', 'textarea'])(
+ 'should append text in <%s> up to maxLength if provided',
+ async type => {
+ const {element} = setup(`<${type} maxlength="10" />`)
+
+ await userEvent.type(element, 'superlong')
+ await userEvent.type(element, 'text')
+ expect(element).toHaveValue('superlongt')
+
+ element.value = ''
+ await userEvent.paste(element, 'superlongtext')
+ expect(element).toHaveValue('superlongt')
+ },
+)
+
+test('should replace selected text all at once', async () => {
+ const {element} = setup('')
+
+ const selectionStart = 'hello world'.search('world')
+ const selectionEnd = selectionStart + 'world'.length
+ element.setSelectionRange(selectionStart, selectionEnd)
+ await userEvent.paste(element, 'friend')
+ expect(element).toHaveValue('hello friend')
+})
diff --git a/src/user-event/index.js b/src/user-event/index.js
index 77e86dae..f4e1926f 100644
--- a/src/user-event/index.js
+++ b/src/user-event/index.js
@@ -1,5 +1,6 @@
export {click, dblClick} from './click'
export {type} from './type'
+export {paste} from './paste'
export {clear} from './clear'
export {tab} from './tab'
export {hover, unhover} from './hover'
diff --git a/src/user-event/paste.js b/src/user-event/paste.js
new file mode 100644
index 00000000..8cff8ebd
--- /dev/null
+++ b/src/user-event/paste.js
@@ -0,0 +1,71 @@
+import {getConfig as getDOMTestingLibraryConfig} from '../config'
+import {fireEvent, getActiveElement, calculateNewValue} from './utils'
+
+// this needs to be wrapped in the asyncWrapper for React's act and angular's change detection
+async function paste(...args) {
+ let result
+ await getDOMTestingLibraryConfig().asyncWrapper(async () => {
+ result = await pasteImpl(...args)
+ })
+ return result
+}
+
+// eslint-disable-next-line complexity
+async function pasteImpl(
+ element,
+ text,
+ {initialSelectionStart, initialSelectionEnd} = {},
+) {
+ if (element.disabled) return
+
+ element.focus()
+
+ // The focused element could change between each event, so get the currently active element each time
+ const currentElement = () => getActiveElement(element.ownerDocument)
+ const currentValue = () => currentElement().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
+ if (
+ currentElement().selectionStart !== null &&
+ currentValue() === newValue
+ ) {
+ currentElement().setSelectionRange?.(newSelectionStart, newSelectionStart)
+ }
+ }
+
+ // by default, a new element has it's selection start and end at 0
+ // but most of the time when people call "paste", they expect it to paste
+ // at the end of the current input value. So, if the selection start
+ // and end are both the default of 0, then we'll go ahead and change
+ // them to the length of the current value.
+ // the only time it would make sense to pass the initialSelectionStart or
+ // initialSelectionEnd is if you have an input with a value and want to
+ // explicitely start typing with the cursor at 0. Not super common.
+ if (
+ currentElement().selectionStart === 0 &&
+ currentElement().selectionEnd === 0
+ ) {
+ currentElement().setSelectionRange(
+ initialSelectionStart ?? currentValue()?.length ?? 0,
+ initialSelectionEnd ?? currentValue()?.length ?? 0,
+ )
+ }
+
+ if (!element.readOnly) {
+ const {newValue, newSelectionStart} = calculateNewValue(
+ text,
+ currentElement(),
+ )
+ await fireEvent.input(element, {
+ target: {value: newValue},
+ })
+ setSelectionRange({newValue, newSelectionStart})
+ }
+}
+
+export {paste}
diff --git a/src/user-event/type.js b/src/user-event/type.js
index 36ea8d8b..5cc768bf 100644
--- a/src/user-event/type.js
+++ b/src/user-event/type.js
@@ -1,5 +1,5 @@
import {wrapAsync} from '../wrap-async'
-import {fireEvent, getActiveElement} from './utils'
+import {fireEvent, getActiveElement, calculateNewValue} from './utils'
import {tick} from './tick'
import {click} from './click'
@@ -305,48 +305,6 @@ function calculateNewDeleteValue(element) {
return {newValue, newSelectionStart: selectionStart}
}
-function calculateNewValue(newEntry, element) {
- const {selectionStart, selectionEnd, value} = element
- // can't use .maxLength property because of a jsdom bug:
- // https://github.com/jsdom/jsdom/issues/2927
- const maxLength = Number(element.getAttribute('maxlength') ?? -1)
- let newValue, newSelectionStart
-
- if (selectionStart === null) {
- // at the end of an input type that does not support selection ranges
- // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
- newValue = value + newEntry
- } else if (selectionStart === selectionEnd) {
- if (selectionStart === 0) {
- // at the beginning of the input
- newValue = newEntry + value
- } else if (selectionStart === value.length) {
- // at the end of the input
- newValue = value + newEntry
- } else {
- // in the middle of the input
- newValue =
- value.slice(0, selectionStart) + newEntry + value.slice(selectionEnd)
- }
- newSelectionStart = selectionStart + newEntry.length
- } else {
- // we have something selected
- const firstPart = value.slice(0, selectionStart) + newEntry
- newValue = firstPart + value.slice(selectionEnd)
- newSelectionStart = firstPart.length
- }
-
- if (maxLength < 0) {
- return {newValue, newSelectionStart}
- } else {
- return {
- newValue: newValue.slice(0, maxLength),
- newSelectionStart:
- newSelectionStart > maxLength ? maxLength : newSelectionStart,
- }
- }
-}
-
function getEventCallbackMap({
currentElement,
fireInputEventIfNeeded,
diff --git a/src/user-event/utils.js b/src/user-event/utils.js
index 90ef0cd1..862925a2 100644
--- a/src/user-event/utils.js
+++ b/src/user-event/utils.js
@@ -129,6 +129,48 @@ function isFocusable(element) {
)
}
+function calculateNewValue(newEntry, element) {
+ const {selectionStart, selectionEnd, value} = element
+ // can't use .maxLength property because of a jsdom bug:
+ // https://github.com/jsdom/jsdom/issues/2927
+ const maxLength = Number(element.getAttribute('maxlength') ?? -1)
+ let newValue, newSelectionStart
+
+ if (selectionStart === null) {
+ // at the end of an input type that does not support selection ranges
+ // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
+ newValue = value + newEntry
+ } else if (selectionStart === selectionEnd) {
+ if (selectionStart === 0) {
+ // at the beginning of the input
+ newValue = newEntry + value
+ } else if (selectionStart === value.length) {
+ // at the end of the input
+ newValue = value + newEntry
+ } else {
+ // in the middle of the input
+ newValue =
+ value.slice(0, selectionStart) + newEntry + value.slice(selectionEnd)
+ }
+ newSelectionStart = selectionStart + newEntry.length
+ } else {
+ // we have something selected
+ const firstPart = value.slice(0, selectionStart) + newEntry
+ newValue = firstPart + value.slice(selectionEnd)
+ newSelectionStart = firstPart.length
+ }
+
+ if (maxLength < 0) {
+ return {newValue, newSelectionStart}
+ } else {
+ return {
+ newValue: newValue.slice(0, maxLength),
+ newSelectionStart:
+ newSelectionStart > maxLength ? maxLength : newSelectionStart,
+ }
+ }
+}
+
export {
FOCUSABLE_SELECTOR,
isFocusable,
@@ -136,4 +178,5 @@ export {
getMouseEventOptions,
isLabelWithInternallyDisabledControl,
getActiveElement,
+ calculateNewValue,
}