Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(userEvent): Add paste API (fixes #640) #645

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
96 changes: 96 additions & 0 deletions 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('<input />')

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('<textarea />')

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('<input readonly />')

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('<input disabled />')

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('<input value="hello world" />')

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')
})
1 change: 1 addition & 0 deletions 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'
Expand Down
71 changes: 71 additions & 0 deletions src/user-event/paste.js
@@ -0,0 +1,71 @@
import {getConfig as getDOMTestingLibraryConfig} from '../config'
import {fireEvent, getActiveElement, calculateNewValue} from './utils'

nickmccurdy marked this conversation as resolved.
Show resolved Hide resolved
// 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
}
nickmccurdy marked this conversation as resolved.
Show resolved Hide resolved

// 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}
44 changes: 1 addition & 43 deletions 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'

Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions src/user-event/utils.js
Expand Up @@ -129,11 +129,54 @@ 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,
fireEvent,
getMouseEventOptions,
isLabelWithInternallyDisabledControl,
getActiveElement,
calculateNewValue,
}