Skip to content

Commit

Permalink
feat(rule): accessibility rule for alt text (#741)
Browse files Browse the repository at this point in the history
  • Loading branch information
mohammedzamakhan authored and mgechev committed Feb 13, 2019
1 parent 762f67f commit 0815ec5
Show file tree
Hide file tree
Showing 3 changed files with 242 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/index.ts
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
@@ -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
@@ -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);
});
});
});

0 comments on commit 0815ec5

Please sign in to comment.