@khanacademy/wonder-blocks-modal 2.2.2 → 2.3.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.
- package/CHANGELOG.md +31 -0
- package/dist/es/index.js +92 -351
- package/dist/index.js +1395 -26
- package/package.json +9 -9
- package/src/components/__tests__/modal-launcher.test.js +134 -34
- 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.
|
|
3
|
+
"version": "2.3.1",
|
|
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.30",
|
|
20
20
|
"@khanacademy/wonder-blocks-color": "^1.1.20",
|
|
21
|
-
"@khanacademy/wonder-blocks-core": "^4.
|
|
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.1",
|
|
22
|
+
"@khanacademy/wonder-blocks-icon": "^1.2.27",
|
|
23
|
+
"@khanacademy/wonder-blocks-icon-button": "^3.4.6",
|
|
24
|
+
"@khanacademy/wonder-blocks-layout": "^1.4.9",
|
|
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.31",
|
|
27
|
+
"@khanacademy/wonder-blocks-typography": "^1.1.31"
|
|
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";
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
350
|
+
userEvent.click(lastButton);
|
|
321
351
|
|
|
322
|
-
//
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
420
|
+
// Close modal
|
|
421
|
+
const modalCloseButton = await screen.findByTestId(
|
|
422
|
+
"modal-close-button",
|
|
423
|
+
);
|
|
424
|
+
userEvent.click(modalCloseButton);
|
|
328
425
|
|
|
329
426
|
// Assert
|
|
330
|
-
|
|
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
|
-
|
|
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
|
+
};
|