@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/CHANGELOG.md +32 -0
- package/dist/es/index.js +92 -351
- package/dist/index.js +1395 -26
- package/package.json +9 -9
- package/src/components/__tests__/modal-backdrop.test.js +0 -4
- package/src/components/__tests__/modal-launcher.test.js +134 -38
- package/src/components/modal-launcher.js +56 -6
- package/src/components/one-pane-dialog.stories.js +48 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanacademy/wonder-blocks-modal",
|
|
3
|
-
"version": "2.2
|
|
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.
|
|
19
|
+
"@khanacademy/wonder-blocks-breadcrumbs": "^1.0.31",
|
|
20
20
|
"@khanacademy/wonder-blocks-color": "^1.1.20",
|
|
21
|
-
"@khanacademy/wonder-blocks-core": "^4.3.
|
|
22
|
-
"@khanacademy/wonder-blocks-icon": "^1.2.
|
|
23
|
-
"@khanacademy/wonder-blocks-icon-button": "^3.4.
|
|
24
|
-
"@khanacademy/wonder-blocks-layout": "^1.4.
|
|
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.
|
|
27
|
-
"@khanacademy/wonder-blocks-typography": "^1.1.
|
|
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.
|
|
35
|
+
"wb-dev-build-settings": "^0.4.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import * as React from "react";
|
|
3
|
-
import {mount
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
346
|
+
userEvent.click(lastButton);
|
|
321
347
|
|
|
322
|
-
//
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
416
|
+
// Close modal
|
|
417
|
+
const modalCloseButton = await screen.findByTestId(
|
|
418
|
+
"modal-close-button",
|
|
419
|
+
);
|
|
420
|
+
userEvent.click(modalCloseButton);
|
|
328
421
|
|
|
329
422
|
// Assert
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
const {onClose} = this.props;
|
|
162
206
|
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
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(
|
|
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
|
+
};
|