Skip to content

Commit b754dd4

Browse files
authored
feat: add userEvent.copy and userEvent.cut (#787)
1 parent 8b6cd1d commit b754dd4

File tree

14 files changed

+517
-30
lines changed

14 files changed

+517
-30
lines changed

src/clipboard/copy.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {fireEvent} from '@testing-library/dom'
2+
import type {UserEvent} from '../setup'
3+
import {copySelection, writeDataTransferToClipboard} from '../utils'
4+
5+
export interface copyOptions {
6+
document?: Document
7+
writeToClipboard?: boolean
8+
}
9+
10+
export function copy(
11+
this: UserEvent,
12+
options: Omit<copyOptions, 'writeToClipboard'> & {writeToClipboard: true},
13+
): Promise<DataTransfer>
14+
export function copy(
15+
this: UserEvent,
16+
options?: Omit<copyOptions, 'writeToClipboard'> & {
17+
writeToClipboard?: boolean
18+
},
19+
): DataTransfer
20+
export function copy(this: UserEvent, options?: copyOptions) {
21+
const doc = options?.document ?? document
22+
const target = doc.activeElement ?? /* istanbul ignore next */ doc.body
23+
24+
const clipboardData = copySelection(target)
25+
26+
if (clipboardData.items.length === 0) {
27+
return
28+
}
29+
30+
fireEvent.copy(target, {
31+
clipboardData,
32+
})
33+
34+
return options?.writeToClipboard
35+
? writeDataTransferToClipboard(doc, clipboardData).then(() => clipboardData)
36+
: clipboardData
37+
}

src/clipboard/cut.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {fireEvent} from '@testing-library/dom'
2+
import type {UserEvent} from '../setup'
3+
import {
4+
copySelection,
5+
isEditable,
6+
prepareInput,
7+
writeDataTransferToClipboard,
8+
} from '../utils'
9+
10+
export interface cutOptions {
11+
document?: Document
12+
writeToClipboard?: boolean
13+
}
14+
15+
export function cut(
16+
this: UserEvent,
17+
options: Omit<cutOptions, 'writeToClipboard'> & {writeToClipboard: true},
18+
): Promise<DataTransfer>
19+
export function cut(
20+
this: UserEvent,
21+
options?: Omit<cutOptions, 'writeToClipboard'> & {writeToClipboard?: boolean},
22+
): DataTransfer
23+
export function cut(this: UserEvent, options?: cutOptions) {
24+
const doc = options?.document ?? document
25+
const target = doc.activeElement ?? /* istanbul ignore next */ doc.body
26+
27+
const clipboardData = copySelection(target)
28+
29+
if (clipboardData.items.length === 0) {
30+
return
31+
}
32+
33+
fireEvent.cut(target, {
34+
clipboardData,
35+
})
36+
37+
if (isEditable(target)) {
38+
prepareInput('', target, 'deleteByCut')?.commit()
39+
}
40+
41+
return options?.writeToClipboard
42+
? writeDataTransferToClipboard(doc, clipboardData).then(() => clipboardData)
43+
: clipboardData
44+
}

src/clipboard/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './copy'
2+
export * from './cut'
3+
export * from './paste'

src/paste.ts src/clipboard/paste.ts

+11-23
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import {fireEvent} from '@testing-library/dom'
2-
import type {UserEvent} from './setup'
2+
import type {UserEvent} from '../setup'
33
import {
44
createDataTransfer,
55
getSpaceUntilMaxLength,
66
prepareInput,
77
isEditable,
8-
readBlobText,
9-
} from './utils'
8+
readDataTransferFromClipboard,
9+
} from '../utils'
1010

1111
export interface pasteOptions {
1212
document?: Document
@@ -37,7 +37,14 @@ export function paste(
3737

3838
return data
3939
? pasteImpl(target, data)
40-
: readClipboardDataFromClipboardApi(doc).then(dt => pasteImpl(target, dt))
40+
: readDataTransferFromClipboard(doc).then(
41+
dt => pasteImpl(target, dt),
42+
() => {
43+
throw new Error(
44+
'`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.',
45+
)
46+
},
47+
)
4148
}
4249

4350
function pasteImpl(target: Element, clipboardData: DataTransfer) {
@@ -61,22 +68,3 @@ function getClipboardDataFromString(text: string) {
6168
dt.setData('text', text)
6269
return dt
6370
}
64-
65-
async function readClipboardDataFromClipboardApi(document: Document) {
66-
const clipboard = document.defaultView?.navigator.clipboard
67-
const items = clipboard && (await clipboard.read())
68-
69-
if (!items) {
70-
throw new Error(
71-
'`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.',
72-
)
73-
}
74-
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
82-
}

src/setup.ts

+34-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +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, pasteOptions} from './paste'
7+
import {
8+
copy,
9+
copyOptions,
10+
cut,
11+
cutOptions,
12+
paste,
13+
pasteOptions,
14+
} from './clipboard'
815
import {createPointerState, pointer} from './pointer'
916
import type {pointerOptions, pointerState} from './pointer/types'
1017
import {deselectOptions, selectOptions} from './selectOptions'
@@ -16,6 +23,8 @@ import {PointerOptions, attachClipboardStubToView} from './utils'
1623
export const userEventApis = {
1724
clear,
1825
click,
26+
copy,
27+
cut,
1928
dblClick,
2029
deselectOptions,
2130
hover,
@@ -37,6 +46,8 @@ export type UserEvent = UserEventApis & {
3746

3847
type ClickOptions = Omit<clickOptions, 'clickCount'>
3948

49+
interface ClipboardOptions extends copyOptions, cutOptions, pasteOptions {}
50+
4051
type KeyboardOptions = Partial<keyboardOptions>
4152

4253
type PointerApiOptions = Partial<pointerOptions>
@@ -52,6 +63,7 @@ type UploadOptions = uploadOptions
5263

5364
interface SetupOptions
5465
extends ClickOptions,
66+
ClipboardOptions,
5567
KeyboardOptions,
5668
PointerOptions,
5769
PointerApiOptions,
@@ -88,6 +100,13 @@ function _setup(
88100
skipClick,
89101
skipHover,
90102
skipPointerEventsCheck = false,
103+
// Changing default return type from DataTransfer to Promise<DataTransfer>
104+
// would require a lot of overloading right now.
105+
// The APIs returned by setup will most likely be changed to async before stable release anyway.
106+
// See https://github.com/testing-library/user-event/issues/504#issuecomment-944883855
107+
// So the default option can be changed during alpha instead of introducing too much code here.
108+
// TODO: This should default to true
109+
writeToClipboard = false,
91110
}: SetupOptions,
92111
{
93112
keyboardState,
@@ -118,8 +137,9 @@ function _setup(
118137
const clickDefaults: clickOptions = {
119138
skipHover,
120139
}
121-
const clipboardDefaults: pasteOptions = {
140+
const clipboardDefaults: ClipboardOptions = {
122141
document,
142+
writeToClipboard,
123143
}
124144
const typeDefaults: TypeOptions = {
125145
delay,
@@ -140,6 +160,18 @@ function _setup(
140160
return click.call(userEvent, ...args)
141161
},
142162

163+
// copy needs typecasting because of the overloading
164+
copy: ((...args: Parameters<typeof copy>) => {
165+
args[0] = {...clipboardDefaults, ...args[0]}
166+
return copy.call(userEvent, ...args)
167+
}) as typeof copy,
168+
169+
// cut needs typecasting because of the overloading
170+
cut: ((...args: Parameters<typeof cut>) => {
171+
args[0] = {...clipboardDefaults, ...args[0]}
172+
return cut.call(userEvent, ...args)
173+
}) as typeof cut,
174+
143175
dblClick: (...args: Parameters<typeof dblClick>) => {
144176
args[1] = {...pointerDefaults, ...clickDefaults, ...args[1]}
145177
return dblClick.call(userEvent, ...args)

src/utils/dataTransfer/Clipboard.ts

+45-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Clipboard is not available in jsdom
22

3-
import {readBlobText} from '..'
3+
import {createDataTransfer, getBlobFromDataTransferItem, readBlobText} from '..'
44

55
// Clipboard API is only fully available in secure context or for browser extensions.
66

@@ -139,6 +139,50 @@ export function detachClipboardStubFromView(
139139
}
140140
}
141141

142+
export async function readDataTransferFromClipboard(document: Document) {
143+
const clipboard = document.defaultView?.navigator.clipboard
144+
const items = clipboard && (await clipboard.read())
145+
146+
if (!items) {
147+
throw new Error('The Clipboard API is unavailable.')
148+
}
149+
150+
const dt = createDataTransfer()
151+
for (const item of items) {
152+
for (const type of item.types) {
153+
dt.setData(type, await item.getType(type).then(b => readBlobText(b)))
154+
}
155+
}
156+
return dt
157+
}
158+
159+
export async function writeDataTransferToClipboard(
160+
document: Document,
161+
clipboardData: DataTransfer,
162+
) {
163+
const clipboard = document.defaultView?.navigator.clipboard
164+
165+
const items = []
166+
for (let i = 0; i < clipboardData.items.length; i++) {
167+
const dtItem = clipboardData.items[i]
168+
const blob = getBlobFromDataTransferItem(dtItem)
169+
items.push(createClipboardItem(blob))
170+
}
171+
172+
const written =
173+
clipboard &&
174+
(await clipboard.write(items).then(
175+
() => true,
176+
// Can happen with other implementations that e.g. require permissions
177+
/* istanbul ignore next */
178+
() => false,
179+
))
180+
181+
if (!written) {
182+
throw new Error('The Clipboard API is unavailable.')
183+
}
184+
}
185+
142186
/* istanbul ignore else */
143187
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
144188
if (afterEach) {

src/utils/dataTransfer/DataTransfer.ts

+11
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,14 @@ export function createDataTransfer(files: File[] = []): DataTransfer {
145145

146146
return dt
147147
}
148+
149+
export function getBlobFromDataTransferItem(item: DataTransferItem) {
150+
if (item.kind === 'file') {
151+
return item.getAsFile() as File
152+
}
153+
let data: string = ''
154+
item.getAsString(s => {
155+
data = s
156+
})
157+
return new Blob([data], {type: item.type})
158+
}

src/utils/focus/copySelection.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {getUISelection, getUIValue} from '../../document'
2+
import {createDataTransfer} from '../dataTransfer/DataTransfer'
3+
import {EditableInputType} from '../edit/isEditable'
4+
import {hasOwnSelection} from './selection'
5+
6+
export function copySelection(target: Element) {
7+
const data: Record<string, string> = hasOwnSelection(target)
8+
? {'text/plain': readSelectedValueFromInput(target)}
9+
: // TODO: We could implement text/html copying of DOM nodes here
10+
{'text/plain': String(target.ownerDocument.getSelection())}
11+
12+
const dt = createDataTransfer()
13+
for (const type in data) {
14+
if (data[type]) {
15+
dt.setData(type, data[type])
16+
}
17+
}
18+
19+
return dt
20+
}
21+
22+
function readSelectedValueFromInput(
23+
target: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement,
24+
) {
25+
const sel = getUISelection(target)
26+
const val = getUIValue(target)
27+
28+
return val.substring(sel.startOffset, sel.endOffset)
29+
}

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './edit/maxLength'
1717
export * from './edit/prepareInput'
1818

1919
export * from './focus/blur'
20+
export * from './focus/copySelection'
2021
export * from './focus/focus'
2122
export * from './focus/getActiveElement'
2223
export * from './focus/getTabDestination'

0 commit comments

Comments
 (0)