@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +11 -9
  3. package/build/hooks/use-copy-on-click/index.cjs +43 -18
  4. package/build/hooks/use-copy-on-click/index.cjs.map +4 -4
  5. package/build/hooks/use-copy-to-clipboard/index.cjs +58 -12
  6. package/build/hooks/use-copy-to-clipboard/index.cjs.map +4 -4
  7. package/build/hooks/use-dialog/index.cjs +2 -2
  8. package/build/hooks/use-dialog/index.cjs.map +3 -3
  9. package/build/hooks/use-focus-on-mount/index.cjs +15 -13
  10. package/build/hooks/use-focus-on-mount/index.cjs.map +3 -3
  11. package/build/hooks/use-media-query/index.cjs +12 -9
  12. package/build/hooks/use-media-query/index.cjs.map +3 -3
  13. package/build/hooks/use-viewport-match/index.cjs +2 -2
  14. package/build/hooks/use-viewport-match/index.cjs.map +2 -2
  15. package/build/index.cjs +2 -2
  16. package/build/index.cjs.map +2 -2
  17. package/build-module/hooks/use-copy-on-click/index.mjs +44 -19
  18. package/build-module/hooks/use-copy-on-click/index.mjs.map +3 -3
  19. package/build-module/hooks/use-copy-to-clipboard/index.mjs +53 -12
  20. package/build-module/hooks/use-copy-to-clipboard/index.mjs.map +3 -3
  21. package/build-module/hooks/use-dialog/index.mjs +1 -1
  22. package/build-module/hooks/use-dialog/index.mjs.map +2 -2
  23. package/build-module/hooks/use-focus-on-mount/index.mjs +15 -9
  24. package/build-module/hooks/use-focus-on-mount/index.mjs.map +3 -3
  25. package/build-module/hooks/use-media-query/index.mjs +12 -9
  26. package/build-module/hooks/use-media-query/index.mjs.map +3 -3
  27. package/build-module/hooks/use-viewport-match/index.mjs +2 -2
  28. package/build-module/hooks/use-viewport-match/index.mjs.map +2 -2
  29. package/build-module/index.mjs +46 -46
  30. package/build-module/index.mjs.map +2 -2
  31. package/build-types/hooks/use-copy-on-click/index.d.ts +8 -9
  32. package/build-types/hooks/use-copy-on-click/index.d.ts.map +1 -1
  33. package/build-types/hooks/use-copy-to-clipboard/index.d.ts +22 -6
  34. package/build-types/hooks/use-copy-to-clipboard/index.d.ts.map +1 -1
  35. package/build-types/hooks/use-dialog/index.d.ts +6 -9
  36. package/build-types/hooks/use-dialog/index.d.ts.map +1 -1
  37. package/build-types/hooks/use-focus-on-mount/index.d.ts +4 -10
  38. package/build-types/hooks/use-focus-on-mount/index.d.ts.map +1 -1
  39. package/build-types/hooks/use-isomorphic-layout-effect/index.d.ts +2 -2
  40. package/build-types/hooks/use-isomorphic-layout-effect/index.d.ts.map +1 -1
  41. package/build-types/hooks/use-media-query/index.d.ts +4 -3
  42. package/build-types/hooks/use-media-query/index.d.ts.map +1 -1
  43. package/build-types/hooks/use-viewport-match/index.d.ts +2 -1
  44. package/build-types/hooks/use-viewport-match/index.d.ts.map +1 -1
  45. package/build-types/index.d.ts +1 -1
  46. package/build-types/lock-unlock.d.ts +2 -0
  47. package/build-types/lock-unlock.d.ts.map +1 -0
  48. package/build-types/private-apis.d.ts +5 -0
  49. package/build-types/private-apis.d.ts.map +1 -0
  50. package/build-types/utils/subscribe-delegated-listener/index.d.ts +27 -0
  51. package/build-types/utils/subscribe-delegated-listener/index.d.ts.map +1 -0
  52. package/package.json +9 -10
  53. package/src/hooks/use-copy-on-click/index.ts +101 -0
  54. package/src/hooks/use-copy-on-click/test/index.tsx +162 -0
  55. package/src/hooks/use-copy-to-clipboard/index.ts +120 -0
  56. package/src/hooks/use-copy-to-clipboard/test/index.tsx +144 -0
  57. package/src/hooks/use-dialog/index.ts +6 -9
  58. package/src/hooks/use-focus-on-mount/{index.ts → index.js} +34 -23
  59. package/src/hooks/use-media-query/{index.js → index.ts} +27 -16
  60. package/src/hooks/use-viewport-match/index.js +3 -2
  61. package/src/hooks/use-viewport-match/test/index.js +32 -10
  62. package/src/index.js +1 -1
  63. package/src/hooks/use-copy-on-click/index.js +0 -75
  64. 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 { useFocusOnMount } from '../use-focus-on-mount';
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 behavior when the dialog mounts.
24
- *
25
- * - `"firstElement"` focuses the first tabbable element within.
26
- * - `"firstInputElement"` focuses the first value control within.
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.Mode;
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
- import { useEffect, useRef } from '@wordpress/element';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
3
10
  import useRefEffect from '../use-ref-effect';
4
11
 
5
12
  /**
6
- * Determines focus behavior when the element mounts.
13
+ * Hook used to focus the first tabbable element on mount.
7
14
  *
8
- * @param focusOnMount Behavioral mode. Defaults to `"firstElement"` which focuses the
9
- * first tabbable element within; `"firstInputElement"` focuses the
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: HTMLElement ): void => {
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< HTMLElement >( ( node ) => {
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
- const timerId = setTimeout( () => {
75
+ timerIdRef.current = setTimeout( () => {
70
76
  // For 'firstInputElement' mode, try to find a form input element first
71
77
  if ( focusOnMountRef.current === 'firstInputElement' ) {
72
- const formInput = node.querySelector< HTMLElement >(
73
- 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])'
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
- clearTimeout( timerId );
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
- const matchMediaCache = new Map();
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 {string} [query] Media Query.
12
- * @return {MediaQueryList|null} A new object for the media query
13
+ * @param view Window.
14
+ * @param [query] Media Query.
13
15
  */
14
- function getMediaQueryList( query ) {
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
- typeof window !== 'undefined' &&
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 {string} [query] Media Query.
41
- * @return {boolean} return value of the media query.
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( query ) {
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
- /** @type {(onStoreChange: () => void) => () => void} */
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 ],