@wordpress/compose 7.41.0 → 7.41.1-next.v.202603161435.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/build/hooks/use-copy-on-click/index.cjs +43 -18
- package/build/hooks/use-copy-on-click/index.cjs.map +4 -4
- package/build/hooks/use-copy-to-clipboard/index.cjs +58 -12
- package/build/hooks/use-copy-to-clipboard/index.cjs.map +4 -4
- package/build-module/hooks/use-copy-on-click/index.mjs +44 -19
- package/build-module/hooks/use-copy-on-click/index.mjs.map +3 -3
- package/build-module/hooks/use-copy-to-clipboard/index.mjs +53 -12
- package/build-module/hooks/use-copy-to-clipboard/index.mjs.map +3 -3
- package/build-types/hooks/use-copy-on-click/index.d.ts +8 -9
- package/build-types/hooks/use-copy-on-click/index.d.ts.map +1 -1
- package/build-types/hooks/use-copy-to-clipboard/index.d.ts +22 -6
- package/build-types/hooks/use-copy-to-clipboard/index.d.ts.map +1 -1
- package/build-types/hooks/use-isomorphic-layout-effect/index.d.ts +2 -2
- package/build-types/hooks/use-isomorphic-layout-effect/index.d.ts.map +1 -1
- package/package.json +9 -10
- package/src/hooks/use-copy-on-click/index.ts +101 -0
- package/src/hooks/use-copy-on-click/test/index.tsx +162 -0
- package/src/hooks/use-copy-to-clipboard/index.ts +120 -0
- package/src/hooks/use-copy-to-clipboard/test/index.tsx +144 -0
- package/src/hooks/use-copy-on-click/index.js +0 -75
- package/src/hooks/use-copy-to-clipboard/index.js +0 -69
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wordpress/compose",
|
|
3
|
-
"version": "7.41.0",
|
|
3
|
+
"version": "7.41.1-next.v.202603161435.0+ab4981c4f",
|
|
4
4
|
"description": "WordPress higher-order components (HOCs).",
|
|
5
5
|
"author": "The WordPress Contributors",
|
|
6
6
|
"license": "GPL-2.0-or-later",
|
|
@@ -47,15 +47,14 @@
|
|
|
47
47
|
"sideEffects": false,
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@types/mousetrap": "^1.6.8",
|
|
50
|
-
"@wordpress/deprecated": "^4.41.0",
|
|
51
|
-
"@wordpress/dom": "^4.41.0",
|
|
52
|
-
"@wordpress/element": "^6.41.0",
|
|
53
|
-
"@wordpress/is-shallow-equal": "^5.41.0",
|
|
54
|
-
"@wordpress/keycodes": "^4.41.0",
|
|
55
|
-
"@wordpress/priority-queue": "^3.41.0",
|
|
56
|
-
"@wordpress/undo-manager": "^1.41.0",
|
|
50
|
+
"@wordpress/deprecated": "^4.41.1-next.v.202603161435.0+ab4981c4f",
|
|
51
|
+
"@wordpress/dom": "^4.41.1-next.v.202603161435.0+ab4981c4f",
|
|
52
|
+
"@wordpress/element": "^6.41.1-next.v.202603161435.0+ab4981c4f",
|
|
53
|
+
"@wordpress/is-shallow-equal": "^5.41.1-next.v.202603161435.0+ab4981c4f",
|
|
54
|
+
"@wordpress/keycodes": "^4.41.1-next.v.202603161435.0+ab4981c4f",
|
|
55
|
+
"@wordpress/priority-queue": "^3.41.1-next.v.202603161435.0+ab4981c4f",
|
|
56
|
+
"@wordpress/undo-manager": "^1.41.1-next.v.202603161435.0+ab4981c4f",
|
|
57
57
|
"change-case": "^4.1.2",
|
|
58
|
-
"clipboard": "^2.0.11",
|
|
59
58
|
"mousetrap": "^1.6.5",
|
|
60
59
|
"use-memo-one": "^1.1.1"
|
|
61
60
|
},
|
|
@@ -68,5 +67,5 @@
|
|
|
68
67
|
"publishConfig": {
|
|
69
68
|
"access": "public"
|
|
70
69
|
},
|
|
71
|
-
"gitHead": "
|
|
70
|
+
"gitHead": "748f4e4564fcc0e6ae90200d90bb993a3cef5828"
|
|
72
71
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useEffect, useState } from '@wordpress/element';
|
|
5
|
+
import deprecated from '@wordpress/deprecated';
|
|
6
|
+
import type { RefObject } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal dependencies
|
|
10
|
+
*/
|
|
11
|
+
import { clearSelection, copyToClipboard } from '../use-copy-to-clipboard';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Copies the text to the clipboard when the element is clicked.
|
|
15
|
+
*
|
|
16
|
+
* @deprecated
|
|
17
|
+
* @param ref Reference with the element.
|
|
18
|
+
* @param text The text to copy.
|
|
19
|
+
* @param timeout Optional timeout to reset the returned
|
|
20
|
+
* state. 4 seconds by default.
|
|
21
|
+
* @return Whether or not the text has been copied. Resets after the
|
|
22
|
+
* timeout.
|
|
23
|
+
*/
|
|
24
|
+
export default function useCopyOnClick(
|
|
25
|
+
ref: RefObject< string | Element | NodeListOf< Element > >,
|
|
26
|
+
text: string | ( () => string ),
|
|
27
|
+
timeout: number = 4000
|
|
28
|
+
): boolean {
|
|
29
|
+
deprecated( 'wp.compose.useCopyOnClick', {
|
|
30
|
+
since: '5.8',
|
|
31
|
+
alternative: 'wp.compose.useCopyToClipboard',
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
const [ hasCopied, setHasCopied ] = useState( false );
|
|
35
|
+
|
|
36
|
+
useEffect( () => {
|
|
37
|
+
// Flag to prevent state updates after unmount when the Promise resolves.
|
|
38
|
+
let isActive = true;
|
|
39
|
+
let timeoutId: ReturnType< typeof setTimeout > | undefined;
|
|
40
|
+
if ( ! ref.current ) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let targets: Element[];
|
|
45
|
+
if ( typeof ref.current === 'string' ) {
|
|
46
|
+
targets =
|
|
47
|
+
typeof document !== 'undefined'
|
|
48
|
+
? Array.from( document.querySelectorAll( ref.current ) )
|
|
49
|
+
: [];
|
|
50
|
+
} else if (
|
|
51
|
+
'length' in ref.current &&
|
|
52
|
+
typeof ref.current.length === 'number'
|
|
53
|
+
) {
|
|
54
|
+
targets = Array.from( ref.current );
|
|
55
|
+
} else {
|
|
56
|
+
targets = [ ref.current as Element ];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if ( targets.length === 0 ) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handleClick = async ( event: Event ) => {
|
|
64
|
+
const trigger = event.currentTarget as Element;
|
|
65
|
+
if ( ! trigger ) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const success = await copyToClipboard(
|
|
69
|
+
typeof text === 'function' ? text() : text || '',
|
|
70
|
+
trigger
|
|
71
|
+
);
|
|
72
|
+
if ( ! isActive ) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if ( success ) {
|
|
76
|
+
clearSelection( trigger );
|
|
77
|
+
if ( timeout ) {
|
|
78
|
+
setHasCopied( true );
|
|
79
|
+
clearTimeout( timeoutId );
|
|
80
|
+
timeoutId = setTimeout(
|
|
81
|
+
() => setHasCopied( false ),
|
|
82
|
+
timeout
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
for ( const target of targets ) {
|
|
89
|
+
target.addEventListener( 'click', handleClick );
|
|
90
|
+
}
|
|
91
|
+
return () => {
|
|
92
|
+
isActive = false;
|
|
93
|
+
for ( const target of targets ) {
|
|
94
|
+
target.removeEventListener( 'click', handleClick );
|
|
95
|
+
}
|
|
96
|
+
clearTimeout( timeoutId );
|
|
97
|
+
};
|
|
98
|
+
}, [ ref, text, timeout ] );
|
|
99
|
+
|
|
100
|
+
return hasCopied;
|
|
101
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* WordPress dependencies
|
|
9
|
+
*/
|
|
10
|
+
import deprecated from '@wordpress/deprecated';
|
|
11
|
+
import { useRef } from '@wordpress/element';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Internal dependencies
|
|
15
|
+
*/
|
|
16
|
+
import useCopyOnClick from '../';
|
|
17
|
+
|
|
18
|
+
jest.mock( '@wordpress/deprecated' );
|
|
19
|
+
|
|
20
|
+
interface TestComponentProps {
|
|
21
|
+
text: string | ( () => string );
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe( 'useCopyOnClick', () => {
|
|
26
|
+
const TestComponent = ( { text, timeout = 4000 }: TestComponentProps ) => {
|
|
27
|
+
const ref = useRef< HTMLButtonElement >( null );
|
|
28
|
+
const hasCopied = useCopyOnClick( ref, text, timeout );
|
|
29
|
+
return (
|
|
30
|
+
<button ref={ ref } type="button">
|
|
31
|
+
{ hasCopied ? 'Copied!' : 'Copy' }
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
it( 'should call deprecated when the hook is used', () => {
|
|
37
|
+
jest.mocked( deprecated ).mockClear();
|
|
38
|
+
render( <TestComponent text="test text" /> );
|
|
39
|
+
|
|
40
|
+
expect( deprecated ).toHaveBeenCalledWith(
|
|
41
|
+
'wp.compose.useCopyOnClick',
|
|
42
|
+
{
|
|
43
|
+
since: '5.8',
|
|
44
|
+
alternative: 'wp.compose.useCopyToClipboard',
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'should copy text on click', async () => {
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
render( <TestComponent text="test text" /> );
|
|
52
|
+
|
|
53
|
+
const writeTextMock = jest
|
|
54
|
+
.spyOn( navigator.clipboard, 'writeText' )
|
|
55
|
+
.mockResolvedValue();
|
|
56
|
+
|
|
57
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
58
|
+
|
|
59
|
+
expect( writeTextMock ).toHaveBeenCalledTimes( 1 );
|
|
60
|
+
expect( writeTextMock ).toHaveBeenCalledWith( 'test text' );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
it( 'should set hasCopied to true when copy succeeds', async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render( <TestComponent text="test text" /> );
|
|
66
|
+
|
|
67
|
+
jest.spyOn( navigator.clipboard, 'writeText' ).mockResolvedValue();
|
|
68
|
+
|
|
69
|
+
expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copy' );
|
|
70
|
+
|
|
71
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
72
|
+
|
|
73
|
+
await act( async () => {
|
|
74
|
+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copied!' );
|
|
78
|
+
} );
|
|
79
|
+
|
|
80
|
+
it( 'should reset hasCopied after timeout', async () => {
|
|
81
|
+
jest.useFakeTimers();
|
|
82
|
+
const user = userEvent.setup( {
|
|
83
|
+
advanceTimers: jest.advanceTimersByTime,
|
|
84
|
+
} );
|
|
85
|
+
render( <TestComponent text="test text" timeout={ 1000 } /> );
|
|
86
|
+
|
|
87
|
+
jest.spyOn( navigator.clipboard, 'writeText' ).mockResolvedValue();
|
|
88
|
+
|
|
89
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
90
|
+
|
|
91
|
+
await act( async () => {
|
|
92
|
+
const p = new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
|
|
93
|
+
jest.advanceTimersByTime( 0 );
|
|
94
|
+
await p;
|
|
95
|
+
} );
|
|
96
|
+
expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copied!' );
|
|
97
|
+
|
|
98
|
+
act( () => {
|
|
99
|
+
jest.advanceTimersByTime( 1000 );
|
|
100
|
+
} );
|
|
101
|
+
expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copy' );
|
|
102
|
+
|
|
103
|
+
jest.useRealTimers();
|
|
104
|
+
} );
|
|
105
|
+
|
|
106
|
+
it( 'should not set hasCopied when copy fails', async () => {
|
|
107
|
+
const user = userEvent.setup();
|
|
108
|
+
render( <TestComponent text="test text" /> );
|
|
109
|
+
|
|
110
|
+
jest.spyOn( navigator.clipboard, 'writeText' ).mockRejectedValue(
|
|
111
|
+
new Error()
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
115
|
+
|
|
116
|
+
await act( async () => {
|
|
117
|
+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
|
|
118
|
+
} );
|
|
119
|
+
|
|
120
|
+
expect( screen.getByRole( 'button' ) ).toHaveTextContent( 'Copy' );
|
|
121
|
+
} );
|
|
122
|
+
|
|
123
|
+
it( 'should not update hasCopied after unmount', async () => {
|
|
124
|
+
const renderSpy = jest.fn();
|
|
125
|
+
|
|
126
|
+
const SpyComponent = ( { text }: { text: string } ) => {
|
|
127
|
+
const ref = useRef< HTMLButtonElement >( null );
|
|
128
|
+
const hasCopied = useCopyOnClick( ref, text );
|
|
129
|
+
renderSpy( hasCopied );
|
|
130
|
+
return (
|
|
131
|
+
<button ref={ ref } type="button">
|
|
132
|
+
{ hasCopied ? 'Copied!' : 'Copy' }
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
let resolvePromise: () => void;
|
|
138
|
+
const delayedPromise = new Promise< void >( ( resolve ) => {
|
|
139
|
+
resolvePromise = resolve;
|
|
140
|
+
} );
|
|
141
|
+
jest.spyOn( navigator.clipboard, 'writeText' ).mockReturnValue(
|
|
142
|
+
delayedPromise as Promise< void >
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const user = userEvent.setup();
|
|
146
|
+
const { unmount } = render( <SpyComponent text="test" /> );
|
|
147
|
+
|
|
148
|
+
expect( renderSpy ).toHaveBeenLastCalledWith( false );
|
|
149
|
+
const renderCountBeforeUnmount = renderSpy.mock.calls.length;
|
|
150
|
+
|
|
151
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
152
|
+
unmount();
|
|
153
|
+
|
|
154
|
+
await act( async () => {
|
|
155
|
+
resolvePromise();
|
|
156
|
+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
|
|
157
|
+
} );
|
|
158
|
+
|
|
159
|
+
// No additional renders after unmount — setHasCopied(true) was not called.
|
|
160
|
+
expect( renderSpy ).toHaveBeenCalledTimes( renderCountBeforeUnmount );
|
|
161
|
+
} );
|
|
162
|
+
} );
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useRef, useLayoutEffect } from '@wordpress/element';
|
|
5
|
+
import type { MutableRefObject, RefCallback } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies
|
|
9
|
+
*/
|
|
10
|
+
import useRefEffect from '../use-ref-effect';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Copies text to the clipboard using the Clipboard API when available,
|
|
14
|
+
* with a fallback for non-secure contexts (e.g. HTTP) and older browsers.
|
|
15
|
+
*
|
|
16
|
+
* @param text The text to copy.
|
|
17
|
+
* @param trigger The element that triggered the copy.
|
|
18
|
+
* @return Resolves to true if successful, false otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export async function copyToClipboard(
|
|
21
|
+
text: string,
|
|
22
|
+
trigger: Element | null
|
|
23
|
+
): Promise< boolean > {
|
|
24
|
+
if ( ! trigger ) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const { ownerDocument } = trigger;
|
|
28
|
+
if ( ! ownerDocument ) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const { defaultView } = ownerDocument;
|
|
32
|
+
try {
|
|
33
|
+
if ( defaultView?.navigator?.clipboard?.writeText ) {
|
|
34
|
+
await defaultView.navigator.clipboard.writeText( text );
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
// Fallback for non-secure contexts (HTTP) and older browsers.
|
|
38
|
+
const textarea = ownerDocument.createElement( 'textarea' );
|
|
39
|
+
textarea.value = text;
|
|
40
|
+
textarea.setAttribute( 'readonly', '' );
|
|
41
|
+
textarea.style.position = 'fixed';
|
|
42
|
+
textarea.style.left = '-9999px';
|
|
43
|
+
textarea.style.top = '-9999px';
|
|
44
|
+
ownerDocument.body.appendChild( textarea );
|
|
45
|
+
textarea.select();
|
|
46
|
+
const success = ownerDocument.execCommand( 'copy' );
|
|
47
|
+
textarea.remove();
|
|
48
|
+
return success;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Clears the current selection and restores focus to the trigger element.
|
|
56
|
+
*
|
|
57
|
+
* @param trigger The element that triggered the copy.
|
|
58
|
+
*/
|
|
59
|
+
export function clearSelection( trigger: Element ): void {
|
|
60
|
+
if ( 'focus' in trigger && typeof trigger.focus === 'function' ) {
|
|
61
|
+
trigger.focus();
|
|
62
|
+
}
|
|
63
|
+
trigger.ownerDocument?.defaultView?.getSelection()?.removeAllRanges();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @template T
|
|
68
|
+
* @param value
|
|
69
|
+
* @return A ref to assign to the target element.
|
|
70
|
+
*/
|
|
71
|
+
function useUpdatedRef< T >( value: T ): MutableRefObject< T > {
|
|
72
|
+
const ref = useRef< T >( value );
|
|
73
|
+
useLayoutEffect( () => {
|
|
74
|
+
ref.current = value;
|
|
75
|
+
}, [ value ] );
|
|
76
|
+
return ref;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Copies the given text to the clipboard when the element is clicked.
|
|
81
|
+
*
|
|
82
|
+
* @template T
|
|
83
|
+
* @param text The text to copy. Use a function if not
|
|
84
|
+
* already available and expensive to compute.
|
|
85
|
+
* @param onSuccess Called when to text is copied.
|
|
86
|
+
*
|
|
87
|
+
* @return A ref to assign to the target element.
|
|
88
|
+
*/
|
|
89
|
+
export default function useCopyToClipboard< T extends HTMLElement >(
|
|
90
|
+
text: string | ( () => string ),
|
|
91
|
+
onSuccess?: () => void
|
|
92
|
+
): RefCallback< T > {
|
|
93
|
+
const textRef = useUpdatedRef( text );
|
|
94
|
+
const onSuccessRef = useUpdatedRef( onSuccess );
|
|
95
|
+
return useRefEffect( ( node ) => {
|
|
96
|
+
// Flag to prevent callbacks after unmount when the Promise resolves.
|
|
97
|
+
let isActive = true;
|
|
98
|
+
const handleClick = async () => {
|
|
99
|
+
const textToCopy =
|
|
100
|
+
typeof textRef.current === 'function'
|
|
101
|
+
? textRef.current()
|
|
102
|
+
: textRef.current || '';
|
|
103
|
+
const success = await copyToClipboard( textToCopy, node );
|
|
104
|
+
if ( ! isActive ) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if ( success ) {
|
|
108
|
+
clearSelection( node );
|
|
109
|
+
if ( onSuccessRef.current ) {
|
|
110
|
+
onSuccessRef.current();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
node.addEventListener( 'click', handleClick );
|
|
115
|
+
return () => {
|
|
116
|
+
isActive = false;
|
|
117
|
+
node.removeEventListener( 'click', handleClick );
|
|
118
|
+
};
|
|
119
|
+
}, [] );
|
|
120
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies
|
|
9
|
+
*/
|
|
10
|
+
import useCopyToClipboard, { copyToClipboard, clearSelection } from '../';
|
|
11
|
+
|
|
12
|
+
interface TestComponentProps {
|
|
13
|
+
text: string | ( () => string );
|
|
14
|
+
onSuccess?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe( 'useCopyToClipboard', () => {
|
|
18
|
+
const TestComponent = ( { text, onSuccess }: TestComponentProps ) => {
|
|
19
|
+
const ref = useCopyToClipboard( text, onSuccess );
|
|
20
|
+
return <button ref={ ref }>Copy</button>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
it( 'should copy text on click', async () => {
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
render( <TestComponent text="test text" /> );
|
|
26
|
+
|
|
27
|
+
const writeTextMock = jest
|
|
28
|
+
.spyOn( navigator.clipboard, 'writeText' )
|
|
29
|
+
.mockResolvedValue();
|
|
30
|
+
|
|
31
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
32
|
+
|
|
33
|
+
expect( writeTextMock ).toHaveBeenCalledTimes( 1 );
|
|
34
|
+
expect( writeTextMock ).toHaveBeenCalledWith( 'test text' );
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
it( 'should call onSuccess when copy succeeds', async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
const onSuccess = jest.fn();
|
|
40
|
+
render( <TestComponent text="test text" onSuccess={ onSuccess } /> );
|
|
41
|
+
|
|
42
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
43
|
+
|
|
44
|
+
expect( onSuccess ).toHaveBeenCalledTimes( 1 );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'should call onSuccess when copy empty text', async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
const onSuccess = jest.fn();
|
|
50
|
+
render( <TestComponent text="" onSuccess={ onSuccess } /> );
|
|
51
|
+
|
|
52
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
53
|
+
|
|
54
|
+
expect( onSuccess ).toHaveBeenCalledTimes( 1 );
|
|
55
|
+
} );
|
|
56
|
+
|
|
57
|
+
it( 'should not call onSuccess when copy fails', async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
const onSuccess = jest.fn();
|
|
60
|
+
render( <TestComponent text="test text" onSuccess={ onSuccess } /> );
|
|
61
|
+
|
|
62
|
+
jest.spyOn( navigator.clipboard, 'writeText' ).mockRejectedValue(
|
|
63
|
+
new Error()
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
67
|
+
|
|
68
|
+
expect( onSuccess ).not.toHaveBeenCalled();
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'should not call onSuccess after unmount', async () => {
|
|
72
|
+
let resolvePromise: () => void;
|
|
73
|
+
const delayedPromise = new Promise< void >( ( resolve ) => {
|
|
74
|
+
resolvePromise = resolve;
|
|
75
|
+
} );
|
|
76
|
+
jest.spyOn( navigator.clipboard, 'writeText' ).mockReturnValue(
|
|
77
|
+
delayedPromise
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
const onSuccess = jest.fn();
|
|
82
|
+
const { unmount } = render(
|
|
83
|
+
<TestComponent text="test" onSuccess={ onSuccess } />
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
87
|
+
unmount();
|
|
88
|
+
|
|
89
|
+
await act( async () => {
|
|
90
|
+
resolvePromise();
|
|
91
|
+
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
expect( onSuccess ).not.toHaveBeenCalled();
|
|
95
|
+
} );
|
|
96
|
+
} );
|
|
97
|
+
|
|
98
|
+
describe( 'copyToClipboard', () => {
|
|
99
|
+
it( 'should use execCommand fallback when clipboard API is not available', async () => {
|
|
100
|
+
const trigger = document.createElement( 'button' );
|
|
101
|
+
document.body.appendChild( trigger );
|
|
102
|
+
|
|
103
|
+
const originalClipboard = navigator.clipboard;
|
|
104
|
+
Object.defineProperty( navigator, 'clipboard', {
|
|
105
|
+
value: undefined,
|
|
106
|
+
configurable: true,
|
|
107
|
+
} );
|
|
108
|
+
|
|
109
|
+
// JSDOM does not implement execCommand; add a mock for the fallback path.
|
|
110
|
+
// See: https://github.com/jsdom/jsdom/issues/1742
|
|
111
|
+
const execCommandMock = jest.fn().mockReturnValue( true );
|
|
112
|
+
Object.defineProperty( document, 'execCommand', {
|
|
113
|
+
value: execCommandMock,
|
|
114
|
+
configurable: true,
|
|
115
|
+
writable: true,
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
const result = await copyToClipboard( 'fallback text', trigger );
|
|
119
|
+
|
|
120
|
+
expect( result ).toBe( true );
|
|
121
|
+
expect( execCommandMock ).toHaveBeenCalledWith( 'copy' );
|
|
122
|
+
|
|
123
|
+
delete ( document as { execCommand?: unknown } ).execCommand;
|
|
124
|
+
Object.defineProperty( navigator, 'clipboard', {
|
|
125
|
+
value: originalClipboard,
|
|
126
|
+
configurable: true,
|
|
127
|
+
} );
|
|
128
|
+
document.body.removeChild( trigger );
|
|
129
|
+
} );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
describe( 'clearSelection', () => {
|
|
133
|
+
it( 'should focus the trigger element', () => {
|
|
134
|
+
const trigger = document.createElement( 'button' );
|
|
135
|
+
document.body.appendChild( trigger );
|
|
136
|
+
const focusMock = jest.spyOn( trigger, 'focus' );
|
|
137
|
+
|
|
138
|
+
clearSelection( trigger );
|
|
139
|
+
|
|
140
|
+
expect( focusMock ).toHaveBeenCalledTimes( 1 );
|
|
141
|
+
|
|
142
|
+
document.body.removeChild( trigger );
|
|
143
|
+
} );
|
|
144
|
+
} );
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* External dependencies
|
|
3
|
-
*/
|
|
4
|
-
import Clipboard from 'clipboard';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* WordPress dependencies
|
|
8
|
-
*/
|
|
9
|
-
import { useRef, useEffect, useState } from '@wordpress/element';
|
|
10
|
-
import deprecated from '@wordpress/deprecated';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Copies the text to the clipboard when the element is clicked.
|
|
14
|
-
*
|
|
15
|
-
* @deprecated
|
|
16
|
-
*
|
|
17
|
-
* @param {React.RefObject<string | Element | NodeListOf<Element>>} ref Reference with the element.
|
|
18
|
-
* @param {string|Function} text The text to copy.
|
|
19
|
-
* @param {number} [timeout] Optional timeout to reset the returned
|
|
20
|
-
* state. 4 seconds by default.
|
|
21
|
-
*
|
|
22
|
-
* @return {boolean} Whether or not the text has been copied. Resets after the
|
|
23
|
-
* timeout.
|
|
24
|
-
*/
|
|
25
|
-
export default function useCopyOnClick( ref, text, timeout = 4000 ) {
|
|
26
|
-
deprecated( 'wp.compose.useCopyOnClick', {
|
|
27
|
-
since: '5.8',
|
|
28
|
-
alternative: 'wp.compose.useCopyToClipboard',
|
|
29
|
-
} );
|
|
30
|
-
|
|
31
|
-
/** @type {React.MutableRefObject<Clipboard | undefined>} */
|
|
32
|
-
const clipboardRef = useRef( undefined );
|
|
33
|
-
const [ hasCopied, setHasCopied ] = useState( false );
|
|
34
|
-
|
|
35
|
-
useEffect( () => {
|
|
36
|
-
/** @type {number | undefined} */
|
|
37
|
-
let timeoutId;
|
|
38
|
-
|
|
39
|
-
if ( ! ref.current ) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Clipboard listens to click events.
|
|
44
|
-
clipboardRef.current = new Clipboard( ref.current, {
|
|
45
|
-
text: () => ( typeof text === 'function' ? text() : text ),
|
|
46
|
-
} );
|
|
47
|
-
|
|
48
|
-
clipboardRef.current.on( 'success', ( { clearSelection, trigger } ) => {
|
|
49
|
-
// Clearing selection will move focus back to the triggering button,
|
|
50
|
-
// ensuring that it is not reset to the body, and further that it is
|
|
51
|
-
// kept within the rendered node.
|
|
52
|
-
clearSelection();
|
|
53
|
-
|
|
54
|
-
// Handle ClipboardJS focus bug, see https://github.com/zenorocha/clipboard.js/issues/680
|
|
55
|
-
if ( trigger ) {
|
|
56
|
-
/** @type {HTMLElement} */ ( trigger ).focus();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if ( timeout ) {
|
|
60
|
-
setHasCopied( true );
|
|
61
|
-
clearTimeout( timeoutId );
|
|
62
|
-
timeoutId = setTimeout( () => setHasCopied( false ), timeout );
|
|
63
|
-
}
|
|
64
|
-
} );
|
|
65
|
-
|
|
66
|
-
return () => {
|
|
67
|
-
if ( clipboardRef.current ) {
|
|
68
|
-
clipboardRef.current.destroy();
|
|
69
|
-
}
|
|
70
|
-
clearTimeout( timeoutId );
|
|
71
|
-
};
|
|
72
|
-
}, [ text, timeout, setHasCopied ] );
|
|
73
|
-
|
|
74
|
-
return hasCopied;
|
|
75
|
-
}
|