-
Notifications
You must be signed in to change notification settings - Fork 244
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat!: replace
userEvent.paste
(#785)
BREAKING CHANGE: The `userEvent.paste` API has new parameters.
- Loading branch information
1 parent
caea162
commit f8fe217
Showing
12 changed files
with
586 additions
and
80 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) | ||
} |
Oops, something went wrong.