-
Notifications
You must be signed in to change notification settings - Fork 6.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(interactivity-checker): improve robustness of isTabbable #1950
fix(interactivity-checker): improve robustness of isTabbable #1950
Conversation
bb1ec76
to
3971af5
Compare
bb310a3
to
b575211
Compare
b575211
to
1fcc687
Compare
@jelbourn This should be ready for review now. |
@devversion can you rebase? |
1fcc687
to
893e6f4
Compare
@jelbourn Done. |
@@ -16,6 +17,8 @@ import {Injectable} from '@angular/core'; | |||
@Injectable() | |||
export class InteractivityChecker { | |||
|
|||
constructor(private platform: MdPlatform) {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_platform
return getComputedStyle(element).getPropertyValue('visibility') == 'visible'; | ||
return getComputedStyle(element).getPropertyValue('visibility') === 'visible'; | ||
|
||
function checkRectangles() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be a module method with a description, similar to the others at the bottom of this file. I'd also call it something like hasGeometry
or elementHasRenderedRect
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. I really like hasGeometry
// See https://github.com/jquery/jquery/blob/master/src/css/hiddenVisibleSelectors.js#L12 | ||
if (!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)) { | ||
// In IE11 audio elements with controls have invalid rectangles, but are still visible. | ||
if (!isControlAudio && !checkRectangles()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean that an audio element with display: none
or visibility: hidden
will still be marked as visible?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't think that's correct. I re-confirmed within IE11 and Edge and this check seems to be unnecessary because we always want to check the rectangles / geometry
- Good catch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
an <audio>
with visibility: hidden
should be fine, but I think it does miss the display: none
case, maybe special-case TRIDENT
/EDGE
since other browsers have the correct behavior anyways
@@ -16,6 +17,8 @@ import {Injectable} from '@angular/core'; | |||
@Injectable() | |||
export class InteractivityChecker { | |||
|
|||
constructor(private platform: MdPlatform) {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you also update the class comment to just say
/**
* Utility for checking the interactivity of an element, such as whether is
* focusable or tabbable.
*/
And then add another //
comment above it that explains how we drew much of this logic from ally.js, omitting checks for platforms and edge-cases we don't support?
@@ -122,3 +214,7 @@ function isPotentiallyFocusable(element: HTMLElement): boolean { | |||
hasValidTabIndex(element); | |||
} | |||
|
|||
|
|||
function getWindow(node: HTMLElement): Window { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs function description
|
||
let nodeName = element.nodeName.toLowerCase(); | ||
let frameElement = getWindow(element).frameElement as HTMLElement; | ||
let frameType = frameElement && frameElement.nodeName.toLowerCase(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declare frameType
, nodeName
, and tabIndexValue
closer to where they're first used
@@ -106,6 +177,27 @@ function hasValidTabIndex(element: HTMLElement): boolean { | |||
return !!(tabIndex && !isNaN(parseInt(tabIndex, 10))); | |||
} | |||
|
|||
function getTabIndexValue(element: HTMLElement) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs function description that explains what this does beyond reading the tabIndex
property directly.
/** Appends elements to the testContainerElement. */ | ||
function appendElements(elements: Element[]) { | ||
for (let e of elements) { | ||
testContainerElement.appendChild(e); | ||
} | ||
} | ||
|
||
function runIf(condition: boolean, runFn: Function): () => void { | ||
return function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return () => {
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to use a normal function because we want to retrieve the arguments
below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can do (...args) => {
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, I will change it - But anyways what's the advantage of an arrow function here? We don't need any access to the this
context here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there's really much advantage to one way or the other, feel free to leave it as is unless Jeremy feels strongly
@mmalerba could you also take a look at this PR? I've spent so much time looking at the tabbable stuff I think a fresh perspective would be good |
@@ -0,0 +1,41 @@ | |||
import {Injectable, NgModule, ModuleWithProviders} from '@angular/core'; | |||
|
|||
// Declare window with type of any. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
comment doesn't tell us anything the code doesn't
|
||
/** Browsers and Platform Types */ | ||
IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; | ||
FIREFOX = /(firefox|minefield)/i.test(navigator.userAgent); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why FIREFOX
and not a GECKO
check above instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's because it would be difficult to detect the plain Gecko layout engine, because actually every browser identifies it self as a Gecko-like browser.
Since we only need to check for Firefox explicit, we can just implement the firefox
check instead of the unstable gecko
check (http://webaim.org/blog/user-agent-string-history/)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add that explanation as a comment?
const hasV8BreakIterator = (window.Intl && (window.Intl as any).v8BreakIterator); | ||
|
||
@Injectable() | ||
export class MdPlatform { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
document where you're getting these checks from
<p>Is Webkit: {{ platform.WEBKIT }}</p> | ||
<p>Is Trident: {{ platform.TRIDENT }}</p> | ||
<p>Is Edge: {{ platform.EDGE }}</p> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extra blank line
}) | ||
export class PlatformDemo { | ||
|
||
constructor(public platform: MdPlatform) {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove extra blank lines
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that's a big deal - Is there any specific convention in the Google JavaScript conventions? - Going to change it though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there's any official convention for it, I just like to keep it consistent throughout the project. Most other files I've looked at seem to not leave space here
// Do not run for Blink, Firefox and iOS because those treat video elements | ||
// with controls different and are covered in other tests. | ||
runIf(!platform.BLINK && !platform.FIREFOX && !platform.IOS, () => { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need spaces between each line
.toBe(false, `Expected <${el.nodeName} tabindex="-1"> not to be tabbable`); | ||
elements.forEach(el => { | ||
expect(checker.isFocusable(el)) | ||
.toBe(true, `Expected <${el.nodeName} tabindex="0"> to be focusable`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
indent +4 for continuation (here and elsewhere in this file)
|
||
// Wait one tick, because the browser takes some time to update the tabIndex | ||
// according to the contentEditable attribute. | ||
setTimeout(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this really necessary? your code checks if the contenteditable attribute is set, shouldn't that work without waiting for the browser to update the tabindex?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it seems like the browser needs some time to validate the new tabindex
.
Here is a snippet to test inside of Chrome 54
let div = document.createElement('div'); document.body.appendChild(div); div.contentEditable = true; console.log(div.tabIndex);
// After that you will see `tabindex = -1` and if you now call
console.log(div.tabIndex) // = 0
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interesting, can we just change the timeout to 0 then, bumping to the end of the queue with no delay should be enough
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mmalerba Makes sense. Changed it.
/** Appends elements to the testContainerElement. */ | ||
function appendElements(elements: Element[]) { | ||
for (let e of elements) { | ||
testContainerElement.appendChild(e); | ||
} | ||
} | ||
|
||
function runIf(condition: boolean, runFn: Function): () => void { | ||
return function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can do (...args) => {
@@ -16,6 +17,8 @@ import {Injectable} from '@angular/core'; | |||
@Injectable() | |||
export class InteractivityChecker { | |||
|
|||
constructor(private platform: MdPlatform) {} | |||
|
|||
/** Gets whether an element is disabled. */ | |||
isDisabled(element: HTMLElement) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can probably improve this too:
return element.hasAttribute('disabled') || element.form && element.form.hasAttribute('disabled');
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would rather address things like that in another PR. We should keep that one as small as possible.
if (frameElement) { | ||
|
||
// Frame elements inherit their tabindex onto all child elements. | ||
if (getTabIndexValue(frameElement) === -1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so if an element inherits its tabindex through the frame it's definitely disabled? (because you have a few places below where if the user sets tabindex=-1 that doesn't necessarily mean non-tabbable, e.g. blink audio)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah if the user explicitly set the tabindex
attribute on the frame element then all child elements are not tabbable.
The checks below for the tabIndex only affect the element and not the frameElement
} | ||
|
||
// In iOS the browser only considers some specific elements as tabbable. | ||
if (this.platform.WEBKIT && this.platform.IOS && !isPotentiallyTabbable(element)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe call the method isPotentiallyTabbableOnIOS
LGTM |
LGTM |
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
A couple of notes:
It's intentional to not nest things like that, because such functions need to be clear.
You may have noticed that there are no specs for the Platform. It's just hard to mock the
userAgent
and stuff likev8BreakIterator
without polluting the other tests.References #1625