diff --git a/src/index.ts b/src/index.ts index 97a70852d..d20b4449e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export { Rule as TemplateAccessibilityTabindexNoPositiveRule } from './templateA export { Rule as TemplateAccessibilityLabelForVisitor } from './templateAccessibilityLabelForRule'; export { Rule as TemplateAccessibilityValidAriaRule } from './templateAccessibilityValidAriaRule'; export { Rule as TemplatesAccessibilityAnchorContentRule } from './templateAccessibilityAnchorContentRule'; +export { Rule as TemplateAccessibilityAltTextRule } from './templateAccessibilityAltTextRule'; export { Rule as TemplatesNoNegatedAsync } from './templatesNoNegatedAsyncRule'; export { Rule as TemplateNoAutofocusRule } from './templateNoAutofocusRule'; export { Rule as TrackByFunctionRule } from './trackByFunctionRule'; diff --git a/src/templateAccessibilityAltTextRule.ts b/src/templateAccessibilityAltTextRule.ts new file mode 100644 index 000000000..c0374a15a --- /dev/null +++ b/src/templateAccessibilityAltTextRule.ts @@ -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 = (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') || {}; + const inputType: BoundElementPropertyAst = element.inputs.find(input => input.name === 'type') || {}; + const type = attrType.value || inputType.value; + if (type !== 'image') { + return true; + } + + return this.area(element); + } +} diff --git a/test/templateAccessibilityAltTextRule.spec.ts b/test/templateAccessibilityAltTextRule.spec.ts new file mode 100644 index 000000000..90ae18c98 --- /dev/null +++ b/test/templateAccessibilityAltTextRule.spec.ts @@ -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: \` + + ~~~~~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: getFailureMessage('img'), + ruleName, + source + }); + }); + + it('should fail when object does not have alt text or labels', () => { + const source = ` + @Component({ + template: \` + + ~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: getFailureMessage('object'), + ruleName, + source + }); + }); + + it('should fail when area does not have alt or label text', () => { + const source = ` + @Component({ + template: \` + + ~~~~~~ + \` + }) + 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: \` + + ~~~~~~~~~~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: getFailureMessage('input'), + ruleName, + source + }); + }); + }); + + describe('success', () => { + it('should work with img with alternative text', () => { + const source = ` + @Component({ + template: \` + Foo eating a sandwich. + + + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should work with object having label, title or meaningful description', () => { + const source = ` + @Component({ + template: \` + + + Meaningful description + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should work with area having label or alternate text', () => { + const source = ` + @Component({ + template: \` + + + This is descriptive! + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should work with input type image having alterate text and labels', () => { + const source = ` + @Component({ + template: \` + + + + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + }); +});