Skip to content

Commit 8b6cd1d

Browse files
authored
feat!: replace userEvent.paste (#785)
* feat!: replace `userEvent.paste` BREAKING CHANGE: The `userEvent.paste` API has new parameters.
1 parent 051fe20 commit 8b6cd1d

File tree

12 files changed

+586
-80
lines changed

12 files changed

+586
-80
lines changed

src/paste.ts

+58-60
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,82 @@
11
import {fireEvent} from '@testing-library/dom'
22
import type {UserEvent} from './setup'
33
import {
4+
createDataTransfer,
45
getSpaceUntilMaxLength,
5-
setSelectionRange,
6-
eventWrapper,
7-
isDisabled,
8-
isElementType,
9-
editableInputTypes,
10-
getInputRange,
116
prepareInput,
7+
isEditable,
8+
readBlobText,
129
} from './utils'
1310

14-
interface pasteOptions {
15-
initialSelectionStart?: number
16-
initialSelectionEnd?: number
17-
}
18-
19-
function isSupportedElement(
20-
element: HTMLElement,
21-
): element is
22-
| HTMLTextAreaElement
23-
| (HTMLInputElement & {type: editableInputTypes}) {
24-
return (
25-
(isElementType(element, 'input') &&
26-
Boolean(editableInputTypes[element.type as editableInputTypes])) ||
27-
isElementType(element, 'textarea')
28-
)
11+
export interface pasteOptions {
12+
document?: Document
2913
}
3014

3115
export function paste(
3216
this: UserEvent,
33-
element: HTMLElement,
34-
text: string,
35-
init?: ClipboardEventInit,
36-
{initialSelectionStart, initialSelectionEnd}: pasteOptions = {},
17+
clipboardData?: undefined,
18+
options?: pasteOptions,
19+
): Promise<void>
20+
export function paste(
21+
this: UserEvent,
22+
clipboardData: DataTransfer | string,
23+
options?: pasteOptions,
24+
): void
25+
export function paste(
26+
this: UserEvent,
27+
clipboardData?: DataTransfer | string,
28+
options?: pasteOptions,
3729
) {
38-
// TODO: implement for contenteditable
39-
if (!isSupportedElement(element)) {
40-
throw new TypeError(
41-
`The given ${element.tagName} element is currently unsupported.
42-
A PR extending this implementation would be very much welcome at https://github.com/testing-library/user-event`,
43-
)
44-
}
30+
const doc = options?.document ?? document
31+
const target = doc.activeElement ?? /* istanbul ignore next */ doc.body
4532

46-
if (isDisabled(element)) {
47-
return
48-
}
33+
const data: DataTransfer | undefined =
34+
typeof clipboardData === 'string'
35+
? getClipboardDataFromString(clipboardData)
36+
: clipboardData
4937

50-
eventWrapper(() => element.focus())
38+
return data
39+
? pasteImpl(target, data)
40+
: readClipboardDataFromClipboardApi(doc).then(dt => pasteImpl(target, dt))
41+
}
5142

52-
// by default, a new element has it's selection start and end at 0
53-
// but most of the time when people call "paste", they expect it to paste
54-
// at the end of the current input value. So, if the selection start
55-
// and end are both the default of 0, then we'll go ahead and change
56-
// them to the length of the current value.
57-
// the only time it would make sense to pass the initialSelectionStart or
58-
// initialSelectionEnd is if you have an input with a value and want to
59-
// explicitely start typing with the cursor at 0. Not super common.
60-
if (element.selectionStart === 0 && element.selectionEnd === 0) {
61-
setSelectionRange(
62-
element,
63-
initialSelectionStart ?? element.value.length,
64-
initialSelectionEnd ?? element.value.length,
65-
)
66-
}
43+
function pasteImpl(target: Element, clipboardData: DataTransfer) {
44+
fireEvent.paste(target, {
45+
clipboardData,
46+
})
6747

68-
fireEvent.paste(element, init)
48+
if (isEditable(target)) {
49+
const data = clipboardData
50+
.getData('text')
51+
.substr(0, getSpaceUntilMaxLength(target))
6952

70-
if (element.readOnly) {
71-
return
53+
if (data) {
54+
prepareInput(data, target, 'insertFromPaste')?.commit()
55+
}
7256
}
57+
}
7358

74-
text = text.substr(0, getSpaceUntilMaxLength(element))
59+
function getClipboardDataFromString(text: string) {
60+
const dt = createDataTransfer()
61+
dt.setData('text', text)
62+
return dt
63+
}
7564

76-
const inputRange = getInputRange(element)
65+
async function readClipboardDataFromClipboardApi(document: Document) {
66+
const clipboard = document.defaultView?.navigator.clipboard
67+
const items = clipboard && (await clipboard.read())
7768

78-
/* istanbul ignore if */
79-
if (!inputRange) {
80-
return
69+
if (!items) {
70+
throw new Error(
71+
'`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.',
72+
)
8173
}
8274

83-
prepareInput(text, element, 'insertFromPaste')?.commit()
75+
const dt = createDataTransfer()
76+
for (const item of items) {
77+
for (const type of item.types) {
78+
dt.setData(type, await item.getType(type).then(b => readBlobText(b)))
79+
}
80+
}
81+
return dt
8482
}

src/setup.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import {prepareDocument} from './document'
44
import {hover, unhover} from './hover'
55
import {createKeyboardState, keyboard, keyboardOptions} from './keyboard'
66
import type {keyboardState} from './keyboard/types'
7-
import {paste} from './paste'
7+
import {paste, pasteOptions} from './paste'
88
import {createPointerState, pointer} from './pointer'
99
import type {pointerOptions, pointerState} from './pointer/types'
1010
import {deselectOptions, selectOptions} from './selectOptions'
1111
import {tab, tabOptions} from './tab'
1212
import {type, typeOptions} from './type'
1313
import {upload, uploadOptions} from './upload'
14-
import {PointerOptions} from './utils'
14+
import {PointerOptions, attachClipboardStubToView} from './utils'
1515

1616
export const userEventApis = {
1717
clear,
@@ -64,7 +64,11 @@ interface SetupOptions
6464
* All APIs returned by this function share an input device state and a default configuration.
6565
*/
6666
export function setup(options: SetupOptions = {}) {
67-
prepareDocument(options.document ?? document)
67+
const doc = options.document ?? document
68+
prepareDocument(doc)
69+
70+
const view = doc.defaultView ?? /* istanbul ignore next */ window
71+
attachClipboardStubToView(view)
6872

6973
return _setup(options, {
7074
keyboardState: createKeyboardState(),
@@ -114,6 +118,9 @@ function _setup(
114118
const clickDefaults: clickOptions = {
115119
skipHover,
116120
}
121+
const clipboardDefaults: pasteOptions = {
122+
document,
123+
}
117124
const typeDefaults: TypeOptions = {
118125
delay,
119126
skipAutoClose,
@@ -157,9 +164,11 @@ function _setup(
157164
}
158165
}) as typeof keyboard,
159166

160-
paste: (...args: Parameters<typeof paste>) => {
167+
// paste needs typecasting because of the overloading
168+
paste: ((...args: Parameters<typeof paste>) => {
169+
args[1] = {...clipboardDefaults, ...args[1]}
161170
return paste.call(userEvent, ...args)
162-
},
171+
}) as typeof paste,
163172

164173
// pointer needs typecasting because of the overloading
165174
pointer: ((...args: Parameters<typeof pointer>) => {

src/utils/dataTransfer/Blob.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// jsdom does not implement Blob.text()
2+
3+
export function readBlobText(blob: Blob) {
4+
return new Promise<string>((res, rej) => {
5+
const fr = new FileReader()
6+
fr.onerror = rej
7+
fr.onabort = rej
8+
fr.onload = () => {
9+
res(String(fr.result))
10+
}
11+
fr.readAsText(blob)
12+
})
13+
}

src/utils/dataTransfer/Clipboard.ts

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Clipboard is not available in jsdom
2+
3+
import {readBlobText} from '..'
4+
5+
// Clipboard API is only fully available in secure context or for browser extensions.
6+
7+
type ItemData = Record<string, Blob | string | Promise<Blob | string>>
8+
9+
class ClipboardItemStub implements ClipboardItem {
10+
private data: ItemData
11+
constructor(data: ItemData) {
12+
this.data = data
13+
}
14+
15+
get types() {
16+
return Array.from(Object.keys(this.data))
17+
}
18+
19+
async getType(type: string) {
20+
const data = await this.data[type]
21+
22+
if (!data) {
23+
throw new Error(
24+
`${type} is not one of the available MIME types on this item.`,
25+
)
26+
}
27+
28+
return data instanceof Blob ? data : new Blob([data], {type})
29+
}
30+
}
31+
32+
const ClipboardStubControl = Symbol('Manage ClipboardSub')
33+
34+
class ClipboardStub extends EventTarget implements Clipboard {
35+
private items: ClipboardItem[] = []
36+
37+
async read() {
38+
return Array.from(this.items)
39+
}
40+
41+
async readText() {
42+
let text = ''
43+
for (const item of this.items) {
44+
const type = item.types.includes('text/plain')
45+
? 'text/plain'
46+
: item.types.find(t => t.startsWith('text/'))
47+
if (type) {
48+
text += await item.getType(type).then(b => readBlobText(b))
49+
}
50+
}
51+
return text
52+
}
53+
54+
async write(data: ClipboardItem[]) {
55+
this.items = data
56+
}
57+
58+
async writeText(text: string) {
59+
this.items = [createClipboardItem(text)]
60+
}
61+
62+
[ClipboardStubControl]: {
63+
resetClipboardStub: () => void
64+
detachClipboardStub: () => void
65+
}
66+
}
67+
68+
// MDN lists string|Blob|Promise<Blob|string> as possible types in ClipboardItemData
69+
// lib.dom.d.ts lists only Promise<Blob|string>
70+
// https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem/ClipboardItem#syntax
71+
export function createClipboardItem(
72+
...blobs: Array<Blob | string>
73+
): ClipboardItem {
74+
// use real ClipboardItem if available
75+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
76+
const constructor =
77+
typeof ClipboardItem === 'undefined'
78+
? ClipboardItemStub
79+
: /* istanbul ignore next */ ClipboardItem
80+
return new constructor(
81+
Object.fromEntries(
82+
blobs.map(b => [
83+
typeof b === 'string' ? 'text/plain' : b.type,
84+
Promise.resolve(b),
85+
]),
86+
),
87+
)
88+
}
89+
90+
export function attachClipboardStubToView(window: Window & typeof globalThis) {
91+
if (window.navigator.clipboard instanceof ClipboardStub) {
92+
return window.navigator.clipboard[ClipboardStubControl]
93+
}
94+
95+
const realClipboard = Object.getOwnPropertyDescriptor(
96+
window.navigator,
97+
'clipboard',
98+
)
99+
100+
let stub = new ClipboardStub()
101+
const control = {
102+
resetClipboardStub: () => {
103+
stub = new ClipboardStub()
104+
stub[ClipboardStubControl] = control
105+
},
106+
detachClipboardStub: () => {
107+
/* istanbul ignore if */
108+
if (realClipboard) {
109+
Object.defineProperty(window.navigator, 'clipboard', realClipboard)
110+
} else {
111+
Object.defineProperty(window.navigator, 'clipboard', {
112+
value: undefined,
113+
configurable: true,
114+
})
115+
}
116+
},
117+
}
118+
stub[ClipboardStubControl] = control
119+
120+
Object.defineProperty(window.navigator, 'clipboard', {
121+
get: () => stub,
122+
configurable: true,
123+
})
124+
125+
return stub[ClipboardStubControl]
126+
}
127+
128+
export function resetClipboardStubOnView(window: Window & typeof globalThis) {
129+
if (window.navigator.clipboard instanceof ClipboardStub) {
130+
window.navigator.clipboard[ClipboardStubControl].resetClipboardStub()
131+
}
132+
}
133+
134+
export function detachClipboardStubFromView(
135+
window: Window & typeof globalThis,
136+
) {
137+
if (window.navigator.clipboard instanceof ClipboardStub) {
138+
window.navigator.clipboard[ClipboardStubControl].detachClipboardStub()
139+
}
140+
}
141+
142+
/* istanbul ignore else */
143+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
144+
if (afterEach) {
145+
afterEach(() => resetClipboardStubOnView(window))
146+
}
147+
148+
/* istanbul ignore else */
149+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
150+
if (afterAll) {
151+
afterAll(() => detachClipboardStubFromView(window))
152+
}

0 commit comments

Comments
 (0)