Skip to content

Commit 2825a9b

Browse files
devversionwagnermaciel
authored andcommitted
fix(cdk/a11y): interactivity checker detecting tabbable state incorrectly for videos, audio and object elements
The interactivity checker has been built a while ago when supported browsers behaved differently for their `tabbable` state. We now updated our browsers to more recent versions, and need to adjust the interactivity checker so that it reflects the latest patterns in supported browsers. Here is a note summary of behavior noticed for `video` and `audio` elements in various supported browsers: https://hackmd.io/@devversion/rkxzcgsJD Another change was for `object` elements. In previous BLINK / Webkit browsers, such elements, or its children were never tabbable. This is incorrect for the currently supported browsers as children inside `object` elements can certainly be tabbable. On the other hand though, object elements itself can be tabbable depending on configuration we cannot reliably detect. For improved accessibility, we never consider object elements itself as tabbable (similar to iframes) (cherry picked from commit 35005f4)
1 parent 2b1ed1b commit 2825a9b

File tree

2 files changed

+78
-141
lines changed

2 files changed

+78
-141
lines changed

src/cdk/a11y/interactivity-checker/interactivity-checker.spec.ts

Lines changed: 49 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Platform} from '@angular/cdk/platform';
2+
import {PLATFORM_ID} from '@angular/core';
23
import {inject} from '@angular/core/testing';
34
import {InteractivityChecker, IsFocusableConfig} from './interactivity-checker';
45

@@ -7,11 +8,11 @@ describe('InteractivityChecker', () => {
78
let testContainerElement: HTMLElement;
89
let checker: InteractivityChecker;
910

10-
beforeEach(inject([Platform, InteractivityChecker], (p: Platform, i: InteractivityChecker) => {
11+
beforeEach(inject([PLATFORM_ID], (platformId: Object) => {
1112
testContainerElement = document.createElement('div');
1213
document.body.appendChild(testContainerElement);
13-
platform = p;
14-
checker = i;
14+
platform = new Platform(platformId);
15+
checker = new InteractivityChecker(platform);
1516
}));
1617

1718
afterEach(() => {
@@ -253,45 +254,48 @@ describe('InteractivityChecker', () => {
253254

254255
describe('isTabbable', () => {
255256

256-
it('should respect the tabindex for video elements with controls', () => {
257-
// Do not run for Blink, Firefox and iOS because those treat video elements
258-
// with controls different and are covered in other tests.
259-
if (platform.BLINK || platform.FIREFOX || platform.IOS) {
260-
return;
261-
}
262-
263-
const video = createFromTemplate('<video controls>', true);
264-
265-
expect(checker.isTabbable(video)).toBe(true);
257+
// Some tests should not run inside of iOS browsers, because those only allow specific
258+
// elements to be tabbable and cause the tests to always fail.
259+
describe('for non-iOS browsers', () => {
260+
let shouldSkip: boolean;
266261

267-
video.tabIndex = -1;
262+
beforeEach(() => {
263+
shouldSkip = platform.IOS;
264+
});
268265

269-
expect(checker.isTabbable(video)).toBe(false);
270-
});
266+
it('should by default treat video elements with controls as tabbable', () => {
267+
if (shouldSkip) {
268+
return;
269+
}
271270

272-
it('should always mark video elements with controls as tabbable (BLINK & FIREFOX)', () => {
273-
// Only run this spec for Blink and Firefox, because those always treat video
274-
// elements with controls as tabbable.
275-
if (!platform.BLINK && !platform.FIREFOX) {
276-
return;
277-
}
271+
const video = createFromTemplate('<video controls>', true);
272+
expect(checker.isTabbable(video)).toBe(true);
273+
});
278274

279-
const video = createFromTemplate('<video controls>', true);
275+
it('should respect the tabindex for video elements with controls', () => {
276+
if (shouldSkip) {
277+
return;
278+
}
280279

281-
expect(checker.isTabbable(video)).toBe(true);
280+
const video = createFromTemplate('<video controls>', true);
281+
expect(checker.isTabbable(video)).toBe(true);
282282

283-
video.tabIndex = -1;
283+
video.tabIndex = -1;
284+
expect(checker.isTabbable(video)).toBe(false);
285+
});
284286

285-
expect(checker.isTabbable(video)).toBe(true);
286-
});
287+
// Firefox always makes video elements (regardless of the controls) as tabbable, unless
288+
// explicitly opted-out by setting the tabindex.
289+
it('should by default treat video elements without controls as tabbable in firefox', () => {
290+
if (!platform.FIREFOX) {
291+
return;
292+
}
287293

288-
// Some tests should not run inside of iOS browsers, because those only allow specific
289-
// elements to be tabbable and cause the tests to always fail.
290-
describe('for non-iOS browsers', () => {
291-
let shouldSkip: boolean;
294+
const video = createFromTemplate('<video>', true);
295+
expect(checker.isTabbable(video)).toBe(true);
292296

293-
beforeEach(() => {
294-
shouldSkip = platform.IOS;
297+
video.tabIndex = -1;
298+
expect(checker.isTabbable(video)).toBe(false);
295299
});
296300

297301
it('should mark form controls and anchors without tabindex attribute as tabbable', () => {
@@ -424,39 +428,25 @@ describe('InteractivityChecker', () => {
424428
}
425429
});
426430

427-
it('should always mark audio elements without controls as not tabbable', () => {
431+
it('should detect audio elements with controls as tabbable', () => {
428432
if (!shouldSkip) {
429-
const audio = createFromTemplate('<audio>', true);
430-
433+
const audio = createFromTemplate('<audio controls>', true);
434+
expect(checker.isTabbable(audio)).toBe(true);
435+
audio.tabIndex = -1;
431436
expect(checker.isTabbable(audio)).toBe(false);
432437
}
433438
});
434439

435-
});
436-
437-
describe('for Blink and Webkit browsers', () => {
438-
let shouldSkip: boolean;
439-
440-
beforeEach(() => {
441-
shouldSkip = !platform.BLINK && !platform.WEBKIT;
442-
});
440+
it('should always detect audio elements without controls as non-tabbable', () => {
441+
if (!shouldSkip) {
442+
const audio = createFromTemplate('<audio>', true);
443+
expect(checker.isTabbable(audio)).toBe(false);
443444

444-
it('should not mark elements inside of object frames as tabbable', () => {
445-
if (shouldSkip) {
446-
return;
445+
// Setting a `tabindex` has no effect. The audio element is expected
446+
// to be still not tabbable.
447+
audio.tabIndex = 0;
448+
expect(checker.isTabbable(audio)).toBe(false);
447449
}
448-
449-
const objectEl = createFromTemplate('<object>', true) as HTMLObjectElement;
450-
const button = createFromTemplate('<button tabindex="0">Not Tabbable</button>');
451-
452-
appendElements([objectEl]);
453-
454-
// This creates an empty contentDocument for the frame element.
455-
objectEl.type = 'text/html';
456-
objectEl.contentDocument!.body.appendChild(button);
457-
458-
expect(checker.isTabbable(objectEl)).toBe(false);
459-
expect(checker.isTabbable(button)).toBe(false);
460450
});
461451

462452
it('should not mark elements inside of invisible frames as tabbable', () => {
@@ -482,57 +472,6 @@ describe('InteractivityChecker', () => {
482472
expect(checker.isTabbable(objectEl)).toBe(false);
483473
}
484474
});
485-
486-
});
487-
488-
describe('for Blink browsers', () => {
489-
let shouldSkip: boolean;
490-
491-
beforeEach(() => {
492-
shouldSkip = !platform.BLINK;
493-
});
494-
495-
it('should always mark audio elements with controls as tabbable', () => {
496-
if (shouldSkip) {
497-
return;
498-
}
499-
500-
const audio = createFromTemplate('<audio controls>', true);
501-
502-
expect(checker.isTabbable(audio)).toBe(true);
503-
504-
audio.tabIndex = -1;
505-
506-
// The audio element will be still tabbable because Blink always
507-
// considers them as tabbable.
508-
expect(checker.isTabbable(audio)).toBe(true);
509-
});
510-
511-
});
512-
513-
describe('for Internet Explorer', () => {
514-
let shouldSkip: boolean;
515-
516-
beforeEach(() => {
517-
shouldSkip = !platform.TRIDENT;
518-
});
519-
520-
it('should never mark video elements without controls as tabbable', () => {
521-
if (shouldSkip) {
522-
return;
523-
}
524-
525-
// In Internet Explorer video elements without controls are never tabbable.
526-
const video = createFromTemplate('<video>', true);
527-
528-
expect(checker.isTabbable(video)).toBe(false);
529-
530-
video.tabIndex = 0;
531-
532-
expect(checker.isTabbable(video)).toBe(false);
533-
534-
});
535-
536475
});
537476

538477
describe('for iOS browsers', () => {

src/cdk/a11y/interactivity-checker/interactivity-checker.ts

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,15 @@ export class InteractivityChecker {
7272
const frameElement = getFrameElement(getWindow(element));
7373

7474
if (frameElement) {
75-
const frameType = frameElement && frameElement.nodeName.toLowerCase();
76-
7775
// Frame elements inherit their tabindex onto all child elements.
7876
if (getTabIndexValue(frameElement) === -1) {
7977
return false;
8078
}
8179

82-
// Webkit and Blink consider anything inside of an <object> element as non-tabbable.
83-
if ((this._platform.BLINK || this._platform.WEBKIT) && frameType === 'object') {
80+
// Browsers disable tabbing to an element inside of an invisible frame.
81+
if (!this.isVisible(frameElement)) {
8482
return false;
8583
}
86-
87-
// Webkit and Blink disable tabbing to an element inside of an invisible frame.
88-
if ((this._platform.BLINK || this._platform.WEBKIT) && !this.isVisible(frameElement)) {
89-
return false;
90-
}
91-
9284
}
9385

9486
let nodeName = element.nodeName.toLowerCase();
@@ -98,40 +90,46 @@ export class InteractivityChecker {
9890
return tabIndexValue !== -1;
9991
}
10092

101-
if (nodeName === 'iframe') {
102-
// The frames may be tabbable depending on content, but it's not possibly to reliably
103-
// investigate the content of the frames.
93+
if (nodeName === 'iframe' || nodeName === 'object') {
94+
// The frame or object's content may be tabbable depending on the content, but it's
95+
// not possibly to reliably detect the content of the frames. We always consider such
96+
// elements as non-tabbable.
97+
return false;
98+
}
99+
100+
// In iOS, the browser only considers some specific elements as tabbable.
101+
if (this._platform.WEBKIT && this._platform.IOS && !isPotentiallyTabbableIOS(element)) {
104102
return false;
105103
}
106104

107105
if (nodeName === 'audio') {
106+
// Audio elements without controls enabled are never tabbable, regardless
107+
// of the tabindex attribute explicitly being set.
108108
if (!element.hasAttribute('controls')) {
109-
// By default an <audio> element without the controls enabled is not tabbable.
110109
return false;
111-
} else if (this._platform.BLINK) {
112-
// In Blink <audio controls> elements are always tabbable.
113-
return true;
114110
}
111+
// Audio elements with controls are by default tabbable unless the
112+
// tabindex attribute is set to `-1` explicitly.
113+
return tabIndexValue !== -1;
115114
}
116115

117116
if (nodeName === 'video') {
118-
if (!element.hasAttribute('controls') && this._platform.TRIDENT) {
119-
// In Trident a <video> element without the controls enabled is not tabbable.
117+
// For all video elements, if the tabindex attribute is set to `-1`, the video
118+
// is not tabbable. Note: We cannot rely on the default `HTMLElement.tabIndex`
119+
// property as that one is set to `-1` in Chrome, Edge and Safari v13.1. The
120+
// tabindex attribute is the source of truth here.
121+
if (tabIndexValue === -1) {
120122
return false;
121-
} else if (this._platform.BLINK || this._platform.FIREFOX) {
122-
// In Chrome and Firefox <video controls> elements are always tabbable.
123+
}
124+
// If the tabindex is explicitly set, and not `-1` (as per check before), the
125+
// video element is always tabbable (regardless of whether it has controls or not).
126+
if (tabIndexValue !== null) {
123127
return true;
124128
}
125-
}
126-
127-
if (nodeName === 'object' && (this._platform.BLINK || this._platform.WEBKIT)) {
128-
// In all Blink and WebKit based browsers <object> elements are never tabbable.
129-
return false;
130-
}
131-
132-
// In iOS the browser only considers some specific elements as tabbable.
133-
if (this._platform.WEBKIT && this._platform.IOS && !isPotentiallyTabbableIOS(element)) {
134-
return false;
129+
// Otherwise (when no explicit tabindex is set), a video is only tabbable if it
130+
// has controls enabled. Firefox is special as videos are always tabbable regardless
131+
// of whether there are controls or not.
132+
return this._platform.FIREFOX || element.hasAttribute('controls');
135133
}
136134

137135
return element.tabIndex >= 0;

0 commit comments

Comments
 (0)