Skip to content

Commit

Permalink
Add ability to pass containerElements to focus-trap (#179)
Browse files Browse the repository at this point in the history
Co-authored-by: Clint Goodman <clintgoodman@workfront.com>
  • Loading branch information
cgood92 and Clint Goodman authored Nov 17, 2020
1 parent 548043e commit 76ed007
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/young-crews-report.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
60 changes: 60 additions & 0 deletions cypress/integration/focus-trap-demo.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,64 @@ describe('<FocusTrap> 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');
});
});
});
6 changes: 6 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ <h2>demo autofocus</h2>
</p>
<div id="demo-autofocus"></div>

<h2>demo containerElements</h2>
<p>
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.
</p>
<div id="demo-containerelements"></div>

<p>
<span style="font-size:2em;vertical-align:middle;"></span>
<a href="https://github.com/focus-trap/focus-trap-react" style="vertical-align:middle;">Return to the repository</a>
Expand Down
82 changes: 82 additions & 0 deletions demo/js/demo-containerelements.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<FocusTrap
containerElements={[this.elementRef1.current, this.elementRef2.current]}
focusTrapOptions={{
onDeactivate: this.unmountTrap,
clickOutsideDeactivates: true,
}}
>
<div className="trap is-active">
<p ref={this.setElementRef('elementRef1')}>
Here is a focus trap <a href="#">with</a> <a href="#">some</a>
<a href="#">focusable</a> parts.
</p>
<p>
Here is <a href="#">something</a>.
</p>
<p ref={this.setElementRef('elementRef2')}>
Here is a another focus trap element. <a href="#">See</a>{' '}
<a href="#">how</a>
it <a href="#">works</a>.
</p>
<p>
<button onClick={this.unmountTrap}>deactivate trap</button>
</p>
</div>
</FocusTrap>
) : (
false
);

return (
<div>
<p>
<button onClick={this.mountTrap}>activate trap</button>
</p>
{trap}
</div>
);
}
}

ReactDOM.render(<DemoContainerElements />, container);
1 change: 1 addition & 0 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ require('./demo-defaults');
require('./demo-ffne');
require('./demo-special-element');
require('./demo-autofocus');
require('./demo-containerelements');
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare namespace FocusTrap {
active?: boolean;
paused?: boolean;
focusTrapOptions?: FocusTrapOptions;
containerElements?: Array<HTMLElement>;
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 56 additions & 32 deletions src/focus-trap-react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}

Expand All @@ -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, {
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 76ed007

Please sign in to comment.