Skip to content

Commit

Permalink
[Modal] Improve the focus logic
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviertassinari committed Feb 15, 2019
1 parent ec23ff8 commit 3a69cb4
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 81 deletions.
11 changes: 9 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ module.exports = [
name: 'The size of the @material-ui/core/Modal component',
webpack: true,
path: 'packages/material-ui/build/esm/Modal/index.js',
limit: '24.1 KB',
limit: '24.3 KB',
},
{
// vs https://bundlephobia.com/result?p=react-popper
Expand All @@ -75,6 +75,13 @@ module.exports = [
path: 'packages/material-ui/build/esm/Popper/index.js',
limit: '9.8 KB',
},
{
// vs https://bundlephobia.com/result?p=focus-trap-react
name: 'The size of the @material-ui/core/Popper component',
webpack: true,
path: 'packages/material-ui/build/esm/Modal/TrapFocus.js',
limit: '1.6 KB',
},
{
// vs https://bundlephobia.com/result?p=react-responsive
// vs https://bundlephobia.com/result?p=react-media
Expand All @@ -87,7 +94,7 @@ module.exports = [
name: 'The main docs bundle',
webpack: false,
path: main.path,
limit: '202 KB',
limit: '203 KB',
},
{
name: 'The docs home page',
Expand Down
2 changes: 1 addition & 1 deletion docs/src/pages/demos/dialogs/dialogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Touching “Cancel” in a confirmation dialog, or pressing Back, cancels the ac

## Accessibility

Be sure to add `aria-labelledby="id..."`, referencing the modal title, to the `Dialog`. Additionally, you may give a description of your modal dialog with the `aria-describedby="id..."` property on the `Dialog`.
Follow the [Modal accessibility section](/utils/modal/#accessibility).

## Scrolling long content

Expand Down
21 changes: 21 additions & 0 deletions docs/src/pages/utils/modal/modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,24 @@ You can **speed up** the rendering by moving the modal body into its own compone

This way, you take advantage of [React render laziness evaluation](https://overreacted.io/react-as-a-ui-runtime/#lazy-evaluation).
The `TableComponent` render method will only be evaluated when opening the modal.

## Accessibility

- Be sure to add `aria-labelledby="id..."`, referencing the modal title, to the `Modal`.
Additionally, you may give a description of your modal with the `aria-describedby="id..."` property on the `Modal`.

```jsx
<Modal
aria-labelledby="simple-modal-title"
aria-describedby="simple-modal-description"
>
<Typography variant="h6" id="modal-title">
My Title
</Typography>
<Typography variant="subtitle1" id="simple-modal-description">
My Description
</Typography>
</Modal>
```

- The [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html) can help you set the initial focus on the most relevant element, based on your modal content.
2 changes: 1 addition & 1 deletion packages/material-ui/src/Menu/Menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class Menu extends React.Component {
// Let's ignore that piece of logic if users are already overriding the width
// of the menu.
if (menuList && element.clientHeight < menuList.clientHeight && !menuList.style.width) {
const size = `${getScrollbarSize()}px`;
const size = `${getScrollbarSize(true)}px`;
menuList.style[theme.direction === 'rtl' ? 'paddingLeft' : 'paddingRight'] = size;
menuList.style.width = `calc(100% + ${size})`;
}
Expand Down
85 changes: 13 additions & 72 deletions packages/material-ui/src/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import warning from 'warning';
import { componentPropType } from '@material-ui/utils';
import ownerDocument from '../utils/ownerDocument';
import RootRef from '../RootRef';
import Portal from '../Portal';
import { createChainedFunction } from '../utils/helpers';
import withStyles from '../styles/withStyles';
import ModalManager from './ModalManager';
import TrapFocus from './TrapFocus';
import Backdrop from '../Backdrop';
import { ariaHidden } from './manageAriaHidden';

Expand Down Expand Up @@ -107,9 +106,7 @@ class Modal extends React.Component {
const container = getContainer(this.props.container, doc.body);

this.props.manager.add(this, container);
doc.addEventListener('focus', this.enforceFocus, true);

if (this.dialogRef) {
if (this.modalRef) {
this.handleOpened();
}
};
Expand All @@ -127,7 +124,6 @@ class Modal extends React.Component {
};

handleOpened = () => {
this.autoFocus();
this.props.manager.mount(this);

// Fix a bug on Chrome where the scroll isn't initially 0.
Expand All @@ -142,11 +138,6 @@ class Modal extends React.Component {
if (!(hasTransition && this.props.closeAfterTransition) || reason === 'unmount') {
this.props.manager.remove(this);
}

const doc = ownerDocument(this.mountNode);
doc.removeEventListener('focus', this.enforceFocus, true);

this.restoreLastFocus();
};

handleExited = () => {
Expand Down Expand Up @@ -196,19 +187,6 @@ class Modal extends React.Component {
}
};

enforceFocus = () => {
// The Modal might not already be mounted.
if (!this.isTopModal() || this.props.disableEnforceFocus || !this.mounted || !this.dialogRef) {
return;
}

const currentActiveElement = ownerDocument(this.mountNode).activeElement;

if (!this.dialogRef.contains(currentActiveElement)) {
this.dialogRef.focus();
}
};

handlePortalRef = ref => {
this.mountNode = ref ? ref.getMountNode() : ref;
};
Expand All @@ -217,54 +195,9 @@ class Modal extends React.Component {
this.modalRef = ref;
};

onRootRef = ref => {
this.dialogRef = ref;
};

autoFocus() {
// We might render an empty child.
if (this.props.disableAutoFocus || !this.dialogRef) {
return;
}

const currentActiveElement = ownerDocument(this.mountNode).activeElement;

if (!this.dialogRef.contains(currentActiveElement)) {
if (!this.dialogRef.hasAttribute('tabIndex')) {
warning(
false,
[
'Material-UI: the modal content node does not accept focus.',
'For the benefit of assistive technologies, ' +
'the tabIndex of the node is being set to "-1".',
].join('\n'),
);
this.dialogRef.setAttribute('tabIndex', -1);
}

this.lastFocus = currentActiveElement;
this.dialogRef.focus();
}
}

restoreLastFocus() {
if (this.props.disableRestoreFocus || !this.lastFocus) {
return;
}

// Not all elements in IE 11 have a focus method.
// Because IE 11 market share is low, we accept the restore focus being broken
// and we silent the issue.
if (this.lastFocus.focus) {
this.lastFocus.focus();
}

this.lastFocus = null;
}

isTopModal() {
isTopModal = () => {
return this.props.manager.isTopModal(this);
}
};

render() {
const {
Expand Down Expand Up @@ -339,7 +272,15 @@ class Modal extends React.Component {
{hideBackdrop ? null : (
<BackdropComponent open={open} onClick={this.handleBackdropClick} {...BackdropProps} />
)}
<RootRef rootRef={this.onRootRef}>{React.cloneElement(children, childProps)}</RootRef>
<TrapFocus
disableEnforceFocus={disableEnforceFocus}
disableAutoFocus={disableAutoFocus}
disableRestoreFocus={disableRestoreFocus}
isEnabled={this.isTopModal}
open={open}
>
{React.cloneElement(children, childProps)}
</TrapFocus>
</div>
</Portal>
);
Expand Down
8 changes: 4 additions & 4 deletions packages/material-ui/src/Modal/Modal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,9 @@ describe('<Modal />', () => {
throw new Error('missing modal');
}

assert.strictEqual(modal.children.length, 2);
assert.strictEqual(modal.children.length, 4);
assert.strictEqual(modal.children[0] != null, true);
assert.strictEqual(modal.children[1], container);
assert.strictEqual(modal.children[2], container);
});
});

Expand All @@ -240,8 +240,8 @@ describe('<Modal />', () => {
throw new Error('missing modal');
}

assert.strictEqual(modal.children.length, 1);
assert.strictEqual(modal.children[0], container);
assert.strictEqual(modal.children.length, 3);
assert.strictEqual(modal.children[1], container);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/material-ui/src/Modal/ModalManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function setContainerStyle(data) {
};

if (data.overflowing) {
const scrollbarSize = getScrollbarSize();
const scrollbarSize = getScrollbarSize(true);

// Use computed style, here to get the real padding to add our scrollbar width.
style.paddingRight = `${getPaddingRight(data.container) + scrollbarSize}px`;
Expand Down
145 changes: 145 additions & 0 deletions packages/material-ui/src/Modal/TrapFocus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/* eslint-disable consistent-return, jsx-a11y/no-noninteractive-tabindex */

import React from 'react';
import PropTypes from 'prop-types';
import warning from 'warning';
import RootRef from '../RootRef';
import ownerDocument from '../utils/ownerDocument';

function TrapFocus(props) {
const { disableEnforceFocus, disableAutoFocus, disableRestoreFocus, isEnabled, open } = props;
const rootRef = React.useRef();
const ignoreNextEnforceFocus = React.useRef();
const sentinelStart = React.useRef();
const sentinelEnd = React.useRef();
const lastFocus = React.useRef();

React.useEffect(() => {
if (!open) {
return;
}

const doc = ownerDocument(rootRef.current);
const currentActiveElement = doc.activeElement;
lastFocus.current = currentActiveElement;

// We might render an empty child.
if (!disableAutoFocus && rootRef.current && !rootRef.current.contains(currentActiveElement)) {
if (!rootRef.current.hasAttribute('tabIndex')) {
warning(
false,
[
'Material-UI: the modal content node does not accept focus.',
'For the benefit of assistive technologies, ' +
'the tabIndex of the node is being set to "-1".',
].join('\n'),
);
rootRef.current.setAttribute('tabIndex', -1);
}

rootRef.current.focus();
}

const enforceFocus = () => {
if (disableEnforceFocus || !isEnabled() || ignoreNextEnforceFocus.current) {
ignoreNextEnforceFocus.current = false;
return;
}

if (!rootRef.current.contains(doc.activeElement)) {
rootRef.current.focus();
}
};

const loopFocus = event => {
if (disableEnforceFocus || !isEnabled() || event.key !== 'Tab') {
return;
}

// Make sure the next tab starts from the right place.
if (doc.activeElement === rootRef.current) {
// We need to ignore the next enforceFocus as
// it will try to move the focus back to the rootRef element.
ignoreNextEnforceFocus.current = true;
if (event.shiftKey) {
sentinelEnd.current.focus();
} else {
sentinelStart.current.focus();
}
}
};

doc.addEventListener('focus', enforceFocus, true);
doc.addEventListener('keydown', loopFocus, true);

return () => {
doc.removeEventListener('focus', enforceFocus, true);
doc.removeEventListener('keydown', loopFocus, true);

// restoreLastFocus()
if (!disableRestoreFocus) {
// Not all elements in IE 11 have a focus method.
// Because IE 11 market share is low, we accept the restore focus being broken
// and we silent the issue.
if (lastFocus.current.focus) {
lastFocus.current.focus();
}

lastFocus.current = null;
}
};
}, [open]);

return (
<React.Fragment>
<div tabIndex={0} ref={sentinelStart} />
<RootRef rootRef={rootRef}>{props.children}</RootRef>
<div tabIndex={0} ref={sentinelEnd} />
</React.Fragment>
);
}

TrapFocus.propTypes = {
/**
* A single child content element.
*/
children: PropTypes.element.isRequired,
/**
* If `true`, the modal will not automatically shift focus to itself when it opens, and
* replace it to the last focused element when it closes.
* This also works correctly with any modal children that have the `disableAutoFocus` prop.
*
* Generally this should never be set to `true` as it makes the modal less
* accessible to assistive technologies, like screen readers.
*/
disableAutoFocus: PropTypes.bool,
/**
* If `true`, the modal will not prevent focus from leaving the modal while open.
*
* Generally this should never be set to `true` as it makes the modal less
* accessible to assistive technologies, like screen readers.
*/
disableEnforceFocus: PropTypes.bool,
/**
* If `true`, the modal will not restore focus to previously focused element once
* modal is hidden.
*/
disableRestoreFocus: PropTypes.bool,
/**
* Do we still want to enforce the focus?
* This property helps nesting TrapFocus elements.
*/
isEnabled: PropTypes.func.isRequired,
/**
* If `true`, the modal is open.
*/
open: PropTypes.bool.isRequired,
};

TrapFocus.defaultProps = {
disableAutoFocus: false,
disableEnforceFocus: false,
disableRestoreFocus: false,
};

export default TrapFocus;

0 comments on commit 3a69cb4

Please sign in to comment.