Skip to content

Commit

Permalink
Add shadow DOM support with ft v6.8.1 (#652)
Browse files Browse the repository at this point in the history
Adds a demo that uses open and closed shadows.

Does NOT add a test for this demo because of lack of support for
Shadow DOM in Cypress (see comment in focus-trap-demo.spec.js).

Adds a warning to the README about testing in JSDom, and fixes
all the JSDom-based tests to use `displayCheck='none'` to get
around JSDom limitations with new APIs used by tabbable in
v5.3.0.
  • Loading branch information
stefcameron authored Apr 22, 2022
1 parent c3ef438 commit 659d44e
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 41 deletions.
9 changes: 9 additions & 0 deletions .changeset/olive-bears-relate.md
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ ReactDOM.render(<Demo />, 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

Expand Down Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions cypress/integration/focus-trap-demo.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,4 +375,16 @@ describe('<FocusTrap> 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.
// });
});
16 changes: 15 additions & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ <h2 id="setReturnFocus-heading">demo setReturnFocus option applied</h2>
View demo source <span aria-hidden="true">&gt;&gt;</span>
</a>
</p>

<h2 id="iframe-heading">demo Iframe with document option applied</h2>
<p>
When integrated in an iframe, you may specify the document (of the said
Expand All @@ -149,7 +150,20 @@ <h2 id="iframe-heading">demo Iframe with document option applied</h2>
View demo source <span aria-hidden="true">&gt;&gt;</span>
</a>
</p>
<div id="demo-iframe" />
<div id="demo-iframe"></div>

<h2 id="with-shadow-dom-heading">with shadow dom</h2>
<p>
This focus trap <em>contains</em> tabbable elements that are <strong>inside</strong>
open and closed Shadow DOMs. It configures <code>tabbable</code> to look for Shadow DOM
elements and provides a reference to the closed Shadow when requested.
</p>
<div id="demo-with-shadow-dom"></div>
<p>
<a href="https://github.com/focus-trap/focus-trap-react/blob/master/demo/js/demo-defaults.js" aria-describedby="defaults-heading">
View demo source <span aria-hidden="true">&gt;&gt;</span>
</a>
</p>

<p>
<span aria-hidden="true" style="font-size:2em;vertical-align:middle;"></span>
Expand Down
107 changes: 107 additions & 0 deletions demo/js/demo-with-shadow-dom.js
Original file line number Diff line number Diff line change
@@ -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 = `
<p style="margin-top: 0; padding-top: 0;">
This field is inside a <strong>${
isOpen ? 'opened' : 'closed'
}</strong> Shadow DOM:
</p>
<input id="text-input" type="text" />
`;

// 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 (
<div>
<p>
<button
onClick={handleTrapActivate}
aria-describedby="with-shadow-dom-heading"
>
activate trap
</button>
</p>
<FocusTrap
active={active}
focusTrapOptions={{
onDeactivate: handleTrapDeactivate,
tabbableOptions: {
getShadowRoot(node) {
if (node === closedShadowHostRef.current) {
return closedShadowHostRef.current;
}
},
},
}}
>
<div className={`trap ${active ? 'is-active' : ''}`}>
<p>
Here is a focus trap <a href="#">with</a> <a href="#">some</a>{' '}
<a href="#">focusable</a> parts.
</p>
<div id="with-shadow-dom-opened-host" ref={openedShadowHostRef}></div>
<div id="with-shadow-dom-closed-host" ref={closedShadowHostRef}></div>
<p>
<button
onClick={handleTrapDeactivate}
aria-describedby="with-shadow-dom-heading"
>
deactivate trap
</button>
</p>
</div>
</FocusTrap>
</div>
);
};

ReactDOM.render(
<DemoWithShadowDom />,
document.getElementById('demo-with-shadow-dom')
);
1 change: 1 addition & 0 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/focus-trap-react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading

0 comments on commit 659d44e

Please sign in to comment.