Skip to content

Commit

Permalink
feat!: replace userEvent.paste (#785)
Browse files Browse the repository at this point in the history
* feat!: replace `userEvent.paste`

BREAKING CHANGE: The `userEvent.paste` API has new parameters.
  • Loading branch information
ph-fritsche committed Nov 17, 2021
1 parent 051fe20 commit 8b6cd1d
Show file tree
Hide file tree
Showing 12 changed files with 586 additions and 80 deletions.
118 changes: 58 additions & 60 deletions src/paste.ts
@@ -1,84 +1,82 @@
import {fireEvent} from '@testing-library/dom'
import type {UserEvent} from './setup'
import {
createDataTransfer,
getSpaceUntilMaxLength,
setSelectionRange,
eventWrapper,
isDisabled,
isElementType,
editableInputTypes,
getInputRange,
prepareInput,
isEditable,
readBlobText,
} from './utils'

interface pasteOptions {
initialSelectionStart?: number
initialSelectionEnd?: number
}

function isSupportedElement(
element: HTMLElement,
): element is
| HTMLTextAreaElement
| (HTMLInputElement & {type: editableInputTypes}) {
return (
(isElementType(element, 'input') &&
Boolean(editableInputTypes[element.type as editableInputTypes])) ||
isElementType(element, 'textarea')
)
export interface pasteOptions {
document?: Document
}

export function paste(
this: UserEvent,
element: HTMLElement,
text: string,
init?: ClipboardEventInit,
{initialSelectionStart, initialSelectionEnd}: pasteOptions = {},
clipboardData?: undefined,
options?: pasteOptions,
): Promise<void>
export function paste(
this: UserEvent,
clipboardData: DataTransfer | string,
options?: pasteOptions,
): void
export function paste(
this: UserEvent,
clipboardData?: DataTransfer | string,
options?: pasteOptions,
) {
// TODO: implement for contenteditable
if (!isSupportedElement(element)) {
throw new TypeError(
`The given ${element.tagName} element is currently unsupported.
A PR extending this implementation would be very much welcome at https://github.com/testing-library/user-event`,
)
}
const doc = options?.document ?? document
const target = doc.activeElement ?? /* istanbul ignore next */ doc.body

if (isDisabled(element)) {
return
}
const data: DataTransfer | undefined =
typeof clipboardData === 'string'
? getClipboardDataFromString(clipboardData)
: clipboardData

eventWrapper(() => element.focus())
return data
? pasteImpl(target, data)
: readClipboardDataFromClipboardApi(doc).then(dt => pasteImpl(target, dt))
}

// 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 (element.selectionStart === 0 && element.selectionEnd === 0) {
setSelectionRange(
element,
initialSelectionStart ?? element.value.length,
initialSelectionEnd ?? element.value.length,
)
}
function pasteImpl(target: Element, clipboardData: DataTransfer) {
fireEvent.paste(target, {
clipboardData,
})

fireEvent.paste(element, init)
if (isEditable(target)) {
const data = clipboardData
.getData('text')
.substr(0, getSpaceUntilMaxLength(target))

if (element.readOnly) {
return
if (data) {
prepareInput(data, target, 'insertFromPaste')?.commit()
}
}
}

text = text.substr(0, getSpaceUntilMaxLength(element))
function getClipboardDataFromString(text: string) {
const dt = createDataTransfer()
dt.setData('text', text)
return dt
}

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

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

prepareInput(text, element, 'insertFromPaste')?.commit()
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
}
19 changes: 14 additions & 5 deletions src/setup.ts
Expand Up @@ -4,14 +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} from './paste'
import {paste, pasteOptions} from './paste'
import {createPointerState, pointer} from './pointer'
import type {pointerOptions, pointerState} from './pointer/types'
import {deselectOptions, selectOptions} from './selectOptions'
import {tab, tabOptions} from './tab'
import {type, typeOptions} from './type'
import {upload, uploadOptions} from './upload'
import {PointerOptions} from './utils'
import {PointerOptions, attachClipboardStubToView} from './utils'

export const userEventApis = {
clear,
Expand Down Expand Up @@ -64,7 +64,11 @@ interface SetupOptions
* All APIs returned by this function share an input device state and a default configuration.
*/
export function setup(options: SetupOptions = {}) {
prepareDocument(options.document ?? document)
const doc = options.document ?? document
prepareDocument(doc)

const view = doc.defaultView ?? /* istanbul ignore next */ window
attachClipboardStubToView(view)

return _setup(options, {
keyboardState: createKeyboardState(),
Expand Down Expand Up @@ -114,6 +118,9 @@ function _setup(
const clickDefaults: clickOptions = {
skipHover,
}
const clipboardDefaults: pasteOptions = {
document,
}
const typeDefaults: TypeOptions = {
delay,
skipAutoClose,
Expand Down Expand Up @@ -157,9 +164,11 @@ function _setup(
}
}) as typeof keyboard,

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

// pointer needs typecasting because of the overloading
pointer: ((...args: Parameters<typeof pointer>) => {
Expand Down
13 changes: 13 additions & 0 deletions src/utils/dataTransfer/Blob.ts
@@ -0,0 +1,13 @@
// jsdom does not implement Blob.text()

export function readBlobText(blob: Blob) {
return new Promise<string>((res, rej) => {
const fr = new FileReader()
fr.onerror = rej
fr.onabort = rej
fr.onload = () => {
res(String(fr.result))
}
fr.readAsText(blob)
})
}
152 changes: 152 additions & 0 deletions src/utils/dataTransfer/Clipboard.ts
@@ -0,0 +1,152 @@
// Clipboard is not available in jsdom

import {readBlobText} from '..'

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

type ItemData = Record<string, Blob | string | Promise<Blob | string>>

class ClipboardItemStub implements ClipboardItem {
private data: ItemData
constructor(data: ItemData) {
this.data = data
}

get types() {
return Array.from(Object.keys(this.data))
}

async getType(type: string) {
const data = await this.data[type]

if (!data) {
throw new Error(
`${type} is not one of the available MIME types on this item.`,
)
}

return data instanceof Blob ? data : new Blob([data], {type})
}
}

const ClipboardStubControl = Symbol('Manage ClipboardSub')

class ClipboardStub extends EventTarget implements Clipboard {
private items: ClipboardItem[] = []

async read() {
return Array.from(this.items)
}

async readText() {
let text = ''
for (const item of this.items) {
const type = item.types.includes('text/plain')
? 'text/plain'
: item.types.find(t => t.startsWith('text/'))
if (type) {
text += await item.getType(type).then(b => readBlobText(b))
}
}
return text
}

async write(data: ClipboardItem[]) {
this.items = data
}

async writeText(text: string) {
this.items = [createClipboardItem(text)]
}

[ClipboardStubControl]: {
resetClipboardStub: () => void
detachClipboardStub: () => void
}
}

// MDN lists string|Blob|Promise<Blob|string> as possible types in ClipboardItemData
// lib.dom.d.ts lists only Promise<Blob|string>
// https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem/ClipboardItem#syntax
export function createClipboardItem(
...blobs: Array<Blob | string>
): ClipboardItem {
// use real ClipboardItem if available
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const constructor =
typeof ClipboardItem === 'undefined'
? ClipboardItemStub
: /* istanbul ignore next */ ClipboardItem
return new constructor(
Object.fromEntries(
blobs.map(b => [
typeof b === 'string' ? 'text/plain' : b.type,
Promise.resolve(b),
]),
),
)
}

export function attachClipboardStubToView(window: Window & typeof globalThis) {
if (window.navigator.clipboard instanceof ClipboardStub) {
return window.navigator.clipboard[ClipboardStubControl]
}

const realClipboard = Object.getOwnPropertyDescriptor(
window.navigator,
'clipboard',
)

let stub = new ClipboardStub()
const control = {
resetClipboardStub: () => {
stub = new ClipboardStub()
stub[ClipboardStubControl] = control
},
detachClipboardStub: () => {
/* istanbul ignore if */
if (realClipboard) {
Object.defineProperty(window.navigator, 'clipboard', realClipboard)
} else {
Object.defineProperty(window.navigator, 'clipboard', {
value: undefined,
configurable: true,
})
}
},
}
stub[ClipboardStubControl] = control

Object.defineProperty(window.navigator, 'clipboard', {
get: () => stub,
configurable: true,
})

return stub[ClipboardStubControl]
}

export function resetClipboardStubOnView(window: Window & typeof globalThis) {
if (window.navigator.clipboard instanceof ClipboardStub) {
window.navigator.clipboard[ClipboardStubControl].resetClipboardStub()
}
}

export function detachClipboardStubFromView(
window: Window & typeof globalThis,
) {
if (window.navigator.clipboard instanceof ClipboardStub) {
window.navigator.clipboard[ClipboardStubControl].detachClipboardStub()
}
}

/* istanbul ignore else */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (afterEach) {
afterEach(() => resetClipboardStubOnView(window))
}

/* istanbul ignore else */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (afterAll) {
afterAll(() => detachClipboardStubFromView(window))
}

0 comments on commit 8b6cd1d

Please sign in to comment.