diff --git a/.changeset/olive-bears-relate.md b/.changeset/olive-bears-relate.md new file mode 100644 index 00000000..cacd7123 --- /dev/null +++ b/.changeset/olive-bears-relate.md @@ -0,0 +1,9 @@ +--- +'focus-trap-react': minor +--- + +<<<<<<< HEAD +Bumps focus-trap to v6.8.0. The big new feature is opt-in Shadow DOM support in focus-trap (in tabbable), and a new `getShadowRoot` tabbable option exposed in a new `focusTrapOptions.tabbableOptions` configuration option. +======= +Bumps focus-trap to v6.8.1. The big new feature is opt-in Shadow DOM support in focus-trap (in tabbable), and new tabbable options exposed in a new `focusTrapOptions.tabbableOptions` configuration option. +>>>>>>> 57d9caa (Add shadow DOM support with ft v6.8.1) diff --git a/README.md b/README.md index a7aece4a..4b68cfd4 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,9 @@ ReactDOM.render(, document.getElementById('root')); Type: `Object`, optional -Pass any of the options available in [`focus-trap`'s `createOptions`](https://github.com/focus-trap/focus-trap#focustrap--createfocustrapelement-createoptions). +Pass any of the options available in focus-trap's [createOptions](https://github.com/focus-trap/focus-trap#createoptions). + +> ⚠️ See notes about __[testing in JSDom](#testing-in-jsdom)__ (e.g. using Jest) if that's what you currently use. #### active @@ -169,6 +171,20 @@ If `containerElements` is subsequently updated (i.e. after the trap has been cre Using `containerElements` does require the use of React refs which, by nature, will require at least one state update in order to get the resolved elements into the prop, resulting in at least one additional render. In the normal case, this is likely more than acceptable, but if you really want to optimize things, then you could consider [using focus-trap directly](https://codesandbox.io/s/focus-trapreact-containerelements-demos-v5ydi) (see `Trap2.js`). +## Help + +### Testing in JSDom + +> ⚠️ JSDom is not officially supported. Your mileage may vary, and tests may break from one release to the next (even a patch or minor release). +> +> This topic is just here to help with what we know may affect your tests. + +In general, a focus trap is best tested in a full browser environment such as Cypress, Playwright, or Nightwatch where a full DOM is available. + +Sometimes, that's not entirely desirable, and depending on what you're testing, you may be able to get away with using JSDom (e.g. via Jest), but you'll have to configure your traps using the `focusTrapOptions.tabbableOptions.displayCheck: 'none'` option. + +See [Testing focus-trap in JSDom](https://github.com/focus-trap/focus-trap#testing-in-jsdom) for more details. + ## Contributing See [CONTRIBUTING](CONTRIBUTING.md). diff --git a/cypress/integration/focus-trap-demo.spec.js b/cypress/integration/focus-trap-demo.spec.js index 59e7bdc6..38b5efa5 100644 --- a/cypress/integration/focus-trap-demo.spec.js +++ b/cypress/integration/focus-trap-demo.spec.js @@ -375,4 +375,16 @@ describe(' component', () => { }); }); }); + + // describe('demo: with-shadow-dom', () => { + // TL/DR: Unfortunately, the https://github.com/Bkucera/cypress-plugin-tab plugin doesn't + // support Shadow DOM, and Cypress itself doesn't have great support for it either + // (see more info below) so there's no point in writing a test for this demo at this time. + // NOTE: Because of how Cypress interacts with Shadown DOMs, it sees the shadow as a black + // box that has focus, so that limits what we can check for in expectations (e.g. we can't + // effectively check that an element inside a shadow has focus; Cypress will always say yes + // because something inside has focus, but it doesn't know what, exactly...). Also, the + // cypress-plugin-tab will complain if we try to .tab() from inside the shadow host saying + // it's not a tabbable element because it doesn't appear to support shadow DOM. + // }); }); diff --git a/demo/index.html b/demo/index.html index 42341d4e..00a131cc 100644 --- a/demo/index.html +++ b/demo/index.html @@ -131,6 +131,7 @@

demo setReturnFocus option applied

View demo source

+

demo Iframe with document option applied

When integrated in an iframe, you may specify the document (of the said @@ -149,7 +150,20 @@

demo Iframe with document option applied

View demo source

-
+
+ +

with shadow dom

+

+ This focus trap contains tabbable elements that are inside + open and closed Shadow DOMs. It configures tabbable to look for Shadow DOM + elements and provides a reference to the closed Shadow when requested. +

+
+

+ + View demo source + +

diff --git a/demo/js/demo-with-shadow-dom.js b/demo/js/demo-with-shadow-dom.js new file mode 100644 index 00000000..106f8b66 --- /dev/null +++ b/demo/js/demo-with-shadow-dom.js @@ -0,0 +1,107 @@ +const React = require('react'); +const ReactDOM = require('react-dom'); +const FocusTrap = require('../../dist/focus-trap-react'); + +const createShadow = function (hostEl, isOpen) { + const containerEl = document.createElement('div'); + containerEl.id = 'with-shadow-dom-closed-container'; + containerEl.style = `border: 1px dotted black; margin-top: 10px; padding: 10px; background-color: ${ + isOpen ? 'transparent' : 'rgba(0, 0, 0, 0.05)' + };`; + containerEl.innerHTML = ` +

+ This field is inside a ${ + isOpen ? 'opened' : 'closed' + } Shadow DOM: +

+ + `; + + // use same styles as host + const styleLinkEl = document.createElement('link'); + styleLinkEl.setAttribute('rel', 'stylesheet'); + styleLinkEl.setAttribute('href', 'style.css'); + + const shadowEl = hostEl.attachShadow({ mode: isOpen ? 'open' : 'closed' }); + shadowEl.appendChild(styleLinkEl); + shadowEl.appendChild(containerEl); + + return shadowEl; +}; + +const DemoWithShadowDom = function () { + const [active, setActive] = React.useState(false); + const openedShadowHostRef = React.useRef(null); + const openedShadowRef = React.useRef(null); + const closedShadowHostRef = React.useRef(null); + const closedShadowRef = React.useRef(null); + + const handleTrapActivate = React.useCallback(function () { + setActive(true); + }, []); + + const handleTrapDeactivate = React.useCallback(function () { + setActive(false); + }, []); + + React.useEffect(function () { + if (openedShadowHostRef.current && !openedShadowRef.current) { + openedShadowRef.current = createShadow(openedShadowHostRef.current, true); + } + + if (closedShadowHostRef.current && !closedShadowRef.current) { + closedShadowRef.current = createShadow( + closedShadowHostRef.current, + false + ); + } + }, []); + + return ( +
+

+ +

+ +
+

+ Here is a focus trap with some{' '} + focusable parts. +

+
+
+

+ +

+
+
+
+ ); +}; + +ReactDOM.render( + , + document.getElementById('demo-with-shadow-dom') +); diff --git a/demo/js/index.js b/demo/js/index.js index b145493e..28a1ed57 100644 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -8,3 +8,4 @@ require('./demo-containerelements'); require('./demo-containerelements-childless'); require('./demo-setReturnFocus'); require('./demo-iframe'); +require('./demo-with-shadow-dom'); // TEST MANUALLY (Cypress doesn't support Shadow DOM well) diff --git a/package.json b/package.json index 0506a030..829da801 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "typescript": "^4.6.3" }, "dependencies": { - "focus-trap": "^6.7.3" + "focus-trap": "^6.8.1" }, "peerDependencies": { "prop-types": "^15.8.1", diff --git a/src/focus-trap-react.js b/src/focus-trap-react.js index d2e1cead..d595694b 100644 --- a/src/focus-trap-react.js +++ b/src/focus-trap-react.js @@ -311,6 +311,10 @@ FocusTrap.propTypes = { ]), allowOutsideClick: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), preventScroll: PropTypes.bool, + tabbableOptions: PropTypes.shape({ + displayCheck: PropTypes.oneOf(['full', 'non-zero-area', 'none']), + getShadowRoot: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + }), }), containerElements: PropTypes.arrayOf(PropTypes.instanceOf(ElementType)), children: PropTypes.oneOfType([ diff --git a/test/focus-trap-react.test.js b/test/focus-trap-react.test.js index 45c7535b..aa5b248c 100644 --- a/test/focus-trap-react.test.js +++ b/test/focus-trap-react.test.js @@ -8,6 +8,28 @@ const { const { default: userEvent } = require('@testing-library/user-event'); const FocusTrap = require('../src/focus-trap-react'); +const getTestFocusTrapOptions = function (focusTrapOptions) { + const { tabbableOptions, ...rest } = focusTrapOptions || {}; + return { + ...rest, + tabbableOptions: { + // NOTE: JSDom doesn't support some of the visibility checks that tabbable + // performs to determine if a node is visible (and so tabbable/focusable) + // so we have to use this displayCheck mode to run tests in this env + displayCheck: 'none', + ...tabbableOptions, + }, + }; +}; + +const mkTestFocusTrap = function () { + // eslint-disable-next-line react/display-name, react/prop-types + return React.forwardRef(function ({ focusTrapOptions, ...props }, ref) { + const options = getTestFocusTrapOptions(focusTrapOptions); + return ; + }); +}; + const pause = (duration) => { return new Promise((resolve) => { setTimeout(resolve, duration); @@ -20,14 +42,13 @@ const FocusTrapExample = ({ focusTrapOptions, ...otherProps }) => { const mountTrap = () => setTrapIsActive(true); const unmountTrap = () => setTrapIsActive(false); + const options = getTestFocusTrapOptions({ + onDeactivate: unmountTrap, + ...focusTrapOptions, + }); + const trap = ( - +

Some text

Link 1 @@ -51,12 +72,15 @@ FocusTrapExample.propTypes = { }; describe('FocusTrap', () => { + let TestFocusTrap; + beforeEach(() => { // This surpresses React error boundary logs for testing intentionally // thrown errors, like in some test cases in this suite. See discussion of // this here: https://github.com/facebook/react/issues/11098 jest.spyOn(console, 'error'); global.console.error.mockImplementation(() => {}); + TestFocusTrap = mkTestFocusTrap(); }); afterEach(() => { @@ -65,18 +89,18 @@ describe('FocusTrap', () => { describe('incorrect children prop usage', () => { it('throws an error if a non-element child is passed', () => { - expect(() => render(Child text)).toThrowError( - 'expected to receive a single React element child' - ); + expect(() => + render(Child text) + ).toThrowError('expected to receive a single React element child'); }); it('throws an error if multiple top-level child elements are passed', () => { expect(() => render( - +

Child 1

Child 2

-
+ ) ).toThrowError('expected to receive a single React element child'); }); @@ -84,9 +108,9 @@ describe('FocusTrap', () => { it('throws an error if no focusable child elements are provided', () => { expect(() => render( - +

Child 1

-
+ ) ).toThrowError( 'Your focus-trap must have at least one container with at least one tabbable node in it at all times' @@ -96,9 +120,9 @@ describe('FocusTrap', () => { it('throws an error if no container child element surrounds the tabbable content', () => { expect(() => render( - + - + ) ).toThrowError( 'Your focus-trap must have at least one container with at least one tabbable node in it at all times' @@ -108,11 +132,11 @@ describe('FocusTrap', () => { it('throws an error if a fragment is given as the child element', () => { expect(() => render( - + <> - + ) ).toThrowError( 'A focus-trap cannot use a Fragment as its child container. Try replacing it with a
element.' @@ -124,17 +148,17 @@ describe('FocusTrap', () => { it('allows a single child element prop to be passed', () => { expect(() => render( - +
-
+ ) ).not.toThrowError('expected to receive a single React element child'); }); it('allows no children prop to be passed', () => { - expect(() => render()).not.toThrowError( + expect(() => render()).not.toThrowError( 'expected to receive a single React element child' ); }); @@ -143,11 +167,11 @@ describe('FocusTrap', () => { const childRef = jest.fn(); render( - +
-
+
); expect(childRef).toHaveBeenCalledTimes(1); @@ -158,13 +182,13 @@ describe('FocusTrap', () => { const childRef = React.useRef(null); return ( - +
-
+ ); }; @@ -754,7 +778,7 @@ describe('FocusTrap', () => { const deactivateTrap = () => setTrapIsActive(false); const trap = ( - { Link 3
- + ); return ( @@ -868,7 +892,7 @@ describe('FocusTrap', () => { const unpauseTrap = () => setTrapIsPaused(false); const trap = ( - {
-
+ ); return ( diff --git a/yarn.lock b/yarn.lock index 0df777a7..f556bb99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4329,12 +4329,12 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.0.tgz#a5d06b4a8b01e3a63771daa5cb7a1903e2e57067" integrity sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA== -focus-trap@^6.7.3: - version "6.7.3" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.7.3.tgz#b5dc195b49c90001f08a63134471d1e6dd381ddd" - integrity sha512-8xCEKndV4KrseGhFKKKmczVA14yx1/hnmFICPOjcFjToxCJYj/NHH43tPc3YE/PLnLRNZoFug0EcWkGQde/miQ== +focus-trap@^6.8.1: + version "6.8.1" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.8.1.tgz#0c9e4e44db8f7242f3d4b1056a518747d9c97125" + integrity sha512-sdz/jAPiP/9cyElo31+X3/estGPi6wgHutg+R/3MFmJtMM5AeeBlFGplejQyy89Ouyds/9xW+qPEH3jFlOAuKg== dependencies: - tabbable "^5.2.1" + tabbable "^5.3.1" follow-redirects@^1.14.0: version "1.14.8" @@ -8300,10 +8300,10 @@ syntax-error@^1.1.1: dependencies: acorn-node "^1.2.0" -tabbable@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c" - integrity sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ== +tabbable@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.1.tgz#059f2a19b829efce2a0ec05785a47dd3bcd0a25b" + integrity sha512-NtO7I7eoAHR+JwwcNsi/PipamtAEebYDnur/k9wM6n238HHy/+1O4+7Zx7e/JaDAbKJPlIFYsfsV/6tPqTOQvg== term-color@^1.0.1: version "1.0.1"