Skip to content

Commit

Permalink
feat: add userEvent.copy and userEvent.cut (#787)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Nov 28, 2021
1 parent f8fe217 commit 8727a2d
Show file tree
Hide file tree
Showing 14 changed files with 517 additions and 30 deletions.
37 changes: 37 additions & 0 deletions src/clipboard/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {fireEvent} from '@testing-library/dom'
import type {UserEvent} from '../setup'
import {copySelection, writeDataTransferToClipboard} from '../utils'

export interface copyOptions {
document?: Document
writeToClipboard?: boolean
}

export function copy(
this: UserEvent,
options: Omit<copyOptions, 'writeToClipboard'> & {writeToClipboard: true},
): Promise<DataTransfer>
export function copy(
this: UserEvent,
options?: Omit<copyOptions, 'writeToClipboard'> & {
writeToClipboard?: boolean
},
): DataTransfer
export function copy(this: UserEvent, options?: copyOptions) {
const doc = options?.document ?? document
const target = doc.activeElement ?? /* istanbul ignore next */ doc.body

const clipboardData = copySelection(target)

if (clipboardData.items.length === 0) {
return
}

fireEvent.copy(target, {
clipboardData,
})

return options?.writeToClipboard
? writeDataTransferToClipboard(doc, clipboardData).then(() => clipboardData)
: clipboardData
}
44 changes: 44 additions & 0 deletions src/clipboard/cut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {fireEvent} from '@testing-library/dom'
import type {UserEvent} from '../setup'
import {
copySelection,
isEditable,
prepareInput,
writeDataTransferToClipboard,
} from '../utils'

export interface cutOptions {
document?: Document
writeToClipboard?: boolean
}

export function cut(
this: UserEvent,
options: Omit<cutOptions, 'writeToClipboard'> & {writeToClipboard: true},
): Promise<DataTransfer>
export function cut(
this: UserEvent,
options?: Omit<cutOptions, 'writeToClipboard'> & {writeToClipboard?: boolean},
): DataTransfer
export function cut(this: UserEvent, options?: cutOptions) {
const doc = options?.document ?? document
const target = doc.activeElement ?? /* istanbul ignore next */ doc.body

const clipboardData = copySelection(target)

if (clipboardData.items.length === 0) {
return
}

fireEvent.cut(target, {
clipboardData,
})

if (isEditable(target)) {
prepareInput('', target, 'deleteByCut')?.commit()
}

return options?.writeToClipboard
? writeDataTransferToClipboard(doc, clipboardData).then(() => clipboardData)
: clipboardData
}
3 changes: 3 additions & 0 deletions src/clipboard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './copy'
export * from './cut'
export * from './paste'
34 changes: 11 additions & 23 deletions src/paste.ts → src/clipboard/paste.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {fireEvent} from '@testing-library/dom'
import type {UserEvent} from './setup'
import type {UserEvent} from '../setup'
import {
createDataTransfer,
getSpaceUntilMaxLength,
prepareInput,
isEditable,
readBlobText,
} from './utils'
readDataTransferFromClipboard,
} from '../utils'

export interface pasteOptions {
document?: Document
Expand Down Expand Up @@ -37,7 +37,14 @@ export function paste(

return data
? pasteImpl(target, data)
: readClipboardDataFromClipboardApi(doc).then(dt => pasteImpl(target, dt))
: readDataTransferFromClipboard(doc).then(
dt => pasteImpl(target, dt),
() => {
throw new Error(
'`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.',
)
},
)
}

function pasteImpl(target: Element, clipboardData: DataTransfer) {
Expand All @@ -61,22 +68,3 @@ function getClipboardDataFromString(text: string) {
dt.setData('text', text)
return dt
}

async function readClipboardDataFromClipboardApi(document: Document) {
const clipboard = document.defaultView?.navigator.clipboard
const items = clipboard && (await clipboard.read())

if (!items) {
throw new Error(
'`userEvent.paste()` without `clipboardData` requires the `ClipboardAPI` to be available.',
)
}

const dt = createDataTransfer()
for (const item of items) {
for (const type of item.types) {
dt.setData(type, await item.getType(type).then(b => readBlobText(b)))
}
}
return dt
}
36 changes: 34 additions & 2 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import {prepareDocument} from './document'
import {hover, unhover} from './hover'
import {createKeyboardState, keyboard, keyboardOptions} from './keyboard'
import type {keyboardState} from './keyboard/types'
import {paste, pasteOptions} from './paste'
import {
copy,
copyOptions,
cut,
cutOptions,
paste,
pasteOptions,
} from './clipboard'
import {createPointerState, pointer} from './pointer'
import type {pointerOptions, pointerState} from './pointer/types'
import {deselectOptions, selectOptions} from './selectOptions'
Expand All @@ -16,6 +23,8 @@ import {PointerOptions, attachClipboardStubToView} from './utils'
export const userEventApis = {
clear,
click,
copy,
cut,
dblClick,
deselectOptions,
hover,
Expand All @@ -37,6 +46,8 @@ export type UserEvent = UserEventApis & {

type ClickOptions = Omit<clickOptions, 'clickCount'>

interface ClipboardOptions extends copyOptions, cutOptions, pasteOptions {}

type KeyboardOptions = Partial<keyboardOptions>

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

interface SetupOptions
extends ClickOptions,
ClipboardOptions,
KeyboardOptions,
PointerOptions,
PointerApiOptions,
Expand Down Expand Up @@ -88,6 +100,13 @@ function _setup(
skipClick,
skipHover,
skipPointerEventsCheck = false,
// Changing default return type from DataTransfer to Promise<DataTransfer>
// would require a lot of overloading right now.
// The APIs returned by setup will most likely be changed to async before stable release anyway.
// See https://github.com/testing-library/user-event/issues/504#issuecomment-944883855
// So the default option can be changed during alpha instead of introducing too much code here.
// TODO: This should default to true
writeToClipboard = false,
}: SetupOptions,
{
keyboardState,
Expand Down Expand Up @@ -118,8 +137,9 @@ function _setup(
const clickDefaults: clickOptions = {
skipHover,
}
const clipboardDefaults: pasteOptions = {
const clipboardDefaults: ClipboardOptions = {
document,
writeToClipboard,
}
const typeDefaults: TypeOptions = {
delay,
Expand All @@ -140,6 +160,18 @@ function _setup(
return click.call(userEvent, ...args)
},

// copy needs typecasting because of the overloading
copy: ((...args: Parameters<typeof copy>) => {
args[0] = {...clipboardDefaults, ...args[0]}
return copy.call(userEvent, ...args)
}) as typeof copy,

// cut needs typecasting because of the overloading
cut: ((...args: Parameters<typeof cut>) => {
args[0] = {...clipboardDefaults, ...args[0]}
return cut.call(userEvent, ...args)
}) as typeof cut,

dblClick: (...args: Parameters<typeof dblClick>) => {
args[1] = {...pointerDefaults, ...clickDefaults, ...args[1]}
return dblClick.call(userEvent, ...args)
Expand Down
46 changes: 45 additions & 1 deletion src/utils/dataTransfer/Clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Clipboard is not available in jsdom

import {readBlobText} from '..'
import {createDataTransfer, getBlobFromDataTransferItem, readBlobText} from '..'

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

Expand Down Expand Up @@ -139,6 +139,50 @@ export function detachClipboardStubFromView(
}
}

export async function readDataTransferFromClipboard(document: Document) {
const clipboard = document.defaultView?.navigator.clipboard
const items = clipboard && (await clipboard.read())

if (!items) {
throw new Error('The Clipboard API is unavailable.')
}

const dt = createDataTransfer()
for (const item of items) {
for (const type of item.types) {
dt.setData(type, await item.getType(type).then(b => readBlobText(b)))
}
}
return dt
}

export async function writeDataTransferToClipboard(
document: Document,
clipboardData: DataTransfer,
) {
const clipboard = document.defaultView?.navigator.clipboard

const items = []
for (let i = 0; i < clipboardData.items.length; i++) {
const dtItem = clipboardData.items[i]
const blob = getBlobFromDataTransferItem(dtItem)
items.push(createClipboardItem(blob))
}

const written =
clipboard &&
(await clipboard.write(items).then(
() => true,
// Can happen with other implementations that e.g. require permissions
/* istanbul ignore next */
() => false,
))

if (!written) {
throw new Error('The Clipboard API is unavailable.')
}
}

/* istanbul ignore else */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (afterEach) {
Expand Down
11 changes: 11 additions & 0 deletions src/utils/dataTransfer/DataTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,14 @@ export function createDataTransfer(files: File[] = []): DataTransfer {

return dt
}

export function getBlobFromDataTransferItem(item: DataTransferItem) {
if (item.kind === 'file') {
return item.getAsFile() as File
}
let data: string = ''
item.getAsString(s => {
data = s
})
return new Blob([data], {type: item.type})
}
29 changes: 29 additions & 0 deletions src/utils/focus/copySelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {getUISelection, getUIValue} from '../../document'
import {createDataTransfer} from '../dataTransfer/DataTransfer'
import {EditableInputType} from '../edit/isEditable'
import {hasOwnSelection} from './selection'

export function copySelection(target: Element) {
const data: Record<string, string> = hasOwnSelection(target)
? {'text/plain': readSelectedValueFromInput(target)}
: // TODO: We could implement text/html copying of DOM nodes here
{'text/plain': String(target.ownerDocument.getSelection())}

const dt = createDataTransfer()
for (const type in data) {
if (data[type]) {
dt.setData(type, data[type])
}
}

return dt
}

function readSelectedValueFromInput(
target: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement,
) {
const sel = getUISelection(target)
const val = getUIValue(target)

return val.substring(sel.startOffset, sel.endOffset)
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './edit/maxLength'
export * from './edit/prepareInput'

export * from './focus/blur'
export * from './focus/copySelection'
export * from './focus/focus'
export * from './focus/getActiveElement'
export * from './focus/getTabDestination'
Expand Down
Loading

0 comments on commit 8727a2d

Please sign in to comment.