From 0e9c6d525e082ecabd153b7a97fff65581a50650 Mon Sep 17 00:00:00 2001 From: Akira Sudoh Date: Fri, 14 Feb 2020 15:25:05 +0900 Subject: [PATCH] feat(components): support automatic focus sentinel (#5260) This change eliminates the need for application to put focus sentinel by having ``, `` and `` automatically put the focus sentinels. This change also add support for reverse-focus-wrap feature to `` and ``, 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. --- .../offline-mirror/lodash.findlast-4.6.0.tgz | Bin 0 -> 15941 bytes packages/react/.storybook/Container.js | 5 - packages/react/package.json | 2 +- .../components/ComposedModal/ComposedModal.js | 70 ++--- .../__snapshots__/ComposedModal-test.js.snap | 16 +- .../react/src/components/Modal/Modal-story.js | 1 - .../react/src/components/Modal/Modal-test.js | 2 +- packages/react/src/components/Modal/Modal.js | 110 ++++---- .../__snapshots__/ModalWrapper-test.js.snap | 252 +++++++++--------- packages/react/src/internal/FloatingMenu.js | 23 +- .../src/internal/__tests__/wrapFocus-test.js | 150 +++++++++++ .../react/src/internal/keyboard/navigation.js | 30 ++- packages/react/src/internal/wrapFocus.js | 95 +++++++ yarn.lock | 14 +- 14 files changed, 537 insertions(+), 233 deletions(-) create mode 100644 .yarn/offline-mirror/lodash.findlast-4.6.0.tgz create mode 100644 packages/react/src/internal/__tests__/wrapFocus-test.js create mode 100644 packages/react/src/internal/wrapFocus.js diff --git a/.yarn/offline-mirror/lodash.findlast-4.6.0.tgz b/.yarn/offline-mirror/lodash.findlast-4.6.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..8d01a4a89b5980a1632825110d9a061e8f5291b0 GIT binary patch literal 15941 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 literal 0 HcmV?d00001 diff --git a/packages/react/.storybook/Container.js b/packages/react/.storybook/Container.js index af9516cc7119..31ebc439da1e 100644 --- a/packages/react/.storybook/Container.js +++ b/packages/react/.storybook/Container.js @@ -37,11 +37,6 @@ function Container({ story }) { }}> {story()} - ); } diff --git a/packages/react/package.json b/packages/react/package.json index 66ca2f4be944..60b6c1e93226 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/components/ComposedModal/ComposedModal.js b/packages/react/src/components/ComposedModal/ComposedModal.js index bee7b6083a83..72d646c6428e 100644 --- a/packages/react/src/components/ComposedModal/ComposedModal.js +++ b/packages/react/src/components/ComposedModal/ComposedModal.js @@ -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; @@ -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 = { /** @@ -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) { @@ -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, + }); } }; @@ -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}> -
+ className={modalClass}> + {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + +
{childrenWithProps}
+ {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel +
); } diff --git a/packages/react/src/components/ComposedModal/__snapshots__/ComposedModal-test.js.snap b/packages/react/src/components/ComposedModal/__snapshots__/ComposedModal-test.js.snap index 3ea2f12e1c4b..0eaefdfd2816 100644 --- a/packages/react/src/components/ComposedModal/__snapshots__/ComposedModal-test.js.snap +++ b/packages/react/src/components/ComposedModal/__snapshots__/ComposedModal-test.js.snap @@ -14,11 +14,25 @@ exports[` renders 1`] = ` onTransitionEnd={[Function]} open={true} role="presentation" - tabIndex={-1} > + + Focus sentinel +
+ + Focus sentinel +
`; diff --git a/packages/react/src/components/Modal/Modal-story.js b/packages/react/src/components/Modal/Modal-story.js index eb2ffaee4133..ef34780ed8a5 100644 --- a/packages/react/src/components/Modal/Modal-story.js +++ b/packages/react/src/components/Modal/Modal-story.js @@ -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 diff --git a/packages/react/src/components/Modal/Modal-test.js b/packages/react/src/components/Modal/Modal-test.js index f45be8aea838..4afcb7a8ffad 100644 --- a/packages/react/src/components/Modal/Modal-test.js +++ b/packages/react/src/components/Modal/Modal-test.js @@ -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', () => { diff --git a/packages/react/src/components/Modal/Modal.js b/packages/react/src/components/Modal/Modal.js index db00762727f6..e97aba523ead 100644 --- a/packages/react/src/components/Modal/Modal.js +++ b/packages/react/src/components/Modal/Modal.js @@ -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; @@ -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 @@ -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) { @@ -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, + }); } }; @@ -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 => { @@ -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; } }; @@ -317,7 +311,6 @@ export default class Modal extends Component { selectorsFloatingMenus, // eslint-disable-line shouldSubmitOnEnter, // eslint-disable-line size, - focusTrap, hasScrollingContent, ...other } = this.props; @@ -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">
{passiveModal && modalButton} {modalLabel && ( @@ -422,7 +416,7 @@ export default class Modal extends Component {
); - const modal = ( + return (
+ {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + {modalBody} + {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel +
); - - return !focusTrap ? ( - modal - ) : ( - // `` has `active: true` in its `defaultProps` - - {modal} - - ); } } diff --git a/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap b/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap index 43e31cfd247e..26cbea0e21c7 100644 --- a/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap +++ b/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap @@ -43,7 +43,6 @@ exports[`ModalWrapper should render 1`] = ` - + + Focus sentinel + - + + Focus sentinel + + diff --git a/packages/react/src/internal/FloatingMenu.js b/packages/react/src/internal/FloatingMenu.js index 2e71a9ab5be2..c5d5ab9776af 100644 --- a/packages/react/src/internal/FloatingMenu.js +++ b/packages/react/src/internal/FloatingMenu.js @@ -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. @@ -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 `` not actually read by screen readers */} + + Focus sentinel + + {this._getChildrenWithProps()} + {/* Non-translatable: Focus management code makes this `` not actually read by screen readers */} + + Focus sentinel + + , !target ? document.body : target() ); } diff --git a/packages/react/src/internal/__tests__/wrapFocus-test.js b/packages/react/src/internal/__tests__/wrapFocus-test.js new file mode 100644 index 000000000000..646f0e1297ed --- /dev/null +++ b/packages/react/src/internal/__tests__/wrapFocus-test.js @@ -0,0 +1,150 @@ +import wrapFocus from '../wrapFocus'; + +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +describe('wrapFocus', () => { + let node; + let spyInnerModal; + let spyButton0; + let spyButton2; + + beforeEach(() => { + node = document.createElement('div'); + node.tabIndex = '-1'; + node.innerHTML = ` + + + +
+ + + +
+ + + +
+ `; + document.body.appendChild(node); + spyInnerModal = jest.spyOn(node.querySelector('#inner-modal'), 'focus'); + spyButton0 = jest.spyOn(node.querySelector('#button-0'), 'focus'); + spyButton2 = jest.spyOn(node.querySelector('#button-2'), 'focus'); + }); + + it('runs forward focus-wrap when following outer node is focused on', () => { + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('#outer-following'), + oldActiveNode: node.querySelector('#button-2'), + }); + expect(spyButton0).toHaveBeenCalled(); + }); + + it('runs forward focus-wrap when following focus sentinel is focused on', () => { + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('#end-sentinel'), + oldActiveNode: node.querySelector('#button-2'), + }); + expect(spyButton0).toHaveBeenCalled(); + }); + + it('runs reverse focus-wrap when preceding outer node is focused on', () => { + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('#outer-preceding'), + oldActiveNode: node.querySelector('#button-0'), + }); + expect(spyButton2).toHaveBeenCalled(); + }); + + it('runs reverse focus-wrap when preceding focus sentinel is focused on', () => { + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('#start-sentinel'), + oldActiveNode: node.querySelector('#button-0'), + }); + expect(spyButton2).toHaveBeenCalled(); + }); + + it('does not run focus-wrap when a floating menu is focused on', () => { + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('.bx--tooltip'), + oldActiveNode: node.querySelector('#button-2'), + }); + expect(spyInnerModal).not.toHaveBeenCalled(); + expect(spyButton0).not.toHaveBeenCalled(); + expect(spyButton2).not.toHaveBeenCalled(); + }); + + it('uses inner modal node as a escape hatch for focusing for forward focus-wrap', () => { + node.querySelector( + '#inner-modal' + ).innerHTML = `
`; + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('#outer-following'), + oldActiveNode: node.querySelector('#dummy-old-active-node'), + }); + expect(spyInnerModal).toHaveBeenCalled(); + }); + + it('uses inner modal node as a escape hatch for focusing for reverse focus-wrap', () => { + node.querySelector( + '#inner-modal' + ).innerHTML = `
`; + wrapFocus({ + modalNode: node.querySelector('#inner-modal'), + startSentinelNode: node.querySelector('#start-sentinel'), + endSentinelNode: node.querySelector('#end-sentinel'), + currentActiveNode: node.querySelector('#outer-preceding'), + oldActiveNode: node.querySelector('#dummy-old-active-node'), + }); + expect(spyInnerModal).toHaveBeenCalled(); + }); + + afterEach(() => { + if (spyButton2) { + spyButton2.mockRestore(); + spyButton2 = null; + } + if (spyButton0) { + spyButton0.mockRestore(); + spyButton0 = null; + } + if (spyInnerModal) { + spyInnerModal.mockRestore(); + spyInnerModal = null; + } + if (node) { + node.parentNode.removeChild(node); + node = null; + } + }); +}); diff --git a/packages/react/src/internal/keyboard/navigation.js b/packages/react/src/internal/keyboard/navigation.js index 14d98c44969f..cdd85de640f4 100644 --- a/packages/react/src/internal/keyboard/navigation.js +++ b/packages/react/src/internal/keyboard/navigation.js @@ -24,7 +24,7 @@ import { match } from './match'; * getNextIndex(keyCodes.RIGHT, 0, 4) */ -const getNextIndex = (key, index, arrayLength) => { +export const getNextIndex = (key, index, arrayLength) => { if (match(key, ArrowRight)) { return (index + 1) % arrayLength; } @@ -33,4 +33,30 @@ const getNextIndex = (key, index, arrayLength) => { } }; -export { getNextIndex }; +/** + * A flag `node.compareDocumentPosition(target)` returns, + * that indicates `target` is located earlier than `node` in the document or `target` contains `node`. + */ +export const DOCUMENT_POSITION_BROAD_PRECEDING = + // Checks `typeof Node` for `react-docgen` + typeof Node !== 'undefined' && + Node.DOCUMENT_POSITION_PRECEDING | Node.DOCUMENT_POSITION_CONTAINS; + +/** + * A flag `node.compareDocumentPosition(target)` returns, + * that indicates `target` is located later than `node` in the document or `node` contains `target`. + */ +export const DOCUMENT_POSITION_BROAD_FOLLOWING = + // Checks `typeof Node` for `react-docgen` + typeof Node !== 'undefined' && + Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_CONTAINED_BY; + +/** + * CSS selector that selects major nodes that is sequential-focusable. + */ +export const selectorTabbable = ` + a[href], area[href], input:not([disabled]):not([tabindex='-1']), + button:not([disabled]):not([tabindex='-1']),select:not([disabled]):not([tabindex='-1']), + textarea:not([disabled]):not([tabindex='-1']), + iframe, object, embed, *[tabindex]:not([tabindex='-1']), *[contenteditable=true] +`; diff --git a/packages/react/src/internal/wrapFocus.js b/packages/react/src/internal/wrapFocus.js new file mode 100644 index 000000000000..27463d0b36ad --- /dev/null +++ b/packages/react/src/internal/wrapFocus.js @@ -0,0 +1,95 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import findLast from 'lodash.findlast'; +import { settings } from 'carbon-components'; +import { + DOCUMENT_POSITION_BROAD_PRECEDING, + DOCUMENT_POSITION_BROAD_FOLLOWING, + selectorTabbable, +} from './keyboard/navigation'; + +const { prefix } = settings; + +/** + * @param {Node} node A DOM node. + * @param {string[]} selectorsFloatingMenus The CSS selectors that matches floating menus. + * @returns {boolean} `true` of the given `node` is in a floating menu. + */ +function elementOrParentIsFloatingMenu( + node, + selectorsFloatingMenus = [ + `.${prefix}--overflow-menu-options`, + `.${prefix}--tooltip`, + '.flatpickr-calendar', + ] +) { + if (node && typeof node.closest === 'function') { + return selectorsFloatingMenus.some(selector => node.closest(selector)); + } +} + +/** + * Ensures the focus is kept in the given `modalNode`, implementing "focus-wrap" behavior. + * @param {object} options The options. + * @param {Node} options.modalNode The DOM node of the inner modal. + * @param {Node} options.startTrapNode The DOM node of the focus sentinel the is placed earlier next to `modalNode`. + * @param {Node} options.endTrapNode The DOM node of the focus sentinel the is placed next to `modalNode`. + * @param {Node} options.currentActiveNode The DOM node that has focus. + * @param {Node} options.oldActiveNode The DOM node that previously had focus. + * @param {Node} [options.selectorsFloatingMenus] The CSS selectors that matches floating menus. + */ +function wrapFocus({ + modalNode, + startTrapNode, + endTrapNode, + currentActiveNode, + oldActiveNode, + selectorsFloatingMenus, +}) { + if ( + modalNode && + currentActiveNode && + oldActiveNode && + !modalNode.contains(currentActiveNode) && + !elementOrParentIsFloatingMenu(currentActiveNode, selectorsFloatingMenus) + ) { + const comparisonResult = oldActiveNode.compareDocumentPosition( + currentActiveNode + ); + if ( + currentActiveNode === startTrapNode || + comparisonResult & DOCUMENT_POSITION_BROAD_PRECEDING + ) { + const tabbable = findLast( + modalNode.querySelectorAll(selectorTabbable), + elem => Boolean(elem.offsetParent) + ); + if (tabbable) { + tabbable.focus(); + } else if (modalNode !== oldActiveNode) { + modalNode.focus(); + } + } else if ( + currentActiveNode === endTrapNode || + comparisonResult & DOCUMENT_POSITION_BROAD_FOLLOWING + ) { + const tabbable = Array.prototype.find.call( + modalNode.querySelectorAll(selectorTabbable), + elem => Boolean(elem.offsetParent) + ); + if (tabbable) { + tabbable.focus(); + } else if (modalNode !== oldActiveNode) { + modalNode.focus(); + } + } + } +} + +export { elementOrParentIsFloatingMenu }; +export default wrapFocus; diff --git a/yarn.lock b/yarn.lock index 5dba2fc33c13..e560b5eee5cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14465,6 +14465,11 @@ lodash.escaperegexp@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= +lodash.findlast@^4.5.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.findlast/-/lodash.findlast-4.6.0.tgz#ea8bb78cf2e7e7804fc8aeb7d1953e07fe31fbc8" + integrity sha1-6ou3jPLn54BPyK630ZU+B/4x+8g= + lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" @@ -19716,14 +19721,7 @@ rollup-pluginutils@2.0.1: estree-walker "^0.3.0" micromatch "^2.3.11" -rollup-pluginutils@2.8.2: - version "2.8.2" - resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" - integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== - dependencies: - estree-walker "^0.6.1" - -rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1: +rollup-pluginutils@2.8.2, rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1: version "2.8.2" resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==