diff --git a/src/index.ts b/src/index.ts index d391bb7c1..4fdf11491 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export { Rule as PreferOutputReadonlyRule } from './preferOutputReadonlyRule'; export { Rule as TemplateConditionalComplexityRule } from './templateConditionalComplexityRule'; export { Rule as TemplateCyclomaticComplexityRule } from './templateCyclomaticComplexityRule'; export { Rule as TemplateAccessibilityTabindexNoPositiveRule } from './templateAccessibilityTabindexNoPositiveRule'; +export { Rule as TemplateAccessibilityLabelForVisitor } from './templateAccessibilityLabelForRule'; export { Rule as TemplatesNoNegatedAsync } from './templatesNoNegatedAsyncRule'; export { Rule as TemplateNoAutofocusRule } from './templateNoAutofocusRule'; export { Rule as TrackByFunctionRule } from './trackByFunctionRule'; diff --git a/src/templateAccessibilityLabelForRule.ts b/src/templateAccessibilityLabelForRule.ts new file mode 100644 index 000000000..46a64c26d --- /dev/null +++ b/src/templateAccessibilityLabelForRule.ts @@ -0,0 +1,100 @@ +import { BoundDirectivePropertyAst, ElementAst } from '@angular/compiler'; +import { sprintf } from 'sprintf-js'; +import { IRuleMetadata, RuleFailure, Rules, Utils } from 'tslint/lib'; +import { SourceFile } from 'typescript/lib/typescript'; +import { NgWalker } from './angular/ngWalker'; +import { BasicTemplateAstVisitor } from './angular/templates/basicTemplateAstVisitor'; +import { mayContainChildComponent } from './util/mayContainChildComponent'; + +interface ILabelForOptions { + labelComponents: string[]; + labelAttributes: string[]; + controlComponents: string[]; +} +export class Rule extends Rules.AbstractRule { + static readonly metadata: IRuleMetadata = { + description: 'Checks if the label has associated for attribute or a form element', + optionExamples: [[true, { labelComponents: ['app-label'], labelAttributes: ['id'], controlComponents: ['app-input', 'app-select'] }]], + options: { + items: { + type: 'object', + properties: { + labelComponents: { + type: 'array', + items: { + type: 'string' + } + }, + labelAttributes: { + type: 'array', + items: { + type: 'string' + } + }, + controlComponents: { + type: 'array', + items: { + type: 'string' + } + } + } + }, + type: 'array' + }, + optionsDescription: 'Add custom label, label attribute and controls', + rationale: Utils.dedent` + The label tag should either have a for attribute or should have associated control. + This rule supports two ways, either the label component should explicitly have a for attribute or a control nested inside the label component + It also supports adding custom control component and custom label component support.`, + ruleName: 'template-accessibility-label-for', + type: 'functionality', + typescriptOnly: true + }; + + static readonly FAILURE_STRING = 'A form label must be associated with a control'; + static readonly FORM_ELEMENTS = ['input', 'select', 'textarea']; + + apply(sourceFile: SourceFile): RuleFailure[] { + return this.applyWithWalker( + new NgWalker(sourceFile, this.getOptions(), { + templateVisitorCtrl: TemplateAccessibilityLabelForVisitor + }) + ); + } +} + +class TemplateAccessibilityLabelForVisitor extends BasicTemplateAstVisitor { + visitElement(element: ElementAst, context: any) { + this.validateElement(element); + super.visitElement(element, context); + } + + private validateElement(element: ElementAst) { + let { labelAttributes, labelComponents, controlComponents }: ILabelForOptions = this.getOptions() || {}; + controlComponents = Rule.FORM_ELEMENTS.concat(controlComponents || []); + labelComponents = ['label'].concat(labelComponents || []); + labelAttributes = ['for'].concat(labelAttributes || []); + + if (labelComponents.indexOf(element.name) === -1) { + return; + } + const hasForAttr = element.attrs.some(attr => labelAttributes.indexOf(attr.name) !== -1); + const hasForInput = element.inputs.some(input => { + return labelAttributes.indexOf(input.name) !== -1; + }); + + const hasImplicitFormElement = controlComponents.some(component => mayContainChildComponent(element, component)); + + if (hasForAttr || hasForInput || hasImplicitFormElement) { + return; + } + const { + sourceSpan: { + end: { offset: endOffset }, + start: { offset: startOffset } + } + } = element; + + this.addFailureFromStartToEnd(startOffset, endOffset, Rule.FAILURE_STRING); + } +} diff --git a/src/util/mayContainChildComponent.ts b/src/util/mayContainChildComponent.ts new file mode 100644 index 000000000..d7f1be99e --- /dev/null +++ b/src/util/mayContainChildComponent.ts @@ -0,0 +1,22 @@ +import { ElementAst } from '@angular/compiler'; + +export function mayContainChildComponent(root: ElementAst, componentName: string): boolean { + function traverseChildren(node: ElementAst): boolean { + if (!node.children) { + return false; + } + if (node.children) { + for (let i = 0; i < node.children.length; i += 1) { + const childNode: ElementAst = node.children[i]; + if (childNode.name === componentName) { + return true; + } + if (traverseChildren(childNode)) { + return true; + } + } + } + return false; + } + return traverseChildren(root); +} diff --git a/test/templateAccessibilityLabelForRule.spec.ts b/test/templateAccessibilityLabelForRule.spec.ts new file mode 100644 index 000000000..5c6f42e3d --- /dev/null +++ b/test/templateAccessibilityLabelForRule.spec.ts @@ -0,0 +1,127 @@ +import { Rule } from '../src/templateAccessibilityLabelForRule'; +import { assertAnnotated, assertSuccess } from './testHelper'; + +const { + FAILURE_STRING, + metadata: { ruleName } +} = Rule; + +describe(ruleName, () => { + describe('failure', () => { + it("should fail when label doesn't have for attribute", () => { + const source = ` + @Component({ + template: \` + + ~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source + }); + }); + + it("should fail when custom label doesn't have label attribute", () => { + const source = ` + @Component({ + template: \` + + ~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING, + ruleName, + source, + options: { + labelComponents: ['app-label'], + labelAttributes: ['id'] + } + }); + }); + }); + + describe('success', () => { + it('should work when label has for attribute', () => { + const source = ` + @Component({ + template: \` + + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should work when label are associated implicitly', () => { + const source = ` + @Component({ + template: \` + + + + + + + + + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source, { + labelComponents: ['app-label'], + controlComponents: ['app-input'] + }); + }); + + it("should fail when label doesn't have for attribute", () => { + const source = ` + @Component({ + template: \` + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + + it('should work when custom label has label attribute', () => { + const source = ` + @Component({ + template: \` + + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source, { + labelComponents: ['app-label'], + labelAttributes: ['id'] + }); + }); + }); +});