@khanacademy/wonder-blocks-modal 2.2.3 → 2.3.2

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.3",
3
+ "version": "2.3.2",
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.29",
19
+ "@khanacademy/wonder-blocks-breadcrumbs": "^1.0.31",
20
20
  "@khanacademy/wonder-blocks-color": "^1.1.20",
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",
21
+ "@khanacademy/wonder-blocks-core": "^4.3.2",
22
+ "@khanacademy/wonder-blocks-icon": "^1.2.28",
23
+ "@khanacademy/wonder-blocks-icon-button": "^3.4.7",
24
+ "@khanacademy/wonder-blocks-layout": "^1.4.10",
25
25
  "@khanacademy/wonder-blocks-spacing": "^3.0.5",
26
- "@khanacademy/wonder-blocks-toolbar": "^2.1.30",
27
- "@khanacademy/wonder-blocks-typography": "^1.1.30"
26
+ "@khanacademy/wonder-blocks-toolbar": "^2.1.32",
27
+ "@khanacademy/wonder-blocks-typography": "^1.1.32"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "aphrodite": "^1.2.5",
@@ -32,6 +32,6 @@
32
32
  "react-dom": "16.14.0"
33
33
  },
34
34
  "devDependencies": {
35
- "wb-dev-build-settings": "^0.3.0"
35
+ "wb-dev-build-settings": "^0.4.0"
36
36
  }
37
37
  }
@@ -38,10 +38,6 @@ const exampleModalWithButtons = (
38
38
  );
39
39
 
40
40
  describe("ModalBackdrop", () => {
41
- beforeEach(() => {
42
- jest.useRealTimers();
43
- });
44
-
45
41
  afterEach(() => {
46
42
  unmountAll();
47
43
  if (document.body) {
@@ -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";
@@ -20,10 +25,6 @@ const exampleModal = (
20
25
  );
21
26
 
22
27
  describe("ModalLauncher", () => {
23
- beforeEach(() => {
24
- jest.useRealTimers();
25
- });
26
-
27
28
  afterEach(() => {
28
29
  unmountAll();
29
30
  if (document.body) {
@@ -49,6 +50,7 @@ describe("ModalLauncher", () => {
49
50
  wrapper.find("button").simulate("click");
50
51
  await wait();
51
52
 
53
+ // eslint-disable-next-line testing-library/no-node-access
52
54
  const portal = global.document.querySelector(
53
55
  "[data-modal-launcher-portal]",
54
56
  );
@@ -108,6 +110,7 @@ describe("ModalLauncher", () => {
108
110
  </ModalLauncher>,
109
111
  );
110
112
  expect(
113
+ // eslint-disable-next-line testing-library/no-node-access
111
114
  global.document.querySelector("[data-modal-launcher-portal]"),
112
115
  ).toBeNull();
113
116
 
@@ -115,6 +118,7 @@ describe("ModalLauncher", () => {
115
118
  // the modal function.
116
119
  opened = true;
117
120
  wrapper.find("button").simulate("click");
121
+ // eslint-disable-next-line testing-library/no-node-access
118
122
  const portal = global.document.querySelector(
119
123
  "[data-modal-launcher-portal]",
120
124
  );
@@ -132,6 +136,7 @@ describe("ModalLauncher", () => {
132
136
 
133
137
  // Launch the modal.
134
138
  wrapper.find("button").simulate("click");
139
+ // eslint-disable-next-line testing-library/no-node-access
135
140
  expect(document.querySelector("[data-modal-child]")).toBeTruthy();
136
141
 
137
142
  // Simulate an Escape keypress.
@@ -146,6 +151,7 @@ describe("ModalLauncher", () => {
146
151
  // NOTE(mdr): This might be fragile once React's async rendering lands.
147
152
  // I wonder if we'll be able to force synchronous rendering in unit
148
153
  // tests?
154
+ // eslint-disable-next-line testing-library/no-node-access
149
155
  expect(document.querySelector("[data-modal-child]")).toBeFalsy();
150
156
  });
151
157
 
@@ -156,7 +162,7 @@ describe("ModalLauncher", () => {
156
162
 
157
163
  // Rather than test this rigorously, we'll just check that a
158
164
  // ScrollDisabler is present, and trust ScrollDisabler to do its job.
159
- const wrapper = shallow(
165
+ const wrapper = mount(
160
166
  <ModalLauncher
161
167
  modal={({closeModal}) => {
162
168
  savedCloseModal = closeModal;
@@ -189,7 +195,7 @@ describe("ModalLauncher", () => {
189
195
  jest.spyOn(console, "warn");
190
196
 
191
197
  // Act
192
- shallow(
198
+ render(
193
199
  // $FlowIgnore
194
200
  <ModalLauncher
195
201
  modal={exampleModal}
@@ -213,7 +219,7 @@ describe("ModalLauncher", () => {
213
219
 
214
220
  // Act
215
221
  // $FlowIgnore
216
- shallow(<ModalLauncher modal={exampleModal} opened={false} />);
222
+ render(<ModalLauncher modal={exampleModal} opened={false} />);
217
223
 
218
224
  // Assert
219
225
  // eslint-disable-next-line no-console
@@ -228,7 +234,7 @@ describe("ModalLauncher", () => {
228
234
 
229
235
  // Act
230
236
  // $FlowIgnore
231
- shallow(<ModalLauncher modal={exampleModal} />);
237
+ render(<ModalLauncher modal={exampleModal} />);
232
238
 
233
239
  // Assert
234
240
  // eslint-disable-next-line no-console
@@ -284,50 +290,140 @@ describe("ModalLauncher", () => {
284
290
  await wait();
285
291
 
286
292
  // Assert
293
+ // eslint-disable-next-line testing-library/no-node-access
287
294
  expect(document.activeElement).not.toBe(lastButton);
288
295
  });
289
296
 
290
297
  test("if modal is closed, return focus to the last element focused outside the modal", async () => {
291
298
  // 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`);
299
+ const ModalLauncherWrapper = () => {
300
+ const [opened, setOpened] = React.useState(false);
301
+
302
+ const handleOpen = () => {
303
+ setOpened(true);
304
+ };
305
+
306
+ const handleClose = () => {
307
+ setOpened(false);
308
+ };
309
+
310
+ return (
311
+ <View>
312
+ <Button>Top of page (should not receive focus)</Button>
313
+ <Button
314
+ testId="launcher-button"
315
+ onClick={() => handleOpen()}
316
+ >
317
+ Open modal
318
+ </Button>
319
+ <ModalLauncher
320
+ onClose={() => handleClose()}
321
+ opened={opened}
322
+ modal={({closeModal}) => (
323
+ <OnePaneDialog
324
+ title="Regular modal"
325
+ content={<View>Hello World</View>}
326
+ footer={
327
+ <Button
328
+ testId="modal-close-button"
329
+ onClick={closeModal}
330
+ >
331
+ Close Modal
332
+ </Button>
333
+ }
334
+ />
335
+ )}
336
+ />
337
+ </View>
338
+ );
297
339
  };
298
340
 
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
- );
341
+ render(<ModalLauncherWrapper />);
312
342
 
313
- const lastButton = wrapper
314
- .find("[data-last-focused-button]")
315
- .getDOMNode();
316
- // force focus
317
- lastButton.focus();
343
+ const lastButton = await screen.findByTestId("launcher-button");
318
344
 
319
345
  // Launch the modal.
320
- wrapper.find("button").simulate("click");
346
+ userEvent.click(lastButton);
321
347
 
322
- // wait for styles to be applied
323
- await wait();
348
+ // Act
349
+ // Close modal
350
+ const modalCloseButton = await screen.findByTestId(
351
+ "modal-close-button",
352
+ );
353
+ userEvent.click(modalCloseButton);
354
+
355
+ // Assert
356
+ await waitFor(() => {
357
+ expect(lastButton).toHaveFocus();
358
+ });
359
+ });
360
+
361
+ test("if `closedFocusId` is passed, shift focus to specified element after the modal closes", async () => {
362
+ // Arrange
363
+ const ModalLauncherWrapper = () => {
364
+ const [opened, setOpened] = React.useState(false);
365
+
366
+ const handleOpen = () => {
367
+ setOpened(true);
368
+ };
369
+
370
+ const handleClose = () => {
371
+ setOpened(false);
372
+ };
373
+
374
+ return (
375
+ <View>
376
+ <Button>Top of page (should not receive focus)</Button>
377
+ <Button id="button-to-focus-on" testId="focused-button">
378
+ Focus here after close
379
+ </Button>
380
+ <Button
381
+ testId="launcher-button"
382
+ onClick={() => handleOpen()}
383
+ >
384
+ Open modal
385
+ </Button>
386
+ <ModalLauncher
387
+ onClose={() => handleClose()}
388
+ opened={opened}
389
+ closedFocusId="button-to-focus-on"
390
+ modal={({closeModal}) => (
391
+ <OnePaneDialog
392
+ title="Triggered from action menu"
393
+ content={<View>Hello World</View>}
394
+ footer={
395
+ <Button
396
+ testId="modal-close-button"
397
+ onClick={closeModal}
398
+ >
399
+ Close Modal
400
+ </Button>
401
+ }
402
+ />
403
+ )}
404
+ />
405
+ </View>
406
+ );
407
+ };
408
+
409
+ render(<ModalLauncherWrapper />);
410
+
411
+ // Launch modal
412
+ const launcherButton = await screen.findByTestId("launcher-button");
413
+ userEvent.click(launcherButton);
324
414
 
325
415
  // Act
326
- savedCloseModal(); // close the modal
327
- wrapper.update();
416
+ // Close modal
417
+ const modalCloseButton = await screen.findByTestId(
418
+ "modal-close-button",
419
+ );
420
+ userEvent.click(modalCloseButton);
328
421
 
329
422
  // Assert
330
- expect(document.activeElement).toBe(lastButton);
423
+ const focusedButton = await screen.findByTestId("focused-button");
424
+ await waitFor(() => {
425
+ expect(focusedButton).toHaveFocus();
426
+ });
331
427
  });
332
428
 
333
429
  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";
@@ -80,7 +81,7 @@ export const Simple: StoryComponentType = () => {
80
81
 
81
82
  const styles = StyleSheet.create({
82
83
  above: {
83
- background: "url(/modal-above.png)",
84
+ background: "url(./modal-above.png)",
84
85
  width: 874,
85
86
  height: 551,
86
87
  position: "absolute",
@@ -89,7 +90,7 @@ const styles = StyleSheet.create({
89
90
  },
90
91
 
91
92
  below: {
92
- background: "url(/modal-below.png)",
93
+ background: "url(./modal-below.png)",
93
94
  width: 868,
94
95
  height: 521,
95
96
  position: "absolute",
@@ -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
+ };