@khanacademy/wonder-blocks-modal 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanacademy/wonder-blocks-modal",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "design": "v2",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -16,15 +16,15 @@
16
16
  "license": "MIT",
17
17
  "dependencies": {
18
18
  "@babel/runtime": "^7.16.3",
19
- "@khanacademy/wonder-blocks-breadcrumbs": "^1.0.28",
19
+ "@khanacademy/wonder-blocks-breadcrumbs": "^1.0.29",
20
20
  "@khanacademy/wonder-blocks-color": "^1.1.20",
21
- "@khanacademy/wonder-blocks-core": "^4.2.1",
22
- "@khanacademy/wonder-blocks-icon": "^1.2.25",
23
- "@khanacademy/wonder-blocks-icon-button": "^3.4.3",
24
- "@khanacademy/wonder-blocks-layout": "^1.4.7",
21
+ "@khanacademy/wonder-blocks-core": "^4.3.0",
22
+ "@khanacademy/wonder-blocks-icon": "^1.2.26",
23
+ "@khanacademy/wonder-blocks-icon-button": "^3.4.5",
24
+ "@khanacademy/wonder-blocks-layout": "^1.4.8",
25
25
  "@khanacademy/wonder-blocks-spacing": "^3.0.5",
26
- "@khanacademy/wonder-blocks-toolbar": "^2.1.29",
27
- "@khanacademy/wonder-blocks-typography": "^1.1.29"
26
+ "@khanacademy/wonder-blocks-toolbar": "^2.1.30",
27
+ "@khanacademy/wonder-blocks-typography": "^1.1.30"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "aphrodite": "^1.2.5",
@@ -1,7 +1,12 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
- import {mount, shallow} from "enzyme";
3
+ import {mount} from "enzyme";
4
4
  import "jest-enzyme";
5
+ import {render, screen, waitFor} from "@testing-library/react";
6
+ import userEvent from "@testing-library/user-event";
7
+
8
+ import {View} from "@khanacademy/wonder-blocks-core";
9
+ import Button from "@khanacademy/wonder-blocks-button";
5
10
 
6
11
  import ModalLauncher from "../modal-launcher.js";
7
12
  import OnePaneDialog from "../one-pane-dialog.js";
@@ -49,6 +54,7 @@ describe("ModalLauncher", () => {
49
54
  wrapper.find("button").simulate("click");
50
55
  await wait();
51
56
 
57
+ // eslint-disable-next-line testing-library/no-node-access
52
58
  const portal = global.document.querySelector(
53
59
  "[data-modal-launcher-portal]",
54
60
  );
@@ -108,6 +114,7 @@ describe("ModalLauncher", () => {
108
114
  </ModalLauncher>,
109
115
  );
110
116
  expect(
117
+ // eslint-disable-next-line testing-library/no-node-access
111
118
  global.document.querySelector("[data-modal-launcher-portal]"),
112
119
  ).toBeNull();
113
120
 
@@ -115,6 +122,7 @@ describe("ModalLauncher", () => {
115
122
  // the modal function.
116
123
  opened = true;
117
124
  wrapper.find("button").simulate("click");
125
+ // eslint-disable-next-line testing-library/no-node-access
118
126
  const portal = global.document.querySelector(
119
127
  "[data-modal-launcher-portal]",
120
128
  );
@@ -132,6 +140,7 @@ describe("ModalLauncher", () => {
132
140
 
133
141
  // Launch the modal.
134
142
  wrapper.find("button").simulate("click");
143
+ // eslint-disable-next-line testing-library/no-node-access
135
144
  expect(document.querySelector("[data-modal-child]")).toBeTruthy();
136
145
 
137
146
  // Simulate an Escape keypress.
@@ -146,6 +155,7 @@ describe("ModalLauncher", () => {
146
155
  // NOTE(mdr): This might be fragile once React's async rendering lands.
147
156
  // I wonder if we'll be able to force synchronous rendering in unit
148
157
  // tests?
158
+ // eslint-disable-next-line testing-library/no-node-access
149
159
  expect(document.querySelector("[data-modal-child]")).toBeFalsy();
150
160
  });
151
161
 
@@ -156,7 +166,7 @@ describe("ModalLauncher", () => {
156
166
 
157
167
  // Rather than test this rigorously, we'll just check that a
158
168
  // ScrollDisabler is present, and trust ScrollDisabler to do its job.
159
- const wrapper = shallow(
169
+ const wrapper = mount(
160
170
  <ModalLauncher
161
171
  modal={({closeModal}) => {
162
172
  savedCloseModal = closeModal;
@@ -189,7 +199,7 @@ describe("ModalLauncher", () => {
189
199
  jest.spyOn(console, "warn");
190
200
 
191
201
  // Act
192
- shallow(
202
+ render(
193
203
  // $FlowIgnore
194
204
  <ModalLauncher
195
205
  modal={exampleModal}
@@ -213,7 +223,7 @@ describe("ModalLauncher", () => {
213
223
 
214
224
  // Act
215
225
  // $FlowIgnore
216
- shallow(<ModalLauncher modal={exampleModal} opened={false} />);
226
+ render(<ModalLauncher modal={exampleModal} opened={false} />);
217
227
 
218
228
  // Assert
219
229
  // eslint-disable-next-line no-console
@@ -228,7 +238,7 @@ describe("ModalLauncher", () => {
228
238
 
229
239
  // Act
230
240
  // $FlowIgnore
231
- shallow(<ModalLauncher modal={exampleModal} />);
241
+ render(<ModalLauncher modal={exampleModal} />);
232
242
 
233
243
  // Assert
234
244
  // eslint-disable-next-line no-console
@@ -284,50 +294,140 @@ describe("ModalLauncher", () => {
284
294
  await wait();
285
295
 
286
296
  // Assert
297
+ // eslint-disable-next-line testing-library/no-node-access
287
298
  expect(document.activeElement).not.toBe(lastButton);
288
299
  });
289
300
 
290
301
  test("if modal is closed, return focus to the last element focused outside the modal", async () => {
291
302
  // Arrange
292
- // We need the elements in the DOM document, it seems, for this test
293
- // to work. Changing to testing-library will likely fix this.
294
- const containerDiv = getElementAttachedToDocument("container");
295
- let savedCloseModal = () => {
296
- throw new Error(`closeModal wasn't saved`);
303
+ const ModalLauncherWrapper = () => {
304
+ const [opened, setOpened] = React.useState(false);
305
+
306
+ const handleOpen = () => {
307
+ setOpened(true);
308
+ };
309
+
310
+ const handleClose = () => {
311
+ setOpened(false);
312
+ };
313
+
314
+ return (
315
+ <View>
316
+ <Button>Top of page (should not receive focus)</Button>
317
+ <Button
318
+ testId="launcher-button"
319
+ onClick={() => handleOpen()}
320
+ >
321
+ Open modal
322
+ </Button>
323
+ <ModalLauncher
324
+ onClose={() => handleClose()}
325
+ opened={opened}
326
+ modal={({closeModal}) => (
327
+ <OnePaneDialog
328
+ title="Regular modal"
329
+ content={<View>Hello World</View>}
330
+ footer={
331
+ <Button
332
+ testId="modal-close-button"
333
+ onClick={closeModal}
334
+ >
335
+ Close Modal
336
+ </Button>
337
+ }
338
+ />
339
+ )}
340
+ />
341
+ </View>
342
+ );
297
343
  };
298
344
 
299
- const wrapper = mount(
300
- <ModalLauncher
301
- modal={({closeModal}) => {
302
- savedCloseModal = closeModal;
303
- return exampleModal;
304
- }}
305
- >
306
- {({openModal}) => (
307
- <button onClick={openModal} data-last-focused-button />
308
- )}
309
- </ModalLauncher>,
310
- {attachTo: containerDiv},
311
- );
345
+ render(<ModalLauncherWrapper />);
312
346
 
313
- const lastButton = wrapper
314
- .find("[data-last-focused-button]")
315
- .getDOMNode();
316
- // force focus
317
- lastButton.focus();
347
+ const lastButton = await screen.findByTestId("launcher-button");
318
348
 
319
349
  // Launch the modal.
320
- wrapper.find("button").simulate("click");
350
+ userEvent.click(lastButton);
321
351
 
322
- // wait for styles to be applied
323
- await wait();
352
+ // Act
353
+ // Close modal
354
+ const modalCloseButton = await screen.findByTestId(
355
+ "modal-close-button",
356
+ );
357
+ userEvent.click(modalCloseButton);
358
+
359
+ // Assert
360
+ await waitFor(() => {
361
+ expect(lastButton).toHaveFocus();
362
+ });
363
+ });
364
+
365
+ test("if `closedFocusId` is passed, shift focus to specified element after the modal closes", async () => {
366
+ // Arrange
367
+ const ModalLauncherWrapper = () => {
368
+ const [opened, setOpened] = React.useState(false);
369
+
370
+ const handleOpen = () => {
371
+ setOpened(true);
372
+ };
373
+
374
+ const handleClose = () => {
375
+ setOpened(false);
376
+ };
377
+
378
+ return (
379
+ <View>
380
+ <Button>Top of page (should not receive focus)</Button>
381
+ <Button id="button-to-focus-on" testId="focused-button">
382
+ Focus here after close
383
+ </Button>
384
+ <Button
385
+ testId="launcher-button"
386
+ onClick={() => handleOpen()}
387
+ >
388
+ Open modal
389
+ </Button>
390
+ <ModalLauncher
391
+ onClose={() => handleClose()}
392
+ opened={opened}
393
+ closedFocusId="button-to-focus-on"
394
+ modal={({closeModal}) => (
395
+ <OnePaneDialog
396
+ title="Triggered from action menu"
397
+ content={<View>Hello World</View>}
398
+ footer={
399
+ <Button
400
+ testId="modal-close-button"
401
+ onClick={closeModal}
402
+ >
403
+ Close Modal
404
+ </Button>
405
+ }
406
+ />
407
+ )}
408
+ />
409
+ </View>
410
+ );
411
+ };
412
+
413
+ render(<ModalLauncherWrapper />);
414
+
415
+ // Launch modal
416
+ const launcherButton = await screen.findByTestId("launcher-button");
417
+ userEvent.click(launcherButton);
324
418
 
325
419
  // Act
326
- savedCloseModal(); // close the modal
327
- wrapper.update();
420
+ // Close modal
421
+ const modalCloseButton = await screen.findByTestId(
422
+ "modal-close-button",
423
+ );
424
+ userEvent.click(modalCloseButton);
328
425
 
329
426
  // Assert
330
- expect(document.activeElement).toBe(lastButton);
427
+ const focusedButton = await screen.findByTestId("focused-button");
428
+ await waitFor(() => {
429
+ expect(focusedButton).toHaveFocus();
430
+ });
331
431
  });
332
432
 
333
433
  test("testId should be added to the Backdrop", () => {
@@ -3,6 +3,12 @@ import * as React from "react";
3
3
  import * as ReactDOM from "react-dom";
4
4
  import {StyleSheet} from "aphrodite";
5
5
 
6
+ import {withActionScheduler} from "@khanacademy/wonder-blocks-timing";
7
+ import type {
8
+ WithActionSchedulerProps,
9
+ WithoutActionScheduler,
10
+ } from "@khanacademy/wonder-blocks-timing";
11
+
6
12
  import FocusTrap from "./focus-trap.js";
7
13
  import ModalBackdrop from "./modal-backdrop.js";
8
14
  import ScrollDisabler from "./scroll-disabler.js";
@@ -43,10 +49,19 @@ type CommonProps = {|
43
49
  */
44
50
  initialFocusId?: string,
45
51
 
52
+ /**
53
+ * The selector for the element that will be focused after the dialog
54
+ * closes. When not set, the last element focused outside the modal will
55
+ * be used if it exists.
56
+ */
57
+ closedFocusId?: string,
58
+
46
59
  /**
47
60
  * Test ID used for e2e testing. It's set on the ModalBackdrop
48
61
  */
49
62
  testId?: string,
63
+
64
+ ...WithActionSchedulerProps,
50
65
  |};
51
66
 
52
67
  type ControlledProps = {|
@@ -104,7 +119,7 @@ type State = {|
104
119
  * like OnePaneDialog and is provided via
105
120
  * the `modal` prop.
106
121
  */
107
- export default class ModalLauncher extends React.Component<Props, State> {
122
+ class ModalLauncher extends React.Component<Props, State> {
108
123
  /**
109
124
  * The most recent element _outside this component_ that received focus.
110
125
  * Be default, it captures the element that triggered the modal opening
@@ -156,14 +171,41 @@ export default class ModalLauncher extends React.Component<Props, State> {
156
171
  this.setState({opened: true});
157
172
  };
158
173
 
174
+ _returnFocus: () => void = () => {
175
+ const {closedFocusId, schedule} = this.props;
176
+ const lastElement = this.lastElementFocusedOutsideModal;
177
+
178
+ // Focus on the specified element after closing the modal.
179
+ if (closedFocusId) {
180
+ const focusElement = (ReactDOM.findDOMNode(
181
+ document.getElementById(closedFocusId),
182
+ ): any);
183
+
184
+ if (focusElement) {
185
+ // Wait for the modal to leave the DOM before trying
186
+ // to focus on the specified element.
187
+ schedule.animationFrame(() => {
188
+ focusElement.focus();
189
+ });
190
+ return;
191
+ }
192
+ }
193
+
194
+ if (lastElement != null) {
195
+ // Wait for the modal to leave the DOM before trying to
196
+ // return focus to the element that triggered the modal.
197
+ schedule.animationFrame(() => {
198
+ lastElement.focus();
199
+ });
200
+ }
201
+ };
202
+
159
203
  handleCloseModal: () => void = () => {
160
204
  this.setState({opened: false}, () => {
161
- this.props.onClose && this.props.onClose();
205
+ const {onClose} = this.props;
162
206
 
163
- if (this.lastElementFocusedOutsideModal != null) {
164
- // return focus to the element that triggered the modal
165
- this.lastElementFocusedOutsideModal.focus();
166
- }
207
+ onClose && onClose();
208
+ this._returnFocus();
167
209
  });
168
210
  };
169
211
 
@@ -269,3 +311,11 @@ const styles = StyleSheet.create({
269
311
  zIndex: 1080,
270
312
  },
271
313
  });
314
+
315
+ type ExportProps = WithoutActionScheduler<
316
+ React.ElementConfig<typeof ModalLauncher>,
317
+ >;
318
+
319
+ export default (withActionScheduler(
320
+ ModalLauncher,
321
+ ): React.ComponentType<ExportProps>);
@@ -6,6 +6,7 @@ import {StyleSheet} from "aphrodite";
6
6
  import {View} from "@khanacademy/wonder-blocks-core";
7
7
  import {Body} from "@khanacademy/wonder-blocks-typography";
8
8
  import Button from "@khanacademy/wonder-blocks-button";
9
+ import {ActionMenu, ActionItem} from "@khanacademy/wonder-blocks-dropdown";
9
10
 
10
11
  import type {StoryComponentType} from "@storybook/react";
11
12
  import OnePaneDialog from "./one-pane-dialog.js";
@@ -200,3 +201,48 @@ WithOpener.parameters = {
200
201
  disableSnapshot: true,
201
202
  },
202
203
  };
204
+
205
+ export const WithClosedFocusId: StoryComponentType = () => {
206
+ const [opened, setOpened] = React.useState(false);
207
+
208
+ const handleOpen = () => {
209
+ setOpened(true);
210
+ };
211
+
212
+ const handleClose = () => {
213
+ setOpened(false);
214
+ };
215
+
216
+ return (
217
+ <View style={{gap: 20}}>
218
+ <Button>Top of page (should not receive focus)</Button>
219
+ <Button id="button-to-focus-on">Focus here after close</Button>
220
+ <ActionMenu menuText="actions">
221
+ <ActionItem label="Open modal" onClick={() => handleOpen()} />
222
+ </ActionMenu>
223
+ <ModalLauncher
224
+ onClose={() => handleClose()}
225
+ opened={opened}
226
+ closedFocusId="button-to-focus-on"
227
+ modal={({closeModal}) => (
228
+ <OnePaneDialog
229
+ title="Triggered from action menu"
230
+ content={<View>Hello World</View>}
231
+ footer={
232
+ <Button onClick={closeModal}>Close Modal</Button>
233
+ }
234
+ />
235
+ )}
236
+ />
237
+ </View>
238
+ );
239
+ };
240
+
241
+ WithClosedFocusId.parameters = {
242
+ chromatic: {
243
+ // Don't take screenshots of this story since the case we want
244
+ // to test doesn't appear on first render - it occurs after
245
+ // we complete a series of steps.
246
+ disableSnapshot: true,
247
+ },
248
+ };