Skip to content

Commit

Permalink
feat(Modal): trap focus in modal (#1161)
Browse files Browse the repository at this point in the history
Closes #310
  • Loading branch information
monteith authored and TheSharpieOne committed Aug 13, 2018
1 parent abbac56 commit e6781d7
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 3 deletions.
2 changes: 1 addition & 1 deletion docs/lib/Components/ModalsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import ModalExternalExample from '../examples/ModalExternal';
const ModalExternalExampleSource = require('!!raw!../examples/ModalExternal');

import ModalCustomCloseIconExample from '../examples/ModalCustomCloseIcon';
const ModalCustomCloseIconExampleSource = require('!!raw!../examples/ModalCutomCloseIcon');
const ModalCustomCloseIconExampleSource = require('!!raw!../examples/ModalCustomCloseIcon');

export default class ModalsPage extends React.Component {
render() {
Expand Down
46 changes: 45 additions & 1 deletion src/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setScrollbarWidth,
mapToCssModules,
omit,
focusableElements,
TransitionTimeouts
} from './utils';

Expand Down Expand Up @@ -83,9 +84,11 @@ class Modal extends React.Component {

this._element = null;
this._originalBodyPadding = null;
this.getFocusableChildren = this.getFocusableChildren.bind(this);
this.handleBackdropClick = this.handleBackdropClick.bind(this);
this.handleBackdropMouseDown = this.handleBackdropMouseDown.bind(this);
this.handleEscape = this.handleEscape.bind(this);
this.handleTab = this.handleTab.bind(this);
this.onOpened = this.onOpened.bind(this);
this.onClosed = this.onClosed.bind(this);

Expand Down Expand Up @@ -166,6 +169,22 @@ class Modal extends React.Component {
}
}

getFocusableChildren() {
return this._element.querySelectorAll(focusableElements.join(', '));
}

getFocusedChild() {
let currentFocus;
const focusableChildren = this.getFocusableChildren();

try {
currentFocus = document.activeElement;
} catch (err) {
currentFocus = focusableChildren[0];
}
return currentFocus;
}

// not mouseUp because scrollbar fires it, shouldn't close when user scrolls
handleBackdropClick(e) {
if (e.target === this._mouseDownElement) {
Expand All @@ -180,6 +199,31 @@ class Modal extends React.Component {
}
}

handleTab(e) {
if (e.which !== 9) return;

const focusableChildren = this.getFocusableChildren();
const totalFocusable = focusableChildren.length;
const currentFocus = this.getFocusedChild();

let focusedIndex = 0;

for (let i = 0; i < totalFocusable; i += 1) {
if (focusableChildren[i] === currentFocus) {
focusedIndex = i;
break;
}
}

if (e.shiftKey && focusedIndex === 0) {
e.preventDefault();
focusableChildren[totalFocusable - 1].focus();
} else if (!e.shiftKey && focusedIndex === totalFocusable - 1) {
e.preventDefault();
focusableChildren[0].focus();
}
}

handleBackdropMouseDown(e) {
this._mouseDownElement = e.target;
}
Expand All @@ -200,7 +244,6 @@ class Modal extends React.Component {
conditionallyUpdateScrollbar();

document.body.appendChild(this._element);

if (!this.bodyClassAdded) {
document.body.className = classNames(
document.body.className,
Expand Down Expand Up @@ -274,6 +317,7 @@ class Modal extends React.Component {
onClick: this.handleBackdropClick,
onMouseDown: this.handleBackdropMouseDown,
onKeyUp: this.handleEscape,
onKeyDown: this.handleTab,
style: { display: 'block' },
'aria-labelledby': labelledBy,
role,
Expand Down
62 changes: 61 additions & 1 deletion src/__tests__/Modal.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
import React from 'react';
import { mount, shallow } from 'enzyme';
import { Modal, ModalBody } from '../';
import { Modal, ModalBody, ModalHeader, ModalFooter, Button } from '../';

describe('Modal', () => {
let isOpen;
Expand Down Expand Up @@ -757,4 +758,63 @@ describe('Modal', () => {
wrapper.setProps({ zIndex: 1 });
expect(wrapper.instance()._element.style.zIndex).toBe('1');
});

it('should allow focus on only focusable elements', () => {
isOpen = true;

const wrapper = mount(
<Modal isOpen={isOpen} toggle={toggle}>
<ModalHeader toggle={toggle}>Modal title</ModalHeader>
<ModalBody>
<a alt="test" href="/">Test</a>
<map name="test">
<area alt="test" href="/" coords="200,5,200,30" />
</map>
<input type="text" />
<input type="hidden" />
<input type="text" disabled value="Test" />
<select name="test" id="select_test">
<option>Test item</option>
</select>
<select name="test" id="select_test_disabled" disabled>
<option>Test item</option>
</select>
<textarea name="textarea_test" id="textarea_test" cols="30" rows="10" />
<textarea name="textarea_test_disabled" id="textarea_test_disabled" cols="30" rows="10" disabled />
<object>Test</object>
<span tabIndex="0">test tab index</span>
</ModalBody>
<ModalFooter>
<Button disabled color="primary" onClick={toggle}>Do Something</Button>{' '}
<Button color="secondary" onClick={toggle}>Cancel</Button>
</ModalFooter>
</Modal>
);

const instance = wrapper.instance();
expect(instance.getFocusableChildren().length).toBe(9);
wrapper.unmount();
});

it('should tab through focusable elements', () => {
isOpen = true;

const mock = jest.fn();

const wrapper = mount(
<Modal isOpen={isOpen} toggle={toggle}>
<ModalBody>
<Button onFocus={mock} color="secondary" onClick={toggle}>Cancel</Button>
</ModalBody>
</Modal>
);

const instance = wrapper.instance();
expect(instance.getFocusedChild()).not.toBe(instance.getFocusableChildren()[0]);
wrapper.find('.modal').hostNodes().simulate('keyDown', { which: 9, key: 'Tab', keyCode: 9 });
expect(instance.getFocusedChild()).toBe(instance.getFocusableChildren()[0]);
expect(mock).toHaveBeenCalled();

wrapper.unmount();
});
});
15 changes: 15 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,18 @@ export function addMultipleEventListeners(_els, handler, _events) {
});
};
}

export const focusableElements = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type=hidden])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'object',
'embed',
'[tabindex]:not(.modal)',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
];
1 change: 1 addition & 0 deletions webpack.dev.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var paths = [
var config = [{
devtool: 'source-map',
devServer: {
disableHostCheck: true,
contentBase: './build',
historyApiFallback: true,
stats: {
Expand Down

0 comments on commit e6781d7

Please sign in to comment.