newspack-components 4.6.2 → 4.7.0-alpha.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 (148) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/colors/colors.module.scss +13 -7
  3. package/dist/cjs/autocomplete-tokenfield/style.scss +4 -2
  4. package/dist/cjs/breadcrumbs/format-count.js +33 -0
  5. package/dist/cjs/breadcrumbs/index.js +78 -20
  6. package/dist/cjs/breadcrumbs/index.test.js +110 -0
  7. package/dist/cjs/breadcrumbs/style.scss +6 -0
  8. package/dist/cjs/button/index.js +41 -17
  9. package/dist/cjs/button/index.test.js +273 -0
  10. package/dist/cjs/card/core-card.js +9 -4
  11. package/dist/cjs/card/core-card.test.js +29 -0
  12. package/dist/cjs/card/index.js +4 -2
  13. package/dist/cjs/card/style-core.scss +59 -0
  14. package/dist/cjs/card-feature/index.js +7 -1
  15. package/dist/cjs/card-feature/index.test.js +92 -0
  16. package/dist/cjs/color-picker/index.js +1 -1
  17. package/dist/cjs/confirm-dialog/index.js +26 -9
  18. package/dist/cjs/confirm-dialog/index.test.js +176 -0
  19. package/dist/cjs/dataviews/index.js +11 -2
  20. package/dist/cjs/divider/index.js +13 -0
  21. package/dist/cjs/drawer/action.js +74 -0
  22. package/dist/cjs/drawer/body.js +79 -0
  23. package/dist/cjs/drawer/close-icon.js +45 -0
  24. package/dist/cjs/drawer/content.js +72 -0
  25. package/dist/cjs/drawer/context.js +23 -0
  26. package/dist/cjs/drawer/context.test.js +43 -0
  27. package/dist/cjs/drawer/divider.js +24 -0
  28. package/dist/cjs/drawer/footer.js +26 -0
  29. package/dist/cjs/drawer/header.js +26 -0
  30. package/dist/cjs/drawer/index.js +42 -0
  31. package/dist/cjs/drawer/index.test.js +1287 -0
  32. package/dist/cjs/drawer/root.js +350 -0
  33. package/dist/cjs/drawer/slots.test.js +422 -0
  34. package/dist/cjs/drawer/style.scss +205 -0
  35. package/dist/cjs/drawer/title.js +54 -0
  36. package/dist/cjs/drawer/types.js +5 -0
  37. package/dist/cjs/drawer/use-exit-animation.js +59 -0
  38. package/dist/cjs/hooks/use-confirm-dialog.js +15 -3
  39. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +19 -4
  40. package/dist/cjs/index.js +28 -1
  41. package/dist/cjs/page/index.js +1 -1
  42. package/dist/cjs/page-control/index.js +82 -0
  43. package/dist/cjs/plugin-settings/SettingsSection.js +7 -1
  44. package/dist/cjs/section-header/index.js +5 -2
  45. package/dist/cjs/section-header/style.scss +25 -12
  46. package/dist/cjs/table-card/index.js +67 -0
  47. package/dist/cjs/table-card/index.test.js +153 -0
  48. package/dist/cjs/with-wizard-screen/style.scss +8 -0
  49. package/dist/cjs/wizard/index.js +102 -38
  50. package/dist/esm/autocomplete-tokenfield/style.scss +4 -2
  51. package/dist/esm/breadcrumbs/format-count.js +27 -0
  52. package/dist/esm/breadcrumbs/index.js +81 -20
  53. package/dist/esm/breadcrumbs/index.test.js +110 -0
  54. package/dist/esm/breadcrumbs/style.scss +6 -0
  55. package/dist/esm/button/index.js +42 -18
  56. package/dist/esm/button/index.test.js +273 -0
  57. package/dist/esm/card/core-card.js +9 -4
  58. package/dist/esm/card/core-card.test.js +29 -0
  59. package/dist/esm/card/index.js +4 -2
  60. package/dist/esm/card/style-core.scss +59 -0
  61. package/dist/esm/card-feature/index.js +8 -2
  62. package/dist/esm/card-feature/index.test.js +96 -0
  63. package/dist/esm/color-picker/index.js +1 -1
  64. package/dist/esm/confirm-dialog/index.js +26 -9
  65. package/dist/esm/confirm-dialog/index.test.js +176 -0
  66. package/dist/esm/dataviews/index.js +11 -2
  67. package/dist/esm/divider/index.js +14 -0
  68. package/dist/esm/drawer/action.js +68 -0
  69. package/dist/esm/drawer/body.js +70 -0
  70. package/dist/esm/drawer/close-icon.js +37 -0
  71. package/dist/esm/drawer/content.js +63 -0
  72. package/dist/esm/drawer/context.js +17 -0
  73. package/dist/esm/drawer/context.test.js +40 -0
  74. package/dist/esm/drawer/divider.js +16 -0
  75. package/dist/esm/drawer/footer.js +18 -0
  76. package/dist/esm/drawer/header.js +18 -0
  77. package/dist/esm/drawer/index.js +35 -0
  78. package/dist/esm/drawer/index.test.js +1285 -0
  79. package/dist/esm/drawer/root.js +342 -0
  80. package/dist/esm/drawer/slots.test.js +422 -0
  81. package/dist/esm/drawer/style.scss +205 -0
  82. package/dist/esm/drawer/title.js +46 -0
  83. package/dist/esm/drawer/types.js +1 -0
  84. package/dist/esm/drawer/use-exit-animation.js +52 -0
  85. package/dist/esm/hooks/use-confirm-dialog.js +15 -3
  86. package/dist/esm/hooks/use-unsaved-changes-dialog.js +20 -5
  87. package/dist/esm/index.js +4 -0
  88. package/dist/esm/page/index.js +1 -1
  89. package/dist/esm/page-control/index.js +82 -0
  90. package/dist/esm/plugin-settings/SettingsSection.js +8 -2
  91. package/dist/esm/section-header/index.js +5 -2
  92. package/dist/esm/section-header/style.scss +25 -12
  93. package/dist/esm/table-card/index.js +60 -0
  94. package/dist/esm/table-card/index.test.js +150 -0
  95. package/dist/esm/with-wizard-screen/style.scss +8 -0
  96. package/dist/esm/wizard/index.js +104 -38
  97. package/package.json +2 -2
  98. package/src/autocomplete-tokenfield/style.scss +4 -2
  99. package/src/breadcrumbs/format-count.js +27 -0
  100. package/src/breadcrumbs/index.js +68 -8
  101. package/src/breadcrumbs/index.test.js +57 -0
  102. package/src/breadcrumbs/style.scss +6 -0
  103. package/src/button/index.test.js +143 -0
  104. package/src/button/index.tsx +32 -7
  105. package/src/card/core-card.js +15 -2
  106. package/src/card/core-card.test.js +19 -0
  107. package/src/card/index.js +3 -0
  108. package/src/card/style-core.scss +59 -0
  109. package/src/card-feature/README.md +4 -0
  110. package/src/card-feature/index.test.js +65 -0
  111. package/src/card-feature/index.tsx +13 -2
  112. package/src/color-picker/index.js +1 -1
  113. package/src/confirm-dialog/README.md +3 -2
  114. package/src/confirm-dialog/index.test.js +146 -0
  115. package/src/confirm-dialog/index.tsx +23 -9
  116. package/src/dataviews/index.tsx +11 -2
  117. package/src/divider/index.js +13 -0
  118. package/src/drawer/README.md +454 -0
  119. package/src/drawer/action.tsx +67 -0
  120. package/src/drawer/body.tsx +60 -0
  121. package/src/drawer/close-icon.tsx +37 -0
  122. package/src/drawer/content.tsx +54 -0
  123. package/src/drawer/context.test.js +31 -0
  124. package/src/drawer/context.ts +25 -0
  125. package/src/drawer/divider.tsx +13 -0
  126. package/src/drawer/footer.tsx +15 -0
  127. package/src/drawer/header.tsx +15 -0
  128. package/src/drawer/index.test.js +1012 -0
  129. package/src/drawer/index.tsx +33 -0
  130. package/src/drawer/root.tsx +328 -0
  131. package/src/drawer/slots.test.js +314 -0
  132. package/src/drawer/style.scss +205 -0
  133. package/src/drawer/title.tsx +40 -0
  134. package/src/drawer/types.ts +79 -0
  135. package/src/drawer/use-exit-animation.ts +46 -0
  136. package/src/hooks/use-confirm-dialog.tsx +14 -3
  137. package/src/hooks/use-unsaved-changes-dialog.tsx +17 -5
  138. package/src/index.js +4 -0
  139. package/src/page/index.js +1 -1
  140. package/src/page-control/index.js +63 -0
  141. package/src/plugin-settings/SettingsSection.js +8 -2
  142. package/src/section-header/index.js +5 -2
  143. package/src/section-header/style.scss +25 -12
  144. package/src/table-card/README.md +100 -0
  145. package/src/table-card/index.test.js +112 -0
  146. package/src/table-card/index.tsx +56 -0
  147. package/src/with-wizard-screen/style.scss +8 -0
  148. package/src/wizard/index.js +95 -34
@@ -0,0 +1,1012 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { Modal } from '@wordpress/components';
5
+ import { useReducedMotion } from '@wordpress/compose';
6
+ import { useLayoutEffect, useRef } from '@wordpress/element';
7
+
8
+ // useMediaQuery caches one subscriber per query for the life of the module, so a
9
+ // later matchMedia mock never reaches the hook.
10
+ jest.mock( '@wordpress/compose', () => ( {
11
+ ...jest.requireActual( '@wordpress/compose' ),
12
+ useReducedMotion: jest.fn( () => false ),
13
+ } ) );
14
+
15
+ afterEach( () => {
16
+ useReducedMotion.mockReturnValue( false );
17
+ } );
18
+
19
+ /**
20
+ * External dependencies
21
+ */
22
+ import { act, fireEvent, render, screen } from '@testing-library/react';
23
+ import { flushSync } from 'react-dom';
24
+ import { createRoot } from 'react-dom/client';
25
+ import { MemoryRouter } from 'react-router-dom';
26
+
27
+ /**
28
+ * Internal dependencies
29
+ */
30
+ import Drawer from '.';
31
+ import useConfirmDialog from '../hooks/use-confirm-dialog';
32
+
33
+ const noop = () => {};
34
+
35
+ // Pass `contents` as an array, not a fragment: Children.toArray flattens arrays only.
36
+ const drawerTree = ( { rootProps = {}, header = true, contents, footer = null } = {} ) => (
37
+ <Drawer.Root isOpen onRequestClose={ noop } { ...rootProps }>
38
+ { header && (
39
+ <Drawer.Header>
40
+ <Drawer.Title>Drawer title</Drawer.Title>
41
+ <Drawer.CloseIcon />
42
+ </Drawer.Header>
43
+ ) }
44
+ { contents || <Drawer.Content>Body</Drawer.Content> }
45
+ { footer }
46
+ </Drawer.Root>
47
+ );
48
+
49
+ const renderDrawer = options => render( drawerTree( options ) );
50
+
51
+ const panel = () => document.querySelector( '.newspack-drawer' );
52
+
53
+ const overlay = () => document.querySelector( '.newspack-drawer__overlay' );
54
+
55
+ const closeButton = () => screen.getByRole( 'button', { name: 'Close Drawer title' } );
56
+
57
+ describe( 'Drawer rendering', () => {
58
+ it( 'renders nothing when closed', () => {
59
+ renderDrawer( { rootProps: { isOpen: false } } );
60
+ expect( panel() ).not.toBeInTheDocument();
61
+ expect( screen.queryByText( 'Body' ) ).not.toBeInTheDocument();
62
+ } );
63
+
64
+ it( 'renders its children when open', () => {
65
+ renderDrawer();
66
+ expect( screen.getByText( 'Body' ) ).toBeInTheDocument();
67
+ } );
68
+
69
+ it( 'applies the medium size by default', () => {
70
+ renderDrawer();
71
+ expect( panel() ).toHaveClass( 'newspack-drawer', 'newspack-drawer--size-medium' );
72
+ } );
73
+
74
+ it( 'applies the requested size', () => {
75
+ renderDrawer( { rootProps: { size: 'large' } } );
76
+ expect( panel() ).toHaveClass( 'newspack-drawer--size-large' );
77
+ } );
78
+
79
+ it( 'falls back to medium and warns on a size it does not know', () => {
80
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
81
+ renderDrawer( { rootProps: { size: 'xlarge' } } );
82
+
83
+ expect( panel() ).toHaveClass( 'newspack-drawer--size-medium' );
84
+ expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'unknown size' ) );
85
+ warn.mockRestore();
86
+ } );
87
+
88
+ it( 'takes a ref, a style and a description', () => {
89
+ const ref = { current: null };
90
+ render(
91
+ <>
92
+ <p id="drawer-description">What this panel is for</p>
93
+ { drawerTree( { rootProps: { ref, style: { zIndex: 5 }, describedBy: 'drawer-description' } } ) }
94
+ </>
95
+ );
96
+
97
+ expect( ref.current ).toBe( overlay() );
98
+ expect( panel() ).toHaveStyle( { zIndex: 5 } );
99
+ expect( panel() ).toHaveAttribute( 'aria-describedby', 'drawer-description' );
100
+ } );
101
+
102
+ const twoTitles = ( { second = true, first = 'Drawer title' } = {} ) => (
103
+ <Drawer.Root isOpen onRequestClose={ noop }>
104
+ <Drawer.Header>
105
+ <Drawer.Title>{ first }</Drawer.Title>
106
+ { second && <Drawer.Title>Second title</Drawer.Title> }
107
+ </Drawer.Header>
108
+ <Drawer.Content>Body</Drawer.Content>
109
+ </Drawer.Root>
110
+ );
111
+
112
+ const nameOf = heading => screen.getByRole( 'heading', { name: heading } ).id;
113
+
114
+ it( 'keeps its name when a second title unmounts', () => {
115
+ const { rerender } = render( twoTitles() );
116
+ rerender( twoTitles( { second: false } ) );
117
+
118
+ expect( panel() ).toHaveAttribute( 'aria-labelledby', nameOf( 'Drawer title' ) );
119
+ } );
120
+
121
+ it( 'does not promote an earlier title when its text changes', () => {
122
+ const { rerender } = render( twoTitles() );
123
+ expect( panel() ).toHaveAttribute( 'aria-labelledby', nameOf( 'Second title' ) );
124
+
125
+ rerender( twoTitles( { first: 'Renamed' } ) );
126
+ expect( panel() ).toHaveAttribute( 'aria-labelledby', nameOf( 'Second title' ) );
127
+ } );
128
+ } );
129
+
130
+ describe( 'Drawer popovers', () => {
131
+ it( 'gives its children a popover slot', () => {
132
+ renderDrawer();
133
+ expect( document.querySelector( '.popover-slot' ) ).toBeInTheDocument();
134
+ } );
135
+ } );
136
+
137
+ describe( 'Drawer accessible name', () => {
138
+ it( 'names the panel by the title Drawer.Title registers', () => {
139
+ renderDrawer();
140
+ const heading = screen.getByRole( 'heading', { name: 'Drawer title' } );
141
+ expect( panel() ).toHaveAttribute( 'aria-labelledby', heading.id );
142
+ expect( screen.getByRole( 'dialog', { name: 'Drawer title' } ) ).toBeInTheDocument();
143
+ } );
144
+
145
+ // RTL's render() flushes passive effects inside act() and would hide the
146
+ // difference, so this drives React itself.
147
+ it( 'names the panel in the first painted frame', () => {
148
+ const wasActEnvironment = global.IS_REACT_ACT_ENVIRONMENT;
149
+ global.IS_REACT_ACT_ENVIRONMENT = false;
150
+ const container = document.body.appendChild( document.createElement( 'div' ) );
151
+ const root = createRoot( container );
152
+
153
+ try {
154
+ flushSync( () => root.render( drawerTree() ) );
155
+ expect( panel() ).toHaveAttribute( 'aria-labelledby', document.querySelector( '.newspack-drawer__title' ).id );
156
+ expect( document.querySelector( '.newspack-drawer__dismiss' ) ).toHaveAttribute( 'aria-label', 'Close Drawer title' );
157
+ } finally {
158
+ flushSync( () => root.unmount() );
159
+ container.remove();
160
+ global.IS_REACT_ACT_ENVIRONMENT = wasActEnvironment;
161
+ }
162
+ } );
163
+
164
+ it( 'names the panel by contentLabel when there is no title', () => {
165
+ renderDrawer( { header: false, rootProps: { contentLabel: 'Settings panel' } } );
166
+ expect( panel() ).toHaveAttribute( 'aria-label', 'Settings panel' );
167
+ } );
168
+
169
+ it( 'warns when it has neither a title nor a contentLabel', () => {
170
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
171
+ renderDrawer( { header: false } );
172
+ expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'contentLabel' ) );
173
+ warn.mockRestore();
174
+ } );
175
+
176
+ it( 'does not warn when a Drawer.Title is rendered', () => {
177
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
178
+ renderDrawer();
179
+ expect( warn ).not.toHaveBeenCalled();
180
+ warn.mockRestore();
181
+ } );
182
+
183
+ it( 'names its own confirmation, whose heading never shows', () => {
184
+ renderDrawer( { rootProps: { isDirty: true } } );
185
+ fireEvent.click( closeButton() );
186
+
187
+ const confirmation = screen.getByRole( 'dialog', { name: 'Unsaved changes' } );
188
+ expect( confirmation ).not.toBe( panel() );
189
+ expect( confirmation ).toHaveClass( 'newspack-modal--hide-title' );
190
+ } );
191
+ } );
192
+
193
+ const bodyOf = node => node.parentElement;
194
+
195
+ describe( 'Drawer sections', () => {
196
+ it( 'groups consecutive content sections into one scroll container', () => {
197
+ renderDrawer( {
198
+ contents: [ <Drawer.Content key="one">One</Drawer.Content>, <Drawer.Content key="two">Two</Drawer.Content> ],
199
+ footer: <Drawer.Footer>Actions</Drawer.Footer>,
200
+ } );
201
+
202
+ const one = screen.getByText( 'One' );
203
+ expect( one ).toHaveClass( 'newspack-drawer__content' );
204
+ expect( bodyOf( one ) ).toHaveClass( 'newspack-drawer__body' );
205
+ expect( bodyOf( screen.getByText( 'Two' ) ) ).toBe( bodyOf( one ) );
206
+ expect( bodyOf( one ) ).not.toContainElement( document.querySelector( '.newspack-drawer__header' ) );
207
+ expect( bodyOf( one ) ).not.toContainElement( screen.getByText( 'Actions' ) );
208
+ } );
209
+
210
+ // `.newspack-drawer__body:has(+ .newspack-drawer__footer)` drops the last
211
+ // section's bottom padding, so only the body the footer abuts may reach it.
212
+ it( 'leaves the footer immediately after the last scroll container', () => {
213
+ renderDrawer( {
214
+ contents: [
215
+ <Drawer.Content key="one">One</Drawer.Content>,
216
+ <div key="split">Split</div>,
217
+ <Drawer.Content key="two">Two</Drawer.Content>,
218
+ ],
219
+ footer: <Drawer.Footer>Actions</Drawer.Footer>,
220
+ } );
221
+
222
+ const bodies = document.querySelectorAll( '.newspack-drawer__body' );
223
+ const footer = document.querySelector( '.newspack-drawer__footer' );
224
+ expect( bodies ).toHaveLength( 2 );
225
+ expect( footer.previousElementSibling ).toBe( bodies[ 1 ] );
226
+ expect( bodies[ 0 ].nextElementSibling ).not.toBe( footer );
227
+ } );
228
+
229
+ // jsdom has no layout, so the overflow the body measures is stubbed on the node.
230
+ describe( 'keyboard access to the scroll container', () => {
231
+ let observers;
232
+
233
+ const setOverflow = ( body, scrollHeight ) => {
234
+ Object.defineProperty( body, 'scrollHeight', { configurable: true, value: scrollHeight } );
235
+ Object.defineProperty( body, 'clientHeight', { configurable: true, value: 100 } );
236
+ };
237
+
238
+ let mutationObservers;
239
+
240
+ beforeEach( () => {
241
+ observers = [];
242
+ mutationObservers = [];
243
+ global.ResizeObserver = class {
244
+ constructor( callback ) {
245
+ this.callback = callback;
246
+ this.boxes = [];
247
+ observers.push( this );
248
+ this.disconnect = jest.fn();
249
+ }
250
+ observe( target, options ) {
251
+ this.boxes.push( options?.box );
252
+ }
253
+ };
254
+ global.MutationObserver = class {
255
+ constructor( callback ) {
256
+ this.callback = callback;
257
+ mutationObservers.push( this );
258
+ this.disconnect = jest.fn();
259
+ }
260
+ observe() {}
261
+ };
262
+ } );
263
+
264
+ // jsdom supplies MutationObserver, so restore it rather than deleting it.
265
+ const nativeMutationObserver = global.MutationObserver;
266
+
267
+ afterEach( () => {
268
+ delete global.ResizeObserver;
269
+ global.MutationObserver = nativeMutationObserver;
270
+ } );
271
+
272
+ it( 'takes no tab stop while the content fits', () => {
273
+ renderDrawer( { contents: [ <Drawer.Content key="one">One</Drawer.Content> ] } );
274
+ const body = bodyOf( screen.getByText( 'One' ) );
275
+ expect( body ).not.toHaveAttribute( 'tabindex' );
276
+ expect( body ).not.toHaveAttribute( 'aria-label' );
277
+ expect( body ).not.toHaveAttribute( 'role' );
278
+ } );
279
+
280
+ it( 'becomes focusable and named once it overflows, and reverts when it fits again', () => {
281
+ renderDrawer( { contents: [ <Drawer.Content key="one">One</Drawer.Content> ] } );
282
+ const body = bodyOf( screen.getByText( 'One' ) );
283
+
284
+ setOverflow( body, 400 );
285
+ act( () => observers.forEach( observer => observer.callback() ) );
286
+ expect( body ).toHaveAttribute( 'tabindex', '0' );
287
+ expect( body ).toHaveAttribute( 'aria-label', 'Scrollable section' );
288
+ expect( body ).toHaveAttribute( 'role', 'group' );
289
+
290
+ setOverflow( body, 50 );
291
+ act( () => observers.forEach( observer => observer.callback() ) );
292
+ expect( body ).not.toHaveAttribute( 'tabindex' );
293
+ expect( body ).not.toHaveAttribute( 'aria-label' );
294
+ expect( body ).not.toHaveAttribute( 'role' );
295
+ } );
296
+
297
+ // A section's padding is an inline custom property, so it grows the border box
298
+ // without touching the content box a default observer would watch.
299
+ it( 'observes its sections by border box', () => {
300
+ renderDrawer( { contents: [ <Drawer.Content key="one">One</Drawer.Content> ] } );
301
+ expect( observers.some( observer => observer.boxes.includes( 'border-box' ) ) ).toBe( true );
302
+ } );
303
+
304
+ // Setup disconnects once itself to re-observe, so count the extra teardown call.
305
+ it( 'disconnects its observer when the drawer goes away', () => {
306
+ const { unmount } = renderDrawer( { contents: [ <Drawer.Content key="one">One</Drawer.Content> ] } );
307
+ expect( observers.length ).toBeGreaterThan( 0 );
308
+ const before = observers.map( observer => observer.disconnect.mock.calls.length );
309
+
310
+ unmount();
311
+ observers.forEach( ( observer, index ) => {
312
+ expect( observer.disconnect.mock.calls.length ).toBeGreaterThan( before[ index ] );
313
+ } );
314
+ // The MutationObserver holds a registration on the now-detached body.
315
+ expect( mutationObservers.length ).toBeGreaterThan( 0 );
316
+ mutationObservers.forEach( observer => expect( observer.disconnect ).toHaveBeenCalled() );
317
+ } );
318
+ } );
319
+
320
+ // The `> * { flex: none }` rule that stops sections compressing depends on this.
321
+ it( 'lays the scroll container out as a flush VStack', () => {
322
+ renderDrawer( { contents: [ <Drawer.Content key="one">One</Drawer.Content> ] } );
323
+
324
+ const body = bodyOf( screen.getByText( 'One' ) );
325
+ expect( body ).toHaveClass( 'components-v-stack' );
326
+ expect( window.getComputedStyle( body ).gap ).toBe( '0' );
327
+ expect( window.getComputedStyle( body ).justifyContent ).toBe( 'flex-start' );
328
+ } );
329
+
330
+ it( 'starts a new container after a non-content child', () => {
331
+ renderDrawer( {
332
+ contents: [
333
+ <Drawer.Content key="one">One</Drawer.Content>,
334
+ <div key="notice">Notice</div>,
335
+ <Drawer.Content key="two">Two</Drawer.Content>,
336
+ ],
337
+ } );
338
+ expect( bodyOf( screen.getByText( 'Two' ) ) ).not.toBe( bodyOf( screen.getByText( 'One' ) ) );
339
+ } );
340
+
341
+ it( 'keeps a divider inside the container it divides', () => {
342
+ renderDrawer( {
343
+ contents: [
344
+ <Drawer.Content key="one">One</Drawer.Content>,
345
+ <Drawer.Divider key="rule" />,
346
+ <Drawer.Content key="two">Two</Drawer.Content>,
347
+ ],
348
+ } );
349
+
350
+ const body = bodyOf( screen.getByText( 'One' ) );
351
+ expect( bodyOf( screen.getByText( 'Two' ) ) ).toBe( body );
352
+ expect( body ).toContainElement( document.querySelector( '.newspack-drawer__divider' ) );
353
+ } );
354
+
355
+ it( 'keeps the same scroll container when a sibling above it toggles', () => {
356
+ const tree = withNotice => (
357
+ <Drawer.Root isOpen contentLabel="Settings panel" onRequestClose={ noop }>
358
+ { withNotice && <div>Notice</div> }
359
+ <Drawer.Content>Body</Drawer.Content>
360
+ </Drawer.Root>
361
+ );
362
+
363
+ const { rerender } = render( tree( false ) );
364
+ const body = bodyOf( screen.getByText( 'Body' ) );
365
+
366
+ rerender( tree( true ) );
367
+ expect( bodyOf( screen.getByText( 'Body' ) ) ).toBe( body );
368
+ } );
369
+ } );
370
+
371
+ // The dialog's X reuses the cancel label, so a role query for "Cancel" is
372
+ // ambiguous. Query the footer buttons by visible text.
373
+ const dialogButton = name => screen.getByText( name, { selector: 'button' } );
374
+
375
+ describe( 'Drawer closing', () => {
376
+ it( 'closes on a close icon click when it is not dirty', () => {
377
+ const onRequestClose = jest.fn();
378
+ renderDrawer( { rootProps: { onRequestClose } } );
379
+ fireEvent.click( closeButton() );
380
+ expect( onRequestClose ).toHaveBeenCalledTimes( 1 );
381
+ } );
382
+
383
+ it( 'closes on Escape when it is not dirty', () => {
384
+ const onRequestClose = jest.fn();
385
+ renderDrawer( { rootProps: { onRequestClose } } );
386
+ fireEvent.keyDown( screen.getByText( 'Body' ), { key: 'Escape' } );
387
+ expect( onRequestClose ).toHaveBeenCalled();
388
+ } );
389
+
390
+ it( 'closes on an Escape reported by code alone', () => {
391
+ const onRequestClose = jest.fn();
392
+ renderDrawer( { rootProps: { onRequestClose } } );
393
+ fireEvent.keyDown( screen.getByText( 'Body' ), { code: 'Escape' } );
394
+ expect( onRequestClose ).toHaveBeenCalled();
395
+ } );
396
+
397
+ it( 'ignores an Escape that ends an IME composition', () => {
398
+ const onRequestClose = jest.fn();
399
+ renderDrawer( { rootProps: { onRequestClose } } );
400
+ fireEvent.keyDown( screen.getByText( 'Body' ), { key: 'Escape', isComposing: true } );
401
+ expect( onRequestClose ).not.toHaveBeenCalled();
402
+ } );
403
+
404
+ it( 'ignores an Escape a child has already handled', () => {
405
+ const onRequestClose = jest.fn();
406
+ renderDrawer( {
407
+ rootProps: { onRequestClose },
408
+ contents: (
409
+ <Drawer.Content>
410
+ <button onKeyDown={ event => event.preventDefault() }>child</button>
411
+ </Drawer.Content>
412
+ ),
413
+ } );
414
+ fireEvent.keyDown( screen.getByRole( 'button', { name: 'child' } ), { key: 'Escape' } );
415
+ expect( onRequestClose ).not.toHaveBeenCalled();
416
+ } );
417
+
418
+ it( 'confirms before closing when it is dirty', () => {
419
+ const onRequestClose = jest.fn();
420
+ renderDrawer( { rootProps: { isDirty: true, onRequestClose } } );
421
+
422
+ fireEvent.click( closeButton() );
423
+ expect( onRequestClose ).not.toHaveBeenCalled();
424
+
425
+ fireEvent.click( dialogButton( 'Discard Changes' ) );
426
+ expect( onRequestClose ).toHaveBeenCalled();
427
+ } );
428
+
429
+ it( 'confirms an Escape when it is dirty', () => {
430
+ const onRequestClose = jest.fn();
431
+ renderDrawer( { rootProps: { isDirty: true, onRequestClose } } );
432
+ fireEvent.keyDown( screen.getByText( 'Body' ), { key: 'Escape' } );
433
+ expect( onRequestClose ).not.toHaveBeenCalled();
434
+ fireEvent.click( dialogButton( 'Discard Changes' ) );
435
+ expect( onRequestClose ).toHaveBeenCalled();
436
+ } );
437
+
438
+ it( 'stays open when a delegated confirmation opens', () => {
439
+ const onRequestClose = jest.fn();
440
+ const Delegating = () => {
441
+ const { confirmDialog, requestConfirm } = useConfirmDialog( {
442
+ message: 'Discard?',
443
+ confirmButtonText: 'Discard Changes',
444
+ } );
445
+ return (
446
+ <>
447
+ { drawerTree( { rootProps: { isDirty: true, requestConfirm, onRequestClose } } ) }
448
+ { confirmDialog }
449
+ </>
450
+ );
451
+ };
452
+ render( <Delegating /> );
453
+
454
+ fireEvent.click( closeButton() );
455
+ expect( screen.getByText( 'Discard Changes', { selector: 'button' } ) ).toBeInTheDocument();
456
+ expect( onRequestClose ).not.toHaveBeenCalled();
457
+ expect( panel() ).toBeInTheDocument();
458
+
459
+ fireEvent.click( screen.getByText( 'Discard Changes', { selector: 'button' } ) );
460
+ expect( onRequestClose ).toHaveBeenCalledTimes( 1 );
461
+ } );
462
+
463
+ it( 'reads the current isDirty when a modal above it dismisses', () => {
464
+ const onRequestClose = jest.fn();
465
+ const above = ( { sibling, isDirty } ) => (
466
+ <>
467
+ { sibling && (
468
+ <Modal title="Above" onRequestClose={ noop }>
469
+ Above
470
+ </Modal>
471
+ ) }
472
+ { drawerTree( { rootProps: { isDirty, onRequestClose } } ) }
473
+ </>
474
+ );
475
+ const { rerender } = render( above( { sibling: false, isDirty: false } ) );
476
+
477
+ rerender( above( { sibling: true, isDirty: true } ) );
478
+ expect( onRequestClose ).not.toHaveBeenCalled();
479
+ } );
480
+
481
+ it( 'warns when a delegated confirmation closes without prompting', () => {
482
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
483
+ const onRequestClose = jest.fn();
484
+ const requestConfirm = callback => callback();
485
+ renderDrawer( { rootProps: { isDirty: true, requestConfirm, onRequestClose } } );
486
+
487
+ fireEvent.click( closeButton() );
488
+ expect( onRequestClose ).toHaveBeenCalled();
489
+ expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'without prompting' ) );
490
+ warn.mockRestore();
491
+ } );
492
+
493
+ it( 'does not warn when a delegated confirmation prompts', () => {
494
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
495
+ renderDrawer( { rootProps: { isDirty: true, requestConfirm: jest.fn(), onRequestClose: noop } } );
496
+
497
+ fireEvent.click( closeButton() );
498
+ expect( warn ).not.toHaveBeenCalled();
499
+ warn.mockRestore();
500
+ } );
501
+
502
+ it( 'leaves a real delegated confirmation to its owner', () => {
503
+ const error = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
504
+ const Delegating = ( { open } ) => {
505
+ const { confirmDialog, requestConfirm } = useConfirmDialog( {
506
+ message: 'Discard?',
507
+ confirmButtonText: 'Discard Changes',
508
+ } );
509
+ return (
510
+ <>
511
+ { drawerTree( { rootProps: { isOpen: open, isDirty: true, requestConfirm, onRequestClose: noop } } ) }
512
+ { confirmDialog }
513
+ </>
514
+ );
515
+ };
516
+ const { rerender } = render( <Delegating open /> );
517
+
518
+ fireEvent.click( closeButton() );
519
+ expect( screen.getByText( 'Discard Changes', { selector: 'button' } ) ).toBeInTheDocument();
520
+
521
+ rerender( <Delegating open={ false } /> );
522
+ expect( screen.getByText( 'Discard Changes', { selector: 'button' } ) ).toBeInTheDocument();
523
+ expect( error ).not.toHaveBeenCalled();
524
+ error.mockRestore();
525
+ } );
526
+
527
+ const withSibling = ( rootProps, sibling ) => (
528
+ <>
529
+ { drawerTree( { rootProps } ) }
530
+ { sibling && (
531
+ <Modal title="Elsewhere" onRequestClose={ noop }>
532
+ Elsewhere
533
+ </Modal>
534
+ ) }
535
+ </>
536
+ );
537
+
538
+ it( 'closes without confirming when another modal replaces it', () => {
539
+ const onRequestClose = jest.fn();
540
+ const rootProps = { onRequestClose };
541
+ const { rerender } = render( withSibling( rootProps, false ) );
542
+
543
+ rerender( withSibling( rootProps, true ) );
544
+ expect( onRequestClose ).toHaveBeenCalledTimes( 1 );
545
+ } );
546
+
547
+ it( 'stays open behind another modal when it is dirty', () => {
548
+ const onRequestClose = jest.fn();
549
+ const rootProps = { isDirty: true, onRequestClose };
550
+ const { rerender } = render( withSibling( rootProps, false ) );
551
+
552
+ rerender( withSibling( rootProps, true ) );
553
+ expect( onRequestClose ).not.toHaveBeenCalled();
554
+ expect( panel() ).toBeInTheDocument();
555
+ expect( screen.queryByText( 'Discard Changes', { selector: 'button' } ) ).not.toBeInTheDocument();
556
+ } );
557
+
558
+ it( 'stays open when the confirmation is cancelled', () => {
559
+ const onRequestClose = jest.fn();
560
+ renderDrawer( { rootProps: { isDirty: true, onRequestClose } } );
561
+
562
+ fireEvent.click( closeButton() );
563
+ fireEvent.click( dialogButton( 'Cancel' ) );
564
+ expect( onRequestClose ).not.toHaveBeenCalled();
565
+ expect( screen.getByText( 'Body' ) ).toBeInTheDocument();
566
+ } );
567
+
568
+ it( 'delegates to requestConfirm when one is supplied', () => {
569
+ const onRequestClose = jest.fn();
570
+ const requestConfirm = jest.fn();
571
+ renderDrawer( { rootProps: { isDirty: true, requestConfirm, onRequestClose } } );
572
+
573
+ fireEvent.click( closeButton() );
574
+ expect( requestConfirm ).toHaveBeenCalledTimes( 1 );
575
+ expect( screen.queryByText( 'Discard Changes', { selector: 'button' } ) ).not.toBeInTheDocument();
576
+
577
+ expect( onRequestClose ).not.toHaveBeenCalled();
578
+ requestConfirm.mock.calls[ 0 ][ 0 ]();
579
+ expect( onRequestClose ).toHaveBeenCalledTimes( 1 );
580
+ } );
581
+
582
+ // jsdom has no PointerEvent, and a plain Event drops the `button` the handler
583
+ // checks. A MouseEvent carries one.
584
+ const pointerEvent = ( type, button = 0 ) => new MouseEvent( type, { bubbles: true, cancelable: true, button } );
585
+
586
+ const pointerDownEvent = button => pointerEvent( 'pointerdown', button );
587
+
588
+ const pointerDown = ( node, button ) => fireEvent( node, pointerDownEvent( button ) );
589
+
590
+ const pointerUp = ( node, button ) => fireEvent( node, pointerEvent( 'pointerup', button ) );
591
+
592
+ const press = ( node, button ) => {
593
+ pointerDown( node, button );
594
+ pointerUp( node, button );
595
+ };
596
+
597
+ it( 'closes on an overlay press', () => {
598
+ const onRequestClose = jest.fn();
599
+ renderDrawer( { rootProps: { onRequestClose } } );
600
+ press( overlay() );
601
+ expect( onRequestClose ).toHaveBeenCalled();
602
+ } );
603
+
604
+ it( 'does not close on a press inside the panel', () => {
605
+ const onRequestClose = jest.fn();
606
+ renderDrawer( { rootProps: { onRequestClose } } );
607
+ press( screen.getByText( 'Body' ) );
608
+ expect( onRequestClose ).not.toHaveBeenCalled();
609
+ } );
610
+
611
+ it( 'closes on an overlay press that spans a re-render', () => {
612
+ const onRequestClose = jest.fn();
613
+ const tree = () => drawerTree( { rootProps: { onRequestClose: () => onRequestClose() } } );
614
+ const { rerender } = render( tree() );
615
+
616
+ pointerDown( overlay() );
617
+ rerender( tree() );
618
+ pointerUp( overlay() );
619
+ expect( onRequestClose ).toHaveBeenCalled();
620
+ } );
621
+
622
+ // Not the commit-versus-paint timing the ref mirror guards: act() drains passive
623
+ // effects, so this passes either way.
624
+ it( 'confirms when the drawer turns dirty mid-gesture', () => {
625
+ const onRequestClose = jest.fn();
626
+ const tree = isDirty => drawerTree( { rootProps: { onRequestClose, isDirty } } );
627
+ const { rerender } = render( tree( false ) );
628
+
629
+ pointerDown( overlay() );
630
+ rerender( tree( true ) );
631
+ pointerUp( overlay() );
632
+
633
+ expect( onRequestClose ).not.toHaveBeenCalled();
634
+ expect( dialogButton( 'Discard Changes' ) ).toBeInTheDocument();
635
+ } );
636
+
637
+ it( 'does not close when the press ends inside the panel', () => {
638
+ const onRequestClose = jest.fn();
639
+ renderDrawer( { rootProps: { onRequestClose } } );
640
+ pointerDown( overlay() );
641
+ pointerUp( screen.getByText( 'Body' ) );
642
+ expect( onRequestClose ).not.toHaveBeenCalled();
643
+ } );
644
+
645
+ it( 'does not close on a release that lands outside the overlay', () => {
646
+ const onRequestClose = jest.fn();
647
+ renderDrawer( { rootProps: { onRequestClose } } );
648
+
649
+ pointerDown( overlay() );
650
+ pointerUp( document.body );
651
+ expect( onRequestClose ).not.toHaveBeenCalled();
652
+
653
+ pointerUp( overlay() );
654
+ expect( onRequestClose ).not.toHaveBeenCalled();
655
+ } );
656
+
657
+ // jsdom implements neither capture method, hence the stubs.
658
+ it( 'releases the implicit pointer capture on an overlay press', () => {
659
+ renderDrawer();
660
+ const scrim = overlay();
661
+ scrim.hasPointerCapture = jest.fn( () => true );
662
+ scrim.releasePointerCapture = jest.fn();
663
+
664
+ const event = pointerDownEvent();
665
+ event.pointerId = 7;
666
+ fireEvent( scrim, event );
667
+ expect( scrim.releasePointerCapture ).toHaveBeenCalledWith( 7 );
668
+ } );
669
+
670
+ it( 'does not close when the press is cancelled', () => {
671
+ const onRequestClose = jest.fn();
672
+ renderDrawer( { rootProps: { onRequestClose } } );
673
+ pointerDown( overlay() );
674
+ fireEvent( overlay(), pointerEvent( 'pointercancel' ) );
675
+ pointerUp( overlay() );
676
+ expect( onRequestClose ).not.toHaveBeenCalled();
677
+ } );
678
+
679
+ it( 'does not close on a non-primary overlay press', () => {
680
+ const onRequestClose = jest.fn();
681
+ renderDrawer( { rootProps: { onRequestClose } } );
682
+ press( overlay(), 2 );
683
+ expect( onRequestClose ).not.toHaveBeenCalled();
684
+ } );
685
+
686
+ it( 'prevents the default on an overlay press', () => {
687
+ renderDrawer();
688
+ const event = pointerDownEvent();
689
+ fireEvent( overlay(), event );
690
+ expect( event.defaultPrevented ).toBe( true );
691
+ } );
692
+
693
+ describe( 'during the exit animation', () => {
694
+ const closing = rootProps => {
695
+ const { rerender } = render( drawerTree( { rootProps } ) );
696
+ rerender( drawerTree( { rootProps: { ...rootProps, isOpen: false } } ) );
697
+ expect( panel() ).toBeInTheDocument();
698
+ };
699
+
700
+ it( 'ignores a further Escape', () => {
701
+ const onRequestClose = jest.fn();
702
+ closing( { onRequestClose } );
703
+ fireEvent.keyDown( screen.getByText( 'Body' ), { key: 'Escape' } );
704
+ expect( onRequestClose ).not.toHaveBeenCalled();
705
+ } );
706
+
707
+ it( 'ignores a further overlay press', () => {
708
+ const onRequestClose = jest.fn();
709
+ closing( { onRequestClose } );
710
+ press( overlay() );
711
+ expect( onRequestClose ).not.toHaveBeenCalled();
712
+ } );
713
+
714
+ it( 'marks the frame inert', () => {
715
+ const { rerender } = render( drawerTree() );
716
+ expect( panel() ).not.toHaveAttribute( 'inert' );
717
+
718
+ rerender( drawerTree( { rootProps: { isOpen: false } } ) );
719
+ expect( panel() ).toHaveAttribute( 'inert' );
720
+ } );
721
+
722
+ it( 'does not reopen the confirmation of a dirty drawer', () => {
723
+ const onRequestClose = jest.fn();
724
+ closing( { isDirty: true, onRequestClose } );
725
+
726
+ fireEvent.keyDown( screen.getByText( 'Body' ), { key: 'Escape' } );
727
+ expect( screen.queryByText( 'Discard Changes', { selector: 'button' } ) ).not.toBeInTheDocument();
728
+ expect( onRequestClose ).not.toHaveBeenCalled();
729
+ } );
730
+ } );
731
+ } );
732
+
733
+ // jsdom emulates none of inert's behaviour; these observe the explicit return.
734
+ describe( 'Drawer focus return', () => {
735
+ // Plain DOM: RTL's cleanup would fight a React-owned node removed by hand below.
736
+ let opener;
737
+ let elsewhere;
738
+
739
+ beforeEach( () => {
740
+ opener = document.body.appendChild( document.createElement( 'button' ) );
741
+ elsewhere = document.body.appendChild( document.createElement( 'button' ) );
742
+ opener.focus();
743
+ } );
744
+
745
+ afterEach( () => {
746
+ opener.remove();
747
+ elsewhere.remove();
748
+ } );
749
+
750
+ const closed = { rootProps: { isOpen: false } };
751
+
752
+ it( 'returns focus to the opener as the exit begins', () => {
753
+ const { rerender } = render( drawerTree( closed ) );
754
+
755
+ rerender( drawerTree() );
756
+ closeButton().focus();
757
+ expect( panel() ).toContainElement( document.activeElement );
758
+
759
+ rerender( drawerTree( closed ) );
760
+ expect( panel() ).toBeInTheDocument();
761
+ expect( document.activeElement ).toBe( opener );
762
+ } );
763
+
764
+ it( 'leaves focus on the opener once the panel unmounts', () => {
765
+ const { rerender } = render( drawerTree( closed ) );
766
+
767
+ rerender( drawerTree() );
768
+ rerender( drawerTree( closed ) );
769
+ fireEvent.animationEnd( document.querySelector( '.components-modal__frame' ) );
770
+ expect( panel() ).not.toBeInTheDocument();
771
+ expect( document.activeElement ).toBe( opener );
772
+ } );
773
+
774
+ it( 'closes without complaint when the opener has gone', () => {
775
+ const { rerender } = render( drawerTree( closed ) );
776
+
777
+ rerender( drawerTree() );
778
+ opener.remove();
779
+
780
+ expect( () => rerender( drawerTree( closed ) ) ).not.toThrow();
781
+ expect( panel() ).toBeInTheDocument();
782
+ } );
783
+
784
+ it( 'leaves focus alone when it has moved outside the panel', () => {
785
+ const { rerender } = render( drawerTree( closed ) );
786
+
787
+ rerender( drawerTree() );
788
+ elsewhere.focus();
789
+
790
+ rerender( drawerTree( closed ) );
791
+ expect( document.activeElement ).toBe( elsewhere );
792
+ } );
793
+
794
+ const editor = ( editing, child ) => (
795
+ <Drawer.Root isOpen={ !! editing } contentLabel="Editor" onRequestClose={ noop }>
796
+ <Drawer.Content>{ editing && child }</Drawer.Content>
797
+ </Drawer.Root>
798
+ );
799
+
800
+ it( 'returns focus to the opener when the focused child unmounts with the close', () => {
801
+ const { rerender } = render( editor( false, <input aria-label="Name" /> ) );
802
+
803
+ rerender( editor( true, <input aria-label="Name" /> ) );
804
+ screen.getByLabelText( 'Name' ).focus();
805
+ expect( panel() ).toContainElement( document.activeElement );
806
+
807
+ rerender( editor( false, <input aria-label="Name" /> ) );
808
+ expect( screen.queryByLabelText( 'Name' ) ).not.toBeInTheDocument();
809
+ expect( document.activeElement ).toBe( opener );
810
+ } );
811
+
812
+ it( 'restores focus to the panel when it reopens mid-exit', () => {
813
+ const { rerender } = render( drawerTree( closed ) );
814
+
815
+ rerender( drawerTree() );
816
+ rerender( drawerTree( closed ) );
817
+ expect( document.activeElement ).toBe( opener );
818
+
819
+ rerender( drawerTree() );
820
+ expect( panel() ).not.toHaveAttribute( 'inert' );
821
+ expect( panel() ).toContainElement( document.activeElement );
822
+ expect( document.activeElement ).not.toBe( opener );
823
+ } );
824
+
825
+ it( 'leaves focus on content that claims it when it reopens mid-exit', () => {
826
+ const SelfFocusing = () => {
827
+ const ref = useRef( null );
828
+ useLayoutEffect( () => ref.current?.focus(), [] );
829
+ return <input aria-label="Name" ref={ ref } />;
830
+ };
831
+ const { rerender } = render( editor( false, <SelfFocusing /> ) );
832
+
833
+ rerender( editor( true, <SelfFocusing /> ) );
834
+ rerender( editor( false, <SelfFocusing /> ) );
835
+ expect( document.activeElement ).toBe( opener );
836
+
837
+ rerender( editor( true, <SelfFocusing /> ) );
838
+ expect( document.activeElement ).toBe( screen.getByLabelText( 'Name' ) );
839
+ } );
840
+ } );
841
+
842
+ describe( 'Drawer closed from the parent while its confirmation is open', () => {
843
+ let opener;
844
+
845
+ beforeEach( () => {
846
+ opener = document.body.appendChild( document.createElement( 'button' ) );
847
+ opener.focus();
848
+ } );
849
+
850
+ afterEach( () => {
851
+ opener.remove();
852
+ } );
853
+
854
+ const dirty = { isDirty: true, onRequestClose: noop };
855
+
856
+ const confirmingThenClosed = () => {
857
+ const { rerender } = render( drawerTree( { rootProps: dirty } ) );
858
+ fireEvent.click( closeButton() );
859
+ expect( dialogButton( 'Discard Changes' ) ).toBeInTheDocument();
860
+
861
+ rerender( drawerTree( { rootProps: { ...dirty, isOpen: false } } ) );
862
+ return rerender;
863
+ };
864
+
865
+ const confirmation = () => screen.queryByText( 'Discard Changes', { selector: 'button' } );
866
+
867
+ it( 'dismisses the confirmation', () => {
868
+ confirmingThenClosed();
869
+ expect( confirmation() ).not.toBeInTheDocument();
870
+ } );
871
+
872
+ it( 'returns focus to the opener', () => {
873
+ confirmingThenClosed();
874
+ expect( document.activeElement ).toBe( opener );
875
+ } );
876
+
877
+ it( 'leaves focus on the opener once the panel unmounts', () => {
878
+ confirmingThenClosed();
879
+ fireEvent.animationEnd( document.querySelector( '.components-modal__frame' ) );
880
+ expect( panel() ).not.toBeInTheDocument();
881
+ expect( document.activeElement ).toBe( opener );
882
+ } );
883
+
884
+ it( 'does not restore the confirmation when it opens again', () => {
885
+ const rerender = confirmingThenClosed();
886
+ fireEvent.animationEnd( document.querySelector( '.components-modal__frame' ) );
887
+
888
+ rerender( drawerTree( { rootProps: dirty } ) );
889
+ expect( confirmation() ).not.toBeInTheDocument();
890
+ } );
891
+
892
+ it( 'returns focus to the opener under prefers-reduced-motion', () => {
893
+ useReducedMotion.mockReturnValue( true );
894
+ confirmingThenClosed();
895
+ expect( panel() ).not.toBeInTheDocument();
896
+ expect( confirmation() ).not.toBeInTheDocument();
897
+ expect( document.activeElement ).toBe( opener );
898
+ } );
899
+ } );
900
+
901
+ describe( 'Drawer confirmation inside a router', () => {
902
+ const renderRouted = rootProps => render( <MemoryRouter>{ drawerTree( { rootProps } ) }</MemoryRouter> );
903
+
904
+ it( 'confirms a close', () => {
905
+ const onRequestClose = jest.fn();
906
+ renderRouted( { isDirty: true, onRequestClose } );
907
+
908
+ fireEvent.click( closeButton() );
909
+ fireEvent.click( dialogButton( 'Discard Changes' ) );
910
+ expect( onRequestClose ).toHaveBeenCalled();
911
+ } );
912
+
913
+ it( 'cancels a close, and can still confirm afterwards', () => {
914
+ const onRequestClose = jest.fn();
915
+ renderRouted( { isDirty: true, onRequestClose } );
916
+
917
+ fireEvent.click( closeButton() );
918
+ fireEvent.click( dialogButton( 'Cancel' ) );
919
+ expect( onRequestClose ).not.toHaveBeenCalled();
920
+
921
+ fireEvent.click( closeButton() );
922
+ fireEvent.click( dialogButton( 'Discard Changes' ) );
923
+ expect( onRequestClose ).toHaveBeenCalled();
924
+ } );
925
+ } );
926
+
927
+ // jsdom has no AnimationEvent, so `fireEvent.animationEnd` cannot carry a name.
928
+ const animationEnd = ( node, animationName ) => {
929
+ const event = new Event( 'animationend', { bubbles: true } );
930
+ event.animationName = animationName;
931
+ fireEvent( node, event );
932
+ };
933
+
934
+ describe( 'Drawer exit animation', () => {
935
+ afterEach( () => {
936
+ jest.useRealTimers();
937
+ } );
938
+
939
+ const closed = { rootProps: { isOpen: false } };
940
+
941
+ it( 'marks the frame as exiting, then removes it', () => {
942
+ jest.useFakeTimers();
943
+ const { rerender } = render( drawerTree() );
944
+
945
+ rerender( drawerTree( closed ) );
946
+ expect( panel() ).toHaveClass( 'components-modal__frame', 'is-exiting' );
947
+
948
+ act( () => {
949
+ jest.runAllTimers();
950
+ } );
951
+ expect( panel() ).not.toBeInTheDocument();
952
+ } );
953
+
954
+ it( 'keeps the same frame across a close', () => {
955
+ const { rerender } = render( drawerTree() );
956
+ const opened = panel();
957
+
958
+ rerender( drawerTree( closed ) );
959
+ expect( panel() ).toBe( opened );
960
+ } );
961
+
962
+ it( 'marks the overlay as exiting', () => {
963
+ jest.useFakeTimers();
964
+ const { rerender } = render( drawerTree() );
965
+
966
+ rerender( drawerTree( closed ) );
967
+ expect( overlay() ).toHaveClass( 'is-exiting' );
968
+
969
+ act( () => {
970
+ jest.runAllTimers();
971
+ } );
972
+ expect( overlay() ).not.toBeInTheDocument();
973
+ } );
974
+
975
+ it( 'ignores an animationend from the overlay fade', () => {
976
+ const { rerender } = render( drawerTree() );
977
+
978
+ rerender( drawerTree( closed ) );
979
+ animationEnd( overlay(), 'newspack-drawer-overlay-fade-out' );
980
+ expect( panel() ).toBeInTheDocument();
981
+
982
+ animationEnd( document.querySelector( '.components-modal__frame' ), 'newspack-drawer-slide-out' );
983
+ expect( panel() ).not.toBeInTheDocument();
984
+ } );
985
+
986
+ it( 'unmounts the panel when its frame finishes animating out', () => {
987
+ const { rerender } = render( drawerTree() );
988
+
989
+ rerender( drawerTree( closed ) );
990
+ fireEvent.animationEnd( document.querySelector( '.components-modal__frame' ) );
991
+ expect( panel() ).not.toBeInTheDocument();
992
+ } );
993
+
994
+ it( 'ignores an animationend from a nested animation', () => {
995
+ const { rerender } = render( drawerTree() );
996
+
997
+ rerender( drawerTree( closed ) );
998
+ animationEnd( screen.getByText( 'Body' ), 'components-popover__appear' );
999
+ expect( panel() ).toBeInTheDocument();
1000
+
1001
+ animationEnd( panel(), 'newspack-drawer-slide-out' );
1002
+ expect( panel() ).not.toBeInTheDocument();
1003
+ } );
1004
+
1005
+ it( 'removes the panel at once under prefers-reduced-motion', () => {
1006
+ useReducedMotion.mockReturnValue( true );
1007
+ const { rerender } = render( drawerTree() );
1008
+
1009
+ rerender( drawerTree( closed ) );
1010
+ expect( panel() ).not.toBeInTheDocument();
1011
+ } );
1012
+ } );