From f4b560d0cf842c62ecd0e05339d33df96681fd8c Mon Sep 17 00:00:00 2001 From: Zama Khan Mohammed Date: Fri, 8 Feb 2019 17:47:04 -0600 Subject: [PATCH] feat(rule): use valid aria rules --- package.json | 2 + src/index.ts | 4 ++ src/templateAccessibilityValidAriaRule.ts | 66 +++++++++++++++++++ src/util/getSuggestion.ts | 17 +++++ ...templateAccessibilityValidAriaRule.spec.ts | 59 +++++++++++++++++ test/util/getSuggestion.spec.ts | 15 +++++ yarn.lock | 19 ++++++ 7 files changed, 182 insertions(+) create mode 100644 src/templateAccessibilityValidAriaRule.ts create mode 100644 src/util/getSuggestion.ts create mode 100644 test/templateAccessibilityValidAriaRule.spec.ts create mode 100644 test/util/getSuggestion.spec.ts diff --git a/package.json b/package.json index ede87a36a..148e93656 100644 --- a/package.json +++ b/package.json @@ -101,8 +101,10 @@ }, "dependencies": { "app-root-path": "^2.1.0", + "aria-query": "^3.0.0", "css-selector-tokenizer": "^0.7.0", "cssauron": "^1.4.0", + "damerau-levenshtein": "^1.0.4", "semver-dsl": "^1.0.1", "source-map": "^0.5.7", "sprintf-js": "^1.1.1" diff --git a/src/index.ts b/src/index.ts index 4fdf11491..1b896d973 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,8 +28,12 @@ export { Rule as PreferInlineDecorator } from './preferInlineDecoratorRule'; export { Rule as PreferOutputReadonlyRule } from './preferOutputReadonlyRule'; export { Rule as TemplateConditionalComplexityRule } from './templateConditionalComplexityRule'; export { Rule as TemplateCyclomaticComplexityRule } from './templateCyclomaticComplexityRule'; +<<<<<<< HEAD export { Rule as TemplateAccessibilityTabindexNoPositiveRule } from './templateAccessibilityTabindexNoPositiveRule'; export { Rule as TemplateAccessibilityLabelForVisitor } from './templateAccessibilityLabelForRule'; +======= +export { Rule as TemplateAccessibilityValidAriaRule } from './templateAccessibilityValidAriaRule'; +>>>>>>> feat(rule): use valid aria rules export { Rule as TemplatesNoNegatedAsync } from './templatesNoNegatedAsyncRule'; export { Rule as TemplateNoAutofocusRule } from './templateNoAutofocusRule'; export { Rule as TrackByFunctionRule } from './trackByFunctionRule'; diff --git a/src/templateAccessibilityValidAriaRule.ts b/src/templateAccessibilityValidAriaRule.ts new file mode 100644 index 000000000..d10190cc9 --- /dev/null +++ b/src/templateAccessibilityValidAriaRule.ts @@ -0,0 +1,66 @@ +import { AttrAst, BoundElementPropertyAst } 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'; +import { aria } from 'aria-query'; +import { getSuggestion } from './util/getSuggestion'; + +const ariaAttributes: string[] = [...(Array.from(aria.keys()))]; + +export class Rule extends Rules.AbstractRule { + static readonly metadata: IRuleMetadata = { + description: 'Ensures that the correct ARIA attributes are used', + options: null, + optionsDescription: 'Not configurable.', + rationale: 'Elements should not use invalid aria attributes (AX_ARIA_11)', + ruleName: 'template-accessibility-valid-aria', + type: 'functionality', + typescriptOnly: true + }; + + apply(sourceFile: SourceFile): RuleFailure[] { + return this.applyWithWalker( + new NgWalker(sourceFile, this.getOptions(), { + templateVisitorCtrl: TemplateAccessibilityValidAriaVisitor + }) + ); + } +} + +export const getFailureMessage = (name: string): string => { + const suggestions = getSuggestion(name, ariaAttributes); + const message = `${name}: This attribute is an invalid ARIA attribute.`; + + if (suggestions.length > 0) { + return `${message} Did you mean to use ${suggestions}?`; + } + + return message; +}; + +class TemplateAccessibilityValidAriaVisitor extends BasicTemplateAstVisitor { + visitAttr(ast: AttrAst, context: any) { + this.validateAttribute(ast); + super.visitAttr(ast, context); + } + + visitElementProperty(ast: BoundElementPropertyAst) { + this.validateAttribute(ast); + super.visitElementProperty(ast, context); + } + + private validateAttribute(ast: AttrAst | BoundElementPropertyAst) { + if (ast.name.indexOf('aria-') !== 0) return; + const isValid = ariaAttributes.indexOf(ast.name) > -1; + if (isValid) return; + + const { + sourceSpan: { + end: { offset: endOffset }, + start: { offset: startOffset } + } + } = ast; + this.addFailureFromStartToEnd(startOffset, endOffset, getFailureMessage(ast.name)); + } +} diff --git a/src/util/getSuggestion.ts b/src/util/getSuggestion.ts new file mode 100644 index 000000000..3eda7a08a --- /dev/null +++ b/src/util/getSuggestion.ts @@ -0,0 +1,17 @@ +import * as editDistance from 'damerau-levenshtein'; + +const THRESHOLD = 2; + +export const getSuggestion = (word: string, dictionary: string[] = [], limit = 2) => { + const distances = dictionary.reduce((suggestions, dictionaryWord: string) => { + const distance = editDistance(word.toUpperCase(), dictionaryWord.toUpperCase()); + const { steps } = distance; + suggestions[dictionaryWord] = steps; + return suggestions; + }, {}); + + return Object.keys(distances) + .filter(suggestion => distances[suggestion] <= THRESHOLD) + .sort((a, b) => distances[a] - distances[b]) + .slice(0, limit); +}; diff --git a/test/templateAccessibilityValidAriaRule.spec.ts b/test/templateAccessibilityValidAriaRule.spec.ts new file mode 100644 index 000000000..e793a0616 --- /dev/null +++ b/test/templateAccessibilityValidAriaRule.spec.ts @@ -0,0 +1,59 @@ +import { getFailureMessage, Rule } from '../src/templateAccessibilityValidAriaRule'; +import { assertAnnotated, assertSuccess } from './testHelper'; + +const { + metadata: { ruleName } +} = Rule; + +describe(ruleName, () => { + describe('failure', () => { + it('should fail when aria attributes are misspelled or if they does not exist', () => { + const source = ` + @Component({ + template: \` + + ~~~~~~~~~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: getFailureMessage('aria-labelby'), + ruleName, + source + }); + }); + + it('should fail when using wrong aria attributes with inputs', () => { + const source = ` + @Component({ + template: \` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + \` + }) + class Bar {} + `; + assertAnnotated({ + message: getFailureMessage('aria-labelby'), + ruleName, + source + }); + }); + }); + + describe('success', () => { + it('should work when the aria attributes are correctly named', () => { + const source = ` + @Component({ + template: \` + + + \` + }) + class Bar {} + `; + assertSuccess(ruleName, source); + }); + }); +}); diff --git a/test/util/getSuggestion.spec.ts b/test/util/getSuggestion.spec.ts new file mode 100644 index 000000000..c27ed0f80 --- /dev/null +++ b/test/util/getSuggestion.spec.ts @@ -0,0 +1,15 @@ +import { expect } from 'chai'; + +import { getSuggestion } from '../../src/util/getSuggestion'; + +describe('getSuggestion', () => { + it('should suggest based on dictionary', () => { + const suggestion = getSuggestion('wordd', ['word', 'words', 'wording'], 2); + expect(suggestion).to.deep.equals(['word', 'words']); + }); + + it("should not suggest if the dictionary doesn't have any similar words", () => { + const suggestion = getSuggestion('ink', ['word', 'words', 'wording'], 2); + expect(suggestion).to.deep.equals([]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1f97e47cf..31cb05ec5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -269,6 +269,13 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +aria-query@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" + dependencies: + ast-types-flow "0.0.7" + commander "^2.11.0" + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -324,6 +331,10 @@ assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" +ast-types-flow@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" + async-foreach@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" @@ -673,6 +684,10 @@ commander@2.15.1, commander@^2.12.1, commander@^2.14.1: version "2.15.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" +commander@^2.11.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" + commander@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" @@ -944,6 +959,10 @@ d@^0.1.1, d@~0.1.1: dependencies: es5-ext "~0.10.2" +damerau-levenshtein@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" + dargs@^4.0.1: version "4.1.0" resolved "https://registry.yarnpkg.com/dargs/-/dargs-4.1.0.tgz#03a9dbb4b5c2f139bf14ae53f0b8a2a6a86f4e17"