Skip to content

Commit

Permalink
feat(components): support automatic focus sentinel
Browse files Browse the repository at this point in the history
This change eliminates the need for application to put focus sentinel
by having `<Modal>`, `<ComposedModal>` and `<FloatingMenu>`
automatically put the focus sentinels.

This change also add support for reverse-focus-wrap feature to
`<Modal>` and `<ComposedModal>`, without needing using 3rd-party
`focus-trap-react` library. This helps applications hitting adverse
side-effects that `focus-trap-react` library causes (e.g. carbon-design-system#3021, carbon-design-system#3665
and carbon-design-system#4600).

Fixes carbon-design-system#3817.
Fixes carbon-design-system#4036.
Fixes carbon-design-system#4600.
  • Loading branch information
asudoh committed Feb 4, 2020
1 parent af33d29 commit 1bbe349
Show file tree
Hide file tree
Showing 10 changed files with 383 additions and 104 deletions.
Binary file added .yarn/offline-mirror/lodash.findlast-4.6.0.tgz
Binary file not shown.
5 changes: 0 additions & 5 deletions packages/react/.storybook/Container.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,6 @@ function Container({ story }) {
}}>
{story()}
</div>
<input
aria-label="input-text-offleft"
type="text"
className={`${prefix}--visually-hidden`}
/>
</React.StrictMode>
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@
"classnames": "2.2.6",
"downshift": "^1.31.14",
"flatpickr": "4.6.1",
"focus-trap-react": "^6.0.0",
"invariant": "^2.2.3",
"lodash.debounce": "^4.0.8",
"lodash.findlast": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.omit": "^4.5.0",
"react-is": "^16.8.6",
Expand Down
70 changes: 38 additions & 32 deletions packages/react/src/components/ComposedModal/ComposedModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { settings } from 'carbon-components';
import { Close20 } from '@carbon/icons-react';
import toggleClass from '../../tools/toggleClass';
import requiredIfGivenPropExists from '../../prop-types/requiredIfGivenPropExists';
import wrapFocus from '../../internal/wrapFocus';

const { prefix } = settings;

Expand All @@ -27,6 +28,8 @@ export default class ComposedModal extends Component {
outerModal = React.createRef();
innerModal = React.createRef();
button = React.createRef();
startSentinel = React.createRef();
endSentinel = React.createRef();

static propTypes = {
/**
Expand Down Expand Up @@ -78,19 +81,6 @@ export default class ComposedModal extends Component {
};
}

elementOrParentIsFloatingMenu = target => {
const {
selectorsFloatingMenus = [
`.${prefix}--overflow-menu-options`,
`.${prefix}--tooltip`,
'.flatpickr-calendar',
],
} = this.props;
if (target && typeof target.closest === 'function') {
return selectorsFloatingMenus.some(selector => target.closest(selector));
}
};

handleKeyDown = evt => {
// Esc key
if (evt.which === 27) {
Expand All @@ -109,22 +99,23 @@ export default class ComposedModal extends Component {
}
};

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

handleBlur = evt => {
// Keyboard trap
if (
this.innerModal.current &&
this.props.open &&
evt.relatedTarget &&
!this.innerModal.current.contains(evt.relatedTarget) &&
!this.elementOrParentIsFloatingMenu(evt.relatedTarget)
) {
this.focusModal();
handleBlur = ({
target: oldActiveNode,
relatedTarget: currentActiveNode,
}) => {
const { open, selectorsFloatingMenus } = this.props;
if (open && currentActiveNode && oldActiveNode) {
const { current: modalNode } = this.innerModal;
const { current: startSentinelNode } = this.startSentinel;
const { current: endSentinelNode } = this.endSentinel;
wrapFocus({
modalNode,
startSentinelNode,
endSentinelNode,
currentActiveNode,
oldActiveNode,
selectorsFloatingMenus,
});
}
};

Expand Down Expand Up @@ -240,11 +231,26 @@ export default class ComposedModal extends Component {
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onTransitionEnd={open ? this.handleTransitionEnd : undefined}
className={modalClass}
tabIndex={-1}>
<div ref={this.innerModal} className={containerClass}>
className={modalClass}>
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */}
<span
ref={this.startSentinel}
tabIndex="0"
role="link"
class={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
<div ref={this.innerModal} className={containerClass} tabIndex={-1}>
{childrenWithProps}
</div>
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */}
<span
ref={this.endSentinel}
tabIndex="0"
role="link"
class={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
</div>
);
}
Expand Down
103 changes: 48 additions & 55 deletions packages/react/src/components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import React, { Component } from 'react';
import classNames from 'classnames';
import { settings } from 'carbon-components';
import { Close20 } from '@carbon/icons-react';
import FocusTrap from 'focus-trap-react';
import toggleClass from '../../tools/toggleClass';
import Button from '../Button';
import deprecate from '../../prop-types/deprecate';
import requiredIfGivenPropExists from '../../prop-types/requiredIfGivenPropExists';
import wrapFocus from '../../internal/wrapFocus';
import setupGetInstanceId from '../../tools/setupGetInstanceId';

const { prefix } = settings;
Expand Down Expand Up @@ -138,10 +139,13 @@ export default class Modal extends Component {
size: PropTypes.oneOf(['xs', 'sm', 'lg']),

/**
* Specify whether the modal should use 3rd party `focus-trap-react` for the focus-wrap feature.
* NOTE: by default this is true.
* Deprecated; Used for advanced focust-wrapping feature using 3rd party library,
* but it's now achieved without a 3rd party library.
*/
focusTrap: PropTypes.bool,
focusTrap: deprecate(
PropTypes.bool,
`\nThe prop \`focusTrap\` for Modal has been deprecated, as the feature of \`persistent\` runs by default.`
),

/**
* Specify whether the modal contains scrolling content
Expand All @@ -167,30 +171,18 @@ export default class Modal extends Component {
modalHeading: '',
modalLabel: '',
selectorPrimaryFocus: '[data-modal-primary-focus]',
focusTrap: true,
hasScrollingContent: false,
};

button = React.createRef();
outerModal = React.createRef();
innerModal = React.createRef();
startSentinel = React.createRef();
endSentinel = React.createRef();
modalInstanceId = `modal-${getInstanceId()}`;
modalLabelId = `${prefix}--modal-header__label--${this.modalInstanceId}`;
modalHeadingId = `${prefix}--modal-header__heading--${this.modalInstanceId}`;

elementOrParentIsFloatingMenu = target => {
const {
selectorsFloatingMenus = [
`.${prefix}--overflow-menu-options`,
`.${prefix}--tooltip`,
'.flatpickr-calendar',
],
} = this.props;
if (target && typeof target.closest === 'function') {
return selectorsFloatingMenus.some(selector => target.closest(selector));
}
};

handleKeyDown = evt => {
if (this.props.open) {
if (evt.which === 27) {
Expand All @@ -212,22 +204,23 @@ export default class Modal extends Component {
}
};

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

handleBlur = evt => {
// Keyboard trap
if (
this.innerModal.current &&
this.props.open &&
evt.relatedTarget &&
!this.innerModal.current.contains(evt.relatedTarget) &&
!this.elementOrParentIsFloatingMenu(evt.relatedTarget)
) {
this.focusModal();
handleBlur = ({
target: oldActiveNode,
relatedTarget: currentActiveNode,
}) => {
const { open, selectorsFloatingMenus } = this.props;
if (open && currentActiveNode && oldActiveNode) {
const { current: modalNode } = this.innerModal;
const { current: startSentinelNode } = this.startSentinel;
const { current: endSentinelNode } = this.endSentinel;
wrapFocus({
modalNode,
startSentinelNode,
endSentinelNode,
currentActiveNode,
oldActiveNode,
selectorsFloatingMenus,
});
}
};

Expand Down Expand Up @@ -277,9 +270,7 @@ export default class Modal extends Component {
if (!this.props.open) {
return;
}
if (!this.props.focusTrap) {
this.focusButton(this.innerModal.current);
}
this.focusButton(this.innerModal.current);
}

handleTransitionEnd = evt => {
Expand All @@ -290,9 +281,7 @@ export default class Modal extends Component {
this.outerModal.current.offsetHeight &&
this.beingOpen
) {
if (!this.props.focusTrap) {
this.focusButton(evt.currentTarget);
}
this.focusButton(evt.currentTarget);
this.beingOpen = false;
}
};
Expand All @@ -317,7 +306,6 @@ export default class Modal extends Component {
selectorsFloatingMenus, // eslint-disable-line
shouldSubmitOnEnter, // eslint-disable-line
size,
focusTrap,
hasScrollingContent,
...other
} = this.props;
Expand Down Expand Up @@ -379,7 +367,8 @@ export default class Modal extends Component {
role="dialog"
className={containerClasses}
aria-label={ariaLabel}
aria-modal="true">
aria-modal="true"
tabIndex="-1">
<div className={`${prefix}--modal-header`}>
{passiveModal && modalButton}
{modalLabel && (
Expand Down Expand Up @@ -422,30 +411,34 @@ export default class Modal extends Component {
</div>
);

const modal = (
return (
<div
{...other}
onKeyDown={this.handleKeyDown}
onMouseDown={this.handleMousedown}
onBlur={this.handleBlur}
className={modalClasses}
role="presentation"
tabIndex={-1}
onTransitionEnd={this.props.open ? this.handleTransitionEnd : undefined}
ref={this.outerModal}>
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */}
<span
ref={this.startSentinel}
tabIndex="0"
role="link"
class={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
{modalBody}
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */}
<span
ref={this.endSentinel}
tabIndex="0"
role="link"
class={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
</div>
);

return !focusTrap ? (
modal
) : (
// `<FocusTrap>` has `active: true` in its `defaultProps`
<FocusTrap
active={!!open}
focusTrapOptions={{ initialFocus: this.initialFocus }}>
{modal}
</FocusTrap>
);
}
}
23 changes: 22 additions & 1 deletion packages/react/src/internal/FloatingMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import window from 'window-or-global';
import { settings } from 'carbon-components';

const { prefix } = settings;

/**
* The structure for the position of floating menu.
Expand Down Expand Up @@ -333,7 +336,25 @@ class FloatingMenu extends React.Component {
if (typeof document !== 'undefined') {
const { target } = this.props;
return ReactDOM.createPortal(
this._getChildrenWithProps(),
<>
{/* Non-translatable: Focus management code makes this `<span>` not actually read by screen readers */}
<span
ref={this.startSentinel}
tabIndex="0"
role="link"
class={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
{this._getChildrenWithProps()}
{/* Non-translatable: Focus management code makes this `<span>` not actually read by screen readers */}
<span
ref={this.endSentinel}
tabIndex="0"
role="link"
class={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
</>,
!target ? document.body : target()
);
}
Expand Down
Loading

0 comments on commit 1bbe349

Please sign in to comment.