@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/dist/es/index.js
CHANGED
|
@@ -6,20 +6,11 @@ import Spacing from '@khanacademy/wonder-blocks-spacing';
|
|
|
6
6
|
import Color from '@khanacademy/wonder-blocks-color';
|
|
7
7
|
import { HeadingMedium, LabelSmall } from '@khanacademy/wonder-blocks-typography';
|
|
8
8
|
import * as ReactDOM from 'react-dom';
|
|
9
|
+
import { withActionScheduler } from '@khanacademy/wonder-blocks-timing';
|
|
9
10
|
import _extends from '@babel/runtime/helpers/extends';
|
|
10
11
|
import { icons } from '@khanacademy/wonder-blocks-icon';
|
|
11
12
|
import IconButton from '@khanacademy/wonder-blocks-icon-button';
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
-
* `ModalDialog` is a component that contains these elements:
|
|
15
|
-
* - The visual dialog element itself (`<div role="dialog"/>`)
|
|
16
|
-
* - The custom contents below and/or above the Dialog itself (e.g. decorative graphics).
|
|
17
|
-
*
|
|
18
|
-
* **Accessibility notes:**
|
|
19
|
-
* - By default (e.g. using `OnePaneDialog`), `aria-labelledby` is populated automatically using the dialog title `id`.
|
|
20
|
-
* - If there is a custom Dialog implementation (e.g. `TwoPaneDialog`), the dialog element doesn’t have to have
|
|
21
|
-
* the `aria-labelledby` attribute however this is recommended. It should match the `id` of the dialog title.
|
|
22
|
-
*/
|
|
23
14
|
class ModalDialog extends React.Component {
|
|
24
15
|
render() {
|
|
25
16
|
const {
|
|
@@ -35,23 +26,23 @@ class ModalDialog extends React.Component {
|
|
|
35
26
|
ssrSize: "large",
|
|
36
27
|
mediaSpec: MEDIA_MODAL_SPEC
|
|
37
28
|
};
|
|
38
|
-
return
|
|
29
|
+
return React.createElement(MediaLayoutContext.Provider, {
|
|
39
30
|
value: contextValue
|
|
40
|
-
},
|
|
31
|
+
}, React.createElement(MediaLayout, {
|
|
41
32
|
styleSheets: styleSheets$3
|
|
42
33
|
}, ({
|
|
43
34
|
styles
|
|
44
|
-
}) =>
|
|
35
|
+
}) => React.createElement(View, {
|
|
45
36
|
style: [styles.wrapper, style]
|
|
46
|
-
}, below &&
|
|
37
|
+
}, below && React.createElement(View, {
|
|
47
38
|
style: styles.below
|
|
48
|
-
}, below),
|
|
39
|
+
}, below), React.createElement(View, {
|
|
49
40
|
role: role,
|
|
50
41
|
"aria-modal": "true",
|
|
51
42
|
"aria-labelledby": ariaLabelledBy,
|
|
52
43
|
style: styles.dialog,
|
|
53
44
|
testId: testId
|
|
54
|
-
}, children), above &&
|
|
45
|
+
}, children), above && React.createElement(View, {
|
|
55
46
|
style: styles.above
|
|
56
47
|
}, above))));
|
|
57
48
|
}
|
|
@@ -70,10 +61,6 @@ const styleSheets$3 = {
|
|
|
70
61
|
height: "100%",
|
|
71
62
|
position: "relative"
|
|
72
63
|
},
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Ensures the dialog container uses the container size
|
|
76
|
-
*/
|
|
77
64
|
dialog: {
|
|
78
65
|
width: "100%",
|
|
79
66
|
height: "100%",
|
|
@@ -107,15 +94,6 @@ const styleSheets$3 = {
|
|
|
107
94
|
})
|
|
108
95
|
};
|
|
109
96
|
|
|
110
|
-
/**
|
|
111
|
-
* Modal footer included after the content.
|
|
112
|
-
*
|
|
113
|
-
* **Implementation notes**:
|
|
114
|
-
*
|
|
115
|
-
* If you are creating a custom Dialog, make sure to follow these guidelines:
|
|
116
|
-
* - Make sure to include it as part of [ModalPanel](/#modalpanel) by using the `footer` prop.
|
|
117
|
-
* - The footer is completely flexible. Meaning the developer needs to add its own custom layout to match design specs.
|
|
118
|
-
*/
|
|
119
97
|
class ModalFooter extends React.Component {
|
|
120
98
|
static isClassOf(instance) {
|
|
121
99
|
return instance && instance.type && instance.type.__IS_MODAL_FOOTER__;
|
|
@@ -125,7 +103,7 @@ class ModalFooter extends React.Component {
|
|
|
125
103
|
const {
|
|
126
104
|
children
|
|
127
105
|
} = this.props;
|
|
128
|
-
return
|
|
106
|
+
return React.createElement(View, {
|
|
129
107
|
style: styles$3.footer
|
|
130
108
|
}, children);
|
|
131
109
|
}
|
|
@@ -149,49 +127,6 @@ const styles$3 = StyleSheet.create({
|
|
|
149
127
|
}
|
|
150
128
|
});
|
|
151
129
|
|
|
152
|
-
/**
|
|
153
|
-
* This is a helper component that is never rendered by itself. It is always
|
|
154
|
-
* pinned to the top of the dialog, is responsive using the same behavior as its
|
|
155
|
-
* parent dialog, and has the following properties:
|
|
156
|
-
* - title
|
|
157
|
-
* - breadcrumb OR subtitle, but not both.
|
|
158
|
-
*
|
|
159
|
-
* **Accessibility notes:**
|
|
160
|
-
*
|
|
161
|
-
* - By default (e.g. using [OnePaneDialog](/#onepanedialog)), `titleId` is
|
|
162
|
-
* populated automatically by the parent container.
|
|
163
|
-
* - If there is a custom Dialog implementation (e.g. `TwoPaneDialog`), the
|
|
164
|
-
* ModalHeader doesn’t have to have the `titleId` prop however this is
|
|
165
|
-
* recommended. It should match the `aria-labelledby` prop of the
|
|
166
|
-
* [ModalDialog](/#modaldialog) component. If you want to see an example of
|
|
167
|
-
* how to generate this ID, check [IDProvider](/#idprovider).
|
|
168
|
-
*
|
|
169
|
-
* **Implementation notes:**
|
|
170
|
-
*
|
|
171
|
-
* If you are creating a custom Dialog, make sure to follow these guidelines:
|
|
172
|
-
* - Make sure to include it as part of [ModalPanel](/#modalpanel) by using the
|
|
173
|
-
* `header` prop.
|
|
174
|
-
* - Add a title (required).
|
|
175
|
-
* - Optionally add a subtitle or breadcrumbs.
|
|
176
|
-
* - We encourage you to add `titleId` (see Accessibility notes).
|
|
177
|
-
* - If the `ModalPanel` has a dark background, make sure to set `light` to
|
|
178
|
-
* `false`.
|
|
179
|
-
* - If you need to create e2e tests, make sure to pass a `testId` prop and
|
|
180
|
-
* add a sufix to scope the testId to this component: e.g.
|
|
181
|
-
* `some-random-id-ModalHeader`. This scope will also be passed to the title
|
|
182
|
-
* and subtitle elements: e.g. `some-random-id-ModalHeader-title`.
|
|
183
|
-
*
|
|
184
|
-
* Example:
|
|
185
|
-
*
|
|
186
|
-
* ```js
|
|
187
|
-
* <ModalHeader
|
|
188
|
-
* title="Sidebar using ModalHeader"
|
|
189
|
-
* subtitle="subtitle"
|
|
190
|
-
* titleId="uniqueTitleId"
|
|
191
|
-
* light={false}
|
|
192
|
-
* />
|
|
193
|
-
* ```
|
|
194
|
-
*/
|
|
195
130
|
class ModalHeader extends React.Component {
|
|
196
131
|
render() {
|
|
197
132
|
const {
|
|
@@ -207,20 +142,20 @@ class ModalHeader extends React.Component {
|
|
|
207
142
|
throw new Error("'subtitle' and 'breadcrumbs' can't be used together");
|
|
208
143
|
}
|
|
209
144
|
|
|
210
|
-
return
|
|
145
|
+
return React.createElement(MediaLayout, {
|
|
211
146
|
styleSheets: styleSheets$2
|
|
212
147
|
}, ({
|
|
213
148
|
styles
|
|
214
|
-
}) =>
|
|
149
|
+
}) => React.createElement(View, {
|
|
215
150
|
style: [styles.header, !light && styles.dark],
|
|
216
151
|
testId: testId
|
|
217
|
-
}, breadcrumbs &&
|
|
152
|
+
}, breadcrumbs && React.createElement(View, {
|
|
218
153
|
style: styles.breadcrumbs
|
|
219
|
-
}, breadcrumbs),
|
|
154
|
+
}, breadcrumbs), React.createElement(HeadingMedium, {
|
|
220
155
|
style: styles.title,
|
|
221
156
|
id: titleId,
|
|
222
157
|
testId: testId && `${testId}-title`
|
|
223
|
-
}, title), subtitle &&
|
|
158
|
+
}, title), subtitle && React.createElement(LabelSmall, {
|
|
224
159
|
style: light && styles.subtitle,
|
|
225
160
|
testId: testId && `${testId}-subtitle`
|
|
226
161
|
}, subtitle)));
|
|
@@ -250,7 +185,6 @@ const styleSheets$2 = {
|
|
|
250
185
|
marginBottom: Spacing.xSmall_8
|
|
251
186
|
},
|
|
252
187
|
title: {
|
|
253
|
-
// Prevent title from overlapping the close button
|
|
254
188
|
paddingRight: Spacing.medium_16
|
|
255
189
|
},
|
|
256
190
|
subtitle: {
|
|
@@ -270,22 +204,11 @@ const styleSheets$2 = {
|
|
|
270
204
|
};
|
|
271
205
|
|
|
272
206
|
class FocusTrap extends React.Component {
|
|
273
|
-
/** The most recent node _inside this component_ to receive focus. */
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Whether we're currently applying programmatic focus, and should therefore
|
|
277
|
-
* ignore focus change events.
|
|
278
|
-
*/
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Tabbing is restricted to descendents of this element.
|
|
282
|
-
*/
|
|
283
207
|
constructor(props) {
|
|
284
208
|
super(props);
|
|
285
209
|
|
|
286
210
|
this.getModalRoot = node => {
|
|
287
211
|
if (!node) {
|
|
288
|
-
// The component is being umounted
|
|
289
212
|
return;
|
|
290
213
|
}
|
|
291
214
|
|
|
@@ -299,8 +222,6 @@ class FocusTrap extends React.Component {
|
|
|
299
222
|
};
|
|
300
223
|
|
|
301
224
|
this.handleGlobalFocus = e => {
|
|
302
|
-
// If we're busy applying our own programmatic focus, we ignore focus
|
|
303
|
-
// changes, to avoid an infinite loop.
|
|
304
225
|
if (this.ignoreFocusChanges) {
|
|
305
226
|
return;
|
|
306
227
|
}
|
|
@@ -308,7 +229,6 @@ class FocusTrap extends React.Component {
|
|
|
308
229
|
const target = e.target;
|
|
309
230
|
|
|
310
231
|
if (!(target instanceof Node)) {
|
|
311
|
-
// Sometimes focus events trigger on the document itself. Ignore!
|
|
312
232
|
return;
|
|
313
233
|
}
|
|
314
234
|
|
|
@@ -319,25 +239,13 @@ class FocusTrap extends React.Component {
|
|
|
319
239
|
}
|
|
320
240
|
|
|
321
241
|
if (modalRoot.contains(target)) {
|
|
322
|
-
// If the newly focused node is inside the modal, we just keep track
|
|
323
|
-
// of that.
|
|
324
242
|
this.lastNodeFocusedInModal = target;
|
|
325
243
|
} else {
|
|
326
|
-
|
|
327
|
-
// the first focusable node of the modal. (This could be the user
|
|
328
|
-
// pressing Tab on the last node of the modal, or focus escaping in
|
|
329
|
-
// some other way.)
|
|
330
|
-
this.focusFirstElementIn(modalRoot); // But, if it turns out that the first focusable node of the modal
|
|
331
|
-
// was what we were previously focusing, then this is probably the
|
|
332
|
-
// user pressing Shift-Tab on the first node, wanting to go to the
|
|
333
|
-
// end. So, we instead try focusing the last focusable node of the
|
|
334
|
-
// modal.
|
|
244
|
+
this.focusFirstElementIn(modalRoot);
|
|
335
245
|
|
|
336
246
|
if (document.activeElement === this.lastNodeFocusedInModal) {
|
|
337
247
|
this.focusLastElementIn(modalRoot);
|
|
338
|
-
}
|
|
339
|
-
// node as the last node focused in the modal.
|
|
340
|
-
|
|
248
|
+
}
|
|
341
249
|
|
|
342
250
|
this.lastNodeFocusedInModal = document.activeElement;
|
|
343
251
|
}
|
|
@@ -355,27 +263,18 @@ class FocusTrap extends React.Component {
|
|
|
355
263
|
window.removeEventListener("focus", this.handleGlobalFocus, true);
|
|
356
264
|
}
|
|
357
265
|
|
|
358
|
-
/** Try to focus the given node. Return true iff successful. */
|
|
359
266
|
tryToFocus(node) {
|
|
360
267
|
if (node instanceof HTMLElement) {
|
|
361
268
|
this.ignoreFocusChanges = true;
|
|
362
269
|
|
|
363
270
|
try {
|
|
364
271
|
node.focus();
|
|
365
|
-
} catch (e) {
|
|
366
|
-
}
|
|
272
|
+
} catch (e) {}
|
|
367
273
|
|
|
368
274
|
this.ignoreFocusChanges = false;
|
|
369
275
|
return document.activeElement === node;
|
|
370
276
|
}
|
|
371
277
|
}
|
|
372
|
-
/**
|
|
373
|
-
* Focus the first focusable descendant of the given node.
|
|
374
|
-
*
|
|
375
|
-
* Return true if we succeed. Or, if the given node has no focusable
|
|
376
|
-
* descendants, return false.
|
|
377
|
-
*/
|
|
378
|
-
|
|
379
278
|
|
|
380
279
|
focusFirstElementIn(currentParent) {
|
|
381
280
|
const children = currentParent.childNodes;
|
|
@@ -390,13 +289,6 @@ class FocusTrap extends React.Component {
|
|
|
390
289
|
|
|
391
290
|
return false;
|
|
392
291
|
}
|
|
393
|
-
/**
|
|
394
|
-
* Focus the last focusable descendant of the given node.
|
|
395
|
-
*
|
|
396
|
-
* Return true if we succeed. Or, if the given node has no focusable
|
|
397
|
-
* descendants, return false.
|
|
398
|
-
*/
|
|
399
|
-
|
|
400
292
|
|
|
401
293
|
focusLastElementIn(currentParent) {
|
|
402
294
|
const children = currentParent.childNodes;
|
|
@@ -411,22 +303,20 @@ class FocusTrap extends React.Component {
|
|
|
411
303
|
|
|
412
304
|
return false;
|
|
413
305
|
}
|
|
414
|
-
/** This method is called when any node on the page is focused. */
|
|
415
|
-
|
|
416
306
|
|
|
417
307
|
render() {
|
|
418
308
|
const {
|
|
419
309
|
style
|
|
420
310
|
} = this.props;
|
|
421
|
-
return
|
|
311
|
+
return React.createElement(React.Fragment, null, React.createElement("div", {
|
|
422
312
|
tabIndex: "0",
|
|
423
313
|
style: {
|
|
424
314
|
position: "fixed"
|
|
425
315
|
}
|
|
426
|
-
}),
|
|
316
|
+
}), React.createElement(View, {
|
|
427
317
|
style: style,
|
|
428
318
|
ref: this.getModalRoot
|
|
429
|
-
}, this.props.children),
|
|
319
|
+
}, this.props.children), React.createElement("div", {
|
|
430
320
|
tabIndex: "0",
|
|
431
321
|
style: {
|
|
432
322
|
position: "fixed"
|
|
@@ -436,43 +326,23 @@ class FocusTrap extends React.Component {
|
|
|
436
326
|
|
|
437
327
|
}
|
|
438
328
|
|
|
439
|
-
/**
|
|
440
|
-
* The attribute used to identify a modal launcher portal.
|
|
441
|
-
*/
|
|
442
329
|
const ModalLauncherPortalAttributeName = "data-modal-launcher-portal";
|
|
443
330
|
|
|
444
|
-
/**
|
|
445
|
-
* List of elements that can be focused
|
|
446
|
-
* @see https://www.w3.org/TR/html5/editing.html#can-be-focused
|
|
447
|
-
*/
|
|
448
331
|
const FOCUSABLE_ELEMENTS = 'a[href], details, input, textarea, select, button:not([aria-label^="Close"])';
|
|
449
332
|
function findFocusableNodes(root) {
|
|
450
333
|
return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
|
|
451
334
|
}
|
|
452
335
|
|
|
453
|
-
/**
|
|
454
|
-
* A private component used by ModalLauncher. This is the fixed-position
|
|
455
|
-
* container element that gets mounted outside the DOM. It overlays the modal
|
|
456
|
-
* content (provided as `children`) over the content, with a gray backdrop
|
|
457
|
-
* behind it.
|
|
458
|
-
*
|
|
459
|
-
* This component is also responsible for cloning the provided modal `children`,
|
|
460
|
-
* and adding an `onClose` prop that will call `onCloseModal`. If an
|
|
461
|
-
* `onClose` prop is already provided, the two are merged.
|
|
462
|
-
*/
|
|
463
336
|
class ModalBackdrop extends React.Component {
|
|
464
337
|
constructor(...args) {
|
|
465
338
|
super(...args);
|
|
466
339
|
this._mousePressedOutside = false;
|
|
467
340
|
|
|
468
341
|
this.handleMouseDown = e => {
|
|
469
|
-
// Confirm that it is the backdrop that is being clicked, not the child
|
|
470
342
|
this._mousePressedOutside = e.target === e.currentTarget;
|
|
471
343
|
};
|
|
472
344
|
|
|
473
345
|
this.handleMouseUp = e => {
|
|
474
|
-
// Confirm that it is the backdrop that is being clicked, not the child
|
|
475
|
-
// and that the mouse was pressed in the backdrop first.
|
|
476
346
|
if (e.target === e.currentTarget && this._mousePressedOutside) {
|
|
477
347
|
this.props.onCloseModal();
|
|
478
348
|
}
|
|
@@ -488,20 +358,13 @@ class ModalBackdrop extends React.Component {
|
|
|
488
358
|
return;
|
|
489
359
|
}
|
|
490
360
|
|
|
491
|
-
const firstFocusableElement =
|
|
492
|
-
this._getInitialFocusElement(node) || // 2. get first occurence from list of focusable elements
|
|
493
|
-
this._getFirstFocusableElement(node) || // 3. get the dialog itself
|
|
494
|
-
this._getDialogElement(node); // wait for styles to applied
|
|
495
|
-
|
|
361
|
+
const firstFocusableElement = this._getInitialFocusElement(node) || this._getFirstFocusableElement(node) || this._getDialogElement(node);
|
|
496
362
|
|
|
497
363
|
setTimeout(() => {
|
|
498
364
|
firstFocusableElement.focus();
|
|
499
365
|
}, 0);
|
|
500
366
|
}
|
|
501
367
|
|
|
502
|
-
/**
|
|
503
|
-
* Returns an element specified by the user
|
|
504
|
-
*/
|
|
505
368
|
_getInitialFocusElement(node) {
|
|
506
369
|
const {
|
|
507
370
|
initialFocusId
|
|
@@ -513,41 +376,22 @@ class ModalBackdrop extends React.Component {
|
|
|
513
376
|
|
|
514
377
|
return ReactDOM.findDOMNode(node.querySelector(`#${initialFocusId}`));
|
|
515
378
|
}
|
|
516
|
-
/**
|
|
517
|
-
* Returns the first focusable element found inside the Dialog
|
|
518
|
-
*/
|
|
519
|
-
|
|
520
379
|
|
|
521
380
|
_getFirstFocusableElement(node) {
|
|
522
|
-
// get a collection of elements that can be focused
|
|
523
381
|
const focusableElements = findFocusableNodes(node);
|
|
524
382
|
|
|
525
383
|
if (!focusableElements) {
|
|
526
384
|
return null;
|
|
527
|
-
}
|
|
528
|
-
|
|
385
|
+
}
|
|
529
386
|
|
|
530
387
|
return focusableElements[0];
|
|
531
388
|
}
|
|
532
|
-
/**
|
|
533
|
-
* Returns the dialog element
|
|
534
|
-
*/
|
|
535
|
-
|
|
536
389
|
|
|
537
390
|
_getDialogElement(node) {
|
|
538
|
-
|
|
539
|
-
// the dialog content element itself will receive focus.
|
|
540
|
-
const dialogElement = ReactDOM.findDOMNode(node.querySelector('[role="dialog"]')); // add tabIndex to make the Dialog focusable
|
|
541
|
-
|
|
391
|
+
const dialogElement = ReactDOM.findDOMNode(node.querySelector('[role="dialog"]'));
|
|
542
392
|
dialogElement.tabIndex = -1;
|
|
543
393
|
return dialogElement;
|
|
544
394
|
}
|
|
545
|
-
/**
|
|
546
|
-
* When the user clicks on the gray backdrop area (i.e., the click came
|
|
547
|
-
* _directly_ from the positioner, not bubbled up from its children), close
|
|
548
|
-
* the modal.
|
|
549
|
-
*/
|
|
550
|
-
|
|
551
395
|
|
|
552
396
|
render() {
|
|
553
397
|
const {
|
|
@@ -557,7 +401,7 @@ class ModalBackdrop extends React.Component {
|
|
|
557
401
|
const backdropProps = {
|
|
558
402
|
[ModalLauncherPortalAttributeName]: true
|
|
559
403
|
};
|
|
560
|
-
return
|
|
404
|
+
return React.createElement(View, _extends({
|
|
561
405
|
style: styles$2.modalPositioner,
|
|
562
406
|
onMouseDown: this.handleMouseDown,
|
|
563
407
|
onMouseUp: this.handleMouseUp,
|
|
@@ -575,30 +419,11 @@ const styles$2 = StyleSheet.create({
|
|
|
575
419
|
height: "100%",
|
|
576
420
|
alignItems: "center",
|
|
577
421
|
justifyContent: "center",
|
|
578
|
-
// If the modal ends up being too big for the viewport (e.g., the min
|
|
579
|
-
// height is triggered), add another scrollbar specifically for
|
|
580
|
-
// scrolling modal content.
|
|
581
|
-
//
|
|
582
|
-
// TODO(mdr): The specified behavior is that the modal should scroll
|
|
583
|
-
// with the rest of the page, rather than separately, if overflow
|
|
584
|
-
// turns out to be necessary. That sounds hard to do; punting for
|
|
585
|
-
// now!
|
|
586
422
|
overflow: "auto",
|
|
587
423
|
background: Color.offBlack64
|
|
588
424
|
}
|
|
589
425
|
});
|
|
590
426
|
|
|
591
|
-
/**
|
|
592
|
-
* A UI-less component that lets `ModalLauncher` disable page scroll.
|
|
593
|
-
*
|
|
594
|
-
* The positioning of the modal requires some global page state changed
|
|
595
|
-
* unfortunately, and this handles that in an encapsulated way.
|
|
596
|
-
*
|
|
597
|
-
* NOTE(mdr): This component was copied from webapp. Be wary of sync issues. It
|
|
598
|
-
* also doesn't have unit tests, and we haven't added any, since it's a
|
|
599
|
-
* relatively stable component that has now been stress-tested lots in prod.
|
|
600
|
-
*/
|
|
601
|
-
|
|
602
427
|
const needsHackyMobileSafariScrollDisabler = (() => {
|
|
603
428
|
if (typeof window === "undefined") {
|
|
604
429
|
return false;
|
|
@@ -615,13 +440,10 @@ class ScrollDisabler extends React.Component {
|
|
|
615
440
|
|
|
616
441
|
if (!body) {
|
|
617
442
|
throw new Error("couldn't find document.body");
|
|
618
|
-
}
|
|
619
|
-
// opened.
|
|
620
|
-
|
|
443
|
+
}
|
|
621
444
|
|
|
622
445
|
ScrollDisabler.oldOverflow = body.style.overflow;
|
|
623
|
-
ScrollDisabler.oldScrollY = window.scrollY;
|
|
624
|
-
// modified any of them.
|
|
446
|
+
ScrollDisabler.oldScrollY = window.scrollY;
|
|
625
447
|
|
|
626
448
|
if (needsHackyMobileSafariScrollDisabler) {
|
|
627
449
|
ScrollDisabler.oldPosition = body.style.position;
|
|
@@ -629,9 +451,7 @@ class ScrollDisabler extends React.Component {
|
|
|
629
451
|
ScrollDisabler.oldTop = body.style.top;
|
|
630
452
|
}
|
|
631
453
|
|
|
632
|
-
body.style.overflow = "hidden";
|
|
633
|
-
// fixed is also required. Setting style.top = -scollTop maintains
|
|
634
|
-
// the scroll position (without which we'd scroll to the top).
|
|
454
|
+
body.style.overflow = "hidden";
|
|
635
455
|
|
|
636
456
|
if (needsHackyMobileSafariScrollDisabler) {
|
|
637
457
|
body.style.position = "fixed";
|
|
@@ -651,8 +471,7 @@ class ScrollDisabler extends React.Component {
|
|
|
651
471
|
|
|
652
472
|
if (!body) {
|
|
653
473
|
throw new Error("couldn't find document.body");
|
|
654
|
-
}
|
|
655
|
-
|
|
474
|
+
}
|
|
656
475
|
|
|
657
476
|
body.style.overflow = ScrollDisabler.oldOverflow;
|
|
658
477
|
|
|
@@ -679,24 +498,8 @@ ScrollDisabler.numModalsOpened = 0;
|
|
|
679
498
|
const defaultContext = {
|
|
680
499
|
closeModal: undefined
|
|
681
500
|
};
|
|
682
|
-
var ModalContext =
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* This component enables you to launch a modal, covering the screen.
|
|
686
|
-
*
|
|
687
|
-
* Children have access to `openModal` function via the function-as-children
|
|
688
|
-
* pattern, so one common use case is for this component to wrap a button:
|
|
689
|
-
*
|
|
690
|
-
* ```js
|
|
691
|
-
* <ModalLauncher modal={<TwoColumnModal ... />}>
|
|
692
|
-
* {({openModal}) => <button onClick={openModal}>Learn more</button>}
|
|
693
|
-
* </ModalLauncher>
|
|
694
|
-
* ```
|
|
695
|
-
*
|
|
696
|
-
* The actual modal itself is constructed separately, using a layout component
|
|
697
|
-
* like OnePaneDialog and is provided via
|
|
698
|
-
* the `modal` prop.
|
|
699
|
-
*/
|
|
501
|
+
var ModalContext = React.createContext(defaultContext);
|
|
502
|
+
|
|
700
503
|
class ModalLauncher extends React.Component {
|
|
701
504
|
constructor(...args) {
|
|
702
505
|
super(...args);
|
|
@@ -705,7 +508,6 @@ class ModalLauncher extends React.Component {
|
|
|
705
508
|
};
|
|
706
509
|
|
|
707
510
|
this._saveLastElementFocused = () => {
|
|
708
|
-
// keep a reference of the element that triggers the modal
|
|
709
511
|
this.lastElementFocusedOutsideModal = document.activeElement;
|
|
710
512
|
};
|
|
711
513
|
|
|
@@ -717,33 +519,55 @@ class ModalLauncher extends React.Component {
|
|
|
717
519
|
});
|
|
718
520
|
};
|
|
719
521
|
|
|
522
|
+
this._returnFocus = () => {
|
|
523
|
+
const {
|
|
524
|
+
closedFocusId,
|
|
525
|
+
schedule
|
|
526
|
+
} = this.props;
|
|
527
|
+
const lastElement = this.lastElementFocusedOutsideModal;
|
|
528
|
+
|
|
529
|
+
if (closedFocusId) {
|
|
530
|
+
const focusElement = ReactDOM.findDOMNode(document.getElementById(closedFocusId));
|
|
531
|
+
|
|
532
|
+
if (focusElement) {
|
|
533
|
+
schedule.animationFrame(() => {
|
|
534
|
+
focusElement.focus();
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (lastElement != null) {
|
|
541
|
+
schedule.animationFrame(() => {
|
|
542
|
+
lastElement.focus();
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
720
547
|
this.handleCloseModal = () => {
|
|
721
548
|
this.setState({
|
|
722
549
|
opened: false
|
|
723
550
|
}, () => {
|
|
724
|
-
|
|
551
|
+
const {
|
|
552
|
+
onClose
|
|
553
|
+
} = this.props;
|
|
554
|
+
onClose && onClose();
|
|
725
555
|
|
|
726
|
-
|
|
727
|
-
// return focus to the element that triggered the modal
|
|
728
|
-
this.lastElementFocusedOutsideModal.focus();
|
|
729
|
-
}
|
|
556
|
+
this._returnFocus();
|
|
730
557
|
});
|
|
731
558
|
};
|
|
732
559
|
}
|
|
733
560
|
|
|
734
561
|
static getDerivedStateFromProps(props, state) {
|
|
735
562
|
if (typeof props.opened === "boolean" && props.children) {
|
|
736
|
-
// eslint-disable-next-line no-console
|
|
737
563
|
console.warn("'children' and 'opened' can't be used together");
|
|
738
564
|
}
|
|
739
565
|
|
|
740
566
|
if (typeof props.opened === "boolean" && !props.onClose) {
|
|
741
|
-
// eslint-disable-next-line no-console
|
|
742
567
|
console.warn("'onClose' should be used with 'opened'");
|
|
743
568
|
}
|
|
744
569
|
|
|
745
570
|
if (typeof props.opened !== "boolean" && !props.children) {
|
|
746
|
-
// eslint-disable-next-line no-console
|
|
747
571
|
console.warn("either 'children' or 'opened' must be set");
|
|
748
572
|
}
|
|
749
573
|
|
|
@@ -753,7 +577,6 @@ class ModalLauncher extends React.Component {
|
|
|
753
577
|
}
|
|
754
578
|
|
|
755
579
|
componentDidUpdate(prevProps) {
|
|
756
|
-
// ensures the element is stored only when the modal is opened
|
|
757
580
|
if (!prevProps.opened && this.props.opened) {
|
|
758
581
|
this._saveLastElementFocused();
|
|
759
582
|
}
|
|
@@ -781,34 +604,22 @@ class ModalLauncher extends React.Component {
|
|
|
781
604
|
return null;
|
|
782
605
|
}
|
|
783
606
|
|
|
784
|
-
return (
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
correct z-index so that it'll be above the global nav in webapp. */
|
|
798
|
-
React.createElement(FocusTrap, {
|
|
799
|
-
style: styles$1.container
|
|
800
|
-
}, /*#__PURE__*/React.createElement(ModalBackdrop, {
|
|
801
|
-
initialFocusId: this.props.initialFocusId,
|
|
802
|
-
testId: this.props.testId,
|
|
803
|
-
onCloseModal: this.props.backdropDismissEnabled ? this.handleCloseModal : () => {}
|
|
804
|
-
}, this._renderModal())), body), this.state.opened && /*#__PURE__*/React.createElement(ModalLauncherKeypressListener, {
|
|
805
|
-
onClose: this.handleCloseModal
|
|
806
|
-
}), this.state.opened && /*#__PURE__*/React.createElement(ScrollDisabler, null))
|
|
807
|
-
);
|
|
607
|
+
return React.createElement(ModalContext.Provider, {
|
|
608
|
+
value: {
|
|
609
|
+
closeModal: this.handleCloseModal
|
|
610
|
+
}
|
|
611
|
+
}, renderedChildren, this.state.opened && ReactDOM.createPortal(React.createElement(FocusTrap, {
|
|
612
|
+
style: styles$1.container
|
|
613
|
+
}, React.createElement(ModalBackdrop, {
|
|
614
|
+
initialFocusId: this.props.initialFocusId,
|
|
615
|
+
testId: this.props.testId,
|
|
616
|
+
onCloseModal: this.props.backdropDismissEnabled ? this.handleCloseModal : () => {}
|
|
617
|
+
}, this._renderModal())), body), this.state.opened && React.createElement(ModalLauncherKeypressListener, {
|
|
618
|
+
onClose: this.handleCloseModal
|
|
619
|
+
}), this.state.opened && React.createElement(ScrollDisabler, null));
|
|
808
620
|
}
|
|
809
621
|
|
|
810
622
|
}
|
|
811
|
-
/** A component that, when mounted, calls `onClose` when Escape is pressed. */
|
|
812
623
|
|
|
813
624
|
ModalLauncher.defaultProps = {
|
|
814
625
|
backdropDismissEnabled: true
|
|
@@ -819,16 +630,7 @@ class ModalLauncherKeypressListener extends React.Component {
|
|
|
819
630
|
super(...args);
|
|
820
631
|
|
|
821
632
|
this._handleKeyup = e => {
|
|
822
|
-
// We check the key as that's keyboard layout agnostic and also avoids
|
|
823
|
-
// the minefield of deprecated number type properties like keyCode and
|
|
824
|
-
// which, with the replacement code, which uses a string instead.
|
|
825
633
|
if (e.key === "Escape") {
|
|
826
|
-
// Stop the event going any further.
|
|
827
|
-
// For cancellation events, like the Escape key, we generally should
|
|
828
|
-
// air on the side of caution and only allow it to cancel one thing.
|
|
829
|
-
// So, it's polite for us to stop propagation of the event.
|
|
830
|
-
// Otherwise, we end up with UX where one Escape key press
|
|
831
|
-
// unexpectedly cancels multiple things.
|
|
832
634
|
e.preventDefault();
|
|
833
635
|
e.stopPropagation();
|
|
834
636
|
this.props.onClose();
|
|
@@ -852,18 +654,11 @@ class ModalLauncherKeypressListener extends React.Component {
|
|
|
852
654
|
|
|
853
655
|
const styles$1 = StyleSheet.create({
|
|
854
656
|
container: {
|
|
855
|
-
// This z-index is copied from the Khan Academy webapp.
|
|
856
|
-
//
|
|
857
|
-
// TODO(mdr): Should we keep this in a constants file somewhere? Or
|
|
858
|
-
// not hardcode it at all, and provide it to Wonder Blocks via
|
|
859
|
-
// configuration?
|
|
860
657
|
zIndex: 1080
|
|
861
658
|
}
|
|
862
659
|
});
|
|
660
|
+
var modalLauncher = withActionScheduler(ModalLauncher);
|
|
863
661
|
|
|
864
|
-
/**
|
|
865
|
-
* The Modal content included after the header
|
|
866
|
-
*/
|
|
867
662
|
class ModalContent extends React.Component {
|
|
868
663
|
static isClassOf(instance) {
|
|
869
664
|
return instance && instance.type && instance.type.__IS_MODAL_CONTENT__;
|
|
@@ -875,13 +670,13 @@ class ModalContent extends React.Component {
|
|
|
875
670
|
style,
|
|
876
671
|
children
|
|
877
672
|
} = this.props;
|
|
878
|
-
return
|
|
673
|
+
return React.createElement(MediaLayout, {
|
|
879
674
|
styleSheets: styleSheets$1
|
|
880
675
|
}, ({
|
|
881
676
|
styles
|
|
882
|
-
}) =>
|
|
677
|
+
}) => React.createElement(View, {
|
|
883
678
|
style: [styles.wrapper, scrollOverflow && styles.scrollOverflow]
|
|
884
|
-
},
|
|
679
|
+
}, React.createElement(View, {
|
|
885
680
|
style: [styles.content, style]
|
|
886
681
|
}, children)));
|
|
887
682
|
}
|
|
@@ -895,8 +690,6 @@ const styleSheets$1 = {
|
|
|
895
690
|
all: StyleSheet.create({
|
|
896
691
|
wrapper: {
|
|
897
692
|
flex: 1,
|
|
898
|
-
// This helps to ensure that the paddingBottom is preserved when
|
|
899
|
-
// the contents start to overflow, this goes away on display: flex
|
|
900
693
|
display: "block"
|
|
901
694
|
},
|
|
902
695
|
scrollOverflow: {
|
|
@@ -924,17 +717,15 @@ class CloseButton extends React.Component {
|
|
|
924
717
|
style,
|
|
925
718
|
testId
|
|
926
719
|
} = this.props;
|
|
927
|
-
return
|
|
720
|
+
return React.createElement(ModalContext.Consumer, null, ({
|
|
928
721
|
closeModal
|
|
929
722
|
}) => {
|
|
930
723
|
if (closeModal && onClick) {
|
|
931
724
|
throw new Error("You've specified 'onClose' on a modal when using ModalLauncher. Please specify 'onClose' on the ModalLauncher instead");
|
|
932
725
|
}
|
|
933
726
|
|
|
934
|
-
return
|
|
935
|
-
icon: icons.dismiss
|
|
936
|
-
// TODO(kevinb): provide a way to set this label
|
|
937
|
-
,
|
|
727
|
+
return React.createElement(IconButton, {
|
|
728
|
+
icon: icons.dismiss,
|
|
938
729
|
"aria-label": "Close modal",
|
|
939
730
|
onClick: onClick || closeModal,
|
|
940
731
|
kind: light ? "primary" : "tertiary",
|
|
@@ -947,26 +738,6 @@ class CloseButton extends React.Component {
|
|
|
947
738
|
|
|
948
739
|
}
|
|
949
740
|
|
|
950
|
-
/**
|
|
951
|
-
* ModalPanel is the content container.
|
|
952
|
-
*
|
|
953
|
-
* **Implementation notes:**
|
|
954
|
-
*
|
|
955
|
-
* If you are creating a custom Dialog, make sure to follow these guidelines:
|
|
956
|
-
* - Make sure to add this component inside the [ModalDialog](/#modaldialog).
|
|
957
|
-
* - If needed, you can also add a [ModalHeader](/#modalheader) using the
|
|
958
|
-
* `header` prop. Same goes for [ModalFooter](/#modalfooter).
|
|
959
|
-
* - If you need to create e2e tests, make sure to pass a `testId` prop. This
|
|
960
|
-
* will be passed down to this component using a sufix: e.g.
|
|
961
|
-
* `some-random-id-ModalPanel`. This scope will be propagated to the
|
|
962
|
-
* CloseButton element as well: e.g. `some-random-id-CloseButton`.
|
|
963
|
-
*
|
|
964
|
-
* ```js
|
|
965
|
-
* <ModalDialog>
|
|
966
|
-
* <ModalPanel content={"custom content goes here"} />
|
|
967
|
-
* </ModalDialog>
|
|
968
|
-
* ```
|
|
969
|
-
*/
|
|
970
741
|
class ModalPanel extends React.Component {
|
|
971
742
|
renderMainContent() {
|
|
972
743
|
const {
|
|
@@ -974,19 +745,14 @@ class ModalPanel extends React.Component {
|
|
|
974
745
|
footer,
|
|
975
746
|
scrollOverflow
|
|
976
747
|
} = this.props;
|
|
977
|
-
const mainContent = ModalContent.isClassOf(content) ? content :
|
|
748
|
+
const mainContent = ModalContent.isClassOf(content) ? content : React.createElement(ModalContent, null, content);
|
|
978
749
|
|
|
979
750
|
if (!mainContent) {
|
|
980
751
|
return mainContent;
|
|
981
752
|
}
|
|
982
753
|
|
|
983
|
-
return
|
|
984
|
-
// Pass the scrollOverflow and header in to the main content
|
|
754
|
+
return React.cloneElement(mainContent, {
|
|
985
755
|
scrollOverflow,
|
|
986
|
-
// We override the styling of the main content to help position
|
|
987
|
-
// it if there is a footer or close button being
|
|
988
|
-
// shown. We have to do this here as the ModalContent doesn't
|
|
989
|
-
// know about things being positioned around it.
|
|
990
756
|
style: [!!footer && styles.hasFooter, mainContent.props.style]
|
|
991
757
|
});
|
|
992
758
|
}
|
|
@@ -1002,15 +768,15 @@ class ModalPanel extends React.Component {
|
|
|
1002
768
|
testId
|
|
1003
769
|
} = this.props;
|
|
1004
770
|
const mainContent = this.renderMainContent();
|
|
1005
|
-
return
|
|
771
|
+
return React.createElement(View, {
|
|
1006
772
|
style: [styles.wrapper, !light && styles.dark, style],
|
|
1007
773
|
testId: testId && `${testId}-panel`
|
|
1008
|
-
}, closeButtonVisible &&
|
|
774
|
+
}, closeButtonVisible && React.createElement(CloseButton, {
|
|
1009
775
|
light: !light,
|
|
1010
776
|
onClick: onClose,
|
|
1011
777
|
style: styles.closeButton,
|
|
1012
778
|
testId: testId && `${testId}-close`
|
|
1013
|
-
}), header, mainContent, !footer || ModalFooter.isClassOf(footer) ? footer :
|
|
779
|
+
}), header, mainContent, !footer || ModalFooter.isClassOf(footer) ? footer : React.createElement(ModalFooter, null, footer));
|
|
1014
780
|
}
|
|
1015
781
|
|
|
1016
782
|
}
|
|
@@ -1035,8 +801,6 @@ const styles = StyleSheet.create({
|
|
|
1035
801
|
position: "absolute",
|
|
1036
802
|
right: Spacing.medium_16,
|
|
1037
803
|
top: Spacing.medium_16,
|
|
1038
|
-
// This is to allow the button to be tab-ordered before the modal
|
|
1039
|
-
// content but still be above the header and content.
|
|
1040
804
|
zIndex: 1
|
|
1041
805
|
},
|
|
1042
806
|
dark: {
|
|
@@ -1048,12 +812,6 @@ const styles = StyleSheet.create({
|
|
|
1048
812
|
}
|
|
1049
813
|
});
|
|
1050
814
|
|
|
1051
|
-
/**
|
|
1052
|
-
* This is the standard layout for most straightforward modal experiences.
|
|
1053
|
-
*
|
|
1054
|
-
* The ModalHeader is required, but the ModalFooter is optional.
|
|
1055
|
-
* The content of the dialog itself is fully customizable, but the left/right/top/bottom padding is fixed.
|
|
1056
|
-
*/
|
|
1057
815
|
class OnePaneDialog extends React.Component {
|
|
1058
816
|
renderHeader(uniqueId) {
|
|
1059
817
|
const {
|
|
@@ -1064,21 +822,21 @@ class OnePaneDialog extends React.Component {
|
|
|
1064
822
|
} = this.props;
|
|
1065
823
|
|
|
1066
824
|
if (breadcrumbs) {
|
|
1067
|
-
return
|
|
825
|
+
return React.createElement(ModalHeader, {
|
|
1068
826
|
title: title,
|
|
1069
827
|
breadcrumbs: breadcrumbs,
|
|
1070
828
|
titleId: uniqueId,
|
|
1071
829
|
testId: testId && `${testId}-header`
|
|
1072
830
|
});
|
|
1073
831
|
} else if (subtitle) {
|
|
1074
|
-
return
|
|
832
|
+
return React.createElement(ModalHeader, {
|
|
1075
833
|
title: title,
|
|
1076
834
|
subtitle: subtitle,
|
|
1077
835
|
titleId: uniqueId,
|
|
1078
836
|
testId: testId && `${testId}-header`
|
|
1079
837
|
});
|
|
1080
838
|
} else {
|
|
1081
|
-
return
|
|
839
|
+
return React.createElement(ModalHeader, {
|
|
1082
840
|
title: title,
|
|
1083
841
|
titleId: uniqueId,
|
|
1084
842
|
testId: testId && `${testId}-header`
|
|
@@ -1099,21 +857,21 @@ class OnePaneDialog extends React.Component {
|
|
|
1099
857
|
titleId,
|
|
1100
858
|
role
|
|
1101
859
|
} = this.props;
|
|
1102
|
-
return
|
|
860
|
+
return React.createElement(MediaLayout, {
|
|
1103
861
|
styleSheets: styleSheets
|
|
1104
862
|
}, ({
|
|
1105
863
|
styles
|
|
1106
|
-
}) =>
|
|
864
|
+
}) => React.createElement(IDProvider, {
|
|
1107
865
|
id: titleId,
|
|
1108
866
|
scope: "modal"
|
|
1109
|
-
}, uniqueId =>
|
|
867
|
+
}, uniqueId => React.createElement(ModalDialog, {
|
|
1110
868
|
style: [styles.dialog, style],
|
|
1111
869
|
above: above,
|
|
1112
870
|
below: below,
|
|
1113
871
|
testId: testId,
|
|
1114
872
|
"aria-labelledby": uniqueId,
|
|
1115
873
|
role: role
|
|
1116
|
-
},
|
|
874
|
+
}, React.createElement(ModalPanel, {
|
|
1117
875
|
onClose: onClose,
|
|
1118
876
|
header: this.renderHeader(uniqueId),
|
|
1119
877
|
content: content,
|
|
@@ -1145,14 +903,6 @@ const styleSheets = {
|
|
|
1145
903
|
})
|
|
1146
904
|
};
|
|
1147
905
|
|
|
1148
|
-
/**
|
|
1149
|
-
* From a given element, finds its next ancestor that is a modal launcher portal
|
|
1150
|
-
* element.
|
|
1151
|
-
* @param {?(Element | Text)} element The element whose ancestors are to be
|
|
1152
|
-
* walked.
|
|
1153
|
-
* @returns {?Element} The nearest parent modal launcher portal.
|
|
1154
|
-
*/
|
|
1155
|
-
|
|
1156
906
|
function maybeGetNextAncestorModalLauncherPortal(element) {
|
|
1157
907
|
let candidateElement = element && element.parentElement;
|
|
1158
908
|
|
|
@@ -1162,18 +912,9 @@ function maybeGetNextAncestorModalLauncherPortal(element) {
|
|
|
1162
912
|
|
|
1163
913
|
return candidateElement;
|
|
1164
914
|
}
|
|
1165
|
-
/**
|
|
1166
|
-
* From a given element, finds the next modal host that has been mounted in
|
|
1167
|
-
* a modal portal.
|
|
1168
|
-
* @param {?(Element | Text)} element The element whose ancestors are to be
|
|
1169
|
-
* walked.
|
|
1170
|
-
* @returns {?Element} The next portal-mounted modal host element.
|
|
1171
|
-
* TODO(kevinb): look into getting rid of this
|
|
1172
|
-
*/
|
|
1173
|
-
|
|
1174
915
|
|
|
1175
916
|
function maybeGetPortalMountedModalHostElement(element) {
|
|
1176
917
|
return maybeGetNextAncestorModalLauncherPortal(element);
|
|
1177
918
|
}
|
|
1178
919
|
|
|
1179
|
-
export { ModalDialog, ModalFooter, ModalHeader, ModalLauncher, ModalPanel, OnePaneDialog, maybeGetPortalMountedModalHostElement };
|
|
920
|
+
export { ModalDialog, ModalFooter, ModalHeader, modalLauncher as ModalLauncher, ModalPanel, OnePaneDialog, maybeGetPortalMountedModalHostElement };
|