Skip to content

Commit a9c4ae9

Browse files
mohammedzamakhanmgechev
authored andcommitted
fix(rule): don't check keyup events for some elements (#772)
1 parent 865ec3b commit a9c4ae9

10 files changed

+377
-8
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"dependencies": {
103103
"app-root-path": "^2.1.0",
104104
"aria-query": "^3.0.0",
105+
"axobject-query": "^2.0.2",
105106
"css-selector-tokenizer": "^0.7.0",
106107
"cssauron": "^1.4.0",
107108
"damerau-levenshtein": "^1.0.4",

src/templateClickEventsHaveKeyEventsRule.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { ElementAst } from '@angular/compiler';
2+
import { dom } from 'aria-query';
23
import { IRuleMetadata, RuleFailure, Rules } from 'tslint/lib';
34
import { SourceFile } from 'typescript/lib/typescript';
45
import { NgWalker } from './angular/ngWalker';
56
import { BasicTemplateAstVisitor } from './angular/templates/basicTemplateAstVisitor';
7+
import { isInteractiveElement } from './util/isInteractiveElement';
8+
import { isPresentationRole } from './util/isPresentationRole';
9+
import { isHiddenFromScreenReader } from './util/isHiddenFromScreenReader';
10+
11+
const domElements = new Set(dom.keys());
612

713
export class Rule extends Rules.AbstractRule {
814
static readonly metadata: IRuleMetadata = {
@@ -37,6 +43,21 @@ class TemplateClickEventsHaveKeyEventsVisitor extends BasicTemplateAstVisitor {
3743
if (!hasClick) {
3844
return;
3945
}
46+
47+
if (!domElements.has(el.name)) {
48+
// Do not test components, as we do not know what
49+
// low-level DOM element this maps to.
50+
return;
51+
}
52+
53+
if (isPresentationRole(el) || isHiddenFromScreenReader(el)) {
54+
return;
55+
}
56+
57+
if (isInteractiveElement(el)) {
58+
return;
59+
}
60+
4061
const hasKeyEvent = el.outputs.some(output => output.name === 'keyup' || output.name === 'keydown' || output.name === 'keypress');
4162

4263
if (hasKeyEvent) {

src/util/attributesComparator.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ElementAst, AttrAst, BoundElementPropertyAst } from '@angular/compiler';
2+
import { getAttributeValue } from './getAttributeValue';
3+
import { getLiteralValue } from './getLiteralValue';
4+
5+
export function attributesComparator(baseAttributes: any = [], el: ElementAst): boolean {
6+
const attributes: Array<AttrAst | BoundElementPropertyAst> = [...el.attrs, ...el.inputs];
7+
return baseAttributes.every(
8+
(baseAttr): boolean =>
9+
attributes.some(
10+
(attribute): boolean => {
11+
if (el.name === 'a' && attribute.name === 'routerLink') {
12+
return true;
13+
}
14+
if (baseAttr.name !== attribute.name) {
15+
return false;
16+
}
17+
if (baseAttr.value && baseAttr.value !== getLiteralValue(getAttributeValue(el, baseAttr.name))) {
18+
return false;
19+
}
20+
return true;
21+
}
22+
)
23+
);
24+
}

src/util/getAttributeValue.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { ElementAst } from '@angular/compiler';
1+
import { ElementAst, ASTWithSource, LiteralPrimitive } from '@angular/compiler';
2+
import { PROPERTY } from './isHiddenFromScreenReader';
23

34
export const getAttributeValue = (element: ElementAst, property: string) => {
45
const attr = element.attrs.find(attr => attr.name === property);
56
const input = element.inputs.find(input => input.name === property);
67
if (attr) {
78
return attr.value;
89
}
9-
if (input) {
10-
return (<any>input.value).ast.value;
10+
if (!input || !(input.value instanceof ASTWithSource)) {
11+
return undefined;
1112
}
13+
14+
if (input.value.ast instanceof LiteralPrimitive) {
15+
return input.value.ast.value;
16+
}
17+
18+
return PROPERTY;
1219
};

src/util/getLiteralValue.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const getLiteralValue = value => {
2+
if (value === 'true') {
3+
return true;
4+
} else if (value === 'false') {
5+
return false;
6+
}
7+
return value;
8+
};

src/util/isHiddenFromScreenReader.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ElementAst } from '@angular/compiler';
2+
import { getAttributeValue } from './getAttributeValue';
3+
import { getLiteralValue } from './getLiteralValue';
4+
5+
export const PROPERTY = ['PROPERTY'];
6+
/**
7+
* Returns boolean indicating that the aria-hidden prop
8+
* is present or the value is true. Will also return true if
9+
* there is an input with type='hidden'.
10+
*
11+
* <div aria-hidden /> is equivalent to the DOM as <div aria-hidden=true />.
12+
*/
13+
export const isHiddenFromScreenReader = (el: ElementAst) => {
14+
if (el.name.toUpperCase() === 'INPUT') {
15+
const hidden = getAttributeValue(el, 'type');
16+
17+
if (hidden && hidden.toUpperCase() === 'HIDDEN') {
18+
return true;
19+
}
20+
}
21+
22+
const ariaHidden = getLiteralValue(getAttributeValue(el, 'aria-hidden'));
23+
return ariaHidden === PROPERTY || ariaHidden === true;
24+
};

src/util/isInteractiveElement.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { dom, elementRoles, roles } from 'aria-query';
2+
3+
import { AXObjects, elementAXObjects } from 'axobject-query';
4+
5+
import { attributesComparator } from './attributesComparator';
6+
import { ElementAst } from '@angular/compiler';
7+
8+
const domKeys = <string[]>Array.from(dom.keys());
9+
const roleKeys: any = <string[]>Array.from(roles.keys());
10+
const elementRoleEntries = Array.from(elementRoles);
11+
12+
const nonInteractiveRoles = new Set(
13+
roleKeys.filter(name => {
14+
const role = roles.get(name);
15+
return !role.abstract && !role.superClass.some(classes => classes.indexOf('widget') !== 0);
16+
})
17+
);
18+
19+
const interactiveRoles: any = new Set(
20+
[
21+
...roleKeys,
22+
// 'toolbar' does not descend from widget, but it does support
23+
// aria-activedescendant, thus in practice we treat it as a widget.
24+
'toolbar'
25+
].filter(name => {
26+
const role = roles.get(name);
27+
return !role.abstract && role.superClass.some(classes => classes.indexOf('widget') !== 0);
28+
})
29+
);
30+
31+
const nonInteractiveElementRoleSchemas = elementRoleEntries.reduce((accumulator: any, [elementSchema, roleSet]: any) => {
32+
if (Array.from(roleSet).every((role): boolean => nonInteractiveRoles.has(role))) {
33+
accumulator.push(elementSchema);
34+
}
35+
return accumulator;
36+
}, []);
37+
38+
const interactiveElementRoleSchemas = elementRoleEntries.reduce((accumulator: any, [elementSchema, roleSet]: any) => {
39+
if (Array.from(roleSet).some((role): boolean => interactiveRoles.has(role))) {
40+
accumulator.push(elementSchema);
41+
}
42+
return accumulator;
43+
}, []);
44+
45+
const interactiveAXObjects = new Set(Array.from(AXObjects.keys()).filter(name => AXObjects.get(name).type === 'widget'));
46+
47+
const interactiveElementAXObjectSchemas = Array.from(elementAXObjects).reduce((accumulator: any, [elementSchema, AXObjectSet]: any) => {
48+
if (Array.from(AXObjectSet).every((role): boolean => interactiveAXObjects.has(role))) {
49+
accumulator.push(elementSchema);
50+
}
51+
return accumulator;
52+
}, []);
53+
54+
function checkIsInteractiveElement(el: ElementAst): boolean {
55+
function elementSchemaMatcher(elementSchema) {
56+
return el.name === elementSchema.name && attributesComparator(elementSchema.attributes, el);
57+
}
58+
// Check in elementRoles for inherent interactive role associations for
59+
// this element.
60+
const isInherentInteractiveElement = interactiveElementRoleSchemas.some(elementSchemaMatcher);
61+
62+
if (isInherentInteractiveElement) {
63+
return true;
64+
}
65+
// Check in elementRoles for inherent non-interactive role associations for
66+
// this element.
67+
const isInherentNonInteractiveElement = nonInteractiveElementRoleSchemas.some(elementSchemaMatcher);
68+
69+
if (isInherentNonInteractiveElement) {
70+
return false;
71+
}
72+
// Check in elementAXObjects for AX Tree associations for this element.
73+
const isInteractiveAXElement = interactiveElementAXObjectSchemas.some(elementSchemaMatcher);
74+
75+
if (isInteractiveAXElement) {
76+
return true;
77+
}
78+
79+
return false;
80+
}
81+
82+
/**
83+
* Returns boolean indicating whether the given element is
84+
* interactive on the DOM or not. Usually used when an element
85+
* has a dynamic handler on it and we need to discern whether or not
86+
* it's intention is to be interacted with on the DOM.
87+
*/
88+
export const isInteractiveElement = (el: ElementAst): boolean => {
89+
if (domKeys.indexOf(el.name) === -1) {
90+
return false;
91+
}
92+
93+
return checkIsInteractiveElement(el);
94+
};

src/util/isPresentationRole.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getAttributeValue } from './getAttributeValue';
2+
import { ElementAst } from '@angular/compiler';
3+
import { PROPERTY } from './isHiddenFromScreenReader';
4+
5+
const presentationRoles = new Set(['presentation', 'none', PROPERTY]);
6+
7+
export const isPresentationRole = (el: ElementAst) => presentationRoles.has(getAttributeValue(el, 'role'));

0 commit comments

Comments
 (0)