Skip to content

Commit

Permalink
Make it possible to have <FocusTrap /> without children (#186)
Browse files Browse the repository at this point in the history
Only when `containerElements` are used.
  • Loading branch information
stefcameron authored Nov 18, 2020
1 parent 0836c6d commit c4e4837
Show file tree
Hide file tree
Showing 11 changed files with 301 additions and 97 deletions.
7 changes: 7 additions & 0 deletions .changeset/slow-adults-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'focus-trap-react': minor
---

- Remove the need for a child in `<FocusTrap />` when `containerElements` is used. The child was already being ignored anyway (when `containerElements` is used; if the prop is not used, then a single child is still required).
- Update the typings related to the `children` prop to make it optional. Prop-types already had `children` as optional, however the use of `React.Children.only()` in all cases was still forcing the presence of a single child. That's no longer the case.
- Add additional notes about the use of the `containerElements` prop in the documentation.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,13 @@ If you would like to pause or unpause the focus trap (see [`focus-trap`'s docume

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.
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.

> Note that when you use `containerElements`, the need for a child is eliminated as the child is __always__ ignored when the prop is specified, even if the prop is `[]` (an empty array). Also note that if the refs you're putting into the array like `containerElements={[ref1.current, ref2.current]}` and one or both refs aren't resolved yet, resulting in `[null, null]` for example, the trap will not get created. The array must contain at least one `HTMLElement` in order for the trap to get updated.
If `containerElements` is subsequently updated (i.e. after the trap has been created) to an empty array (or an array of falsy values like `[null, null]`), the trap will still be active, but the TAB key will do nothing because the trap will not contain any tabbable groups of nodes. At this point, the trap can either be deactivated manually or by unmounting, or an updated set of elements can be given to `containerElements` to resume use of the TAB key.

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`).

## Contributing

Expand Down
172 changes: 117 additions & 55 deletions cypress/integration/focus-trap-demo.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,62 +243,124 @@ describe('<FocusTrap> component', () => {
});

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');
describe('with child', () => {
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');
});
});

// 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');
describe('without child', () => {
it('containerElements can be passed in and used as multiple boundaries to keep the focus within', () => {
cy.get('#demo-containerelements-childless').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');
});
});
});
});
11 changes: 10 additions & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,19 @@ <h2>demo autofocus</h2>

<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.
You may pass in an array prop <code>containerElements</code> that contains nodes to trap focus within, which if passed will be used instead of the direct child. The direct child, if any, <strong>will be ignored by the trap</strong> (unless also given as an element in <code>containerElements</code>).
</p>
<div id="demo-containerelements"></div>

<h2>demo containerElements (no child)</h2>
<p>
Slight <em>underlying</em> difference from the previous "containerElements" demo in that the
focus trap element is not given a child: <code>&lt;FocusTrap containerElements={[...]} /&gt;</code>.
The result is the same, but this further reinforces the fact that when <code>containerElements</code> is
used, the child is not considered, and therefore not even necessary.
</p>
<div id="demo-containerelements-childless"></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
94 changes: 94 additions & 0 deletions demo/js/demo-containerelements-childless.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const React = require('react');
const ReactDOM = require('react-dom');
const FocusTrap = require('../../dist/focus-trap-react');

const container = document.getElementById('demo-containerelements-childless');

class DemoContainerElementsChildless 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.element1 = null;
this.element2 = null;
}

mountTrap() {
this.setState({ activeTrap: true });
}

unmountTrap() {
this.setState({ activeTrap: false });
}

setElementRef(refName) {
return (element) => {
if (element && (!this[refName] || this[refName] !== element)) {
this[refName] = element;
this.forceUpdate(); // re-render
}
};
}

render() {
const trap = this.state.activeTrap ? (
<>
<FocusTrap
containerElements={[this.element1, this.element2]}
focusTrapOptions={{
onDeactivate: this.unmountTrap,
allowOutsideClick(event) {
return (
event.target.id ===
'demo-containerelements-childless-deactivate'
);
},
}}
/>

<div className="trap is-active">
<p ref={this.setElementRef('element1')}>
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('element2')}>
Here is a another focus trap element. <a href="#">See</a>{' '}
<a href="#">how</a>
it <a href="#">works</a>.
</p>
<p>
<button
id="demo-containerelements-childless-deactivate"
onClick={this.unmountTrap}
>
deactivate trap
</button>
</p>
</div>
</>
) : (
false
);

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

ReactDOM.render(<DemoContainerElementsChildless />, container);
28 changes: 18 additions & 10 deletions demo/js/demo-containerelements.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ class DemoContainerElements extends React.Component {
this.unmountTrap = this.unmountTrap.bind(this);
this.setElementRef = this.setElementRef.bind(this);

this.elementRef1 = React.createRef();
this.elementRef2 = React.createRef();
this.element1 = null;
this.element2 = null;
}

mountTrap() {
Expand All @@ -30,37 +30,45 @@ class DemoContainerElements extends React.Component {

setElementRef(refName) {
return (element) => {
if (!this[refName].current) {
this[refName].current = element;
this.forceUpdate();
if (element && (!this[refName] || this[refName] !== element)) {
this[refName] = element;
this.forceUpdate(); // re-render
}
};
}

render() {
const trap = this.state.activeTrap ? (
<FocusTrap
containerElements={[this.elementRef1.current, this.elementRef2.current]}
containerElements={[this.element1, this.element2]}
focusTrapOptions={{
onDeactivate: this.unmountTrap,
clickOutsideDeactivates: true,
allowOutsideClick(event) {
return event.target.id === 'demo-containerelements-deactivate';
},
}}
>
{/* NOTE: child is IGNORED in favor of `containerElements` */}
<div className="trap is-active">
<p ref={this.setElementRef('elementRef1')}>
<p ref={this.setElementRef('element1')}>
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')}>
<p ref={this.setElementRef('element2')}>
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>
<button
id="demo-containerelements-deactivate"
onClick={this.unmountTrap}
>
deactivate trap
</button>
</p>
</div>
</FocusTrap>
Expand Down
1 change: 1 addition & 0 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ require('./demo-ffne');
require('./demo-special-element');
require('./demo-autofocus');
require('./demo-containerelements');
require('./demo-containerelements-childless');
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export = FocusTrap;

declare namespace FocusTrap {
export interface Props extends React.AllHTMLAttributes<any> {
children: React.ReactNode;
children?: React.ReactNode;
active?: boolean;
paused?: boolean;
focusTrapOptions?: FocusTrapOptions;
Expand Down
Loading

0 comments on commit c4e4837

Please sign in to comment.