-
Notifications
You must be signed in to change notification settings - Fork 235
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rule): accessibility rule for alt text (#741)
- Loading branch information
1 parent
762f67f
commit 0815ec5
Showing
3 changed files
with
242 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
import { ElementAst, AttrAst, BoundElementPropertyAst, TextAst } from '@angular/compiler'; | ||
import { sprintf } from 'sprintf-js'; | ||
import { IRuleMetadata, RuleFailure, Rules } from 'tslint/lib'; | ||
import { SourceFile } from 'typescript/lib/typescript'; | ||
import { NgWalker } from './angular/ngWalker'; | ||
import { BasicTemplateAstVisitor } from './angular/templates/basicTemplateAstVisitor'; | ||
|
||
export class Rule extends Rules.AbstractRule { | ||
static readonly metadata: IRuleMetadata = { | ||
description: 'Enforces alternate text for elements which require the alt, aria-label, aria-labelledby attributes', | ||
options: null, | ||
optionsDescription: 'Not configurable.', | ||
rationale: 'Alternate text lets screen readers provide more information to end users.', | ||
ruleName: 'template-accessibility-alt-text', | ||
type: 'functionality', | ||
typescriptOnly: true | ||
}; | ||
|
||
static readonly FAILURE_STRING = '%s element must have a text alternative.'; | ||
static readonly DEFAULT_ELEMENTS = ['img', 'object', 'area', 'input[type="image"]']; | ||
|
||
apply(sourceFile: SourceFile): RuleFailure[] { | ||
return this.applyWithWalker( | ||
new NgWalker(sourceFile, this.getOptions(), { | ||
templateVisitorCtrl: TemplateAccessibilityAltTextVisitor | ||
}) | ||
); | ||
} | ||
} | ||
|
||
export const getFailureMessage = (name: string): string => { | ||
return sprintf(Rule.FAILURE_STRING, name); | ||
}; | ||
|
||
class TemplateAccessibilityAltTextVisitor extends BasicTemplateAstVisitor { | ||
visitElement(ast: ElementAst, context: any) { | ||
this.validateElement(ast); | ||
super.visitElement(ast, context); | ||
} | ||
|
||
validateElement(element: ElementAst) { | ||
const typesToValidate = Rule.DEFAULT_ELEMENTS.map(type => { | ||
if (type === 'input[type="image"]') { | ||
return 'input'; | ||
} | ||
return type; | ||
}); | ||
if (typesToValidate.indexOf(element.name) === -1) { | ||
return; | ||
} | ||
|
||
const isValid = this[element.name](element); | ||
if (isValid) { | ||
return; | ||
} | ||
const { | ||
sourceSpan: { | ||
end: { offset: endOffset }, | ||
start: { offset: startOffset } | ||
} | ||
} = element; | ||
this.addFailureFromStartToEnd(startOffset, endOffset, getFailureMessage(element.name)); | ||
} | ||
|
||
img(element: ElementAst) { | ||
const hasAltAttr = element.attrs.some(attr => attr.name === 'alt'); | ||
const hasAltInput = element.inputs.some(input => input.name === 'alt'); | ||
return hasAltAttr || hasAltInput; | ||
} | ||
|
||
object(element: ElementAst) { | ||
let elementHasText: string = ''; | ||
const hasLabelAttr = element.attrs.some(attr => attr.name === 'aria-label' || attr.name === 'aria-labelledby'); | ||
const hasLabelInput = element.inputs.some(input => input.name === 'aria-label' || input.name === 'aria-labelledby'); | ||
const hasTitleAttr = element.attrs.some(attr => attr.name === 'title'); | ||
const hasTitleInput = element.inputs.some(input => input.name === 'title'); | ||
if (element.children.length) { | ||
elementHasText = (<TextAst>element.children[0]).value; | ||
} | ||
return hasLabelAttr || hasLabelInput || hasTitleAttr || hasTitleInput || elementHasText; | ||
} | ||
|
||
area(element: ElementAst) { | ||
const hasLabelAttr = element.attrs.some(attr => attr.name === 'aria-label' || attr.name === 'aria-labelledby'); | ||
const hasLabelInput = element.inputs.some(input => input.name === 'aria-label' || input.name === 'aria-labelledby'); | ||
const hasAltAttr = element.attrs.some(attr => attr.name === 'alt'); | ||
const hasAltInput = element.inputs.some(input => input.name === 'alt'); | ||
console.log(element); | ||
return hasAltAttr || hasAltInput || hasLabelAttr || hasLabelInput; | ||
} | ||
|
||
input(element: ElementAst) { | ||
const attrType: AttrAst = element.attrs.find(attr => attr.name === 'type') || <AttrAst>{}; | ||
const inputType: BoundElementPropertyAst = element.inputs.find(input => input.name === 'type') || <BoundElementPropertyAst>{}; | ||
const type = attrType.value || inputType.value; | ||
if (type !== 'image') { | ||
return true; | ||
} | ||
|
||
return this.area(element); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
import { getFailureMessage, Rule } from '../src/templateAccessibilityAltTextRule'; | ||
import { assertAnnotated, assertSuccess } from './testHelper'; | ||
|
||
const { | ||
metadata: { ruleName } | ||
} = Rule; | ||
|
||
describe(ruleName, () => { | ||
describe('failure', () => { | ||
it('should fail image does not have alt text', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<img src="foo"> | ||
~~~~~~~~~~~~~~~ | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertAnnotated({ | ||
message: getFailureMessage('img'), | ||
ruleName, | ||
source | ||
}); | ||
}); | ||
|
||
it('should fail when object does not have alt text or labels', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<object></object> | ||
~~~~~~~~ | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertAnnotated({ | ||
message: getFailureMessage('object'), | ||
ruleName, | ||
source | ||
}); | ||
}); | ||
|
||
it('should fail when area does not have alt or label text', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<area></area> | ||
~~~~~~ | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertAnnotated({ | ||
message: getFailureMessage('area'), | ||
ruleName, | ||
source | ||
}); | ||
}); | ||
|
||
it('should fail when input element with type image does not have alt or text image', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<input type="image"></input> | ||
~~~~~~~~~~~~~~~~~~~~ | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertAnnotated({ | ||
message: getFailureMessage('input'), | ||
ruleName, | ||
source | ||
}); | ||
}); | ||
}); | ||
|
||
describe('success', () => { | ||
it('should work with img with alternative text', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<img src="foo" alt="Foo eating a sandwich."> | ||
<img src="foo" [attr.alt]="altText"> | ||
<img src="foo" [attr.alt]="'Alt Text'"> | ||
<img src="foo" alt=""> | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertSuccess(ruleName, source); | ||
}); | ||
|
||
it('should work with object having label, title or meaningful description', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<object aria-label="foo"> | ||
<object aria-labelledby="id1"> | ||
<object>Meaningful description</object> | ||
<object title="An object"> | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertSuccess(ruleName, source); | ||
}); | ||
|
||
it('should work with area having label or alternate text', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<area aria-label="foo"></area> | ||
<area aria-labelledby="id1"></area> | ||
<area alt="This is descriptive!"></area> | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertSuccess(ruleName, source); | ||
}); | ||
|
||
it('should work with input type image having alterate text and labels', () => { | ||
const source = ` | ||
@Component({ | ||
template: \` | ||
<input type="text"> | ||
<input type="image" alt="This is descriptive!"> | ||
<input type="image" aria-label="foo"> | ||
<input type="image" aria-labelledby="id1"> | ||
\` | ||
}) | ||
class Bar {} | ||
`; | ||
assertSuccess(ruleName, source); | ||
}); | ||
}); | ||
}); |