@khanacademy/wonder-blocks-popover 3.0.23 → 3.1.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 +27 -0
- package/dist/components/focus-manager.d.ts +18 -1
- package/dist/components/popover-event-listener.d.ts +1 -1
- package/dist/components/popover.d.ts +11 -1
- package/dist/es/index.js +135 -61
- package/dist/index.js +135 -63
- package/dist/util/util.d.ts +5 -0
- package/package.json +5 -6
- package/src/components/__tests__/focus-manager.test.tsx +115 -36
- package/src/components/__tests__/popover.test.tsx +421 -34
- package/src/components/focus-manager.tsx +155 -54
- package/src/components/popover-content-core.tsx +13 -14
- package/src/components/popover-content.tsx +14 -14
- package/src/components/popover-dialog.tsx +2 -2
- package/src/components/popover-event-listener.ts +12 -3
- package/src/components/popover.tsx +38 -2
- package/src/util/__tests__/util.test.tsx +38 -0
- package/src/util/util.ts +8 -0
- package/tsconfig-build.json +1 -2
- package/tsconfig-build.tsbuildinfo +1 -1
package/dist/index.js
CHANGED
|
@@ -8,9 +8,8 @@ var wonderBlocksCore = require('@khanacademy/wonder-blocks-core');
|
|
|
8
8
|
var wonderBlocksTooltip = require('@khanacademy/wonder-blocks-tooltip');
|
|
9
9
|
var wonderBlocksModal = require('@khanacademy/wonder-blocks-modal');
|
|
10
10
|
var aphrodite = require('aphrodite');
|
|
11
|
-
var
|
|
11
|
+
var wonderBlocksTokens = require('@khanacademy/wonder-blocks-tokens');
|
|
12
12
|
var wonderBlocksTypography = require('@khanacademy/wonder-blocks-typography');
|
|
13
|
-
var Colors = require('@khanacademy/wonder-blocks-color');
|
|
14
13
|
var xIcon = require('@phosphor-icons/core/regular/x.svg');
|
|
15
14
|
var IconButton = require('@khanacademy/wonder-blocks-icon-button');
|
|
16
15
|
|
|
@@ -36,8 +35,6 @@ function _interopNamespace(e) {
|
|
|
36
35
|
|
|
37
36
|
var React__namespace = /*#__PURE__*/_interopNamespace(React);
|
|
38
37
|
var ReactDOM__namespace = /*#__PURE__*/_interopNamespace(ReactDOM);
|
|
39
|
-
var Spacing__default = /*#__PURE__*/_interopDefaultLegacy(Spacing);
|
|
40
|
-
var Colors__default = /*#__PURE__*/_interopDefaultLegacy(Colors);
|
|
41
38
|
var xIcon__default = /*#__PURE__*/_interopDefaultLegacy(xIcon);
|
|
42
39
|
var IconButton__default = /*#__PURE__*/_interopDefaultLegacy(IconButton);
|
|
43
40
|
|
|
@@ -164,6 +161,9 @@ const FOCUSABLE_ELEMENTS = 'button, [href], input, select, textarea, [tabindex]:
|
|
|
164
161
|
function findFocusableNodes(root) {
|
|
165
162
|
return Array.from(root.querySelectorAll(FOCUSABLE_ELEMENTS));
|
|
166
163
|
}
|
|
164
|
+
function isFocusable(element) {
|
|
165
|
+
return element.matches(FOCUSABLE_ELEMENTS);
|
|
166
|
+
}
|
|
167
167
|
|
|
168
168
|
class InitialFocus extends React__namespace.Component {
|
|
169
169
|
constructor(...args) {
|
|
@@ -211,17 +211,49 @@ class FocusManager extends React__namespace.Component {
|
|
|
211
211
|
super(...args);
|
|
212
212
|
this.nextElementAfterPopover = void 0;
|
|
213
213
|
this.rootNode = void 0;
|
|
214
|
-
this.
|
|
214
|
+
this.elementsThatCanBeFocusableInsidePopover = [];
|
|
215
|
+
this.firstFocusableElementInPopover = null;
|
|
216
|
+
this.lastFocusableElementInPopover = null;
|
|
215
217
|
this.addEventListeners = () => {
|
|
216
218
|
const {
|
|
217
219
|
anchorElement
|
|
218
220
|
} = this.props;
|
|
219
221
|
if (anchorElement) {
|
|
220
|
-
anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement
|
|
222
|
+
anchorElement.addEventListener("keydown", this.handleKeydownPreviousFocusableElement);
|
|
223
|
+
}
|
|
224
|
+
if (this.rootNode) {
|
|
225
|
+
this.elementsThatCanBeFocusableInsidePopover = findFocusableNodes(this.rootNode);
|
|
226
|
+
this.firstFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[0];
|
|
227
|
+
this.lastFocusableElementInPopover = this.elementsThatCanBeFocusableInsidePopover[this.elementsThatCanBeFocusableInsidePopover.length - 1];
|
|
221
228
|
}
|
|
222
229
|
this.nextElementAfterPopover = this.getNextFocusableElement();
|
|
230
|
+
if (!this.nextElementAfterPopover) {
|
|
231
|
+
window.addEventListener("blur", () => {
|
|
232
|
+
this.changeFocusabilityInsidePopover(true);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (this.firstFocusableElementInPopover) {
|
|
236
|
+
this.firstFocusableElementInPopover.addEventListener("keydown", this.handleKeydownFirstFocusableElement);
|
|
237
|
+
}
|
|
238
|
+
if (this.lastFocusableElementInPopover) {
|
|
239
|
+
this.lastFocusableElementInPopover.addEventListener("keydown", this.handleKeydownLastFocusableElement);
|
|
240
|
+
}
|
|
223
241
|
if (this.nextElementAfterPopover) {
|
|
224
|
-
this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement
|
|
242
|
+
this.nextElementAfterPopover.addEventListener("keydown", this.handleKeydownNextFocusableElement);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
this.handleKeydownFirstFocusableElement = e => {
|
|
246
|
+
if (e.key === "Tab" && e.shiftKey) {
|
|
247
|
+
var _this$props$anchorEle;
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
(_this$props$anchorEle = this.props.anchorElement) == null ? void 0 : _this$props$anchorEle.focus();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
this.handleKeydownLastFocusableElement = e => {
|
|
253
|
+
if (this.nextElementAfterPopover && e.key === "Tab" && !e.shiftKey) {
|
|
254
|
+
var _this$nextElementAfte;
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
(_this$nextElementAfte = this.nextElementAfterPopover) == null ? void 0 : _this$nextElementAfte.focus();
|
|
225
257
|
}
|
|
226
258
|
};
|
|
227
259
|
this.getNextFocusableElement = () => {
|
|
@@ -232,10 +264,14 @@ class FocusManager extends React__namespace.Component {
|
|
|
232
264
|
return;
|
|
233
265
|
}
|
|
234
266
|
const focusableElements = findFocusableNodes(document);
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
267
|
+
const focusableElementsOutside = focusableElements.filter(element => {
|
|
268
|
+
const index = this.elementsThatCanBeFocusableInsidePopover.indexOf(element);
|
|
269
|
+
return index < 0;
|
|
270
|
+
});
|
|
271
|
+
const anchorIndex = focusableElementsOutside.indexOf(anchorElement);
|
|
272
|
+
if (anchorIndex >= 0 && anchorIndex !== focusableElementsOutside.length - 1) {
|
|
273
|
+
const nextElementIndex = anchorIndex < focusableElementsOutside.length - 1 ? anchorIndex + 1 : 0;
|
|
274
|
+
return focusableElementsOutside[nextElementIndex];
|
|
239
275
|
}
|
|
240
276
|
return;
|
|
241
277
|
};
|
|
@@ -248,13 +284,18 @@ class FocusManager extends React__namespace.Component {
|
|
|
248
284
|
throw new Error("Assertion error: root node should exist after mount");
|
|
249
285
|
}
|
|
250
286
|
this.rootNode = rootNode;
|
|
251
|
-
this.focusableElementsInPopover = findFocusableNodes(this.rootNode);
|
|
252
287
|
};
|
|
253
288
|
this.handleFocusPreviousFocusableElement = () => {
|
|
254
289
|
if (this.props.anchorElement) {
|
|
255
290
|
this.props.anchorElement.focus();
|
|
256
291
|
}
|
|
257
292
|
};
|
|
293
|
+
this.changeFocusabilityInsidePopover = (enabled = true) => {
|
|
294
|
+
const tabIndex = enabled ? "0" : "-1";
|
|
295
|
+
this.elementsThatCanBeFocusableInsidePopover.forEach(element => {
|
|
296
|
+
element.setAttribute("tabIndex", tabIndex);
|
|
297
|
+
});
|
|
298
|
+
};
|
|
258
299
|
this.handleFocusNextFocusableElement = () => {
|
|
259
300
|
if (this.nextElementAfterPopover) {
|
|
260
301
|
this.nextElementAfterPopover.focus();
|
|
@@ -262,15 +303,16 @@ class FocusManager extends React__namespace.Component {
|
|
|
262
303
|
};
|
|
263
304
|
this.handleKeydownPreviousFocusableElement = e => {
|
|
264
305
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
306
|
+
var _this$firstFocusableE;
|
|
265
307
|
e.preventDefault();
|
|
266
|
-
this.
|
|
308
|
+
(_this$firstFocusableE = this.firstFocusableElementInPopover) == null ? void 0 : _this$firstFocusableE.focus();
|
|
267
309
|
}
|
|
268
310
|
};
|
|
269
311
|
this.handleKeydownNextFocusableElement = e => {
|
|
270
312
|
if (e.key === "Tab" && e.shiftKey) {
|
|
313
|
+
var _this$lastFocusableEl;
|
|
271
314
|
e.preventDefault();
|
|
272
|
-
|
|
273
|
-
this.focusableElementsInPopover[lastElementIndex].focus();
|
|
315
|
+
(_this$lastFocusableEl = this.lastFocusableElementInPopover) == null ? void 0 : _this$lastFocusableEl.focus();
|
|
274
316
|
}
|
|
275
317
|
};
|
|
276
318
|
}
|
|
@@ -278,41 +320,53 @@ class FocusManager extends React__namespace.Component {
|
|
|
278
320
|
this.addEventListeners();
|
|
279
321
|
}
|
|
280
322
|
componentDidUpdate() {
|
|
323
|
+
this.removeEventListeners();
|
|
281
324
|
this.addEventListeners();
|
|
282
325
|
}
|
|
283
326
|
componentWillUnmount() {
|
|
327
|
+
this.changeFocusabilityInsidePopover(true);
|
|
328
|
+
this.removeEventListeners();
|
|
329
|
+
}
|
|
330
|
+
removeEventListeners() {
|
|
284
331
|
const {
|
|
285
332
|
anchorElement
|
|
286
333
|
} = this.props;
|
|
287
334
|
if (anchorElement) {
|
|
288
|
-
|
|
289
|
-
|
|
335
|
+
anchorElement.removeEventListener("keydown", this.handleKeydownPreviousFocusableElement);
|
|
336
|
+
}
|
|
337
|
+
if (!this.nextElementAfterPopover) {
|
|
338
|
+
window.removeEventListener("blur", () => {
|
|
339
|
+
this.changeFocusabilityInsidePopover(true);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (this.firstFocusableElementInPopover) {
|
|
343
|
+
this.firstFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownFirstFocusableElement);
|
|
344
|
+
}
|
|
345
|
+
if (this.lastFocusableElementInPopover) {
|
|
346
|
+
this.lastFocusableElementInPopover.removeEventListener("keydown", this.handleKeydownLastFocusableElement);
|
|
290
347
|
}
|
|
291
348
|
if (this.nextElementAfterPopover) {
|
|
292
|
-
this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement
|
|
349
|
+
this.nextElementAfterPopover.removeEventListener("keydown", this.handleKeydownNextFocusableElement);
|
|
293
350
|
}
|
|
294
351
|
}
|
|
295
352
|
render() {
|
|
296
353
|
const {
|
|
297
354
|
children
|
|
298
355
|
} = this.props;
|
|
299
|
-
return React__namespace.createElement(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
356
|
+
return React__namespace.createElement("div", {
|
|
357
|
+
ref: this.getComponentRootNode,
|
|
358
|
+
onClick: () => {
|
|
359
|
+
this.changeFocusabilityInsidePopover(true);
|
|
360
|
+
},
|
|
361
|
+
onFocus: () => {
|
|
362
|
+
this.changeFocusabilityInsidePopover(true);
|
|
363
|
+
},
|
|
364
|
+
onBlur: () => {
|
|
365
|
+
this.changeFocusabilityInsidePopover(false);
|
|
304
366
|
}
|
|
305
|
-
}), React__namespace.createElement("div", {
|
|
306
|
-
ref: this.getComponentRootNode
|
|
307
367
|
}, React__namespace.createElement(InitialFocus, {
|
|
308
368
|
initialFocusId: this.props.initialFocusId
|
|
309
|
-
}, children))
|
|
310
|
-
tabIndex: 0,
|
|
311
|
-
onFocus: this.handleFocusNextFocusableElement,
|
|
312
|
-
style: {
|
|
313
|
-
position: "fixed"
|
|
314
|
-
}
|
|
315
|
-
}));
|
|
369
|
+
}, children));
|
|
316
370
|
}
|
|
317
371
|
}
|
|
318
372
|
|
|
@@ -326,7 +380,7 @@ class PopoverEventListener extends React__namespace.Component {
|
|
|
326
380
|
if (e.key === "Escape") {
|
|
327
381
|
e.preventDefault();
|
|
328
382
|
e.stopPropagation();
|
|
329
|
-
this.props.onClose();
|
|
383
|
+
this.props.onClose(true);
|
|
330
384
|
}
|
|
331
385
|
};
|
|
332
386
|
this._handleClick = e => {
|
|
@@ -341,7 +395,8 @@ class PopoverEventListener extends React__namespace.Component {
|
|
|
341
395
|
if (node && !node.contains(e.target)) {
|
|
342
396
|
e.preventDefault();
|
|
343
397
|
e.stopPropagation();
|
|
344
|
-
|
|
398
|
+
const shouldReturnFocus = !isFocusable(e.target);
|
|
399
|
+
this.props.onClose(shouldReturnFocus);
|
|
345
400
|
}
|
|
346
401
|
};
|
|
347
402
|
}
|
|
@@ -366,19 +421,36 @@ class Popover extends React__namespace.Component {
|
|
|
366
421
|
placement: this.props.placement
|
|
367
422
|
};
|
|
368
423
|
this.contentRef = React__namespace.createRef();
|
|
369
|
-
this.
|
|
424
|
+
this.maybeReturnFocus = () => {
|
|
425
|
+
const {
|
|
426
|
+
anchorElement
|
|
427
|
+
} = this.state;
|
|
428
|
+
const {
|
|
429
|
+
closedFocusId
|
|
430
|
+
} = this.props;
|
|
431
|
+
if (closedFocusId) {
|
|
432
|
+
const focusElement = ReactDOM__namespace.findDOMNode(document.getElementById(closedFocusId));
|
|
433
|
+
focusElement == null ? void 0 : focusElement.focus();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (anchorElement) {
|
|
437
|
+
anchorElement.focus();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
this.handleClose = (shouldReturnFocus = true) => {
|
|
370
441
|
this.setState({
|
|
371
442
|
opened: false
|
|
372
443
|
}, () => {
|
|
373
444
|
var _this$props$onClose, _this$props;
|
|
374
445
|
(_this$props$onClose = (_this$props = this.props).onClose) == null ? void 0 : _this$props$onClose.call(_this$props);
|
|
446
|
+
if (shouldReturnFocus) {
|
|
447
|
+
this.maybeReturnFocus();
|
|
448
|
+
}
|
|
375
449
|
});
|
|
376
450
|
};
|
|
377
451
|
this.handleOpen = () => {
|
|
378
452
|
if (this.props.dismissEnabled && this.state.opened) {
|
|
379
|
-
this.
|
|
380
|
-
opened: false
|
|
381
|
-
});
|
|
453
|
+
this.handleClose(true);
|
|
382
454
|
} else {
|
|
383
455
|
this.setState({
|
|
384
456
|
opened: true
|
|
@@ -531,29 +603,29 @@ PopoverContentCore.defaultProps = {
|
|
|
531
603
|
};
|
|
532
604
|
const styles$1 = aphrodite.StyleSheet.create({
|
|
533
605
|
content: {
|
|
534
|
-
borderRadius:
|
|
535
|
-
border: `solid 1px ${
|
|
536
|
-
backgroundColor:
|
|
537
|
-
boxShadow: `0 ${
|
|
606
|
+
borderRadius: wonderBlocksTokens.spacing.xxxSmall_4,
|
|
607
|
+
border: `solid 1px ${wonderBlocksTokens.color.offBlack16}`,
|
|
608
|
+
backgroundColor: wonderBlocksTokens.color.white,
|
|
609
|
+
boxShadow: `0 ${wonderBlocksTokens.spacing.xSmall_8}px ${wonderBlocksTokens.spacing.xSmall_8}px 0 ${wonderBlocksTokens.color.offBlack8}`,
|
|
538
610
|
margin: 0,
|
|
539
|
-
maxWidth:
|
|
540
|
-
padding:
|
|
611
|
+
maxWidth: wonderBlocksTokens.spacing.medium_16 * 18,
|
|
612
|
+
padding: wonderBlocksTokens.spacing.large_24,
|
|
541
613
|
overflow: "hidden",
|
|
542
614
|
justifyContent: "center"
|
|
543
615
|
},
|
|
544
616
|
blue: {
|
|
545
|
-
backgroundColor:
|
|
546
|
-
color:
|
|
617
|
+
backgroundColor: wonderBlocksTokens.color.blue,
|
|
618
|
+
color: wonderBlocksTokens.color.white
|
|
547
619
|
},
|
|
548
620
|
darkBlue: {
|
|
549
|
-
backgroundColor:
|
|
550
|
-
color:
|
|
621
|
+
backgroundColor: wonderBlocksTokens.color.darkBlue,
|
|
622
|
+
color: wonderBlocksTokens.color.white
|
|
551
623
|
},
|
|
552
624
|
closeButton: {
|
|
553
625
|
margin: 0,
|
|
554
626
|
position: "absolute",
|
|
555
|
-
right:
|
|
556
|
-
top:
|
|
627
|
+
right: wonderBlocksTokens.spacing.xxxSmall_4,
|
|
628
|
+
top: wonderBlocksTokens.spacing.xxxSmall_4,
|
|
557
629
|
zIndex: 1
|
|
558
630
|
}
|
|
559
631
|
});
|
|
@@ -667,7 +739,7 @@ PopoverContent.defaultProps = {
|
|
|
667
739
|
};
|
|
668
740
|
const styles = aphrodite.StyleSheet.create({
|
|
669
741
|
actions: {
|
|
670
|
-
marginTop:
|
|
742
|
+
marginTop: wonderBlocksTokens.spacing.large_24,
|
|
671
743
|
flexDirection: "row",
|
|
672
744
|
alignItems: "center",
|
|
673
745
|
justifyContent: "flex-end"
|
|
@@ -676,15 +748,15 @@ const styles = aphrodite.StyleSheet.create({
|
|
|
676
748
|
justifyContent: "center"
|
|
677
749
|
},
|
|
678
750
|
title: {
|
|
679
|
-
marginBottom:
|
|
751
|
+
marginBottom: wonderBlocksTokens.spacing.xSmall_8
|
|
680
752
|
},
|
|
681
753
|
iconContainer: {
|
|
682
754
|
alignItems: "center",
|
|
683
755
|
justifyContent: "center",
|
|
684
|
-
height:
|
|
685
|
-
width:
|
|
686
|
-
minWidth:
|
|
687
|
-
marginRight:
|
|
756
|
+
height: wonderBlocksTokens.spacing.xxxLarge_64,
|
|
757
|
+
width: wonderBlocksTokens.spacing.xxxLarge_64,
|
|
758
|
+
minWidth: wonderBlocksTokens.spacing.xxxLarge_64,
|
|
759
|
+
marginRight: wonderBlocksTokens.spacing.medium_16,
|
|
688
760
|
overflow: "hidden"
|
|
689
761
|
},
|
|
690
762
|
icon: {
|
|
@@ -694,15 +766,15 @@ const styles = aphrodite.StyleSheet.create({
|
|
|
694
766
|
flexDirection: "row"
|
|
695
767
|
},
|
|
696
768
|
image: {
|
|
697
|
-
marginBottom:
|
|
698
|
-
marginLeft: -
|
|
699
|
-
marginRight: -
|
|
700
|
-
marginTop: -
|
|
701
|
-
width: `calc(100% + ${
|
|
769
|
+
marginBottom: wonderBlocksTokens.spacing.large_24,
|
|
770
|
+
marginLeft: -wonderBlocksTokens.spacing.large_24,
|
|
771
|
+
marginRight: -wonderBlocksTokens.spacing.large_24,
|
|
772
|
+
marginTop: -wonderBlocksTokens.spacing.large_24,
|
|
773
|
+
width: `calc(100% + ${wonderBlocksTokens.spacing.large_24 * 2}px)`
|
|
702
774
|
},
|
|
703
775
|
imageToBottom: {
|
|
704
|
-
marginBottom: -
|
|
705
|
-
marginTop:
|
|
776
|
+
marginBottom: -wonderBlocksTokens.spacing.large_24,
|
|
777
|
+
marginTop: wonderBlocksTokens.spacing.large_24,
|
|
706
778
|
order: 1
|
|
707
779
|
}
|
|
708
780
|
});
|
package/dist/util/util.d.ts
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
1
|
export declare function findFocusableNodes(root: HTMLElement | Document): Array<HTMLElement>;
|
|
2
|
+
/**
|
|
3
|
+
* Checks if an element is focusable
|
|
4
|
+
* @see https://html.spec.whatwg.org/multipage/interaction.html#focusable-area
|
|
5
|
+
*/
|
|
6
|
+
export declare function isFocusable(element: HTMLElement): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanacademy/wonder-blocks-popover",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"design": "v1",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@babel/runtime": "^7.18.6",
|
|
19
|
-
"@khanacademy/wonder-blocks-color": "^3.0.0",
|
|
20
19
|
"@khanacademy/wonder-blocks-core": "^6.3.1",
|
|
21
|
-
"@khanacademy/wonder-blocks-icon-button": "^5.1.
|
|
22
|
-
"@khanacademy/wonder-blocks-modal": "^4.2.
|
|
23
|
-
"@khanacademy/wonder-blocks-
|
|
24
|
-
"@khanacademy/wonder-blocks-tooltip": "^2.1.
|
|
20
|
+
"@khanacademy/wonder-blocks-icon-button": "^5.1.9",
|
|
21
|
+
"@khanacademy/wonder-blocks-modal": "^4.2.2",
|
|
22
|
+
"@khanacademy/wonder-blocks-tokens": "^0.2.0",
|
|
23
|
+
"@khanacademy/wonder-blocks-tooltip": "^2.1.26",
|
|
25
24
|
"@khanacademy/wonder-blocks-typography": "^2.1.10"
|
|
26
25
|
},
|
|
27
26
|
"peerDependencies": {
|
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import * as ReactDOM from "react-dom";
|
|
3
2
|
import {render, screen} from "@testing-library/react";
|
|
4
3
|
import userEvent from "@testing-library/user-event";
|
|
5
4
|
|
|
6
5
|
import FocusManager from "../focus-manager";
|
|
7
|
-
import {findFocusableNodes} from "../../util/util";
|
|
8
6
|
|
|
9
7
|
describe("FocusManager", () => {
|
|
10
8
|
it("should focus on the first focusable element inside the popover", async () => {
|
|
11
9
|
// Arrange
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
render(nodes);
|
|
20
|
-
});
|
|
21
|
-
// @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ReactInstance | null | undefined'.
|
|
22
|
-
const domNode = ReactDOM.findDOMNode(ref) as HTMLElement;
|
|
23
|
-
|
|
24
|
-
// mock focusable elements in document
|
|
25
|
-
// eslint-disable-next-line testing-library/no-node-access
|
|
26
|
-
global.document.querySelectorAll = jest
|
|
27
|
-
.fn()
|
|
28
|
-
.mockImplementation(() => findFocusableNodes(domNode));
|
|
10
|
+
const externalNodes = (
|
|
11
|
+
<div>
|
|
12
|
+
<button>Open popover</button>
|
|
13
|
+
<button>Next focusable element outside</button>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
render(externalNodes);
|
|
29
17
|
|
|
30
18
|
// get the anchor reference to be able pass it to the FocusManager
|
|
31
19
|
const anchorElementNode = screen.getByRole("button", {
|
|
@@ -58,23 +46,13 @@ describe("FocusManager", () => {
|
|
|
58
46
|
|
|
59
47
|
it("should focus on the last focusable element inside the popover", async () => {
|
|
60
48
|
// Arrange
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
render(nodes);
|
|
69
|
-
});
|
|
70
|
-
// @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'ReactInstance | null | undefined'.
|
|
71
|
-
const domNode = ReactDOM.findDOMNode(ref) as HTMLElement;
|
|
72
|
-
|
|
73
|
-
// mock focusable elements in document
|
|
74
|
-
// eslint-disable-next-line testing-library/no-node-access
|
|
75
|
-
global.document.querySelectorAll = jest
|
|
76
|
-
.fn()
|
|
77
|
-
.mockImplementation(() => findFocusableNodes(domNode));
|
|
49
|
+
const externalNodes = (
|
|
50
|
+
<div>
|
|
51
|
+
<button>Open popover</button>
|
|
52
|
+
<button>Next focusable element outside</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
render(externalNodes);
|
|
78
56
|
|
|
79
57
|
// get the anchor reference to be able pass it to the FocusManager
|
|
80
58
|
const anchorElementNode = screen.getByRole("button", {
|
|
@@ -109,4 +87,105 @@ describe("FocusManager", () => {
|
|
|
109
87
|
// Assert
|
|
110
88
|
expect(lastFocusableElementInside).toHaveFocus();
|
|
111
89
|
});
|
|
90
|
+
|
|
91
|
+
it("should allow flowing the focus correctly", async () => {
|
|
92
|
+
// Arrange
|
|
93
|
+
const externalNodes = (
|
|
94
|
+
<div>
|
|
95
|
+
<button>Prev focusable element outside</button>
|
|
96
|
+
<button>Open popover</button>
|
|
97
|
+
<button>Next focusable element outside</button>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
render(externalNodes);
|
|
101
|
+
|
|
102
|
+
// get the anchor reference to be able pass it to the FocusManager
|
|
103
|
+
const anchorElementNode = screen.getByRole("button", {
|
|
104
|
+
name: "Open popover",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<FocusManager anchorElement={anchorElementNode}>
|
|
109
|
+
<div>
|
|
110
|
+
<button>first focusable element inside</button>
|
|
111
|
+
</div>
|
|
112
|
+
</FocusManager>,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Act
|
|
116
|
+
// 1. focus on the previous element before the popover
|
|
117
|
+
userEvent.tab();
|
|
118
|
+
|
|
119
|
+
// 2. focus on the anchor element
|
|
120
|
+
userEvent.tab();
|
|
121
|
+
|
|
122
|
+
// 3. focus on focusable element inside the popover
|
|
123
|
+
userEvent.tab();
|
|
124
|
+
|
|
125
|
+
// 4. focus on the next focusable element outside the popover (this will
|
|
126
|
+
// be the first focusable element outside the popover)
|
|
127
|
+
userEvent.tab();
|
|
128
|
+
|
|
129
|
+
// NOTE: At this point, the focus moves to the document body, so we need
|
|
130
|
+
// to press tab again to move the focus to the next focusable element.
|
|
131
|
+
userEvent.tab();
|
|
132
|
+
|
|
133
|
+
// 5. Finally focus on the first element in the document
|
|
134
|
+
userEvent.tab();
|
|
135
|
+
|
|
136
|
+
// find previous focusable element outside the popover
|
|
137
|
+
const prevFocusableElementOutside = screen.getByRole("button", {
|
|
138
|
+
name: "Prev focusable element outside",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
expect(prevFocusableElementOutside).toHaveFocus();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should disallow focusability on internal elements if the user focus out of the focus manager", async () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
const externalNodes = (
|
|
148
|
+
<div>
|
|
149
|
+
<button>Prev focusable element outside</button>
|
|
150
|
+
<button>Open popover</button>
|
|
151
|
+
<button>Next focusable element outside</button>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
render(externalNodes);
|
|
155
|
+
|
|
156
|
+
// get the anchor reference to be able pass it to the FocusManager
|
|
157
|
+
const anchorElementNode = screen.getByRole("button", {
|
|
158
|
+
name: "Open popover",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
render(
|
|
162
|
+
<FocusManager anchorElement={anchorElementNode}>
|
|
163
|
+
<div>
|
|
164
|
+
<button>first focusable element inside</button>
|
|
165
|
+
</div>
|
|
166
|
+
</FocusManager>,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Act
|
|
170
|
+
// 1. focus on the previous element before the popover
|
|
171
|
+
userEvent.tab();
|
|
172
|
+
|
|
173
|
+
// 2. focus on the anchor element
|
|
174
|
+
userEvent.tab();
|
|
175
|
+
|
|
176
|
+
// 3. focus on focusable element inside the popover
|
|
177
|
+
userEvent.tab();
|
|
178
|
+
|
|
179
|
+
// 4. focus on the next focusable element outside the popover (this will
|
|
180
|
+
// be the first focusable element outside the popover)
|
|
181
|
+
userEvent.tab();
|
|
182
|
+
|
|
183
|
+
// The elements inside the focus manager should not be focusable anymore.
|
|
184
|
+
const focusableElementInside = screen.getByRole("button", {
|
|
185
|
+
name: "first focusable element inside",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(focusableElementInside).toHaveAttribute("tabIndex", "-1");
|
|
190
|
+
});
|
|
112
191
|
});
|