Skip to content

Commit

Permalink
fix(Modal): Trap focus on opened modal (reactstrap#1941)
Browse files Browse the repository at this point in the history
* Modal: Trap focus on opened component (FIX reactstrap#1497 and FIX reactstrap#1679)

* fix shift+tab focus navigation controls on nested modal

* Changes this.modalCount(1-based) -> this.modalIndex(0-based index), to improve readability

* Sets tabstop=2 and includes comments for trapFocus function.

* add/remove focus listener with correct signatures (using the capture flag)

Co-authored-by: Kyle Tsang <6854874+kyletsang@users.noreply.github.com>
  • Loading branch information
watinha and kyletsang committed Dec 23, 2020
1 parent 80e4913 commit bf46484
Show file tree
Hide file tree
Showing 2 changed files with 169 additions and 3 deletions.
33 changes: 32 additions & 1 deletion src/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class Modal extends React.Component {
this.onClosed = this.onClosed.bind(this);
this.manageFocusAfterClose = this.manageFocusAfterClose.bind(this);
this.clearBackdropAnimationTimeout = this.clearBackdropAnimationTimeout.bind(this);
this.trapFocus = this.trapFocus.bind(this);

this.state = {
isOpen: false,
Expand All @@ -127,6 +128,9 @@ class Modal extends React.Component {
onEnter();
}

// traps focus inside the Modal, even if the browser address bar is focused
document.addEventListener('focus', this.trapFocus, true);

this._isMounted = true;
}

Expand Down Expand Up @@ -162,9 +166,34 @@ class Modal extends React.Component {
}
}

document.removeEventListener('focus', this.trapFocus, true);
this._isMounted = false;
}

trapFocus (ev) {
if (!this._element) //element is not attached
return ;

if (this._dialog && this._dialog.parentNode === ev.target) // initial focus when the Modal is opened
return ;

if (this.modalIndex < (Modal.openCount - 1)) // last opened modal
return ;

const children = this.getFocusableChildren();

for (let i = 0; i < children.length; i++) { // focus is already inside the Modal
if (children[i] === ev.target)
return ;
}

if (children.length > 0) { // otherwise focus the first focusable element in the Modal
ev.preventDefault();
ev.stopPropagation();
children[0].focus();
}
}

onOpened(node, isAppearing) {
this.props.onOpened();
(this.props.modalTransition.onEntered || noop)(node, isAppearing);
Expand Down Expand Up @@ -229,6 +258,7 @@ class Modal extends React.Component {

handleTab(e) {
if (e.which !== 9) return;
if (this.modalIndex < (Modal.openCount - 1)) return; // last opened modal

const focusableChildren = this.getFocusableChildren();
const totalFocusable = focusableChildren.length;
Expand Down Expand Up @@ -268,7 +298,7 @@ class Modal extends React.Component {
else if (this.props.backdrop === 'static') {
e.preventDefault();
e.stopPropagation();

this.handleStaticBackdropAnimation();
}
}
Expand Down Expand Up @@ -308,6 +338,7 @@ class Modal extends React.Component {
);
}

this.modalIndex = Modal.openCount;
Modal.openCount += 1;
}

Expand Down
139 changes: 137 additions & 2 deletions src/__tests__/Modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ describe('Modal', () => {

expect(isOpen).toBe(true);
expect(document.getElementsByClassName('modal').length).toBe(1);

const modal = document.getElementsByClassName('modal')[0];

const mouseDownEvent = document.createEvent('MouseEvents');
Expand Down Expand Up @@ -756,7 +756,7 @@ describe('Modal', () => {

expect(isOpen).toBe(true);
expect(document.getElementsByClassName('modal').length).toBe(1);

const modalDialog = document.getElementsByClassName('modal-dialog')[0];

const mouseDownEvent = document.createEvent('MouseEvents');
Expand Down Expand Up @@ -1133,5 +1133,140 @@ describe('Modal', () => {
jest.runAllTimers();

expect(document.activeElement === button).toEqual(false);
wrapper.unmount();
});

it('should attach/detach trapFocus for dialogs', () => {
const addEventListenerFn = document.addEventListener,
removeEventListenerFn = document.removeEventListener;
document.addEventListener = jest.fn();
document.removeEventListener = jest.fn();

const MockComponent = () => (
<>
<Modal isOpen={true}>
<ModalBody>
<Button className={'focus'}>focusable element</Button>
</ModalBody>
</Modal>
</>),
wrapper = didMount(<MockComponent />),
modal_instance = wrapper.find(Modal).instance();

expect(document.addEventListener.mock.calls.length).toBe(1);
expect(document.addEventListener.mock.calls[0]).toEqual(['focus', modal_instance.trapFocus, true]);

wrapper.unmount();

expect(document.removeEventListener.mock.calls.length).toBe(1);
expect(document.removeEventListener.mock.calls[0]).toEqual(['focus', modal_instance.trapFocus, true]);

// restore global document mock
document.addEventListener = addEventListenerFn;
document.removeEventListener = removeEventListenerFn;
});

it('should trap focus inside the open dialog', () => {
const MockComponent = () => (
<>
<Button className={'first'}>Focused</Button>
<Modal isOpen={true}>
<ModalBody>
Something else to see
<Button className={'focus'}>focusable element</Button>
</ModalBody>
</Modal>
</>),
wrapper = didMount(<MockComponent />);
const button = wrapper.find('.first').hostNodes().getDOMNode(),
button2 = wrapper.find('.focus').hostNodes().getDOMNode(),
modal_instance = wrapper.find(Modal).instance(),
ev_mock = {
target: button,
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
button.focus();
modal_instance.trapFocus(ev_mock);
jest.runAllTimers();

expect(document.activeElement).not.toBe(button);
expect(document.activeElement).toBe(button2);
expect(ev_mock.preventDefault.mock.calls.length).toBe(1);
expect(ev_mock.stopPropagation.mock.calls.length).toBe(1);
wrapper.unmount();
});

it('should not trap focus when there is a nested modal', () => {
isOpen = true;

const wrapper = didMount(
<Modal isOpen={isOpen} toggle={toggle}>
<ModalBody>
<Button className={'b0'} onClick={toggle}>Cancel</Button>
<Modal isOpen={true}>
<ModalBody>
<Button className={'b1'}>Click 1</Button>
<Button className={'b2'}>Click 2</Button>
</ModalBody>
</Modal>
</ModalBody>
</Modal>
);

const instance = wrapper.instance(),
nested = wrapper.find(Modal).at(1).instance(),
button = wrapper.find('.b0').hostNodes().getDOMNode(),
button1 = wrapper.find('.b1').hostNodes().getDOMNode(),
button2 = wrapper.find('.b2').hostNodes().getDOMNode(),
ev_mock = {
target: button,
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
button2.focus();
instance.trapFocus(ev_mock);
jest.runAllTimers();

expect(document.activeElement).not.toBe(button);
expect(document.activeElement).toBe(button2);

wrapper.unmount();
});

it('should not handle tab if there is a nested Modal', () => {
const wrapper = didMount(
<Modal isOpen={true} toggle={toggle}>
<ModalBody>
<Button className={'b0'} onClick={toggle}>Cancel</Button>
<Modal isOpen={true}>
<ModalBody>
<Button className={'b1'}>Click 1</Button>
</ModalBody>
</Modal>
</ModalBody>
</Modal>
);

const instance = wrapper.instance(),
nested = wrapper.find(Modal).at(1).instance(),
button = wrapper.find('.b0').hostNodes().getDOMNode(),
button1 = wrapper.find('.b1').hostNodes().getDOMNode(),
ev_mock = {
target: button1,
which: 9,
shiftKey: true,
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
button1.focus();
instance.getFocusableChildren = jest.fn();
instance.getFocusableChildren.mockReturnValue([]);
instance.handleTab(ev_mock);
jest.runAllTimers();

expect(instance.getFocusableChildren.mock.calls.length).toBe(0);

wrapper.unmount();
});
});

0 comments on commit bf46484

Please sign in to comment.