Skip to content

Commit fe2b0ad

Browse files
authored
Respect returnFocusOnDeactivate when deactivating trap (#169)
Fixes #103 The flag wasn't being respected when the trap is deactivated as a result of `clickOutsideDeactivates=true` option.
1 parent 99f8021 commit fe2b0ad

File tree

5 files changed

+189
-23
lines changed

5 files changed

+189
-23
lines changed

.changeset/loud-countries-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'focus-trap': patch
3+
---
4+
5+
Fixed #103: `returnFocusOnDeactivate` is now respected on auto-deactivation with `clickOutsideDeactivates=true`.

cypress/integration/focus-trap-demo.spec.js

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,119 @@ describe('focus-trap', () => {
274274
});
275275

276276
describe('demo: clickoutsidedeactivates', () => {
277-
// TODO
277+
const activateTrap = function () {
278+
cy.get('@testRoot')
279+
.findByRole('button', { name: 'activate trap' })
280+
.as('lastlyFocusedElementBeforeTrapIsActivated')
281+
.click();
282+
};
283+
284+
const checkTrap = function () {
285+
// 1st element should be focused
286+
cy.get('@testRoot')
287+
.findByRole('link', { name: 'with' })
288+
.as('firstElementInTrap')
289+
.should('be.focused');
290+
291+
// trap is active (keep focus in trap by tabbing through the focus trap's tabbable elements)
292+
cy.get('@firstElementInTrap')
293+
.tab()
294+
.should('have.text', 'some')
295+
.should('be.focused')
296+
.tab()
297+
.should('have.text', 'focusable')
298+
.should('be.focused')
299+
.tab()
300+
.as('lastElementInTrap')
301+
.should('contain', 'nothing')
302+
.should('be.focused')
303+
.tab();
304+
305+
// trap is active (keep focus in trap by shift-tabbing through the focus trap's tabbable elements)
306+
cy.get('@firstElementInTrap').should('be.focused').tab({ shift: true });
307+
cy.get('@lastElementInTrap').should('be.focused');
308+
};
309+
310+
it('traps focus, deactivates on outside click on checkbox and checkbox focused, returnFocusOnDeactivate=true', () => {
311+
cy.get('#demo-clickoutsidedeactivates').as('testRoot');
312+
313+
// set returnFocusOnDeactivate=TRUE
314+
cy.get('#select-returnfocusondeactivate-clickoutsidedeactivates').select(
315+
'true'
316+
);
317+
318+
activateTrap();
319+
checkTrap();
320+
321+
// deactivate trap by toggling FOCUSABLE checkbox
322+
cy.get('#checkbox-clickoutsidedeactivates').click();
323+
cy.get('#checkbox-clickoutsidedeactivates').should('be.checked');
324+
325+
// implies trap no longer active since checkbox is outside trap
326+
cy.get('#checkbox-clickoutsidedeactivates').should('be.focused');
327+
328+
cy.get('@lastElementInTrap').should('not.be.focused');
329+
});
330+
331+
it('traps focus, deactivates on outside click on document and "activate trap" button focused', () => {
332+
cy.get('#demo-clickoutsidedeactivates').as('testRoot');
333+
334+
// set returnFocusOnDeactivate=TRUE
335+
cy.get('#select-returnfocusondeactivate-clickoutsidedeactivates').select(
336+
'true'
337+
);
338+
339+
activateTrap();
340+
checkTrap();
341+
342+
// deactivate trap by clicking NON-focusable element
343+
cy.get('#clickoutsidedeactivates-heading').click();
344+
cy.get('@lastlyFocusedElementBeforeTrapIsActivated').should('be.focused');
345+
346+
cy.get('@lastElementInTrap').should('not.be.focused');
347+
});
348+
349+
it('traps focus, deactivates on outside click on checkbox and checkbox focused, returnFocusOnDeactivate=false', () => {
350+
cy.get('#demo-clickoutsidedeactivates').as('testRoot');
351+
352+
// set returnFocusOnDeactivate=FALSE
353+
cy.get('#select-returnfocusondeactivate-clickoutsidedeactivates').select(
354+
'false'
355+
);
356+
357+
activateTrap();
358+
checkTrap();
359+
360+
// deactivate trap by toggling FOCUSABLE checkbox
361+
cy.get('#checkbox-clickoutsidedeactivates').click();
362+
cy.get('#checkbox-clickoutsidedeactivates').should('be.checked');
363+
364+
// implies trap no longer active since checkbox is outside trap
365+
cy.get('#checkbox-clickoutsidedeactivates').should('be.focused');
366+
367+
cy.get('@lastElementInTrap').should('not.be.focused');
368+
});
369+
370+
it('traps focus, deactivates on outside click on document, and nothing is focused', () => {
371+
cy.get('#demo-clickoutsidedeactivates').as('testRoot');
372+
373+
// set returnFocusOnDeactivate=FALSE
374+
cy.get('#select-returnfocusondeactivate-clickoutsidedeactivates').select(
375+
'false'
376+
);
377+
378+
activateTrap();
379+
checkTrap();
380+
381+
// deactivate trap by clicking NON-focusable element
382+
cy.get('#clickoutsidedeactivates-heading').click();
383+
384+
cy.get('@lastlyFocusedElementBeforeTrapIsActivated').should(
385+
'not.be.focused'
386+
);
387+
cy.get('@lastElementInTrap').should('not.be.focused');
388+
cy.get('*:focus').should('not.exist'); // nothing has focus
389+
});
278390
});
279391

280392
describe('demo: setreturnfocus', () => {

demo/index.html

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,6 @@ <h2 id="delay-heading">delay</h2>
285285
</div>
286286
</div>
287287

288-
289288
<div id="demo-no-delay">
290289
<h2 id="delay-heading-explicit">No delay</h2>
291290
<p>
@@ -397,13 +396,30 @@ <h2 id="clickoutsidedeactivates-heading">clickOutsideDeactivates option</h2>
397396
<p>
398397
This focus trap can be closed simply by <strong>clicking anywhere outside</strong>, and the click outside will also go through and do what it was intentionally dispatched to do (like toggling the checkbox). ESC is <strong>disabled</strong> for this trap.
399398
</p>
399+
<p>
400+
The <code>returnFocusOnDeactivate</code> option controls whether focus should be returned to the last-focused element when the trap was activated (the "activate trap" button) or not -- IIF what you click on outside the trap is <strong>not</strong> focusable:
401+
</p>
402+
<ul>
403+
<li>If this option is <code>true</code> (the default behavior) but you click on a focusable node outside the trap (like the checkbox), the click will go through, the checkbox will be toggled, and focus will remain on the checkbox.</li>
404+
<li>If <code>true</code> but you click on something <strong>not</strong> focusable (like the page/document), then focus will return to the "activate trap" button (since that was the last-focused node before the trap was activate).</li>
405+
<li>If <code>false</code>, then it doesn't matter what you click on (focusable or not), focus will go away from the trap to where you clicked, or to nowhere if you clicked on something not focusable (like the page/document).</li>
406+
</ul>
400407
<p>
401408
<button id="activate-clickoutsidedeactivates">
402409
activate trap
403410
</button>
404411
<label>
405-
Toggling me deactivates (but doesn't know about the trap):
406-
<input type="checkbox" />
412+
Set <code>returnFocusOnDeactivate</code> as:
413+
<select id="select-returnfocusondeactivate-clickoutsidedeactivates">
414+
<option value="true">true</option><!-- default -->
415+
<option value="false">false</option>
416+
</select>
417+
</label>
418+
</p>
419+
<p>
420+
<label>
421+
Toggling me causes auto-deactivation of the trap:
422+
<input type="checkbox" id="checkbox-clickoutsidedeactivates" />
407423
</label>
408424
</p>
409425
<div id="clickoutsidedeactivates" class="trap">
Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,41 @@
1-
var { createFocusTrap } = require('../../dist/focus-trap');
1+
const { createFocusTrap } = require('../../dist/focus-trap');
22

3-
var container = document.getElementById('clickoutsidedeactivates');
4-
var trigger = document.getElementById('activate-clickoutsidedeactivates');
5-
var active = false;
3+
const container = document.getElementById('clickoutsidedeactivates');
4+
const trigger = document.getElementById('activate-clickoutsidedeactivates');
5+
let active = false;
6+
let returnFocusOnDeactivate = true;
67

7-
const focusTrap = createFocusTrap('#clickoutsidedeactivates', {
8-
clickOutsideDeactivates: true,
9-
escapeDeactivates: false,
10-
onActivate: function () {
11-
container.className = 'trap is-active';
12-
},
13-
onDeactivate: function () {
14-
active = false;
15-
container.className = 'trap';
16-
},
17-
});
8+
const initialize = function () {
9+
return createFocusTrap('#clickoutsidedeactivates', {
10+
returnFocusOnDeactivate,
11+
clickOutsideDeactivates: true,
12+
escapeDeactivates: false,
13+
onActivate: function () {
14+
container.className = 'trap is-active';
15+
},
16+
onDeactivate: function () {
17+
active = false;
18+
container.className = 'trap';
19+
},
20+
});
21+
};
1822

19-
function activate() {
23+
const activate = function () {
2024
active = true;
2125
focusTrap.activate();
22-
}
26+
};
27+
28+
let focusTrap = initialize();
2329

2430
trigger.addEventListener('click', function () {
2531
if (!active) {
2632
activate();
2733
}
2834
});
35+
36+
document
37+
.getElementById('select-returnfocusondeactivate-clickoutsidedeactivates')
38+
.addEventListener('change', function (event) {
39+
returnFocusOnDeactivate = event.target.value === 'true';
40+
focusTrap = initialize();
41+
});

index.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,30 @@ function createFocusTrap(element, userOptions) {
227227
// This needs to be done on mousedown and touchstart instead of click
228228
// so that it precedes the focus event.
229229
function checkPointerDown(e) {
230-
if (container.contains(e.target)) return;
230+
if (container.contains(e.target)) {
231+
// allow the click since it ocurred inside the trap
232+
return;
233+
}
234+
231235
if (config.clickOutsideDeactivates) {
236+
// immediately deactivate the trap
232237
deactivate({
233-
returnFocus: !isFocusable(e.target),
238+
// if, on deactivation, we should return focus to the node originally-focused
239+
// when the trap was activated (or the configured `setReturnFocus` node),
240+
// then assume it's also OK to return focus to the outside node that was
241+
// just clicked, causing deactivation, as long as that node is focusable;
242+
// if it isn't focusable, then return focus to the original node focused
243+
// on activation (or the configured `setReturnFocus` node)
244+
// NOTE: by setting `returnFocus: false`, deactivate() will do nothing,
245+
// which will result in the outside click setting focus to the node
246+
// that was clicked, whether it's focusable or not; by setting
247+
// `returnFocus: true`, we'll attempt to re-focus the node originally-focused
248+
// on activation (or the configured `setReturnFocus` node)
249+
returnFocus: config.returnFocusOnDeactivate && !isFocusable(e.target),
234250
});
235251
return;
236252
}
253+
237254
// This is needed for mobile devices.
238255
// (If we'll only let `click` events through,
239256
// then on mobile they will be blocked anyways if `touchstart` is blocked.)
@@ -243,8 +260,11 @@ function createFocusTrap(element, userOptions) {
243260
? config.allowOutsideClick
244261
: config.allowOutsideClick(e))
245262
) {
263+
// allow the click outside the trap to take place
246264
return;
247265
}
266+
267+
// otherwise, prevent the click
248268
e.preventDefault();
249269
}
250270

0 commit comments

Comments
 (0)