@wordpress/compose 7.46.0 → 8.1.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -0
  3. package/build/hooks/use-copy-on-click/index.cjs +1 -1
  4. package/build/hooks/use-copy-on-click/index.cjs.map +2 -2
  5. package/build/hooks/use-copy-to-clipboard/index.cjs +8 -10
  6. package/build/hooks/use-copy-to-clipboard/index.cjs.map +2 -2
  7. package/build/hooks/use-dialog/index.cjs +8 -13
  8. package/build/hooks/use-dialog/index.cjs.map +2 -2
  9. package/build/hooks/use-media-query/index.cjs +42 -36
  10. package/build/hooks/use-media-query/index.cjs.map +2 -2
  11. package/build/index.cjs +3 -0
  12. package/build/index.cjs.map +2 -2
  13. package/build/lock-unlock.cjs +37 -0
  14. package/build/lock-unlock.cjs.map +7 -0
  15. package/build/private-apis.cjs +46 -0
  16. package/build/private-apis.cjs.map +7 -0
  17. package/build/utils/subscribe-delegated-listener/index.cjs +101 -0
  18. package/build/utils/subscribe-delegated-listener/index.cjs.map +7 -0
  19. package/build-module/hooks/use-copy-on-click/index.mjs +2 -2
  20. package/build-module/hooks/use-copy-on-click/index.mjs.map +2 -2
  21. package/build-module/hooks/use-copy-to-clipboard/index.mjs +6 -8
  22. package/build-module/hooks/use-copy-to-clipboard/index.mjs.map +2 -2
  23. package/build-module/hooks/use-dialog/index.mjs +8 -13
  24. package/build-module/hooks/use-dialog/index.mjs.map +2 -2
  25. package/build-module/hooks/use-media-query/index.mjs +43 -37
  26. package/build-module/hooks/use-media-query/index.mjs.map +2 -2
  27. package/build-module/index.mjs +2 -0
  28. package/build-module/index.mjs.map +2 -2
  29. package/build-module/lock-unlock.mjs +11 -0
  30. package/build-module/lock-unlock.mjs.map +7 -0
  31. package/build-module/private-apis.mjs +11 -0
  32. package/build-module/private-apis.mjs.map +7 -0
  33. package/build-module/utils/subscribe-delegated-listener/index.mjs +80 -0
  34. package/build-module/utils/subscribe-delegated-listener/index.mjs.map +7 -0
  35. package/build-types/hooks/use-copy-to-clipboard/index.d.ts +2 -2
  36. package/build-types/hooks/use-copy-to-clipboard/index.d.ts.map +1 -1
  37. package/build-types/hooks/use-dialog/index.d.ts +8 -2
  38. package/build-types/hooks/use-dialog/index.d.ts.map +1 -1
  39. package/build-types/hooks/use-media-query/index.d.ts.map +1 -1
  40. package/build-types/index.d.ts +1 -0
  41. package/build-types/index.d.ts.map +1 -1
  42. package/build-types/lock-unlock.d.ts +2 -0
  43. package/build-types/lock-unlock.d.ts.map +1 -0
  44. package/build-types/private-apis.d.ts +5 -0
  45. package/build-types/private-apis.d.ts.map +1 -0
  46. package/build-types/utils/subscribe-delegated-listener/index.d.ts +27 -0
  47. package/build-types/utils/subscribe-delegated-listener/index.d.ts.map +1 -0
  48. package/package.json +10 -9
  49. package/src/hooks/use-copy-on-click/index.ts +2 -2
  50. package/src/hooks/use-copy-to-clipboard/index.ts +8 -8
  51. package/src/hooks/use-copy-to-clipboard/test/index.tsx +33 -5
  52. package/src/hooks/use-dialog/README.md +52 -18
  53. package/src/hooks/use-dialog/index.ts +30 -20
  54. package/src/hooks/use-dialog/test/index.tsx +162 -34
  55. package/src/hooks/use-media-query/index.ts +54 -52
  56. package/src/index.js +3 -0
  57. package/src/lock-unlock.ts +10 -0
  58. package/src/private-apis.ts +13 -0
  59. package/src/utils/subscribe-delegated-listener/index.ts +128 -0
  60. package/src/utils/subscribe-delegated-listener/test/index.js +170 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AACA,cAAc,uCAAuC,CAAC;AAEtD,cAAc,kBAAkB,CAAC;AAEjC,cAAc,kBAAkB,CAAC;AAEjC,cAAc,wBAAwB,CAAC;AAGvC,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAGtD,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAC5E,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAC9E,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAGjE,OAAO,EAAE,OAAO,IAAI,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAC9E,OAAO,EAAE,OAAO,IAAI,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AACxE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,OAAO,IAAI,6BAA6B,EAAE,MAAM,2BAA2B,CAAC;AACrF,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,sCAAsC,CAAC;AAC5F,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAChF,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,gCAAgC,EAAE,MAAM,+BAA+B,CAAC;AAC5F,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export declare const lock: (object: unknown, privateData: unknown) => void, unlock: <T = any>(object: unknown) => T;
2
+ //# sourceMappingURL=lock-unlock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock-unlock.d.ts","sourceRoot":"","sources":["../src/lock-unlock.ts"],"names":[],"mappings":"AAKA,eAAO,MAAQ,IAAI,mDAAE,MAAM,iCAIzB,CAAC"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Private @wordpress/compose APIs.
3
+ */
4
+ export declare const privateApis: {};
5
+ //# sourceMappingURL=private-apis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"private-apis.d.ts","sourceRoot":"","sources":["../src/private-apis.ts"],"names":[],"mappings":"AAMA;;GAEG;AACH,eAAO,MAAM,WAAW,IAAK,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Adds a callback to a shared `addEventListener`. Only one underlying
3
+ * native listener is attached per (root, event type, phase); subscribers
4
+ * join an in-JS registry that dispatches events along the DOM ancestry
5
+ * of `event.target`.
6
+ *
7
+ * The model mirrors React's synthetic event system: a single root
8
+ * listener handles every event of a given type, and callbacks bound to
9
+ * an `Element` only fire when that element is on the target's path.
10
+ * Callbacks bound to a `Document` always fire (document is the root of
11
+ * every event in that document); callbacks bound to a `Window` always
12
+ * fire as a flat fan-out, since `window` isn't on the DOM tree.
13
+ *
14
+ * @param target `Element`, `Document`, or `Window` to bind the
15
+ * callback to. For `Element`, the callback only fires
16
+ * when the event happens on the element or a
17
+ * descendant.
18
+ * @param eventType DOM event name.
19
+ * @param callback Listener to be invoked with the event.
20
+ * @param capture Use the capture phase. Required when ancestor
21
+ * listeners gate on `event.defaultPrevented`, since a
22
+ * bubble-phase root listener fires after them. Defaults
23
+ * to `false`.
24
+ * @return Unsubscribe function.
25
+ */
26
+ export default function subscribeDelegatedListener(target: EventTarget, eventType: string, callback: EventListener, capture?: boolean): () => void;
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/subscribe-delegated-listener/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAYH,MAAM,CAAC,OAAO,UAAU,0BAA0B,CACjD,MAAM,EAAE,WAAW,EACnB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,aAAa,EACvB,OAAO,GAAE,OAAe,GACtB,MAAM,IAAI,CAsFZ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wordpress/compose",
3
- "version": "7.46.0",
3
+ "version": "8.1.0",
4
4
  "description": "WordPress higher-order components (HOCs).",
5
5
  "author": "The WordPress Contributors",
6
6
  "license": "GPL-2.0-or-later",
@@ -47,13 +47,14 @@
47
47
  "sideEffects": false,
48
48
  "dependencies": {
49
49
  "@types/mousetrap": "^1.6.8",
50
- "@wordpress/deprecated": "^4.46.0",
51
- "@wordpress/dom": "^4.46.0",
52
- "@wordpress/element": "^6.46.0",
53
- "@wordpress/is-shallow-equal": "^5.46.0",
54
- "@wordpress/keycodes": "^4.46.0",
55
- "@wordpress/priority-queue": "^3.46.0",
56
- "@wordpress/undo-manager": "^1.46.0",
50
+ "@wordpress/deprecated": "^4.48.0",
51
+ "@wordpress/dom": "^4.48.0",
52
+ "@wordpress/element": "^8.0.0",
53
+ "@wordpress/is-shallow-equal": "^5.48.0",
54
+ "@wordpress/keycodes": "^4.48.0",
55
+ "@wordpress/priority-queue": "^3.48.0",
56
+ "@wordpress/private-apis": "^1.48.0",
57
+ "@wordpress/undo-manager": "^1.48.0",
57
58
  "change-case": "^4.1.2",
58
59
  "mousetrap": "^1.6.5",
59
60
  "use-memo-one": "^1.1.1"
@@ -67,5 +68,5 @@
67
68
  "publishConfig": {
68
69
  "access": "public"
69
70
  },
70
- "gitHead": "51264e33b95fadff9a06b68141e674ce9cde9675"
71
+ "gitHead": "e7856693aeb4e2522d13608cd32c994e4a97cb9c"
71
72
  }
@@ -8,7 +8,7 @@ import type { RefObject } from 'react';
8
8
  /**
9
9
  * Internal dependencies
10
10
  */
11
- import { clearSelection, copyToClipboard } from '../use-copy-to-clipboard';
11
+ import { restoreFocus, copyToClipboard } from '../use-copy-to-clipboard';
12
12
 
13
13
  /**
14
14
  * Copies the text to the clipboard when the element is clicked.
@@ -73,7 +73,7 @@ export default function useCopyOnClick(
73
73
  return;
74
74
  }
75
75
  if ( success ) {
76
- clearSelection( trigger );
76
+ restoreFocus( trigger );
77
77
  if ( timeout ) {
78
78
  setHasCopied( true );
79
79
  clearTimeout( timeoutId );
@@ -52,15 +52,14 @@ export async function copyToClipboard(
52
52
  }
53
53
 
54
54
  /**
55
- * Clears the current selection and restores focus to the trigger element.
55
+ * Restores focus to the trigger element.
56
56
  *
57
57
  * @param trigger The element that triggered the copy.
58
58
  */
59
- export function clearSelection( trigger: Element ): void {
59
+ export function restoreFocus( trigger: Element ): void {
60
60
  if ( 'focus' in trigger && typeof trigger.focus === 'function' ) {
61
61
  trigger.focus();
62
62
  }
63
- trigger.ownerDocument?.defaultView?.getSelection()?.removeAllRanges();
64
63
  }
65
64
 
66
65
  /**
@@ -93,7 +92,7 @@ export default function useCopyToClipboard< T extends HTMLElement >(
93
92
  const textRef = useUpdatedRef( text );
94
93
  const onSuccessRef = useUpdatedRef( onSuccess );
95
94
  return useRefEffect( ( node ) => {
96
- // Flag to prevent callbacks after unmount when the Promise resolves.
95
+ // Tracks whether the node is still mounted when the Promise resolves.
97
96
  let isActive = true;
98
97
  const handleClick = async () => {
99
98
  const textToCopy =
@@ -101,11 +100,12 @@ export default function useCopyToClipboard< T extends HTMLElement >(
101
100
  ? textRef.current()
102
101
  : textRef.current || '';
103
102
  const success = await copyToClipboard( textToCopy, node );
104
- if ( ! isActive ) {
105
- return;
106
- }
107
103
  if ( success ) {
108
- clearSelection( node );
104
+ // Restoring focus only matters while the node is mounted.
105
+ if ( isActive ) {
106
+ restoreFocus( node );
107
+ }
108
+ // Always run, even after unmount, to allow updating other UI.
109
109
  if ( onSuccessRef.current ) {
110
110
  onSuccessRef.current();
111
111
  }
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
7
7
  /**
8
8
  * Internal dependencies
9
9
  */
10
- import useCopyToClipboard, { copyToClipboard, clearSelection } from '../';
10
+ import useCopyToClipboard, { copyToClipboard, restoreFocus } from '../';
11
11
 
12
12
  interface TestComponentProps {
13
13
  text: string | ( () => string );
@@ -68,7 +68,7 @@ describe( 'useCopyToClipboard', () => {
68
68
  expect( onSuccess ).not.toHaveBeenCalled();
69
69
  } );
70
70
 
71
- it( 'should not call onSuccess after unmount', async () => {
71
+ it( 'should call onSuccess after the node unmounts', async () => {
72
72
  let resolvePromise: () => void;
73
73
  const delayedPromise = new Promise< void >( ( resolve ) => {
74
74
  resolvePromise = resolve;
@@ -91,7 +91,35 @@ describe( 'useCopyToClipboard', () => {
91
91
  await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
92
92
  } );
93
93
 
94
- expect( onSuccess ).not.toHaveBeenCalled();
94
+ expect( onSuccess ).toHaveBeenCalledTimes( 1 );
95
+ } );
96
+
97
+ it( 'should not restore focus after the node unmounts', async () => {
98
+ let resolvePromise: () => void;
99
+ const delayedPromise = new Promise< void >( ( resolve ) => {
100
+ resolvePromise = resolve;
101
+ } );
102
+ jest.spyOn( navigator.clipboard, 'writeText' ).mockReturnValue(
103
+ delayedPromise
104
+ );
105
+
106
+ const user = userEvent.setup();
107
+ const { unmount } = render( <TestComponent text="test" /> );
108
+
109
+ const button = screen.getByRole( 'button' );
110
+ const focusSpy = jest.spyOn( button, 'focus' );
111
+
112
+ await user.click( button );
113
+ unmount();
114
+ // Discard focus calls from the initial click; we only care about post-unmount calls.
115
+ focusSpy.mockClear();
116
+
117
+ await act( async () => {
118
+ resolvePromise();
119
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
120
+ } );
121
+
122
+ expect( focusSpy ).not.toHaveBeenCalled();
95
123
  } );
96
124
  } );
97
125
 
@@ -129,13 +157,13 @@ describe( 'copyToClipboard', () => {
129
157
  } );
130
158
  } );
131
159
 
132
- describe( 'clearSelection', () => {
160
+ describe( 'restoreFocus', () => {
133
161
  it( 'should focus the trigger element', () => {
134
162
  const trigger = document.createElement( 'button' );
135
163
  document.body.appendChild( trigger );
136
164
  const focusMock = jest.spyOn( trigger, 'focus' );
137
165
 
138
- clearSelection( trigger );
166
+ restoreFocus( trigger );
139
167
 
140
168
  expect( focusMock ).toHaveBeenCalledTimes( 1 );
141
169
 
@@ -6,22 +6,7 @@ React hook to be used on a dialog wrapper to enable the following behaviors:
6
6
  - focus on mount.
7
7
  - return focus on unmount.
8
8
  - focus outside.
9
-
10
- ## Returned value
11
-
12
- The hooks returns an array composed of the two following values:
13
-
14
- ### `ref`
15
-
16
- - Type: `Object|Function`
17
-
18
- A React ref that must be passed to the DOM element where the behavior should be attached.
19
-
20
- ### `props`
21
-
22
- - Type: `Object`
23
-
24
- Extra props to apply to the wrapper.
9
+ - close on Escape.
25
10
 
26
11
  ## Usage
27
12
 
@@ -29,15 +14,64 @@ Extra props to apply to the wrapper.
29
14
  import { __experimentalUseDialog as useDialog } from '@wordpress/compose';
30
15
 
31
16
  const MyDialog = () => {
32
- const [ ref, extraProps ] = useDialog( {
17
+ const [ ref, dialogProps ] = useDialog( {
33
18
  onClose: () => console.log( 'do something to close the dialog' ),
34
19
  } );
35
20
 
36
21
  return (
37
- <div ref={ ref } { ...extraProps }>
22
+ <div ref={ ref } { ...dialogProps }>
38
23
  <Button />
39
24
  <Button />
40
25
  </div>
41
26
  );
42
27
  };
43
28
  ```
29
+
30
+ ## Options
31
+
32
+ ### `focusOnMount`
33
+
34
+ - Type: `'firstElement' | 'firstInputElement' | boolean`
35
+ - Default: `'firstElement'`
36
+
37
+ Determines focus behavior when the dialog mounts:
38
+
39
+ - `'firstElement'` focuses the first tabbable element within the dialog.
40
+ - `'firstInputElement'` focuses the first value control within the dialog.
41
+ - `true` focuses the dialog wrapper itself.
42
+ - `false` does nothing, and _should not be used unless an accessible substitute behavior is implemented_.
43
+
44
+ ### `constrainTabbing`
45
+
46
+ - Type: `boolean`
47
+ - Default: `focusOnMount !== false`
48
+
49
+ Whether tabbing is constrained within the dialog (preventing keyboard focus from leaving without explicit focus elsewhere), or whether the dialog remains part of the wider tab order.
50
+
51
+ ### `onClose`
52
+
53
+ - Type: `() => void`
54
+
55
+ Called when the dialog should close, e.g. when the user presses Escape or focus moves outside.
56
+
57
+ ### `onKeyDown`
58
+
59
+ - Type: `KeyboardEventHandler<HTMLElement>`
60
+
61
+ Optional `onKeyDown` handler, merged with the built-in close-on-Escape handler. Provided to keep things working when the dialog wrapper already has its own `onKeyDown`. The provided handler runs first and can call `event.preventDefault()` to opt out of close-on-Escape.
62
+
63
+ ## Returned value
64
+
65
+ A tuple of:
66
+
67
+ ### `ref`
68
+
69
+ - Type: `RefCallback<HTMLElement>`
70
+
71
+ A React ref to attach to the dialog wrapper DOM element.
72
+
73
+ ### `dialogProps`
74
+
75
+ - Type: `object`
76
+
77
+ Extra props (`onFocus`, `onBlur`, `onKeyDown`, `tabIndex`, …) to spread onto the same wrapper element.
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import type { RefCallback, SyntheticEvent } from 'react';
4
+ import type {
5
+ KeyboardEvent,
6
+ KeyboardEventHandler,
7
+ RefCallback,
8
+ SyntheticEvent,
9
+ } from 'react';
5
10
 
6
11
  /**
7
12
  * WordPress dependencies
8
13
  */
9
14
  import { useRef, useEffect, useCallback } from '@wordpress/element';
10
- import { ESCAPE } from '@wordpress/keycodes';
11
15
 
12
16
  /**
13
17
  * Internal dependencies
@@ -43,6 +47,10 @@ type DialogOptions = {
43
47
  */
44
48
  constrainTabbing?: boolean;
45
49
  onClose?: () => void;
50
+ /**
51
+ * Optional `onKeyDown` handler, merged with the built-in one.
52
+ */
53
+ onKeyDown?: KeyboardEventHandler< HTMLElement >;
46
54
  /**
47
55
  * Use the `onClose` prop instead.
48
56
  *
@@ -56,7 +64,9 @@ type DialogOptions = {
56
64
 
57
65
  type useDialogReturn = [
58
66
  RefCallback< HTMLElement >,
59
- ReturnType< typeof useFocusOutside > & Pick< HTMLElement, 'tabIndex' >,
67
+ ReturnType< typeof useFocusOutside > & {
68
+ onKeyDown: ( event: KeyboardEvent< HTMLElement > ) => void;
69
+ } & Pick< HTMLElement, 'tabIndex' >,
60
70
  ];
61
71
 
62
72
  /**
@@ -86,23 +96,23 @@ function useDialog( options: DialogOptions ): useDialogReturn {
86
96
  currentOptions.current.onClose();
87
97
  }
88
98
  } );
89
- const closeOnEscapeRef = useCallback( ( node: HTMLElement ) => {
90
- if ( ! node ) {
91
- return;
99
+ // Close on Escape via a React `onKeyDown` (rather than a native listener)
100
+ // so portaled descendants that handle Escape and call
101
+ // `event.stopPropagation()` correctly prevent the dialog from closing.
102
+ // See https://github.com/WordPress/gutenberg/issues/78432.
103
+ const onKeyDown = useCallback( ( event: KeyboardEvent< HTMLElement > ) => {
104
+ // Let the consumer-provided handler (if any) run first so it can
105
+ // call `preventDefault()` to opt out of close-on-Escape.
106
+ currentOptions.current?.onKeyDown?.( event );
107
+ if (
108
+ event.key === 'Escape' &&
109
+ ! event.defaultPrevented &&
110
+ currentOptions.current?.onClose
111
+ ) {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ currentOptions.current.onClose();
92
115
  }
93
-
94
- node.addEventListener( 'keydown', ( event: KeyboardEvent ) => {
95
- // Close on escape.
96
- if (
97
- event.keyCode === ESCAPE &&
98
- ! event.defaultPrevented &&
99
- currentOptions.current?.onClose
100
- ) {
101
- event.preventDefault();
102
- event.stopPropagation();
103
- currentOptions.current.onClose();
104
- }
105
- } );
106
116
  }, [] );
107
117
 
108
118
  return [
@@ -110,10 +120,10 @@ function useDialog( options: DialogOptions ): useDialogReturn {
110
120
  constrainTabbing ? constrainedTabbingRef : null,
111
121
  options.focusOnMount !== false ? focusReturnRef : null,
112
122
  options.focusOnMount !== false ? focusOnMountRef : null,
113
- closeOnEscapeRef,
114
123
  ] ),
115
124
  {
116
125
  ...focusOutsideProps,
126
+ onKeyDown,
117
127
  tabIndex: -1,
118
128
  },
119
129
  ];
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import { render, screen, fireEvent } from '@testing-library/react';
4
+ import { render, screen } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { createPortal } from 'react-dom';
5
7
 
6
8
  /**
7
9
  * Internal dependencies
@@ -22,44 +24,58 @@ function Dialog( { onClose }: { onClose?: () => void } ) {
22
24
  );
23
25
  }
24
26
 
25
- // `useDialog` currently detects the Escape key via the deprecated `keyCode`
26
- // property (`event.keyCode === 27`). In jsdom, `userEvent.keyboard('[Escape]')`
27
- // does not set `keyCode` to 27, so we use `fireEvent` to control the event
28
- // shape. Once the hook is updated to use `event.key === 'Escape'`, these tests
29
- // should be rewritten to use `userEvent` for more realistic event simulation.
30
- function pressEscapeOn( element: HTMLElement ) {
31
- fireEvent.keyDown( element, { keyCode: 27, key: 'Escape' } );
32
- }
33
-
34
27
  describe( 'useDialog', () => {
35
- it( 'should call onClose when Escape is pressed', () => {
28
+ it( 'should call onClose when Escape is pressed', async () => {
29
+ const user = userEvent.setup();
36
30
  const onClose = jest.fn();
37
31
 
38
32
  render( <Dialog onClose={ onClose } /> );
39
33
 
40
- pressEscapeOn( screen.getByRole( 'dialog' ) );
34
+ screen.getByRole( 'dialog' ).focus();
35
+ await user.keyboard( '[Escape]' );
41
36
 
42
37
  expect( onClose ).toHaveBeenCalledTimes( 1 );
43
38
  } );
44
39
 
45
- it( 'should not call onClose when Escape is pressed if defaultPrevented', () => {
40
+ it( 'should not call onClose when Escape is pressed if defaultPrevented', async () => {
41
+ const user = userEvent.setup();
46
42
  const onClose = jest.fn();
47
43
 
48
- render( <Dialog onClose={ onClose } /> );
44
+ function DialogWithChild() {
45
+ const [ ref, props ] = useDialog( {
46
+ onClose,
47
+ focusOnMount: false,
48
+ } );
49
+ return (
50
+ <div
51
+ ref={ ref }
52
+ { ...props }
53
+ role="dialog"
54
+ aria-label="Test dialog"
55
+ >
56
+ <button
57
+ onKeyDown={ ( event ) => {
58
+ if ( event.key === 'Escape' ) {
59
+ event.preventDefault();
60
+ }
61
+ } }
62
+ >
63
+ Inside
64
+ </button>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ render( <DialogWithChild /> );
49
70
 
50
- const dialog = screen.getByRole( 'dialog' );
51
- const event = new KeyboardEvent( 'keydown', {
52
- keyCode: 27,
53
- bubbles: true,
54
- cancelable: true,
55
- } );
56
- event.preventDefault();
57
- dialog.dispatchEvent( event );
71
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
72
+ await user.keyboard( '[Escape]' );
58
73
 
59
74
  expect( onClose ).not.toHaveBeenCalled();
60
75
  } );
61
76
 
62
- it( 'should stop Escape event from propagating to parent elements', () => {
77
+ it( 'should stop Escape event from propagating to parent elements', async () => {
78
+ const user = userEvent.setup();
63
79
  const onClose = jest.fn();
64
80
  const parentKeyDownHandler = jest.fn();
65
81
 
@@ -70,13 +86,15 @@ describe( 'useDialog', () => {
70
86
  </div>
71
87
  );
72
88
 
73
- pressEscapeOn( screen.getByRole( 'button', { name: 'Inside' } ) );
89
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
90
+ await user.keyboard( '[Escape]' );
74
91
 
75
92
  expect( onClose ).toHaveBeenCalledTimes( 1 );
76
93
  expect( parentKeyDownHandler ).not.toHaveBeenCalled();
77
94
  } );
78
95
 
79
- it( 'should let Escape propagate when there is no onClose handler', () => {
96
+ it( 'should let Escape propagate when there is no onClose handler', async () => {
97
+ const user = userEvent.setup();
80
98
  const parentKeyDownHandler = jest.fn();
81
99
 
82
100
  render(
@@ -86,12 +104,62 @@ describe( 'useDialog', () => {
86
104
  </div>
87
105
  );
88
106
 
89
- pressEscapeOn( screen.getByRole( 'button', { name: 'Inside' } ) );
107
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
108
+ await user.keyboard( '[Escape]' );
90
109
 
91
110
  expect( parentKeyDownHandler ).toHaveBeenCalledTimes( 1 );
92
111
  } );
93
112
 
94
- it( 'should close only the innermost dialog when nested', () => {
113
+ it( 'should not close when a portaled descendant handles Escape and stops propagation', async () => {
114
+ const user = userEvent.setup();
115
+ const onClose = jest.fn();
116
+ const descendantHandled = jest.fn();
117
+
118
+ const portalTarget = document.createElement( 'div' );
119
+ document.body.appendChild( portalTarget );
120
+
121
+ function DialogWithPortaledChild() {
122
+ const [ ref, props ] = useDialog( {
123
+ onClose,
124
+ focusOnMount: false,
125
+ } );
126
+ return (
127
+ <div
128
+ ref={ ref }
129
+ { ...props }
130
+ role="dialog"
131
+ aria-label="Test dialog"
132
+ >
133
+ { createPortal(
134
+ <button
135
+ onKeyDown={ ( event ) => {
136
+ if ( event.key === 'Escape' ) {
137
+ descendantHandled();
138
+ event.stopPropagation();
139
+ }
140
+ } }
141
+ >
142
+ Portaled
143
+ </button>,
144
+ portalTarget
145
+ ) }
146
+ </div>
147
+ );
148
+ }
149
+
150
+ render( <DialogWithPortaledChild /> );
151
+
152
+ screen.getByText( 'Portaled' ).focus();
153
+ await user.keyboard( '[Escape]' );
154
+
155
+ expect( descendantHandled ).toHaveBeenCalledTimes( 1 );
156
+ expect( onClose ).not.toHaveBeenCalled();
157
+
158
+ document.body.removeChild( portalTarget );
159
+ } );
160
+
161
+ it( 'should close only the innermost dialog when nested', async () => {
162
+ const user = userEvent.setup();
95
163
  const outerOnClose = jest.fn();
96
164
  const innerOnClose = jest.fn();
97
165
 
@@ -126,22 +194,82 @@ describe( 'useDialog', () => {
126
194
 
127
195
  render( <NestedDialogs /> );
128
196
 
129
- pressEscapeOn( screen.getByRole( 'button', { name: 'Focusable' } ) );
197
+ screen.getByRole( 'button', { name: 'Focusable' } ).focus();
198
+ await user.keyboard( '[Escape]' );
130
199
 
131
200
  expect( innerOnClose ).toHaveBeenCalledTimes( 1 );
132
201
  expect( outerOnClose ).not.toHaveBeenCalled();
133
202
  } );
134
203
 
135
- it( 'should not call onClose for non-Escape keys', () => {
204
+ it( 'should call the consumer-provided onKeyDown alongside close-on-Escape', async () => {
205
+ const user = userEvent.setup();
136
206
  const onClose = jest.fn();
207
+ const consumerOnKeyDown = jest.fn();
137
208
 
138
- render( <Dialog onClose={ onClose } /> );
209
+ function DialogWithConsumerOnKeyDown() {
210
+ const [ ref, props ] = useDialog( {
211
+ onClose,
212
+ onKeyDown: consumerOnKeyDown,
213
+ focusOnMount: false,
214
+ } );
215
+ return (
216
+ <div
217
+ ref={ ref }
218
+ { ...props }
219
+ role="dialog"
220
+ aria-label="Test dialog"
221
+ />
222
+ );
223
+ }
224
+
225
+ render( <DialogWithConsumerOnKeyDown /> );
226
+
227
+ screen.getByRole( 'dialog' ).focus();
228
+ await user.keyboard( '[Escape]' );
229
+
230
+ expect( consumerOnKeyDown ).toHaveBeenCalledTimes( 1 );
231
+ expect( consumerOnKeyDown.mock.calls[ 0 ][ 0 ].key ).toBe( 'Escape' );
232
+ expect( onClose ).toHaveBeenCalledTimes( 1 );
233
+ } );
234
+
235
+ it( 'should let the consumer-provided onKeyDown opt out of close-on-Escape via preventDefault', async () => {
236
+ const user = userEvent.setup();
237
+ const onClose = jest.fn();
139
238
 
140
- const dialog = screen.getByRole( 'dialog' );
239
+ function DialogOptingOut() {
240
+ const [ ref, props ] = useDialog( {
241
+ onClose,
242
+ onKeyDown: ( event ) => event.preventDefault(),
243
+ focusOnMount: false,
244
+ } );
245
+ return (
246
+ <div
247
+ ref={ ref }
248
+ { ...props }
249
+ role="dialog"
250
+ aria-label="Test dialog"
251
+ />
252
+ );
253
+ }
254
+
255
+ render( <DialogOptingOut /> );
256
+
257
+ screen.getByRole( 'dialog' ).focus();
258
+ await user.keyboard( '[Escape]' );
259
+
260
+ expect( onClose ).not.toHaveBeenCalled();
261
+ } );
262
+
263
+ it( 'should not call onClose for non-Escape keys', async () => {
264
+ const user = userEvent.setup();
265
+ const onClose = jest.fn();
266
+
267
+ render( <Dialog onClose={ onClose } /> );
141
268
 
142
- fireEvent.keyDown( dialog, { keyCode: 13, key: 'Enter' } );
143
- fireEvent.keyDown( dialog, { keyCode: 65, key: 'a' } );
144
- fireEvent.keyDown( dialog, { keyCode: 9, key: 'Tab' } );
269
+ screen.getByRole( 'dialog' ).focus();
270
+ await user.keyboard( '[Enter]' );
271
+ await user.keyboard( 'a' );
272
+ await user.keyboard( '[Tab]' );
145
273
 
146
274
  expect( onClose ).not.toHaveBeenCalled();
147
275
  } );