Skip to content

Commit 0085fb9

Browse files
committed
Merge pull request PolymerElements#182 from PolymerLabs/focus-trap
Focus trap for app-drawer
2 parents 7a0a6ed + bfd2c1e commit 0085fb9

File tree

4 files changed

+200
-45
lines changed

4 files changed

+200
-45
lines changed

app-drawer/app-drawer.html

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115

116116
visibility: visible;
117117

118-
width: 15px;
118+
width: 20px;
119119

120120
content: '';
121121
}
@@ -219,6 +219,14 @@
219219
type: Boolean,
220220
value: false,
221221
reflectToAttribute: true
222+
},
223+
224+
/**
225+
* Trap keyboard focus when the drawer is opened and not persistent.
226+
*/
227+
noFocusTrap: {
228+
type: Boolean,
229+
value: false
222230
}
223231
},
224232

@@ -233,7 +241,13 @@
233241

234242
_drawerState: 0,
235243

236-
_boundKeydownHandler: null,
244+
_boundEscKeydownHandler: null,
245+
246+
_lastFocusedElement: null,
247+
248+
_firstTabStop: null,
249+
250+
_lastTabStop: null,
237251

238252
ready: function() {
239253
// Set the scroll direction so you can vertically scroll inside the drawer.
@@ -249,16 +263,17 @@
249263
// may need to set the initial opened state which should not be transitioned).
250264
Polymer.RenderStatus.afterNextRender(this, function() {
251265
this._setTransitionDuration('');
252-
this._boundKeydownHandler = this._keydownHandler.bind(this);
266+
this._boundEscKeydownHandler = this._escKeydownHandler.bind(this);
253267
this._resetDrawerState();
254268

255269
this.listen(this, 'track', '_track');
256270
this.addEventListener('transitionend', this._transitionend.bind(this));
271+
this.addEventListener('keydown', this._tabKeydownHandler.bind(this))
257272
});
258273
},
259274

260275
detached: function() {
261-
document.removeEventListener('keydown', this._boundKeydownHandler);
276+
document.removeEventListener('keydown', this._boundEscKeydownHandler);
262277
},
263278

264279
/**
@@ -313,7 +328,7 @@
313328
this._setPosition(this.align);
314329
},
315330

316-
_keydownHandler: function(event) {
331+
_escKeydownHandler: function(event) {
317332
var ESC_KEYCODE = 27;
318333
if (event.keyCode === ESC_KEYCODE) {
319334
// Prevent any side effects if app-drawer closes.
@@ -536,10 +551,12 @@
536551

537552
if (oldState !== this._drawerState) {
538553
if (this._drawerState === this._DRAWER_STATE.OPENED) {
539-
document.addEventListener('keydown', this._boundKeydownHandler);
554+
document.addEventListener('keydown', this._boundEscKeydownHandler);
555+
this._addKeyboardFocusTrap();
540556
document.body.style.overflow = 'hidden';
541557
} else {
542-
document.removeEventListener('keydown', this._boundKeydownHandler);
558+
document.removeEventListener('keydown', this._boundEscKeydownHandler);
559+
this._removeKeyboardFocusTrap();
543560
document.body.style.overflow = '';
544561
}
545562

@@ -550,6 +567,77 @@
550567
}
551568
},
552569

570+
_addKeyboardFocusTrap: function() {
571+
if (this.noFocusTrap) {
572+
return;
573+
}
574+
575+
this._lastFocusedElement = this._getDeepActiveElement();
576+
577+
// NOTE: Unless we use /deep/ (which we shouldn't since it's deprecated), this will
578+
// not select focusable elements inside shadow roots.
579+
var focusableElementsSelector = [
580+
'a[href]:not([tabindex="-1"])',
581+
'area[href]:not([tabindex="-1"])',
582+
'input:not([disabled]):not([tabindex="-1"])',
583+
'select:not([disabled]):not([tabindex="-1"])',
584+
'textarea:not([disabled]):not([tabindex="-1"])',
585+
'button:not([disabled]):not([tabindex="-1"])',
586+
'iframe:not([tabindex="-1"])',
587+
'[tabindex]:not([tabindex="-1"])',
588+
'[contentEditable=true]:not([tabindex="-1"])'
589+
].join(',');
590+
var focusableElements = Polymer.dom(this).querySelectorAll(focusableElementsSelector);
591+
592+
if (focusableElements.length > 0) {
593+
this._firstTabStop = focusableElements[0];
594+
this._lastTabStop = focusableElements[focusableElements.length - 1];
595+
this._firstTabStop.focus();
596+
} else {
597+
// Reset saved tab stops when there are no focusable elements in the drawer.
598+
this._firstTabStop = null;
599+
this._lastTabStop = null;
600+
}
601+
},
602+
603+
_removeKeyboardFocusTrap: function() {
604+
if (!this.noFocusTrap && this._lastFocusedElement) {
605+
this._lastFocusedElement.focus();
606+
}
607+
},
608+
609+
_tabKeydownHandler: function(event) {
610+
if (this.noFocusTrap) {
611+
return;
612+
}
613+
614+
var TAB_KEYCODE = 9;
615+
if (this._drawerState === this._DRAWER_STATE.OPENED && event.keyCode === TAB_KEYCODE) {
616+
if (event.shiftKey) {
617+
if (this._firstTabStop && Polymer.dom(event).localTarget === this._firstTabStop) {
618+
event.preventDefault();
619+
this._lastTabStop.focus();
620+
}
621+
} else {
622+
if (this._lastTabStop && Polymer.dom(event).localTarget === this._lastTabStop) {
623+
event.preventDefault();
624+
this._firstTabStop.focus();
625+
}
626+
}
627+
}
628+
},
629+
630+
_getDeepActiveElement: function() {
631+
// document.activeElement can be null
632+
// https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement
633+
// In case of null, default it to document.body.
634+
var active = document.activeElement || document.body;
635+
while (active.root && Polymer.dom(active.root).activeElement) {
636+
active = Polymer.dom(active.root).activeElement;
637+
}
638+
return active;
639+
},
640+
553641
_MIN_FLING_THRESHOLD: 0.2,
554642

555643
_MIN_TRANSITION_VELOCITY: 1.2,

app-drawer/demo/demo2.html

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565

6666
<sample-content size="100"></sample-content>
6767

68-
<app-drawer id="drawer" align="end" swipe-open on-app-drawer-transitioned="drawerTransitioned">
68+
<app-drawer id="drawer" align="end" swipe-open>
6969
<div class="drawer-contents">
7070
<template is="dom-repeat" id="menu" items="[[items]]">
7171
<paper-icon-item>
@@ -85,21 +85,6 @@
8585
scope.$.drawer.toggle();
8686
};
8787

88-
scope.drawerTransitioned = function() {
89-
if (scope.$.drawer.opened) {
90-
document.querySelector('paper-icon-item').focus();
91-
document.addEventListener('focus', scope.focusHandler, true /* useCapture */);
92-
} else {
93-
document.removeEventListener('focus', scope.focusHandler, true /* useCapture */);
94-
}
95-
};
96-
97-
scope.focusHandler = function(event) {
98-
if (Polymer.dom(event).path.indexOf(scope.$.drawer) === -1) {
99-
document.querySelector('paper-icon-item').focus();
100-
}
101-
};
102-
10388
var icons = ['inbox', 'favorite', 'polymer', 'question-answer', 'send', 'archive', 'backup', 'dashboard'];
10489
scope.items = icons.concat(icons).concat(icons);
10590

app-drawer/test/app-drawer.html

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,24 @@
3939
</template>
4040
</test-fixture>
4141

42-
<dom-module id="x-button">
42+
<test-fixture id="focusDrawer">
4343
<template>
44-
<button id="btn">Shadow</button>
44+
<focus-drawer></focus-drawer>
4545
</template>
46+
</test-fixture>
4647

48+
<dom-module id="focus-drawer">
49+
<template>
50+
<button>Button</button>
51+
<app-drawer>
52+
<input type="text">
53+
<div tabindex="0">Div</div>
54+
<span>Not focusable</span>
55+
</app-drawer>
56+
</template>
4757
<script>
4858
HTMLImports.whenReady(function() {
49-
Polymer({ is: 'x-button' });
59+
Polymer({ is: 'focus-drawer' });
5060
});
5161
</script>
5262
</dom-module>
@@ -56,6 +66,17 @@
5666
suite('basic features', function() {
5767
var drawer, scrim, contentContainer, transformSpy;
5868

69+
function fireKeydownEvent(target, keyCode, shiftKey) {
70+
var e = new CustomEvent('keydown', {
71+
bubbles: true,
72+
cancelable: true
73+
});
74+
e.keyCode = keyCode;
75+
e.shiftKey = !!shiftKey;
76+
target.dispatchEvent(e);
77+
return e;
78+
}
79+
5980
function assertDrawerStyles(translateX, opacity, desc) {
6081
assert.equal(transformSpy.lastCall.args[0], 'translate3d(' + translateX + 'px,0,0)', desc);
6182
assert.equal(parseFloat(scrim.style.opacity).toFixed(4), opacity.toFixed(4), desc);
@@ -93,6 +114,7 @@
93114
assert.isFalse(drawer.persistent);
94115
assert.equal(drawer.align, 'left');
95116
assert.isFalse(drawer.swipeOpen);
117+
assert.isFalse(drawer.noFocusTrap);
96118
});
97119

98120
test('set scroll direction', function() {
@@ -579,22 +601,80 @@
579601
}, 100);
580602
});
581603

582-
test('esc key handler', function(done) {
604+
test('focus trap', function(done) {
605+
var focusDrawer = fixture('focusDrawer');
606+
var root = Polymer.dom(focusDrawer.root);
607+
var button = root.querySelector('button');
608+
var drawer = root.querySelector('app-drawer');
609+
var input = Polymer.dom(drawer).querySelector('input');
610+
var div = Polymer.dom(drawer).querySelector('div[tabindex]');
611+
var buttonFocusSpy = sinon.spy(button, 'focus');
612+
var inputFocusSpy = sinon.spy(input, 'focus');
613+
var divFocusSpy = sinon.spy(div, 'focus');
614+
button.focus();
615+
drawer.opened = true;
616+
583617
window.setTimeout(function() {
584-
drawer.opened = true;
618+
assert.isTrue(inputFocusSpy.called);
619+
620+
var e = fireKeydownEvent(input, 9);
621+
622+
assert.isFalse(e.defaultPrevented, 'should not prevent default');
623+
624+
input.focus();
625+
inputFocusSpy.reset();
626+
e = fireKeydownEvent(input, 9, true /* shiftKey */);
627+
628+
assert.isTrue(divFocusSpy.called);
629+
assert.isTrue(e.defaultPrevented, 'should prevent default');
630+
631+
e = fireKeydownEvent(div, 9, true /* shiftKey */);
632+
633+
assert.isFalse(e.defaultPrevented, 'should not prevent default');
634+
635+
div.focus();
636+
e = fireKeydownEvent(div, 9);
637+
638+
assert.isTrue(inputFocusSpy.called);
639+
assert.isTrue(e.defaultPrevented, 'should prevent default');
640+
641+
buttonFocusSpy.reset();
642+
drawer.opened = false;
585643

586644
window.setTimeout(function() {
587-
var e = new CustomEvent('keydown', {
588-
cancelable: true
589-
});
590-
e.keyCode = 27;
591-
document.dispatchEvent(e);
592-
593-
assert.isFalse(drawer.opened, 'should close drawer on esc');
594-
assert.isTrue(e.defaultPrevented, 'should prevent default');
645+
assert.isTrue(buttonFocusSpy.called);
595646
done();
596647
}, 350);
597-
}, 100);
648+
}, 350);
649+
});
650+
651+
test('no focus trap', function(done) {
652+
var focusDrawer = fixture('focusDrawer');
653+
var root = Polymer.dom(focusDrawer.root);
654+
var button = root.querySelector('button');
655+
var drawer = root.querySelector('app-drawer');
656+
var input = Polymer.dom(drawer).querySelector('input');
657+
var inputFocusSpy = sinon.spy(input, 'focus');
658+
drawer.noFocusTrap = true;
659+
button.focus();
660+
drawer.opened = true;
661+
662+
window.setTimeout(function() {
663+
assert.isFalse(inputFocusSpy.called);
664+
done();
665+
}, 350);
666+
});
667+
668+
test('esc key handler', function(done) {
669+
drawer.opened = true;
670+
671+
window.setTimeout(function() {
672+
var e = fireKeydownEvent(document, 27);
673+
674+
assert.isFalse(drawer.opened, 'should close drawer on esc');
675+
assert.isTrue(e.defaultPrevented, 'should prevent default');
676+
done();
677+
}, 350);
598678
});
599679

600680
test('scrim', function() {

app-header/test/app-header.html

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,16 @@
108108

109109

110110
suite('basic features', function() {
111-
var container, header, toolbar;
112-
var testEffect = {
113-
setUp: sinon.spy(),
114-
tearDown: sinon.spy(),
115-
run: sinon.spy()
116-
};
117-
118-
Polymer.AppLayout.registerEffect('test-effect', testEffect);
111+
var container, header, toolbar, testEffect;
112+
113+
suiteSetup(function() {
114+
testEffect = {
115+
setUp: sinon.spy(),
116+
tearDown: sinon.spy(),
117+
run: sinon.spy()
118+
};
119+
Polymer.AppLayout.registerEffect('test-effect', testEffect);
120+
});
119121

120122
setup(function() {
121123
container = fixture('testHeader');

0 commit comments

Comments
 (0)