Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
102 changes: 102 additions & 0 deletions src/templateAccessibilityAltTextRule.ts
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);
}
}
139 changes: 139 additions & 0 deletions test/templateAccessibilityAltTextRule.spec.ts
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);
});
});
});