Skip to content

Commit

Permalink
feat(components): support automatic focus sentinel (#5260)
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. #3021, #3665
and #4600).

Fixes #3817.
Fixes #4036.
Fixes #4600.
  • Loading branch information
asudoh authored Feb 14, 2020
1 parent d6b3478 commit 0e9c6d5
Show file tree
Hide file tree
Showing 14 changed files with 537 additions and 233 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"
className={`${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"
className={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,25 @@ exports[`<ComposedModal /> renders 1`] = `
onTransitionEnd={[Function]}
open={true}
role="presentation"
tabIndex={-1}
>
<span
className="bx--visually-hidden"
role="link"
tabIndex="0"
>
Focus sentinel
</span>
<div
className="bx--modal-container"
tabIndex={-1}
/>
<span
className="bx--visually-hidden"
role="link"
tabIndex="0"
>
Focus sentinel
</span>
</div>
</ComposedModal>
`;
1 change: 0 additions & 1 deletion packages/react/src/components/Modal/Modal-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ const props = () => ({
'Enter key to submit (shouldSubmitOnEnter)',
false
),
focusTrap: boolean('Trap focus (focusTrap)', false),
hasScrollingContent: boolean(
'Modal contains scrollable content (hasScrollingContent)',
false
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/Modal/Modal-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { settings } from 'carbon-components';
const { prefix } = settings;

// The modal is the 0th child inside the wrapper on account of focus-trap-react
const getModal = wrapper => wrapper.childAt(0);
const getModal = wrapper => wrapper.find('.bx--modal');

describe('Modal', () => {
describe('Renders as expected', () => {
Expand Down
110 changes: 54 additions & 56 deletions packages/react/src/components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ 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, {
elementOrParentIsFloatingMenu,
} from '../../internal/wrapFocus';
import setupGetInstanceId from '../../tools/setupGetInstanceId';

const { prefix } = settings;
Expand Down Expand Up @@ -138,10 +141,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 focus-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 \`focusTrap\` runs by default.`
),

/**
* Specify whether the modal contains scrolling content
Expand All @@ -167,30 +173,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();
startTrap = React.createRef();
endTrap = 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 @@ -206,28 +200,32 @@ export default class Modal extends Component {
if (
this.innerModal.current &&
!this.innerModal.current.contains(evt.target) &&
!this.elementOrParentIsFloatingMenu(evt.target)
!elementOrParentIsFloatingMenu(
evt.target,
this.props.selectorsFloatingMenus
)
) {
this.props.onRequestClose(evt);
}
};

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: startTrapNode } = this.startTrap;
const { current: endTrapNode } = this.endTrap;
wrapFocus({
modalNode,
startTrapNode,
endTrapNode,
currentActiveNode,
oldActiveNode,
selectorsFloatingMenus,
});
}
};

Expand Down Expand Up @@ -277,9 +275,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 +286,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 +311,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 +372,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 +416,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.startTrap}
tabIndex="0"
role="link"
className={`${prefix}--visually-hidden`}>
Focus sentinel
</span>
{modalBody}
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */}
<span
ref={this.endTrap}
tabIndex="0"
role="link"
className={`${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>
);
}
}
Loading

0 comments on commit 0e9c6d5

Please sign in to comment.