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

fix(Modal): add keyboard trap #1115

Merged
merged 9 commits into from
Aug 9, 2018
61 changes: 60 additions & 1 deletion src/components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { iconClose } from 'carbon-icons';
import Icon from '../Icon';
import Button from '../Button';

const matchesFuncName =
typeof Element !== 'undefined' &&
['matches', 'webkitMatchesSelector', 'msMatchesSelector'].filter(
name => typeof Element.prototype[name] === 'function'
)[0];

export default class Modal extends Component {
static propTypes = {
children: PropTypes.node,
Expand All @@ -25,6 +31,7 @@ export default class Modal extends Component {
onSecondarySubmit: PropTypes.func,
danger: PropTypes.bool,
shouldSubmitOnEnter: PropTypes.bool,
selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string),
};

static defaultProps = {
Expand All @@ -36,10 +43,38 @@ export default class Modal extends Component {
iconDescription: 'close the modal',
modalHeading: '',
modalLabel: '',
selectorsFloatingMenus: [
'.bx--overflow-menu-options',
'.bx--tooltip',
'.flatpickr-calendar',
],
};

button = React.createRef();

elementOrParentIsFloatingMenu = target => {
if (target && typeof target.closest === 'function') {
return this.props.selectorsFloatingMenus.some(selector =>
target.closest(selector)
);
} else {
// Alternative if closest does not exist.
while (target) {
if (typeof target[matchesFuncName] === 'function') {
if (
this.props.selectorsFloatingMenus.some(selector =>
target[matchesFuncName](selector)
)
) {
return true;
}
}
target = target.parentNode;
}
return false;
}
};

handleKeyDown = evt => {
if (evt.which === 27) {
this.props.onRequestClose();
Expand All @@ -50,11 +85,27 @@ export default class Modal extends Component {
};

handleClick = evt => {
if (this.innerModal && !this.innerModal.contains(evt.target)) {
if (
this.innerModal &&
!this.innerModal.contains(evt.target) &&
!this.elementOrParentIsFloatingMenu(evt.target)
) {
this.props.onRequestClose();
}
};

handleBlur = evt => {
// Keyboard trap
if (
this.innerModal &&
this.props.open &&
(!evt.relatedTarget || !this.innerModal.contains(evt.relatedTarget)) &&
!this.elementOrParentIsFloatingMenu(evt.relatedTarget)
) {
this.focusModal();
}
};

componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.beingOpen = true;
Expand All @@ -63,6 +114,12 @@ export default class Modal extends Component {
}
}

focusModal = () => {
if (this.outerModal) {
this.outerModal.focus();
}
};

focusButton = () => {
if (this.button) {
this.button.current.focus();
Expand Down Expand Up @@ -95,6 +152,7 @@ export default class Modal extends Component {
iconDescription,
primaryButtonDisabled,
danger,
selectorsFloatingMenus, // eslint-disable-line
...other
} = this.props;

Expand Down Expand Up @@ -167,6 +225,7 @@ export default class Modal extends Component {
{...other}
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
onBlur={this.handleBlur}
className={modalClasses}
role="presentation"
tabIndex={-1}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,18 @@ exports[`ModalWrapper should render 1`] = `
primaryButtonDisabled={false}
primaryButtonText="Save"
secondaryButtonText="Cancel"
selectorsFloatingMenus={
Array [
".bx--overflow-menu-options",
".bx--tooltip",
".flatpickr-calendar",
]
}
>
<div
className="bx--modal bx--modal-tall"
id="modal"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
role="presentation"
Expand Down
14 changes: 10 additions & 4 deletions src/components/OverflowMenu/OverflowMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,11 @@ export default class OverflowMenu extends Component {
};

closeMenu = () => {
let wasOpen = this.state.open;
this.setState({ open: false }, () => {
if (wasOpen) {
this.focusMenuEl();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
this.props.onClose();
});
};
Expand All @@ -332,6 +336,12 @@ export default class OverflowMenu extends Component {
this.menuEl = menuEl;
};

focusMenuEl = () => {
if (this.menuEl) {
this.menuEl.focus();
}
};

/**
* Handles the floating menu being unmounted.
* @param {Element} menuBody The DOM element of the menu body.
Expand Down Expand Up @@ -370,10 +380,6 @@ export default class OverflowMenu extends Component {
!matches(target, '.bx--overflow-menu,.bx--overflow-menu-options')
) {
this.closeMenu();
// Note:
// The last focusable element in the page should NOT be the trigger button of overflow menu.
// Doing so breaks the code that detects if floating menu losing focus, e.g. by keyboard events.
this.menuEl.focus();
}
},
!hasFocusin
Expand Down