Skip to content

Commit 3971af5

Browse files
committed
fix(focus-trap): improve robustness
1 parent 3b80a6c commit 3971af5

File tree

12 files changed

+354
-17
lines changed

12 files changed

+354
-17
lines changed

src/demo-app/demo-app-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {PortalDemo, ScienceJoke} from './portal/portal-demo';
3434
import {MenuDemo} from './menu/menu-demo';
3535
import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tabs/tabs-demo';
3636
import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-demo';
37+
import {PlatformDemo} from './platform/platform-demo';
3738

3839
@NgModule({
3940
imports: [
@@ -84,6 +85,7 @@ import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-d
8485
SunnyTabContent,
8586
RainyTabContent,
8687
FoggyTabContent,
88+
PlatformDemo
8789
],
8890
entryComponents: [
8991
DemoApp,

src/demo-app/demo-app/demo-app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class DemoApp {
4646
{name: 'Snack Bar', route: 'snack-bar'},
4747
{name: 'Tabs', route: 'tabs'},
4848
{name: 'Toolbar', route: 'toolbar'},
49-
{name: 'Tooltip', route: 'tooltip'}
49+
{name: 'Tooltip', route: 'tooltip'},
50+
{name: 'Platform', route: 'platform'}
5051
];
5152
}

src/demo-app/demo-app/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {TooltipDemo} from '../tooltip/tooltip-demo';
2929
import {SnackBarDemo} from '../snack-bar/snack-bar-demo';
3030
import {ProjectionDemo} from '../projection/projection-demo';
3131
import {TABS_DEMO_ROUTES} from '../tabs/routes';
32+
import {PlatformDemo} from '../platform/platform-demo';
3233

3334
export const DEMO_APP_ROUTES: Routes = [
3435
{path: '', component: Home},
@@ -60,4 +61,5 @@ export const DEMO_APP_ROUTES: Routes = [
6061
{path: 'dialog', component: DialogDemo},
6162
{path: 'tooltip', component: TooltipDemo},
6263
{path: 'snack-bar', component: SnackBarDemo},
64+
{path: 'platform', component: PlatformDemo}
6365
];
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Component} from '@angular/core';
2+
import {MdPlatform} from '@angular/material';
3+
4+
@Component({
5+
template: `
6+
<p>Is Android: {{ platform.ANDROID }}</p>
7+
<p>Is iOS: {{ platform.IOS }}</p>
8+
<p>Is Firefox: {{ platform.FIREFOX }}</p>
9+
<p>Is Blink: {{ platform.BLINK }}</p>
10+
<p>Is Webkit: {{ platform.WEBKIT }}</p>
11+
<p>Is Trident: {{ platform.TRIDENT }}</p>
12+
13+
`
14+
})
15+
export class PlatformDemo {
16+
17+
constructor(public platform: MdPlatform) {}
18+
19+
}

src/lib/core/a11y/focus-trap.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {By} from '@angular/platform-browser';
33
import {Component} from '@angular/core';
44
import {FocusTrap} from './focus-trap';
55
import {InteractivityChecker} from './interactivity-checker';
6+
import {MdPlatform} from '../platform/platform';
67

78

89
describe('FocusTrap', () => {
@@ -12,7 +13,7 @@ describe('FocusTrap', () => {
1213

1314
beforeEach(() => TestBed.configureTestingModule({
1415
declarations: [FocusTrap, FocusTrapTestApp],
15-
providers: [InteractivityChecker]
16+
providers: [InteractivityChecker, MdPlatform]
1617
}));
1718

1819
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
@@ -45,7 +46,7 @@ describe('FocusTrap', () => {
4546

4647
beforeEach(() => TestBed.configureTestingModule({
4748
declarations: [FocusTrap, FocusTrapTargetTestApp],
48-
providers: [InteractivityChecker]
49+
providers: [InteractivityChecker, MdPlatform]
4950
}));
5051

5152
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {

src/lib/core/a11y/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import {NgModule, ModuleWithProviders} from '@angular/core';
22
import {FocusTrap} from './focus-trap';
33
import {MdLiveAnnouncer} from './live-announcer';
44
import {InteractivityChecker} from './interactivity-checker';
5+
import {PlatformModule} from '../platform/platform';
56

67
export const A11Y_PROVIDERS = [MdLiveAnnouncer, InteractivityChecker];
78

89
@NgModule({
10+
imports: [PlatformModule],
911
declarations: [FocusTrap],
1012
exports: [FocusTrap],
1113
})

src/lib/core/a11y/interactivity-checker.spec.ts

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import {InteractivityChecker} from './interactivity-checker';
2+
import {MdPlatform} from '../platform/platform';
3+
import {async} from '@angular/core/testing';
24

35
describe('InteractivityChecker', () => {
46
let testContainerElement: HTMLElement;
57
let checker: InteractivityChecker;
8+
let platform: MdPlatform;
69

710
beforeEach(() => {
811
testContainerElement = document.createElement('div');
912
document.body.appendChild(testContainerElement);
1013

11-
checker = new InteractivityChecker();
14+
platform = new MdPlatform();
15+
checker = new InteractivityChecker(platform);
1216
});
1317

1418
afterEach(() => {
@@ -280,13 +284,200 @@ describe('InteractivityChecker', () => {
280284
.toBe(true, `Expected <${el.nodeName} tabindex="0"> to be tabbable`);
281285
});
282286
});
287+
288+
it('should respect the inherited tabindex inside of frame elements', () => {
289+
let iframe = createFromTemplate('<iframe>', true) as HTMLFrameElement;
290+
let button = createFromTemplate('<button tabindex="0">Not Tabbable</button>');
291+
292+
appendElements([iframe]);
293+
294+
iframe.tabIndex = -1;
295+
iframe.contentDocument.body.appendChild(button);
296+
297+
expect(checker.isTabbable(iframe)).toBe(false);
298+
expect(checker.isTabbable(button)).toBe(false);
299+
300+
iframe.tabIndex = null;
301+
302+
expect(checker.isTabbable(iframe)).toBe(false);
303+
expect(checker.isTabbable(button)).toBe(true);
304+
});
305+
306+
it('should not mark elements inside of object frames as tabbable (BLINK & WEBKIT)', () => {
307+
platform.BLINK = true;
308+
309+
let objectEl = createFromTemplate('<object>', true) as HTMLObjectElement;
310+
let button = createFromTemplate('<button tabindex="0">Not Tabbable</button>');
311+
312+
appendElements([objectEl]);
313+
314+
// This is a hack to create an empty contentDocument for the frame element.
315+
objectEl.type = 'text/html';
316+
objectEl.contentDocument.body.appendChild(button);
317+
318+
expect(checker.isTabbable(objectEl)).toBe(false);
319+
expect(checker.isTabbable(button)).toBe(false);
320+
});
321+
322+
it('should not mark elements inside of invisible frames as tabbable (BLINK & WEBKIT)', () => {
323+
let iframe = createFromTemplate('<iframe>', true) as HTMLFrameElement;
324+
let button = createFromTemplate('<button tabindex="0">Not Tabbable</button>');
325+
326+
appendElements([iframe]);
327+
328+
iframe.style.display = 'none';
329+
iframe.contentDocument.body.appendChild(button);
330+
331+
expect(checker.isTabbable(iframe)).toBe(false);
332+
expect(checker.isTabbable(button)).toBe(false);
333+
});
334+
335+
it('should mark elements which are contentEditable as tabbable', async(() => {
336+
let editableEl = createFromTemplate('<div contenteditable="true">', true);
337+
338+
// Wait one tick, because the browser takes some time to update the tabIndex
339+
// according to the contentEditable attribute.
340+
setTimeout(() => {
341+
342+
expect(checker.isTabbable(editableEl)).toBe(true);
343+
344+
editableEl.tabIndex = -1;
345+
346+
expect(checker.isTabbable(editableEl)).toBe(false);
347+
348+
}, 1);
349+
350+
}));
351+
352+
it('should never mark iframe elements as tabbable', () => {
353+
let iframe = createFromTemplate('<iframe>', true);
354+
355+
// iFrame elements will be never marked as tabbable, because it depends on the content
356+
// which is mostly not detectable due to CORS and also the checks will be not reliable.
357+
expect(checker.isTabbable(iframe)).toBe(false);
358+
});
359+
360+
it('should never mark object frame elements as tabbable', () => {
361+
let objectEl = createFromTemplate('<object>', true);
362+
363+
expect(checker.isTabbable(objectEl)).toBe(false);
364+
});
365+
366+
it('should always mark audio elements without controls as not tabbable', () => {
367+
let audio = createFromTemplate('<audio>', true);
368+
369+
expect(checker.isTabbable(audio)).toBe(false);
370+
});
371+
372+
it('should always mark audio elements with controls as tabbable (BLINK)', () => {
373+
platform.BLINK = true;
374+
375+
let audio = createFromTemplate('<audio controls>', true);
376+
377+
expect(checker.isTabbable(audio)).toBe(true);
378+
379+
audio.tabIndex = -1;
380+
381+
// The audio element will be still tabbable because Blink always
382+
// considers them as tabbable.
383+
expect(checker.isTabbable(audio)).toBe(true);
384+
385+
platform.BLINK = false;
386+
387+
expect(checker.isTabbable(audio)).toBe(false);
388+
});
389+
390+
it('should never mark video elements without controls as tabbable (IE11)', () => {
391+
platform.TRIDENT = true;
392+
393+
let video = createFromTemplate('<video>', true);
394+
395+
expect(checker.isTabbable(video)).toBe(false);
396+
});
397+
398+
it('should always mark video elements with controls as tabbable (BLINK & FIREFOX)', () => {
399+
platform.BLINK = true;
400+
401+
let video = createFromTemplate('<video controls>', true);
402+
403+
expect(checker.isTabbable(video)).toBe(true);
404+
405+
video.tabIndex = -1;
406+
407+
expect(checker.isTabbable(video)).toBe(true);
408+
});
409+
410+
it('should respect the tabindex for video elements with controls', () => {
411+
// Don't run the test as Blink or Firefox, because those will always mark
412+
// video elements with controls as tabbable.
413+
platform.BLINK = false;
414+
platform.FIREFOX = false;
415+
416+
let video = createFromTemplate('<video controls>', true);
417+
418+
expect(checker.isTabbable(video)).toBe(true);
419+
420+
video.tabIndex = -1;
421+
422+
expect(checker.isTabbable(video)).toBe(false);
423+
});
424+
425+
describe('for iOS browsers', () => {
426+
427+
beforeEach(() => {
428+
platform.IOS = true;
429+
platform.WEBKIT = true;
430+
});
431+
432+
it('should never allow div elements to be tabbable', () => {
433+
let divEl = createFromTemplate('<div tabindex="0">', true);
434+
435+
expect(checker.isTabbable(divEl)).toBe(false);
436+
});
437+
438+
it('should never allow span elements to be tabbable', () => {
439+
let spanEl = createFromTemplate('<span tabindex="0">Text</span>', true);
440+
441+
expect(checker.isTabbable(spanEl)).toBe(false);
442+
});
443+
444+
it('should never allow button elements to be tabbable', () => {
445+
let buttonEl = createFromTemplate('<button tabindex="0">', true);
446+
447+
expect(checker.isTabbable(buttonEl)).toBe(false);
448+
});
449+
450+
it('should never allow anchor elements to be tabbable', () => {
451+
let anchorEl = createFromTemplate('<a tabindex="0">Link</a>', true);
452+
453+
expect(checker.isTabbable(anchorEl)).toBe(false);
454+
});
455+
456+
});
457+
458+
283459
});
284460

285461
/** Creates an array of elements with the given node names. */
286462
function createElements(...nodeNames: string[]) {
287463
return nodeNames.map(name => document.createElement(name));
288464
}
289465

466+
function createFromTemplate(template: string, append = false) {
467+
let tmpRoot = document.createElement('div');
468+
tmpRoot.innerHTML = template;
469+
470+
let element = tmpRoot.firstElementChild;
471+
472+
tmpRoot.removeChild(element);
473+
474+
if (append) {
475+
appendElements([element]);
476+
}
477+
478+
return element as HTMLElement;
479+
}
480+
290481
/** Appends elements to the testContainerElement. */
291482
function appendElements(elements: Element[]) {
292483
for (let e of elements) {

0 commit comments

Comments
 (0)