@wordpress/compose 7.40.1-next.v.202602271551.0 → 7.40.1
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/CHANGELOG.md +4 -0
- package/README.md +11 -9
- 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/hooks/use-dialog/index.cjs +2 -2
- package/build/hooks/use-dialog/index.cjs.map +3 -3
- package/build/hooks/use-focus-on-mount/index.cjs +15 -13
- package/build/hooks/use-focus-on-mount/index.cjs.map +3 -3
- package/build/hooks/use-media-query/index.cjs +12 -9
- package/build/hooks/use-media-query/index.cjs.map +3 -3
- package/build/hooks/use-viewport-match/index.cjs +2 -2
- package/build/hooks/use-viewport-match/index.cjs.map +2 -2
- package/build/index.cjs +2 -2
- package/build/index.cjs.map +2 -2
- 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-module/hooks/use-dialog/index.mjs +1 -1
- package/build-module/hooks/use-dialog/index.mjs.map +2 -2
- package/build-module/hooks/use-focus-on-mount/index.mjs +15 -9
- package/build-module/hooks/use-focus-on-mount/index.mjs.map +3 -3
- package/build-module/hooks/use-media-query/index.mjs +12 -9
- package/build-module/hooks/use-media-query/index.mjs.map +3 -3
- package/build-module/hooks/use-viewport-match/index.mjs +2 -2
- package/build-module/hooks/use-viewport-match/index.mjs.map +2 -2
- package/build-module/index.mjs +46 -46
- package/build-module/index.mjs.map +2 -2
- 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-dialog/index.d.ts +6 -9
- package/build-types/hooks/use-dialog/index.d.ts.map +1 -1
- package/build-types/hooks/use-focus-on-mount/index.d.ts +4 -10
- package/build-types/hooks/use-focus-on-mount/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/build-types/hooks/use-media-query/index.d.ts +4 -3
- package/build-types/hooks/use-media-query/index.d.ts.map +1 -1
- package/build-types/hooks/use-viewport-match/index.d.ts +2 -1
- package/build-types/hooks/use-viewport-match/index.d.ts.map +1 -1
- package/build-types/index.d.ts +1 -1
- package/build-types/lock-unlock.d.ts +2 -0
- package/build-types/lock-unlock.d.ts.map +1 -0
- package/build-types/private-apis.d.ts +5 -0
- package/build-types/private-apis.d.ts.map +1 -0
- package/build-types/utils/subscribe-delegated-listener/index.d.ts +27 -0
- package/build-types/utils/subscribe-delegated-listener/index.d.ts.map +1 -0
- 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-dialog/index.ts +6 -9
- package/src/hooks/use-focus-on-mount/{index.ts → index.js} +34 -23
- package/src/hooks/use-media-query/{index.js → index.ts} +27 -16
- package/src/hooks/use-viewport-match/index.js +3 -2
- package/src/hooks/use-viewport-match/test/index.js +32 -10
- package/src/index.js +1 -1
- package/src/hooks/use-copy-on-click/index.js +0 -75
- package/src/hooks/use-copy-to-clipboard/index.js +0 -69
|
@@ -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
|
+
} );
|
|
@@ -13,24 +13,21 @@ import { ESCAPE } from '@wordpress/keycodes';
|
|
|
13
13
|
* Internal dependencies
|
|
14
14
|
*/
|
|
15
15
|
import useConstrainedTabbing from '../use-constrained-tabbing';
|
|
16
|
-
import
|
|
16
|
+
import useFocusOnMount from '../use-focus-on-mount';
|
|
17
17
|
import useFocusReturn from '../use-focus-return';
|
|
18
18
|
import useFocusOutside from '../use-focus-outside';
|
|
19
19
|
import useMergeRefs from '../use-merge-refs';
|
|
20
20
|
|
|
21
21
|
type DialogOptions = {
|
|
22
22
|
/**
|
|
23
|
-
* Determines focus
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* - `true` focuses the element itself.
|
|
28
|
-
* - `false` does nothing and _should not be used unless an accessible
|
|
29
|
-
* substitute behavior is implemented_.
|
|
23
|
+
* Determines whether focus should be automatically moved to the popover
|
|
24
|
+
* when it mounts. `false` causes no focus shift, `true` causes the popover
|
|
25
|
+
* itself to gain focus, and `firstElement` focuses the first focusable
|
|
26
|
+
* element within the popover.
|
|
30
27
|
*
|
|
31
28
|
* @default 'firstElement'
|
|
32
29
|
*/
|
|
33
|
-
focusOnMount?: useFocusOnMount
|
|
30
|
+
focusOnMount?: Parameters< typeof useFocusOnMount >[ 0 ];
|
|
34
31
|
/**
|
|
35
32
|
* Determines whether tabbing is constrained to within the popover,
|
|
36
33
|
* preventing keyboard focus from leaving the popover content without
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useRef, useEffect } from '@wordpress/element';
|
|
1
5
|
import { focus } from '@wordpress/dom';
|
|
2
|
-
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies
|
|
9
|
+
*/
|
|
3
10
|
import useRefEffect from '../use-ref-effect';
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
|
-
*
|
|
13
|
+
* Hook used to focus the first tabbable element on mount.
|
|
7
14
|
*
|
|
8
|
-
* @param
|
|
9
|
-
*
|
|
10
|
-
* first value control within; `true` focuses the element itself;
|
|
11
|
-
* `false` does nothing.
|
|
12
|
-
* @return Ref callback.
|
|
15
|
+
* @param {boolean | 'firstElement' | 'firstInputElement'} focusOnMount Focus on mount mode.
|
|
16
|
+
* @return {React.RefCallback<HTMLElement>} Ref callback.
|
|
13
17
|
*
|
|
14
18
|
* @example
|
|
15
19
|
* ```js
|
|
@@ -26,17 +30,16 @@ import useRefEffect from '../use-ref-effect';
|
|
|
26
30
|
* }
|
|
27
31
|
* ```
|
|
28
32
|
*/
|
|
29
|
-
export function useFocusOnMount(
|
|
30
|
-
focusOnMount: useFocusOnMount.Mode = 'firstElement'
|
|
31
|
-
) {
|
|
33
|
+
export default function useFocusOnMount( focusOnMount = 'firstElement' ) {
|
|
32
34
|
const focusOnMountRef = useRef( focusOnMount );
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* Sets focus on a DOM element.
|
|
36
38
|
*
|
|
37
|
-
* @param target The DOM element to set focus to.
|
|
39
|
+
* @param {HTMLElement} target The DOM element to set focus to.
|
|
40
|
+
* @return {void}
|
|
38
41
|
*/
|
|
39
|
-
const setFocus = ( target
|
|
42
|
+
const setFocus = ( target ) => {
|
|
40
43
|
target.focus( {
|
|
41
44
|
// When focusing newly mounted dialogs,
|
|
42
45
|
// the position of the popover is often not right on the first render
|
|
@@ -45,12 +48,15 @@ export function useFocusOnMount(
|
|
|
45
48
|
} );
|
|
46
49
|
};
|
|
47
50
|
|
|
51
|
+
/** @type {React.MutableRefObject<ReturnType<setTimeout> | undefined>} */
|
|
52
|
+
const timerIdRef = useRef( undefined );
|
|
53
|
+
|
|
48
54
|
useEffect( () => {
|
|
49
55
|
focusOnMountRef.current = focusOnMount;
|
|
50
56
|
}, [ focusOnMount ] );
|
|
51
57
|
|
|
52
|
-
return useRefEffect
|
|
53
|
-
if ( focusOnMountRef.current === false ) {
|
|
58
|
+
return useRefEffect( ( node ) => {
|
|
59
|
+
if ( ! node || focusOnMountRef.current === false ) {
|
|
54
60
|
return;
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -66,12 +72,19 @@ export function useFocusOnMount(
|
|
|
66
72
|
return;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
|
|
75
|
+
timerIdRef.current = setTimeout( () => {
|
|
70
76
|
// For 'firstInputElement' mode, try to find a form input element first
|
|
71
77
|
if ( focusOnMountRef.current === 'firstInputElement' ) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
/** @type {HTMLElement | null} */
|
|
79
|
+
let formInput = null;
|
|
80
|
+
if (
|
|
81
|
+
typeof window !== 'undefined' &&
|
|
82
|
+
node instanceof window.Element
|
|
83
|
+
) {
|
|
84
|
+
formInput = node.querySelector(
|
|
85
|
+
'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
75
88
|
|
|
76
89
|
if ( formInput ) {
|
|
77
90
|
setFocus( formInput );
|
|
@@ -87,11 +100,9 @@ export function useFocusOnMount(
|
|
|
87
100
|
}, 0 );
|
|
88
101
|
|
|
89
102
|
return () => {
|
|
90
|
-
|
|
103
|
+
if ( timerIdRef.current ) {
|
|
104
|
+
clearTimeout( timerIdRef.current );
|
|
105
|
+
}
|
|
91
106
|
};
|
|
92
107
|
}, [] );
|
|
93
108
|
}
|
|
94
|
-
|
|
95
|
-
export namespace useFocusOnMount {
|
|
96
|
-
export type Mode = boolean | 'firstElement' | 'firstInputElement';
|
|
97
|
-
}
|
|
@@ -3,30 +3,38 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { useMemo, useSyncExternalStore } from '@wordpress/element';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
type MQLCache = Map< string, MediaQueryList >;
|
|
7
|
+
|
|
8
|
+
const perWindowCache = new WeakMap< Window, MQLCache >();
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* A new MediaQueryList object for the media query
|
|
10
12
|
*
|
|
11
|
-
* @param
|
|
12
|
-
* @
|
|
13
|
+
* @param view Window.
|
|
14
|
+
* @param [query] Media Query.
|
|
13
15
|
*/
|
|
14
|
-
function getMediaQueryList(
|
|
16
|
+
function getMediaQueryList(
|
|
17
|
+
view: Window,
|
|
18
|
+
query?: string
|
|
19
|
+
): MediaQueryList | null {
|
|
15
20
|
if ( ! query ) {
|
|
16
21
|
return null;
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
const matchMediaCache: MQLCache = perWindowCache.get( view ) ?? new Map();
|
|
25
|
+
|
|
26
|
+
if ( ! perWindowCache.has( view ) ) {
|
|
27
|
+
perWindowCache.set( view, matchMediaCache );
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
let match = matchMediaCache.get( query );
|
|
20
31
|
|
|
21
32
|
if ( match ) {
|
|
22
33
|
return match;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
typeof window.matchMedia === 'function'
|
|
28
|
-
) {
|
|
29
|
-
match = window.matchMedia( query );
|
|
36
|
+
if ( typeof view?.matchMedia === 'function' ) {
|
|
37
|
+
match = view.matchMedia( query );
|
|
30
38
|
matchMediaCache.set( query, match );
|
|
31
39
|
return match;
|
|
32
40
|
}
|
|
@@ -37,16 +45,19 @@ function getMediaQueryList( query ) {
|
|
|
37
45
|
/**
|
|
38
46
|
* Runs a media query and returns its value when it changes.
|
|
39
47
|
*
|
|
40
|
-
* @param
|
|
41
|
-
* @
|
|
48
|
+
* @param [query] Media Query.
|
|
49
|
+
* @param [view] Window instance, else default to global window
|
|
50
|
+
* @return return value of the media query.
|
|
42
51
|
*/
|
|
43
|
-
export default function useMediaQuery(
|
|
52
|
+
export default function useMediaQuery(
|
|
53
|
+
query?: string,
|
|
54
|
+
view: Window = window
|
|
55
|
+
): boolean {
|
|
44
56
|
const source = useMemo( () => {
|
|
45
|
-
const mediaQueryList = getMediaQueryList( query );
|
|
57
|
+
const mediaQueryList = getMediaQueryList( view, query );
|
|
46
58
|
|
|
47
59
|
return {
|
|
48
|
-
|
|
49
|
-
subscribe( onStoreChange ) {
|
|
60
|
+
subscribe( onStoreChange: any ) {
|
|
50
61
|
if ( ! mediaQueryList ) {
|
|
51
62
|
return () => {};
|
|
52
63
|
}
|
|
@@ -64,7 +75,7 @@ export default function useMediaQuery( query ) {
|
|
|
64
75
|
return mediaQueryList?.matches ?? false;
|
|
65
76
|
},
|
|
66
77
|
};
|
|
67
|
-
}, [ query ] );
|
|
78
|
+
}, [ view, query ] );
|
|
68
79
|
|
|
69
80
|
return useSyncExternalStore(
|
|
70
81
|
source.subscribe,
|
|
@@ -64,6 +64,7 @@ ViewportMatchWidthContext.displayName = 'ViewportMatchWidthContext';
|
|
|
64
64
|
*
|
|
65
65
|
* @param {WPBreakpoint} breakpoint Breakpoint size name.
|
|
66
66
|
* @param {WPViewportOperator} [operator=">="] Viewport operator.
|
|
67
|
+
* @param {Window} [view=window] Window instance in which to perform viewport matching.
|
|
67
68
|
*
|
|
68
69
|
* @example
|
|
69
70
|
*
|
|
@@ -74,12 +75,12 @@ ViewportMatchWidthContext.displayName = 'ViewportMatchWidthContext';
|
|
|
74
75
|
*
|
|
75
76
|
* @return {boolean} Whether viewport matches query.
|
|
76
77
|
*/
|
|
77
|
-
const useViewportMatch = ( breakpoint, operator = '>=' ) => {
|
|
78
|
+
const useViewportMatch = ( breakpoint, operator = '>=', view = window ) => {
|
|
78
79
|
const simulatedWidth = useContext( ViewportMatchWidthContext );
|
|
79
80
|
const mediaQuery =
|
|
80
81
|
! simulatedWidth &&
|
|
81
82
|
`(${ CONDITIONS[ operator ] }: ${ BREAKPOINTS[ breakpoint ] }px)`;
|
|
82
|
-
const mediaQueryResult = useMediaQuery( mediaQuery || undefined );
|
|
83
|
+
const mediaQueryResult = useMediaQuery( mediaQuery || undefined, view );
|
|
83
84
|
if ( simulatedWidth ) {
|
|
84
85
|
return OPERATOR_EVALUATORS[ operator ](
|
|
85
86
|
BREAKPOINTS[ breakpoint ],
|