Skip to content
This repository was archived by the owner on Oct 19, 2021. It is now read-only.

Commit 5dc69b9

Browse files
Kyle-Cooleyjoshblack
authored andcommitted
fix(Modal): add keyboard trap (#1115)
* fix(Modal): add keyboard trap Adds a keyboard trap to fix issue #874 * fix(Modal): focus on modal itself when focus leaves Fixes the modal keyboard trap to be consistent with vanilla behavior. * fix(Modal): provide workaround for keyboard trap and floating menus Makes it possible to escape keyboard trap in modals when using floating menus * fix(OverflowMenu): focus on menu after closed This sends focus back to the overflow menu item after the menu gets closed. This is important for floating overflow menus in modals. * fix(OverflowMenu): get rid of redundant code * fix(Modal): improve support for floating menus in keyboard trap Made algorithm to check for floating menus check parents as well and made the list of floating menu selectors consistent with vanilla.
1 parent 3bc0c37 commit 5dc69b9

File tree

3 files changed

+78
-5
lines changed

3 files changed

+78
-5
lines changed

src/components/Modal/Modal.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { iconClose } from 'carbon-icons';
55
import Icon from '../Icon';
66
import Button from '../Button';
77

8+
const matchesFuncName =
9+
typeof Element !== 'undefined' &&
10+
['matches', 'webkitMatchesSelector', 'msMatchesSelector'].filter(
11+
name => typeof Element.prototype[name] === 'function'
12+
)[0];
13+
814
export default class Modal extends Component {
915
static propTypes = {
1016
children: PropTypes.node,
@@ -25,6 +31,7 @@ export default class Modal extends Component {
2531
onSecondarySubmit: PropTypes.func,
2632
danger: PropTypes.bool,
2733
shouldSubmitOnEnter: PropTypes.bool,
34+
selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string),
2835
selectorPrimaryFocus: PropTypes.string,
2936
};
3037

@@ -37,11 +44,39 @@ export default class Modal extends Component {
3744
iconDescription: 'close the modal',
3845
modalHeading: '',
3946
modalLabel: '',
47+
selectorsFloatingMenus: [
48+
'.bx--overflow-menu-options',
49+
'.bx--tooltip',
50+
'.flatpickr-calendar',
51+
],
4052
selectorPrimaryFocus: '[data-modal-primary-focus]',
4153
};
4254

4355
button = React.createRef();
4456

57+
elementOrParentIsFloatingMenu = target => {
58+
if (target && typeof target.closest === 'function') {
59+
return this.props.selectorsFloatingMenus.some(selector =>
60+
target.closest(selector)
61+
);
62+
} else {
63+
// Alternative if closest does not exist.
64+
while (target) {
65+
if (typeof target[matchesFuncName] === 'function') {
66+
if (
67+
this.props.selectorsFloatingMenus.some(selector =>
68+
target[matchesFuncName](selector)
69+
)
70+
) {
71+
return true;
72+
}
73+
}
74+
target = target.parentNode;
75+
}
76+
return false;
77+
}
78+
};
79+
4580
handleKeyDown = evt => {
4681
if (evt.which === 27) {
4782
this.props.onRequestClose();
@@ -52,11 +87,27 @@ export default class Modal extends Component {
5287
};
5388

5489
handleClick = evt => {
55-
if (this.innerModal && !this.innerModal.contains(evt.target)) {
90+
if (
91+
this.innerModal &&
92+
!this.innerModal.contains(evt.target) &&
93+
!this.elementOrParentIsFloatingMenu(evt.target)
94+
) {
5695
this.props.onRequestClose();
5796
}
5897
};
5998

99+
handleBlur = evt => {
100+
// Keyboard trap
101+
if (
102+
this.innerModal &&
103+
this.props.open &&
104+
(!evt.relatedTarget || !this.innerModal.contains(evt.relatedTarget)) &&
105+
!this.elementOrParentIsFloatingMenu(evt.relatedTarget)
106+
) {
107+
this.focusModal();
108+
}
109+
};
110+
60111
componentDidUpdate(prevProps) {
61112
if (!prevProps.open && this.props.open) {
62113
this.beingOpen = true;
@@ -65,6 +116,12 @@ export default class Modal extends Component {
65116
}
66117
}
67118

119+
focusModal = () => {
120+
if (this.outerModal) {
121+
this.outerModal.focus();
122+
}
123+
};
124+
68125
focusButton = evt => {
69126
const primaryFocusElement = evt.currentTarget.querySelector(
70127
this.props.selectorPrimaryFocus
@@ -104,6 +161,7 @@ export default class Modal extends Component {
104161
iconDescription,
105162
primaryButtonDisabled,
106163
danger,
164+
selectorsFloatingMenus, // eslint-disable-line
107165
...other
108166
} = this.props;
109167

@@ -176,6 +234,7 @@ export default class Modal extends Component {
176234
{...other}
177235
onKeyDown={this.handleKeyDown}
178236
onClick={this.handleClick}
237+
onBlur={this.handleBlur}
179238
className={modalClasses}
180239
role="presentation"
181240
tabIndex={-1}

src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,18 @@ exports[`ModalWrapper should render 1`] = `
5353
primaryButtonText="Save"
5454
secondaryButtonText="Cancel"
5555
selectorPrimaryFocus="[data-modal-primary-focus]"
56+
selectorsFloatingMenus={
57+
Array [
58+
".bx--overflow-menu-options",
59+
".bx--tooltip",
60+
".flatpickr-calendar",
61+
]
62+
}
5663
>
5764
<div
5865
className="bx--modal bx--modal-tall"
5966
id="modal"
67+
onBlur={[Function]}
6068
onClick={[Function]}
6169
onKeyDown={[Function]}
6270
role="presentation"

src/components/OverflowMenu/OverflowMenu.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,11 @@ export default class OverflowMenu extends Component {
362362
};
363363

364364
closeMenu = () => {
365+
let wasOpen = this.state.open;
365366
this.setState({ open: false }, () => {
367+
if (wasOpen) {
368+
this.focusMenuEl();
369+
}
366370
this.props.onClose();
367371
});
368372
};
@@ -371,6 +375,12 @@ export default class OverflowMenu extends Component {
371375
this.menuEl = menuEl;
372376
};
373377

378+
focusMenuEl = () => {
379+
if (this.menuEl) {
380+
this.menuEl.focus();
381+
}
382+
};
383+
374384
/**
375385
* Handles the floating menu being unmounted.
376386
* @param {Element} menuBody The DOM element of the menu body.
@@ -409,10 +419,6 @@ export default class OverflowMenu extends Component {
409419
!matches(target, '.bx--overflow-menu,.bx--overflow-menu-options')
410420
) {
411421
this.closeMenu();
412-
// Note:
413-
// The last focusable element in the page should NOT be the trigger button of overflow menu.
414-
// Doing so breaks the code that detects if floating menu losing focus, e.g. by keyboard events.
415-
this.menuEl.focus();
416422
}
417423
},
418424
!hasFocusin

0 commit comments

Comments
 (0)