From 76ed0074317807a2506f9b3fc861a7597d4787ff Mon Sep 17 00:00:00 2001 From: Clint Goodman Date: Tue, 17 Nov 2020 10:19:17 -0700 Subject: [PATCH] Add ability to pass containerElements to focus-trap (#179) Co-authored-by: Clint Goodman --- .changeset/young-crews-report.md | 5 ++ README.md | 6 ++ cypress/integration/focus-trap-demo.spec.js | 60 ++++++++++++++ demo/index.html | 6 ++ demo/js/demo-containerelements.js | 82 +++++++++++++++++++ demo/js/index.js | 1 + index.d.ts | 1 + package.json | 2 +- src/focus-trap-react.js | 88 +++++++++++++-------- test/activation.test.js | 42 +++++++++- yarn.lock | 8 +- 11 files changed, 261 insertions(+), 40 deletions(-) create mode 100644 .changeset/young-crews-report.md create mode 100644 demo/js/demo-containerelements.js diff --git a/.changeset/young-crews-report.md b/.changeset/young-crews-report.md new file mode 100644 index 00000000..3786326a --- /dev/null +++ b/.changeset/young-crews-report.md @@ -0,0 +1,5 @@ +--- +'focus-trap-react': minor +--- + +Add ability to pass containerElements to focus-trap #179. This PR is made possible because of https://github.com/focus-trap/focus-trap/pull/217 and the released version 6.2.0 of focus-trap. diff --git a/README.md b/README.md index 29115b44..fbe02ef4 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,12 @@ Type: `Boolean`, optional If you would like to pause or unpause the focus trap (see [`focus-trap`'s documentation](https://github.com/focus-trap/focus-trap#focustrappause)), toggle this prop. +#### containerElements + +Type: `Array of HTMLElement`, optional + +If passed in, these elements will be used as the boundaries for the focus-trap, instead of the child. These get passed as arguments to focus-trap's updateContainerElements method. + ## Contributing See [CONTRIBUTING](CONTRIBUTING.md). diff --git a/cypress/integration/focus-trap-demo.spec.js b/cypress/integration/focus-trap-demo.spec.js index 7eeedfd0..1ae93e3c 100644 --- a/cypress/integration/focus-trap-demo.spec.js +++ b/cypress/integration/focus-trap-demo.spec.js @@ -241,4 +241,64 @@ describe(' component', () => { verifyCrucialFocusTrapOnClicking('@trapChild'); }); }); + + describe('demo: containerElements prop', () => { + it('containerElements can be passed in and used as multiple boundaries to keep the focus within', () => { + cy.get('#demo-containerelements').as('testRoot'); + + // activate trap + cy.get('@testRoot') + .findByRole('button', { name: 'activate trap' }) + .as('lastlyFocusedElementBeforeTrapIsActivated') + .click(); + + // 1st element should be focused + cy.get('@testRoot') + .findByRole('link', { name: 'with' }) + .as('firstElementInTrap') + .should('be.focused'); + + // trap is active(keep focus in trap by tabbing through the focus trap's tabbable elements) + cy.get('@firstElementInTrap') + .tab() + .should('have.text', 'some') + .should('be.focused') + .tab() + .should('have.text', 'focusable') + .should('be.focused') + .tab() + .should('have.text', 'See') + .should('be.focused') + .tab() + .should('have.text', 'how') + .should('be.focused') + .tab() + .should('have.text', 'works') + .should('be.focused') + .tab() + .should('have.text', 'with') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'works') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'how') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'See') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'focusable') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'some') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'with') + .should('be.focused') + .tab({ shift: true }) + .should('have.text', 'works') + .should('be.focused'); + }); + }); }); diff --git a/demo/index.html b/demo/index.html index 14da7cb2..8ddaa3e4 100644 --- a/demo/index.html +++ b/demo/index.html @@ -57,6 +57,12 @@

demo autofocus

+

demo containerElements

+

+ You may pass in an array prop `containerElements` that contains nodes to trap focus within, which if passed will be used instead of the direct child. +

+
+

Return to the repository diff --git a/demo/js/demo-containerelements.js b/demo/js/demo-containerelements.js new file mode 100644 index 00000000..10a34fb3 --- /dev/null +++ b/demo/js/demo-containerelements.js @@ -0,0 +1,82 @@ +const React = require('react'); +const ReactDOM = require('react-dom'); +const FocusTrap = require('../../dist/focus-trap-react'); + +const container = document.getElementById('demo-containerelements'); + +class DemoContainerElements extends React.Component { + constructor(props) { + super(props); + + this.state = { + activeTrap: false, + }; + + this.mountTrap = this.mountTrap.bind(this); + this.unmountTrap = this.unmountTrap.bind(this); + this.setElementRef = this.setElementRef.bind(this); + + this.elementRef1 = React.createRef(); + this.elementRef2 = React.createRef(); + } + + mountTrap() { + this.setState({ activeTrap: true }); + } + + unmountTrap() { + this.setState({ activeTrap: false }); + } + + setElementRef(refName) { + return (element) => { + if (!this[refName].current) { + this[refName].current = element; + this.forceUpdate(); + } + }; + } + + render() { + const trap = this.state.activeTrap ? ( + +

+

+ Here is a focus trap with some + focusable parts. +

+

+ Here is something. +

+

+ Here is a another focus trap element. See{' '} + how + it works. +

+

+ +

+
+
+ ) : ( + false + ); + + return ( +
+

+ +

+ {trap} +
+ ); + } +} + +ReactDOM.render(, container); diff --git a/demo/js/index.js b/demo/js/index.js index 155eb2d5..90d4b17d 100644 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -2,3 +2,4 @@ require('./demo-defaults'); require('./demo-ffne'); require('./demo-special-element'); require('./demo-autofocus'); +require('./demo-containerelements'); diff --git a/index.d.ts b/index.d.ts index 6f2e36ec..3bc17e60 100644 --- a/index.d.ts +++ b/index.d.ts @@ -9,6 +9,7 @@ declare namespace FocusTrap { active?: boolean; paused?: boolean; focusTrapOptions?: FocusTrapOptions; + containerElements?: Array; } } diff --git a/package.json b/package.json index e786e795..98342825 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "typescript": "^4.0.5" }, "dependencies": { - "focus-trap": "^6.1.4" + "focus-trap": "^6.2.0" }, "peerDependencies": { "prop-types": "^15.7.2", diff --git a/src/focus-trap-react.js b/src/focus-trap-react.js index 8c27ce7e..f4f2c6b7 100644 --- a/src/focus-trap-react.js +++ b/src/focus-trap-react.js @@ -57,43 +57,62 @@ class FocusTrap extends React.Component { } } - componentDidMount() { - const focusTrapElementDOMNode = ReactDOM.findDOMNode(this.focusTrapElement); - - // eslint-disable-next-line react/prop-types -- _createFocusTrap is an internal prop - this.focusTrap = this.props._createFocusTrap( - focusTrapElementDOMNode, - this.tailoredFocusTrapOptions - ); - - if (this.props.active) { - this.focusTrap.activate(); + setupFocusTrap() { + if (!this.focusTrap) { + const focusTrapElementDOMNodes = this.focusTrapElements.map( + ReactDOM.findDOMNode + ); + + const nodesExist = focusTrapElementDOMNodes.some(Boolean); + if (nodesExist) { + // eslint-disable-next-line react/prop-types -- _createFocusTrap is an internal prop + this.focusTrap = this.props._createFocusTrap( + focusTrapElementDOMNodes, + this.tailoredFocusTrapOptions + ); + + if (this.props.active) { + this.focusTrap.activate(); + } + + if (this.props.paused) { + this.focusTrap.pause(); + } + } } + } - if (this.props.paused) { - this.focusTrap.pause(); - } + componentDidMount() { + this.setupFocusTrap(); } componentDidUpdate(prevProps) { - if (prevProps.active && !this.props.active) { - // NOTE: we never let the trap return the focus since we do that ourselves - this.focusTrap.deactivate({ returnFocus: false }); - if (this.returnFocusOnDeactivate) { - this.returnFocus(); + this.setupFocusTrap(); + + if (this.focusTrap) { + if (prevProps.containerElements !== this.props.containerElements) { + this.focusTrap.updateContainerElements(this.props.containerElements); } - return; // un/pause does nothing on an inactive trap - } - if (!prevProps.active && this.props.active) { - this.updatePreviousElement(); - this.focusTrap.activate(); - } + if (prevProps.active && !this.props.active) { + // NOTE: we never let the trap return the focus since we do that ourselves + this.focusTrap.deactivate({ returnFocus: false }); + if (this.returnFocusOnDeactivate) { + this.returnFocus(); + } + return; // un/pause does nothing on an inactive trap + } + + if (!prevProps.active && this.props.active) { + this.updatePreviousElement(); + this.focusTrap.activate(); + } - if (prevProps.paused && !this.props.paused) { - this.focusTrap.unpause(); - } else if (!prevProps.paused && this.props.paused) { - this.focusTrap.pause(); + if (prevProps.paused && !this.props.paused) { + this.focusTrap.unpause(); + } else if (!prevProps.paused && this.props.paused) { + this.focusTrap.pause(); + } } } @@ -105,20 +124,24 @@ class FocusTrap extends React.Component { } } - setFocusTrapElement = (element) => { - this.focusTrapElement = element; + setFocusTrapElements = (elements) => { + this.focusTrapElements = elements; }; render() { const child = React.Children.only(this.props.children); const composedRefCallback = (element) => { - this.setFocusTrapElement(element); + const { containerElements } = this.props; + if (typeof child.ref === 'function') { child.ref(element); } else if (child.ref) { child.ref.current = element; } + + const elements = containerElements ? containerElements : [element]; + this.setFocusTrapElements(elements); }; const childWithRef = React.cloneElement(child, { @@ -159,6 +182,7 @@ FocusTrap.propTypes = { allowOutsideClick: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), preventScroll: PropTypes.bool, }), + containerElements: PropTypes.arrayOf(PropTypes.instanceOf(ElementType)), children: PropTypes.oneOfType([ PropTypes.element, // React element PropTypes.instanceOf(ElementType), // DOM element diff --git a/test/activation.test.js b/test/activation.test.js index 5ce21ce5..3d0d1642 100644 --- a/test/activation.test.js +++ b/test/activation.test.js @@ -14,6 +14,7 @@ describe('activation', () => { activate: jest.fn(), deactivate: jest.fn(), pause: jest.fn(), + updateContainerElements: jest.fn(), }; let mockCreateFocusTrap; @@ -41,7 +42,7 @@ describe('activation', () => { expect(mockCreateFocusTrap).toHaveBeenCalledTimes(1); expect(mockCreateFocusTrap).toHaveBeenCalledWith( - ReactDOM.findDOMNode(trap), + [ReactDOM.findDOMNode(trap)], { onDeactivate: noop, returnFocusOnDeactivate: false, @@ -49,6 +50,41 @@ describe('activation', () => { ); }); + test('activation with containerElements props in new props', () => { + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + + ReactDOM.render( + + + , + domContainer + ); + + expect(mockCreateFocusTrap).not.toHaveBeenCalled(); + + ReactDOM.render( + + + , + domContainer + ); + + expect(mockCreateFocusTrap).toHaveBeenCalledTimes(1); + expect(mockFocusTrap.updateContainerElements).toHaveBeenCalledWith([ + div1, + div2, + ]); + }); + test('activation with initialFocus as selector', () => { const trap = ReactDOM.render( { expect(mockCreateFocusTrap).toHaveBeenCalledTimes(1); expect(mockCreateFocusTrap).toHaveBeenCalledWith( - ReactDOM.findDOMNode(trap), + [ReactDOM.findDOMNode(trap)], { onDeactivate: noop, initialFocus: '#initial-focusee', @@ -125,7 +161,7 @@ describe('activation', () => { expect(mockCreateFocusTrap).toHaveBeenCalledTimes(1); expect(mockCreateFocusTrap).toHaveBeenCalledWith( - ReactDOM.findDOMNode(zone.refs.trap), + [ReactDOM.findDOMNode(zone.refs.trap)], { onDeactivate: noop, returnFocusOnDeactivate: false, diff --git a/yarn.lock b/yarn.lock index dd88bc7d..125d360c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4038,10 +4038,10 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== -focus-trap@^6.1.4: - version "6.1.4" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.1.4.tgz#6297a9f0f45a84377b60e9a8c0d1f8a5b98d94d2" - integrity sha512-jgNc+O8UkGsUpdhNXkyonwlw4i707+ESAWv1vCbyd8+29db5/Wv1BkJImDczfEWMu9O635FvM5ABOjeyqNQpEQ== +focus-trap@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.2.0.tgz#ff8aa4bef1f4c47f6fee3fdcee71474771cab374" + integrity sha512-CO9ovKkTdoo4eKQJ2/nLIsDyCwEDKfbeuW27PqFJZQoPzVwrbDj6RhPvVxPbj2kzOrWN7sVEMeIVzeqSXFd15g== dependencies: tabbable "^5.1.3"