diff --git a/src/index.ts b/src/index.ts index 1e651feb1..be94d9bae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export { Rule as TemplateAccessibilityTableScopeRule } from './templateAccessibi export { Rule as TemplateNoDistractingElementsRule } from './templateNoDistractingElementsRule'; export { Rule as TemplatesNoNegatedAsync } from './templatesNoNegatedAsyncRule'; export { Rule as TemplateNoAutofocusRule } from './templateNoAutofocusRule'; +export { Rule as TemplateMouseEventsHaveKeyEventsRule } from './templateMouseEventsHaveKeyEventsRule'; export { Rule as TrackByFunctionRule } from './trackByFunctionRule'; export { Rule as UseHostPropertyDecoratorRule } from './useHostPropertyDecoratorRule'; export { Rule as UseInputPropertyDecoratorRule } from './useInputPropertyDecoratorRule'; diff --git a/src/templateMouseEventsHaveKeyEventsRule.ts b/src/templateMouseEventsHaveKeyEventsRule.ts new file mode 100644 index 000000000..0579488a8 --- /dev/null +++ b/src/templateMouseEventsHaveKeyEventsRule.ts @@ -0,0 +1,61 @@ +import { ElementAst } from '@angular/compiler'; +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: 'Ensures that the Mouse Events mouseover and mouseout are accompanied with Key Events focus and blur', + options: null, + optionsDescription: 'Not configurable.', + rationale: 'Keyboard is important for users with physical disabilities who cannot use mouse.', + ruleName: 'template-mouse-events-have-key-events', + type: 'functionality', + typescriptOnly: true + }; + + static readonly FAILURE_STRING_MOUSE_OVER = 'mouseover must be accompanied by focus event for accessibility'; + static readonly FAILURE_STRING_MOUSE_OUT = 'mouseout must be accompanied by blur event for accessibility'; + + apply(sourceFile: SourceFile): RuleFailure[] { + return this.applyWithWalker( + new NgWalker(sourceFile, this.getOptions(), { + templateVisitorCtrl: TemplateMouseEventsHaveKeyEventsVisitor + }) + ); + } +} + +class TemplateMouseEventsHaveKeyEventsVisitor extends BasicTemplateAstVisitor { + visitElement(el: ElementAst, context: any) { + this.validateElement(el); + super.visitElement(el, context); + } + + private validateElement(el: ElementAst): void { + const hasMouseOver = el.outputs.some(output => output.name === 'mouseover'); + const hasMouseOut = el.outputs.some(output => output.name === 'mouseout'); + const hasFocus = el.outputs.some(output => output.name === 'focus'); + const hasBlur = el.outputs.some(output => output.name === 'blur'); + + if (!hasMouseOver && !hasMouseOut) { + return; + } + + const { + sourceSpan: { + end: { offset: endOffset }, + start: { offset: startOffset } + } + } = el; + + if (hasMouseOver && !hasFocus) { + this.addFailureFromStartToEnd(startOffset, endOffset, Rule.FAILURE_STRING_MOUSE_OVER); + } + + if (hasMouseOut && !hasBlur) { + this.addFailureFromStartToEnd(startOffset, endOffset, Rule.FAILURE_STRING_MOUSE_OUT); + } + } +} diff --git a/test/templateMouseEventsHaveKeyEventsRule.spec.ts b/test/templateMouseEventsHaveKeyEventsRule.spec.ts new file mode 100644 index 000000000..21f1d6181 --- /dev/null +++ b/test/templateMouseEventsHaveKeyEventsRule.spec.ts @@ -0,0 +1,60 @@ +import { Rule } from '../src/templateMouseEventsHaveKeyEventsRule'; +import { assertAnnotated, assertSuccess } from './testHelper'; + +const { + FAILURE_STRING_MOUSE_OUT, + FAILURE_STRING_MOUSE_OVER, + metadata: { ruleName } +} = Rule; + +describe(ruleName, () => { + describe('failure', () => { + it('should fail when mouseover is not accompanied with focus', () => { + const source = ` + @Component({ + template: \` +
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING_MOUSE_OVER, + ruleName, + source + }); + }); + + it('should fail when mouseout is not accompanied with blur', () => { + const source = ` + @Component({ + template: \` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: FAILURE_STRING_MOUSE_OUT, + ruleName, + source + }); + }); + }); + + describe('success', () => { + it('should work find when mouse events are associated with key events', () => { + const source = ` + @Component({ + template: \` + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + }); +});