Skip to content

Commit 0815ec5

Browse files
mohammedzamakhanmgechev
authored andcommitted
feat(rule): accessibility rule for alt text (#741)
1 parent 762f67f commit 0815ec5

File tree

3 files changed

+242
-0
lines changed

3 files changed

+242
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export { Rule as TemplateAccessibilityTabindexNoPositiveRule } from './templateA
3232
export { Rule as TemplateAccessibilityLabelForVisitor } from './templateAccessibilityLabelForRule';
3333
export { Rule as TemplateAccessibilityValidAriaRule } from './templateAccessibilityValidAriaRule';
3434
export { Rule as TemplatesAccessibilityAnchorContentRule } from './templateAccessibilityAnchorContentRule';
35+
export { Rule as TemplateAccessibilityAltTextRule } from './templateAccessibilityAltTextRule';
3536
export { Rule as TemplatesNoNegatedAsync } from './templatesNoNegatedAsyncRule';
3637
export { Rule as TemplateNoAutofocusRule } from './templateNoAutofocusRule';
3738
export { Rule as TrackByFunctionRule } from './trackByFunctionRule';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { ElementAst, AttrAst, BoundElementPropertyAst, TextAst } from '@angular/compiler';
2+
import { sprintf } from 'sprintf-js';
3+
import { IRuleMetadata, RuleFailure, Rules } from 'tslint/lib';
4+
import { SourceFile } from 'typescript/lib/typescript';
5+
import { NgWalker } from './angular/ngWalker';
6+
import { BasicTemplateAstVisitor } from './angular/templates/basicTemplateAstVisitor';
7+
8+
export class Rule extends Rules.AbstractRule {
9+
static readonly metadata: IRuleMetadata = {
10+
description: 'Enforces alternate text for elements which require the alt, aria-label, aria-labelledby attributes',
11+
options: null,
12+
optionsDescription: 'Not configurable.',
13+
rationale: 'Alternate text lets screen readers provide more information to end users.',
14+
ruleName: 'template-accessibility-alt-text',
15+
type: 'functionality',
16+
typescriptOnly: true
17+
};
18+
19+
static readonly FAILURE_STRING = '%s element must have a text alternative.';
20+
static readonly DEFAULT_ELEMENTS = ['img', 'object', 'area', 'input[type="image"]'];
21+
22+
apply(sourceFile: SourceFile): RuleFailure[] {
23+
return this.applyWithWalker(
24+
new NgWalker(sourceFile, this.getOptions(), {
25+
templateVisitorCtrl: TemplateAccessibilityAltTextVisitor
26+
})
27+
);
28+
}
29+
}
30+
31+
export const getFailureMessage = (name: string): string => {
32+
return sprintf(Rule.FAILURE_STRING, name);
33+
};
34+
35+
class TemplateAccessibilityAltTextVisitor extends BasicTemplateAstVisitor {
36+
visitElement(ast: ElementAst, context: any) {
37+
this.validateElement(ast);
38+
super.visitElement(ast, context);
39+
}
40+
41+
validateElement(element: ElementAst) {
42+
const typesToValidate = Rule.DEFAULT_ELEMENTS.map(type => {
43+
if (type === 'input[type="image"]') {
44+
return 'input';
45+
}
46+
return type;
47+
});
48+
if (typesToValidate.indexOf(element.name) === -1) {
49+
return;
50+
}
51+
52+
const isValid = this[element.name](element);
53+
if (isValid) {
54+
return;
55+
}
56+
const {
57+
sourceSpan: {
58+
end: { offset: endOffset },
59+
start: { offset: startOffset }
60+
}
61+
} = element;
62+
this.addFailureFromStartToEnd(startOffset, endOffset, getFailureMessage(element.name));
63+
}
64+
65+
img(element: ElementAst) {
66+
const hasAltAttr = element.attrs.some(attr => attr.name === 'alt');
67+
const hasAltInput = element.inputs.some(input => input.name === 'alt');
68+
return hasAltAttr || hasAltInput;
69+
}
70+
71+
object(element: ElementAst) {
72+
let elementHasText: string = '';
73+
const hasLabelAttr = element.attrs.some(attr => attr.name === 'aria-label' || attr.name === 'aria-labelledby');
74+
const hasLabelInput = element.inputs.some(input => input.name === 'aria-label' || input.name === 'aria-labelledby');
75+
const hasTitleAttr = element.attrs.some(attr => attr.name === 'title');
76+
const hasTitleInput = element.inputs.some(input => input.name === 'title');
77+
if (element.children.length) {
78+
elementHasText = (<TextAst>element.children[0]).value;
79+
}
80+
return hasLabelAttr || hasLabelInput || hasTitleAttr || hasTitleInput || elementHasText;
81+
}
82+
83+
area(element: ElementAst) {
84+
const hasLabelAttr = element.attrs.some(attr => attr.name === 'aria-label' || attr.name === 'aria-labelledby');
85+
const hasLabelInput = element.inputs.some(input => input.name === 'aria-label' || input.name === 'aria-labelledby');
86+
const hasAltAttr = element.attrs.some(attr => attr.name === 'alt');
87+
const hasAltInput = element.inputs.some(input => input.name === 'alt');
88+
console.log(element);
89+
return hasAltAttr || hasAltInput || hasLabelAttr || hasLabelInput;
90+
}
91+
92+
input(element: ElementAst) {
93+
const attrType: AttrAst = element.attrs.find(attr => attr.name === 'type') || <AttrAst>{};
94+
const inputType: BoundElementPropertyAst = element.inputs.find(input => input.name === 'type') || <BoundElementPropertyAst>{};
95+
const type = attrType.value || inputType.value;
96+
if (type !== 'image') {
97+
return true;
98+
}
99+
100+
return this.area(element);
101+
}
102+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { getFailureMessage, Rule } from '../src/templateAccessibilityAltTextRule';
2+
import { assertAnnotated, assertSuccess } from './testHelper';
3+
4+
const {
5+
metadata: { ruleName }
6+
} = Rule;
7+
8+
describe(ruleName, () => {
9+
describe('failure', () => {
10+
it('should fail image does not have alt text', () => {
11+
const source = `
12+
@Component({
13+
template: \`
14+
<img src="foo">
15+
~~~~~~~~~~~~~~~
16+
\`
17+
})
18+
class Bar {}
19+
`;
20+
assertAnnotated({
21+
message: getFailureMessage('img'),
22+
ruleName,
23+
source
24+
});
25+
});
26+
27+
it('should fail when object does not have alt text or labels', () => {
28+
const source = `
29+
@Component({
30+
template: \`
31+
<object></object>
32+
~~~~~~~~
33+
\`
34+
})
35+
class Bar {}
36+
`;
37+
assertAnnotated({
38+
message: getFailureMessage('object'),
39+
ruleName,
40+
source
41+
});
42+
});
43+
44+
it('should fail when area does not have alt or label text', () => {
45+
const source = `
46+
@Component({
47+
template: \`
48+
<area></area>
49+
~~~~~~
50+
\`
51+
})
52+
class Bar {}
53+
`;
54+
assertAnnotated({
55+
message: getFailureMessage('area'),
56+
ruleName,
57+
source
58+
});
59+
});
60+
61+
it('should fail when input element with type image does not have alt or text image', () => {
62+
const source = `
63+
@Component({
64+
template: \`
65+
<input type="image"></input>
66+
~~~~~~~~~~~~~~~~~~~~
67+
\`
68+
})
69+
class Bar {}
70+
`;
71+
assertAnnotated({
72+
message: getFailureMessage('input'),
73+
ruleName,
74+
source
75+
});
76+
});
77+
});
78+
79+
describe('success', () => {
80+
it('should work with img with alternative text', () => {
81+
const source = `
82+
@Component({
83+
template: \`
84+
<img src="foo" alt="Foo eating a sandwich.">
85+
<img src="foo" [attr.alt]="altText">
86+
<img src="foo" [attr.alt]="'Alt Text'">
87+
<img src="foo" alt="">
88+
\`
89+
})
90+
class Bar {}
91+
`;
92+
assertSuccess(ruleName, source);
93+
});
94+
95+
it('should work with object having label, title or meaningful description', () => {
96+
const source = `
97+
@Component({
98+
template: \`
99+
<object aria-label="foo">
100+
<object aria-labelledby="id1">
101+
<object>Meaningful description</object>
102+
<object title="An object">
103+
\`
104+
})
105+
class Bar {}
106+
`;
107+
assertSuccess(ruleName, source);
108+
});
109+
110+
it('should work with area having label or alternate text', () => {
111+
const source = `
112+
@Component({
113+
template: \`
114+
<area aria-label="foo"></area>
115+
<area aria-labelledby="id1"></area>
116+
<area alt="This is descriptive!"></area>
117+
\`
118+
})
119+
class Bar {}
120+
`;
121+
assertSuccess(ruleName, source);
122+
});
123+
124+
it('should work with input type image having alterate text and labels', () => {
125+
const source = `
126+
@Component({
127+
template: \`
128+
<input type="text">
129+
<input type="image" alt="This is descriptive!">
130+
<input type="image" aria-label="foo">
131+
<input type="image" aria-labelledby="id1">
132+
\`
133+
})
134+
class Bar {}
135+
`;
136+
assertSuccess(ruleName, source);
137+
});
138+
});
139+
});

0 commit comments

Comments
 (0)