From e85169be543a35ea6d59daa1079874eccbc1f148 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:49:06 +0900 Subject: [PATCH] Compose: Implement useCopyToClipboard and useCopyOnClick with native clipboard API (#75723) * Compose: Implement useCopyToClipboard and useCopyOnClick with native clipboard API * Add temporary code to check if Clipboard API is available * Add isSecureContext log * Remove console.log * Migrate to Typescript * Call onSuccess even when empty text is copied * Add isActive flag to prevent state updates after unmount * useCopyToClipboard: Add isActive flag to prevent state updates after unmount * ADd unit tests * Add unit tests for useCopyOnClick * Add e2e test * e2e: correctly click button inside block * useCopyOnClick: check deprecated message in unit test * useCopyOnClick: verify isActive guard in unit test * Add unit tests for copyToClipboard and clearSelection * Preven scroll jump while copying Co-authored-by: Marco Ciampini * useCopyOnClick: use async/await instad of .then() --------- Unlinked contributors: mostafa-abdelbari, noahtallen. Co-authored-by: t-hamano Co-authored-by: ciampo Co-authored-by: Mamaduka Co-authored-by: getdave Co-authored-by: StYankov --- package-lock.json | 32 +--- packages/compose/README.md | 12 +- packages/compose/package.json | 1 - .../src/hooks/use-copy-on-click/index.js | 75 -------- .../src/hooks/use-copy-on-click/index.ts | 101 +++++++++++ .../hooks/use-copy-on-click/test/index.tsx | 162 ++++++++++++++++++ .../src/hooks/use-copy-to-clipboard/index.js | 69 -------- .../src/hooks/use-copy-to-clipboard/index.ts | 120 +++++++++++++ .../use-copy-to-clipboard/test/index.tsx | 144 ++++++++++++++++ .../specs/editor/various/clipboard.spec.js | 68 ++++++++ 10 files changed, 603 insertions(+), 181 deletions(-) delete mode 100644 packages/compose/src/hooks/use-copy-on-click/index.js create mode 100644 packages/compose/src/hooks/use-copy-on-click/index.ts create mode 100644 packages/compose/src/hooks/use-copy-on-click/test/index.tsx delete mode 100644 packages/compose/src/hooks/use-copy-to-clipboard/index.js create mode 100644 packages/compose/src/hooks/use-copy-to-clipboard/index.ts create mode 100644 packages/compose/src/hooks/use-copy-to-clipboard/test/index.tsx create mode 100644 test/e2e/specs/editor/various/clipboard.spec.js diff --git a/package-lock.json b/package-lock.json index 4f30ac0edf9a5c..ee32f04788f52d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29266,11 +29266,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegate": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", - "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==" - }, "node_modules/denodeify": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/denodeify/-/denodeify-1.2.1.tgz", @@ -33795,14 +33790,6 @@ "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==" }, - "node_modules/good-listener": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", - "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", - "dependencies": { - "delegate": "^3.1.2" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -49667,11 +49654,6 @@ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", "dev": true }, - "node_modules/select": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", - "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==" - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -52946,7 +52928,8 @@ "node_modules/tiny-emitter": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "dev": true }, "node_modules/tiny-invariant": { "version": "1.3.3", @@ -58176,7 +58159,6 @@ "@wordpress/priority-queue": "file:../priority-queue", "@wordpress/undo-manager": "file:../undo-manager", "change-case": "^4.1.2", - "clipboard": "^2.0.11", "mousetrap": "^1.6.5", "use-memo-one": "^1.1.1" }, @@ -58191,16 +58173,6 @@ "react": "^18.0.0" } }, - "packages/compose/node_modules/clipboard": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", - "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", - "dependencies": { - "good-listener": "^1.2.2", - "select": "^1.1.2", - "tiny-emitter": "^2.0.0" - } - }, "packages/connectors": { "name": "@wordpress/connectors", "version": "1.0.0", diff --git a/packages/compose/README.md b/packages/compose/README.md index b78faa9840cae4..a93184b18af12a 100644 --- a/packages/compose/README.md +++ b/packages/compose/README.md @@ -220,9 +220,9 @@ Copies the text to the clipboard when the element is clicked. _Parameters_ -- _ref_ `React.RefObject>`: Reference with the element. -- _text_ `string|Function`: The text to copy. -- _timeout_ `[number]`: Optional timeout to reset the returned state. 4 seconds by default. +- _ref_ `RefObject< string | Element | NodeListOf< Element > >`: Reference with the element. +- _text_ `string | ( () => string )`: The text to copy. +- _timeout_ `number`: Optional timeout to reset the returned state. 4 seconds by default. _Returns_ @@ -234,12 +234,12 @@ Copies the given text to the clipboard when the element is clicked. _Parameters_ -- _text_ `string | (() => string)`: The text to copy. Use a function if not already available and expensive to compute. -- _onSuccess_ `Function`: Called when to text is copied. +- _text_ `string | ( () => string )`: The text to copy. Use a function if not already available and expensive to compute. +- _onSuccess_ `() => void`: Called when to text is copied. _Returns_ -- `React.Ref`: A ref to assign to the target element. +- `RefCallback< T >`: A ref to assign to the target element. ### useDebounce diff --git a/packages/compose/package.json b/packages/compose/package.json index fde9d24338c089..b43792819128e9 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -55,7 +55,6 @@ "@wordpress/priority-queue": "file:../priority-queue", "@wordpress/undo-manager": "file:../undo-manager", "change-case": "^4.1.2", - "clipboard": "^2.0.11", "mousetrap": "^1.6.5", "use-memo-one": "^1.1.1" }, diff --git a/packages/compose/src/hooks/use-copy-on-click/index.js b/packages/compose/src/hooks/use-copy-on-click/index.js deleted file mode 100644 index 59799c2d55d1f7..00000000000000 --- a/packages/compose/src/hooks/use-copy-on-click/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * External dependencies - */ -import Clipboard from 'clipboard'; - -/** - * WordPress dependencies - */ -import { useRef, useEffect, useState } from '@wordpress/element'; -import deprecated from '@wordpress/deprecated'; - -/** - * Copies the text to the clipboard when the element is clicked. - * - * @deprecated - * - * @param {React.RefObject>} ref Reference with the element. - * @param {string|Function} text The text to copy. - * @param {number} [timeout] Optional timeout to reset the returned - * state. 4 seconds by default. - * - * @return {boolean} Whether or not the text has been copied. Resets after the - * timeout. - */ -export default function useCopyOnClick( ref, text, timeout = 4000 ) { - deprecated( 'wp.compose.useCopyOnClick', { - since: '5.8', - alternative: 'wp.compose.useCopyToClipboard', - } ); - - /** @type {React.MutableRefObject} */ - const clipboardRef = useRef( undefined ); - const [ hasCopied, setHasCopied ] = useState( false ); - - useEffect( () => { - /** @type {number | undefined} */ - let timeoutId; - - if ( ! ref.current ) { - return; - } - - // Clipboard listens to click events. - clipboardRef.current = new Clipboard( ref.current, { - text: () => ( typeof text === 'function' ? text() : text ), - } ); - - clipboardRef.current.on( 'success', ( { clearSelection, trigger } ) => { - // Clearing selection will move focus back to the triggering button, - // ensuring that it is not reset to the body, and further that it is - // kept within the rendered node. - clearSelection(); - - // Handle ClipboardJS focus bug, see https://github.com/zenorocha/clipboard.js/issues/680 - if ( trigger ) { - /** @type {HTMLElement} */ ( trigger ).focus(); - } - - if ( timeout ) { - setHasCopied( true ); - clearTimeout( timeoutId ); - timeoutId = setTimeout( () => setHasCopied( false ), timeout ); - } - } ); - - return () => { - if ( clipboardRef.current ) { - clipboardRef.current.destroy(); - } - clearTimeout( timeoutId ); - }; - }, [ text, timeout, setHasCopied ] ); - - return hasCopied; -} diff --git a/packages/compose/src/hooks/use-copy-on-click/index.ts b/packages/compose/src/hooks/use-copy-on-click/index.ts new file mode 100644 index 00000000000000..3b61d78d9b5999 --- /dev/null +++ b/packages/compose/src/hooks/use-copy-on-click/index.ts @@ -0,0 +1,101 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import deprecated from '@wordpress/deprecated'; +import type { RefObject } from 'react'; + +/** + * Internal dependencies + */ +import { clearSelection, copyToClipboard } from '../use-copy-to-clipboard'; + +/** + * Copies the text to the clipboard when the element is clicked. + * + * @deprecated + * @param ref Reference with the element. + * @param text The text to copy. + * @param timeout Optional timeout to reset the returned + * state. 4 seconds by default. + * @return Whether or not the text has been copied. Resets after the + * timeout. + */ +export default function useCopyOnClick( + ref: RefObject< string | Element | NodeListOf< Element > >, + text: string | ( () => string ), + timeout: number = 4000 +): boolean { + deprecated( 'wp.compose.useCopyOnClick', { + since: '5.8', + alternative: 'wp.compose.useCopyToClipboard', + } ); + + const [ hasCopied, setHasCopied ] = useState( false ); + + useEffect( () => { + // Flag to prevent state updates after unmount when the Promise resolves. + let isActive = true; + let timeoutId: ReturnType< typeof setTimeout > | undefined; + if ( ! ref.current ) { + return; + } + + let targets: Element[]; + if ( typeof ref.current === 'string' ) { + targets = + typeof document !== 'undefined' + ? Array.from( document.querySelectorAll( ref.current ) ) + : []; + } else if ( + 'length' in ref.current && + typeof ref.current.length === 'number' + ) { + targets = Array.from( ref.current ); + } else { + targets = [ ref.current as Element ]; + } + + if ( targets.length === 0 ) { + return; + } + + const handleClick = async ( event: Event ) => { + const trigger = event.currentTarget as Element; + if ( ! trigger ) { + return; + } + const success = await copyToClipboard( + typeof text === 'function' ? text() : text || '', + trigger + ); + if ( ! isActive ) { + return; + } + if ( success ) { + clearSelection( trigger ); + if ( timeout ) { + setHasCopied( true ); + clearTimeout( timeoutId ); + timeoutId = setTimeout( + () => setHasCopied( false ), + timeout + ); + } + } + }; + + for ( const target of targets ) { + target.addEventListener( 'click', handleClick ); + } + return () => { + isActive = false; + for ( const target of targets ) { + target.removeEventListener( 'click', handleClick ); + } + clearTimeout( timeoutId ); + }; + }, [ ref, text, timeout ] ); + + return hasCopied; +} diff --git a/packages/compose/src/hooks/use-copy-on-click/test/index.tsx b/packages/compose/src/hooks/use-copy-on-click/test/index.tsx new file mode 100644 index 00000000000000..250730fd2ba102 --- /dev/null +++ b/packages/compose/src/hooks/use-copy-on-click/test/index.tsx @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; +import { useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useCopyOnClick from '../'; + +jest.mock( '@wordpress/deprecated' ); + +interface TestComponentProps { + text: string | ( () => string ); + timeout?: number; +} + +describe( 'useCopyOnClick', () => { + const TestComponent = ( { text, timeout = 4000 }: TestComponentProps ) => { + const ref = useRef< HTMLButtonElement >( null ); + const hasCopied = useCopyOnClick( ref, text, timeout ); + return ( + + ); + }; + + it( 'should call deprecated when the hook is used', () => { + jest.mocked( deprecated ).mockClear(); + render( ); + + expect( deprecated ).toHaveBeenCalledWith( + 'wp.compose.useCopyOnClick', + { + since: '5.8', + alternative: 'wp.compose.useCopyToClipboard', + } + ); + } ); + + it( 'should copy text on click', async () => { + const user = userEvent.setup(); + render( ); + + const writeTextMock = jest + .spyOn( navigator.clipboard, 'writeText' ) + .mockResolvedValue(); + + await user.click( screen.getByRole( 'button' ) ); + + expect( writeTextMock ).toHaveBeenCalledTimes( 1 ); + expect( writeTextMock ).toHaveBeenCalledWith( 'test text' ); + } ); + + it( 'should set hasCopied to true when copy succeeds', async () => { + const user = userEvent.setup(); + render( ); + + jest.spyOn( navigator.clipboard, 'writeText' ).mockResolvedValue(); + + expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copy' ); + + await user.click( screen.getByRole( 'button' ) ); + + await act( async () => { + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + } ); + + expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copied!' ); + } ); + + it( 'should reset hasCopied after timeout', async () => { + jest.useFakeTimers(); + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + render( ); + + jest.spyOn( navigator.clipboard, 'writeText' ).mockResolvedValue(); + + await user.click( screen.getByRole( 'button' ) ); + + await act( async () => { + const p = new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + jest.advanceTimersByTime( 0 ); + await p; + } ); + expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copied!' ); + + act( () => { + jest.advanceTimersByTime( 1000 ); + } ); + expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copy' ); + + jest.useRealTimers(); + } ); + + it( 'should not set hasCopied when copy fails', async () => { + const user = userEvent.setup(); + render( ); + + jest.spyOn( navigator.clipboard, 'writeText' ).mockRejectedValue( + new Error() + ); + + await user.click( screen.getByRole( 'button' ) ); + + await act( async () => { + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + } ); + + expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copy' ); + } ); + + it( 'should not update hasCopied after unmount', async () => { + const renderSpy = jest.fn(); + + const SpyComponent = ( { text }: { text: string } ) => { + const ref = useRef< HTMLButtonElement >( null ); + const hasCopied = useCopyOnClick( ref, text ); + renderSpy( hasCopied ); + return ( + + ); + }; + + let resolvePromise: () => void; + const delayedPromise = new Promise< void >( ( resolve ) => { + resolvePromise = resolve; + } ); + jest.spyOn( navigator.clipboard, 'writeText' ).mockReturnValue( + delayedPromise as Promise< void > + ); + + const user = userEvent.setup(); + const { unmount } = render( ); + + expect( renderSpy ).toHaveBeenLastCalledWith( false ); + const renderCountBeforeUnmount = renderSpy.mock.calls.length; + + await user.click( screen.getByRole( 'button' ) ); + unmount(); + + await act( async () => { + resolvePromise(); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + } ); + + // No additional renders after unmount — setHasCopied(true) was not called. + expect( renderSpy ).toHaveBeenCalledTimes( renderCountBeforeUnmount ); + } ); +} ); diff --git a/packages/compose/src/hooks/use-copy-to-clipboard/index.js b/packages/compose/src/hooks/use-copy-to-clipboard/index.js deleted file mode 100644 index c2f380d4103943..00000000000000 --- a/packages/compose/src/hooks/use-copy-to-clipboard/index.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * External dependencies - */ -import Clipboard from 'clipboard'; - -/** - * WordPress dependencies - */ -import { useRef, useLayoutEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import useRefEffect from '../use-ref-effect'; - -/** - * @template T - * @param {T} value - * @return {React.RefObject} The updated ref - */ -function useUpdatedRef( value ) { - const ref = useRef( value ); - useLayoutEffect( () => { - ref.current = value; - }, [ value ] ); - return ref; -} - -/** - * Copies the given text to the clipboard when the element is clicked. - * - * @template {HTMLElement} TElementType - * @param {string | (() => string)} text The text to copy. Use a function if not - * already available and expensive to compute. - * @param {Function} onSuccess Called when to text is copied. - * - * @return {React.Ref} A ref to assign to the target element. - */ -export default function useCopyToClipboard( text, onSuccess ) { - // Store the dependencies as refs and continuously update them so they're - // fresh when the callback is called. - const textRef = useUpdatedRef( text ); - const onSuccessRef = useUpdatedRef( onSuccess ); - return useRefEffect( ( node ) => { - // Clipboard listens to click events. - const clipboard = new Clipboard( node, { - text() { - return typeof textRef.current === 'function' - ? textRef.current() - : textRef.current || ''; - }, - } ); - - clipboard.on( 'success', ( { clearSelection } ) => { - // Clearing selection will move focus back to the triggering - // button, ensuring that it is not reset to the body, and - // further that it is kept within the rendered node. - clearSelection(); - - if ( onSuccessRef.current ) { - onSuccessRef.current(); - } - } ); - - return () => { - clipboard.destroy(); - }; - }, [] ); -} diff --git a/packages/compose/src/hooks/use-copy-to-clipboard/index.ts b/packages/compose/src/hooks/use-copy-to-clipboard/index.ts new file mode 100644 index 00000000000000..4c10c5764455ea --- /dev/null +++ b/packages/compose/src/hooks/use-copy-to-clipboard/index.ts @@ -0,0 +1,120 @@ +/** + * WordPress dependencies + */ +import { useRef, useLayoutEffect } from '@wordpress/element'; +import type { MutableRefObject, RefCallback } from 'react'; + +/** + * Internal dependencies + */ +import useRefEffect from '../use-ref-effect'; + +/** + * Copies text to the clipboard using the Clipboard API when available, + * with a fallback for non-secure contexts (e.g. HTTP) and older browsers. + * + * @param text The text to copy. + * @param trigger The element that triggered the copy. + * @return Resolves to true if successful, false otherwise. + */ +export async function copyToClipboard( + text: string, + trigger: Element | null +): Promise< boolean > { + if ( ! trigger ) { + return false; + } + const { ownerDocument } = trigger; + if ( ! ownerDocument ) { + return false; + } + const { defaultView } = ownerDocument; + try { + if ( defaultView?.navigator?.clipboard?.writeText ) { + await defaultView.navigator.clipboard.writeText( text ); + return true; + } + // Fallback for non-secure contexts (HTTP) and older browsers. + const textarea = ownerDocument.createElement( 'textarea' ); + textarea.value = text; + textarea.setAttribute( 'readonly', '' ); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + ownerDocument.body.appendChild( textarea ); + textarea.select(); + const success = ownerDocument.execCommand( 'copy' ); + textarea.remove(); + return success; + } catch { + return false; + } +} + +/** + * Clears the current selection and restores focus to the trigger element. + * + * @param trigger The element that triggered the copy. + */ +export function clearSelection( trigger: Element ): void { + if ( 'focus' in trigger && typeof trigger.focus === 'function' ) { + trigger.focus(); + } + trigger.ownerDocument?.defaultView?.getSelection()?.removeAllRanges(); +} + +/** + * @template T + * @param value + * @return A ref to assign to the target element. + */ +function useUpdatedRef< T >( value: T ): MutableRefObject< T > { + const ref = useRef< T >( value ); + useLayoutEffect( () => { + ref.current = value; + }, [ value ] ); + return ref; +} + +/** + * Copies the given text to the clipboard when the element is clicked. + * + * @template T + * @param text The text to copy. Use a function if not + * already available and expensive to compute. + * @param onSuccess Called when to text is copied. + * + * @return A ref to assign to the target element. + */ +export default function useCopyToClipboard< T extends HTMLElement >( + text: string | ( () => string ), + onSuccess?: () => void +): RefCallback< T > { + const textRef = useUpdatedRef( text ); + const onSuccessRef = useUpdatedRef( onSuccess ); + return useRefEffect( ( node ) => { + // Flag to prevent callbacks after unmount when the Promise resolves. + let isActive = true; + const handleClick = async () => { + const textToCopy = + typeof textRef.current === 'function' + ? textRef.current() + : textRef.current || ''; + const success = await copyToClipboard( textToCopy, node ); + if ( ! isActive ) { + return; + } + if ( success ) { + clearSelection( node ); + if ( onSuccessRef.current ) { + onSuccessRef.current(); + } + } + }; + node.addEventListener( 'click', handleClick ); + return () => { + isActive = false; + node.removeEventListener( 'click', handleClick ); + }; + }, [] ); +} diff --git a/packages/compose/src/hooks/use-copy-to-clipboard/test/index.tsx b/packages/compose/src/hooks/use-copy-to-clipboard/test/index.tsx new file mode 100644 index 00000000000000..bfc972653fe4c1 --- /dev/null +++ b/packages/compose/src/hooks/use-copy-to-clipboard/test/index.tsx @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import useCopyToClipboard, { copyToClipboard, clearSelection } from '../'; + +interface TestComponentProps { + text: string | ( () => string ); + onSuccess?: () => void; +} + +describe( 'useCopyToClipboard', () => { + const TestComponent = ( { text, onSuccess }: TestComponentProps ) => { + const ref = useCopyToClipboard( text, onSuccess ); + return ; + }; + + it( 'should copy text on click', async () => { + const user = userEvent.setup(); + render( ); + + const writeTextMock = jest + .spyOn( navigator.clipboard, 'writeText' ) + .mockResolvedValue(); + + await user.click( screen.getByRole( 'button' ) ); + + expect( writeTextMock ).toHaveBeenCalledTimes( 1 ); + expect( writeTextMock ).toHaveBeenCalledWith( 'test text' ); + } ); + + it( 'should call onSuccess when copy succeeds', async () => { + const user = userEvent.setup(); + const onSuccess = jest.fn(); + render( ); + + await user.click( screen.getByRole( 'button' ) ); + + expect( onSuccess ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should call onSuccess when copy empty text', async () => { + const user = userEvent.setup(); + const onSuccess = jest.fn(); + render( ); + + await user.click( screen.getByRole( 'button' ) ); + + expect( onSuccess ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should not call onSuccess when copy fails', async () => { + const user = userEvent.setup(); + const onSuccess = jest.fn(); + render( ); + + jest.spyOn( navigator.clipboard, 'writeText' ).mockRejectedValue( + new Error() + ); + + await user.click( screen.getByRole( 'button' ) ); + + expect( onSuccess ).not.toHaveBeenCalled(); + } ); + + it( 'should not call onSuccess after unmount', async () => { + let resolvePromise: () => void; + const delayedPromise = new Promise< void >( ( resolve ) => { + resolvePromise = resolve; + } ); + jest.spyOn( navigator.clipboard, 'writeText' ).mockReturnValue( + delayedPromise + ); + + const user = userEvent.setup(); + const onSuccess = jest.fn(); + const { unmount } = render( + + ); + + await user.click( screen.getByRole( 'button' ) ); + unmount(); + + await act( async () => { + resolvePromise(); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + } ); + + expect( onSuccess ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'copyToClipboard', () => { + it( 'should use execCommand fallback when clipboard API is not available', async () => { + const trigger = document.createElement( 'button' ); + document.body.appendChild( trigger ); + + const originalClipboard = navigator.clipboard; + Object.defineProperty( navigator, 'clipboard', { + value: undefined, + configurable: true, + } ); + + // JSDOM does not implement execCommand; add a mock for the fallback path. + // See: https://github.com/jsdom/jsdom/issues/1742 + const execCommandMock = jest.fn().mockReturnValue( true ); + Object.defineProperty( document, 'execCommand', { + value: execCommandMock, + configurable: true, + writable: true, + } ); + + const result = await copyToClipboard( 'fallback text', trigger ); + + expect( result ).toBe( true ); + expect( execCommandMock ).toHaveBeenCalledWith( 'copy' ); + + delete ( document as { execCommand?: unknown } ).execCommand; + Object.defineProperty( navigator, 'clipboard', { + value: originalClipboard, + configurable: true, + } ); + document.body.removeChild( trigger ); + } ); +} ); + +describe( 'clearSelection', () => { + it( 'should focus the trigger element', () => { + const trigger = document.createElement( 'button' ); + document.body.appendChild( trigger ); + const focusMock = jest.spyOn( trigger, 'focus' ); + + clearSelection( trigger ); + + expect( focusMock ).toHaveBeenCalledTimes( 1 ); + + document.body.removeChild( trigger ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/clipboard.spec.js b/test/e2e/specs/editor/various/clipboard.spec.js new file mode 100644 index 00000000000000..5176546fc4685a --- /dev/null +++ b/test/e2e/specs/editor/various/clipboard.spec.js @@ -0,0 +1,68 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Clipboard (useCopyToClipboard)', () => { + test.beforeEach( async ( { admin, page } ) => { + await admin.createNewPost(); + + await page.evaluate( () => { + const registerBlockType = window.wp.blocks.registerBlockType; + const { createElement, useState } = window.wp.element; + const { useBlockProps } = window.wp.blockEditor; + const { useCopyToClipboard } = window.wp.compose; + + registerBlockType( 'test/copy-button-block', { + apiVersion: 3, + title: 'Copy Button Block', + category: 'text', + edit: function Edit() { + const [ hasCopied, setHasCopied ] = useState( false ); + const ref = useCopyToClipboard( 'Test Text', () => { + setHasCopied( true ); + } ); + return createElement( + 'div', + useBlockProps(), + createElement( + 'button', + { + ref, + type: 'button', + }, + hasCopied ? 'Copied!' : 'Copy' + ) + ); + }, + save: () => null, + } ); + } ); + } ); + + test( 'should copy text to clipboard and show Copied when copy button is clicked', async ( { + editor, + page, + context, + } ) => { + await context.grantPermissions( [ + 'clipboard-read', + 'clipboard-write', + ] ); + + await editor.insertBlock( { name: 'test/copy-button-block' } ); + + const copyButton = editor.canvas + .getByRole( 'document', { + name: 'Block: Copy Button Block', + } ) + .getByRole( 'button' ); + await copyButton.click(); + await expect( copyButton ).toHaveText( 'Copied!' ); + + const clipboardText = await page.evaluate( async () => { + return await window.navigator.clipboard.readText(); + } ); + expect( clipboardText ).toBe( 'Test Text' ); + } ); +} );