From 5ac341dec632dbe7c45e3e921d54141cdd3f8dbd Mon Sep 17 00:00:00 2001 From: Liubin Guo Date: Mon, 12 Sep 2016 20:18:50 +0800 Subject: [PATCH 1/3] Add react-a11y-proptypes rule. --- README.md | 1 + src/reactA11yProptypesRule.ts | 197 +++++++++++ src/utils/TypeGuard.ts | 10 + .../FailingTestInputs/boolean.tsx | 6 + .../FailingTestInputs/integer.tsx | 6 + .../FailingTestInputs/notAllowUndefined.tsx | 4 + .../FailingTestInputs/number.tsx | 6 + .../FailingTestInputs/string.tsx | 5 + .../FailingTestInputs/token.tsx | 7 + .../FailingTestInputs/tokenlist.tsx | 8 + .../FailingTestInputs/tristate.tsx | 6 + .../PassingTestInputs/allowUndefined.tsx | 7 + .../PassingTestInputs/boolean.tsx | 8 + .../canNotCheckUntilRunTime.tsx | 15 + .../PassingTestInputs/integer.tsx | 9 + .../PassingTestInputs/number.tsx | 9 + .../PassingTestInputs/string.tsx | 4 + .../PassingTestInputs/token.tsx | 11 + .../PassingTestInputs/tokenlist.tsx | 10 + .../PassingTestInputs/tristate.tsx | 8 + tests/ReactA11yProptypesRuleTest.ts | 309 ++++++++++++++++++ 21 files changed, 646 insertions(+) create mode 100644 src/reactA11yProptypesRule.ts create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/boolean.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/integer.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/notAllowUndefined.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/number.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/string.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/token.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/tokenlist.tsx create mode 100644 test-data/ReactA11yProptypes/FailingTestInputs/tristate.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/allowUndefined.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/boolean.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/canNotCheckUntilRunTime.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/integer.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/number.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/string.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/token.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/tokenlist.tsx create mode 100644 test-data/ReactA11yProptypes/PassingTestInputs/tristate.tsx create mode 100644 tests/ReactA11yProptypesRuleTest.ts diff --git a/README.md b/README.md index 65c2f8835..cb99af836 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Rule Name | Description | Since `react-a11y-anchors` | For accessibility of your website, anchor element link text should be at least 4 characters long. Links with the same HREF should have the same link text. Links that point to different HREFs should have different link text. Links with images and text content, the alt attribute should be unique to the text content or empty. An an anchor element's href prop value must not be just #.
References:
[WCAG Rule 38: Link text should be as least four 4 characters long](http://oaa-accessibility.org/wcag20/rule/38/)
[WCAG Rule 39: Links with the same HREF should have the same link text](http://oaa-accessibility.org/wcag20/rule/39/)
[WCAG Rule 41: Links that point to different HREFs should have different link text](http://oaa-accessibility.org/wcag20/rule/41/)
[WCAG Rule 43: Links with images and text content, the alt attribute should be unique to the text content or empty](http://oaa-accessibility.org/wcag20/rule/43/)
| 2.0.11 `react-a11y-meta` | For accessibility of your website, HTML meta elements must not have http-equiv="refresh". | 2.0.11 `react-a11y-props` | For accessibility of your website, enforce all `aria-*` attributes are valid. Elements cannot use an invalid `aria-*` attribute. This rule will fail if it finds an `aria-*` attribute that is not listed in [WAI-ARIA states and properties](https://www.w3.org/TR/wai-aria/states_and_properties#state_prop_def). | 2.0.11 +`react-a11y-proptypes` | For accessibility of your website, enforce aria state and property values are valid. | 2.0.11 `react-a11y-role-has-required-aria-props` | For accessibility of your website, elements with aria roles must have all required attributes according to the role.
References:
[ARIA Definition of Roles](https://www.w3.org/TR/wai-aria/roles#role_definitions)
[WCAG Rule 90: Required properties and states should be defined](http://oaa-accessibility.org/wcag20/rule/90/)
[WCAG Rule 91: Required properties and states must not be empty](http://oaa-accessibility.org/wcag20/rule/91/)
| 2.0.11 `react-a11y-role-supports-aria-props` | For accessibility of your website, enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. Many aria attributes (states and properties) can only be used on elements with particular roles. Some elements have implicit roles, such as ``, which will be resolved to `role='link'`. A reference for the implicit roles can be found at [Default Implicit ARIA Semantics](https://www.w3.org/TR/html-aria/#sec-strong-native-semantics).
References:
* [ARIA attributes can only be used with certain roles](http://oaa-accessibility.org/wcag20/rule/87/)
* [Check aria properties and states for valid roles and properties](http://oaa-accessibility.org/wcag20/rule/84/)
* [Check that 'ARIA-' attributes are valid properties and states](http://oaa-accessibility.org/wcag20/rule/93/)| 2.0.11 `react-a11y-role` | For accessibility of your website, elements with aria roles must use a **valid**, **non-abstract** aria role. A reference to role defintions can be found at [WAI-ARIA roles](https://www.w3.org/TR/wai-aria/roles#role_definitions). References:
* [WCAG Rule 92: Role value must be valid](http://oaa-accessibility.org/wcag20/rule/92/)| 2.0.11 diff --git a/src/reactA11yProptypesRule.ts b/src/reactA11yProptypesRule.ts new file mode 100644 index 000000000..58452f549 --- /dev/null +++ b/src/reactA11yProptypesRule.ts @@ -0,0 +1,197 @@ +/** + * Enforce ARIA state and property values are valid. + */ + +import * as ts from 'typescript'; +import * as Lint from 'tslint/lib/lint'; + +import { ExtendedMetadata } from './utils/ExtendedMetadata'; +import { getPropName, getStringLiteral } from './utils/JsxAttribute'; +import { IAria } from './utils/attributes/IAria'; +import { + isStringLiteral, + isNumericLiteral, + isJsxExpression, + isFalseKeyword, + isTrueKeyword, + isNullKeyword +} from './utils/TypeGuard'; + +// tslint:disable-next-line:no-require-imports no-var-requires +const aria: { [attributeName: string]: IAria } = require('./utils/attributes/ariaSchema.json'); + +export function getFailureString(propName: string, expectedType: string, permittedValues: string[]): string { + switch (expectedType) { + case 'tristate': + return `The value for ${propName} must be a boolean or the string 'mixed'.`; + case 'token': + return `The value for ${propName} must be a single token from the following: ${permittedValues}.`; + case 'tokenlist': + return `The value for ${propName} must be a list of one or more tokens from the following: ${permittedValues}.`; + case 'boolean': + case 'string': + case 'integer': + case 'number': + default: // tslint:disable-line:no-switch-case-fall-through + return `The value for ${propName} must be a ${expectedType}.`; + } +} + +export class Rule extends Lint.Rules.AbstractRule { + public static metadata: ExtendedMetadata = { + ruleName: 'react-a11y-proptypes', + type: 'maintainability', + description: 'Enforce ARIA state and property values are valid.', + options: null, + issueClass: 'Non-SDL', + issueType: 'Warning', + severity: 'Important', + level: 'Opportunity for Excellence', + group: 'Accessibility' + }; + + public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { + return sourceFile.languageVariant === ts.LanguageVariant.JSX + ? this.applyWithWalker(new ReactA11yProptypesWalker(sourceFile, this.getOptions())) + : []; + } +} + +class ReactA11yProptypesWalker extends Lint.RuleWalker { + public visitJsxAttribute(node: ts.JsxAttribute): void { + const propName: string = getPropName(node).toLowerCase(); + + // if there is not a aria-* attribute, don't check it. + if (!aria[propName]) { + return; + } + + const allowUndefined: boolean = aria[propName].allowUndefined || false; + const expectedType: string = aria[propName].type; + const permittedValues: string[] = aria[propName].values; + const propValue: string = getStringLiteral(node); + + if (this.isUndefined(node.initializer)) { + if (!allowUndefined) { + this.addFailure(this.createFailure( + node.getStart(), + node.getWidth(), + getFailureString(propName, expectedType, permittedValues) + )); + } + + return; + } else if (this.isComplexType(node.initializer)) { + return; + } + + if (!this.validityCheck(node.initializer, propValue, expectedType, permittedValues)) { + this.addFailure(this.createFailure( + node.getStart(), + node.getWidth(), + getFailureString(propName, expectedType, permittedValues) + )); + } + } + + private validityCheck( + propValueExpression: ts.Expression, + propValue: string, + expectedType: string, + permittedValues: string[] + ): boolean { + switch (expectedType) { + case 'boolean': return this.isBoolean(propValueExpression); + case 'tristate': return this.isBoolean(propValueExpression) || this.isMixed(propValueExpression); + case 'integer': + case 'number': return this.isNumber(propValueExpression); + case 'string': return this.isString(propValueExpression); + case 'token': + return this.isString(propValueExpression) && permittedValues.indexOf(propValue.toLowerCase()) > -1; + case 'tokenlist': + return this.isString(propValueExpression) && + propValue.split(' ').every(token => permittedValues.indexOf(token.toLowerCase()) > -1); + default: + return false; + } + } + + private isUndefined(node: ts.Expression): boolean { + if (!node) { + return true; + } else if (isJsxExpression(node)) { + const expression: ts.Expression = node.expression; + if (!expression) { + return true; + } else if (expression.kind === ts.SyntaxKind.Identifier) { + return expression.getText() === 'undefined'; + } else if (isNullKeyword(expression)) { + return true; + } + } + + return false; + } + + /** + * For this case
+ * we can't check the type of atrribute's expression until running time. + */ + private isComplexType(node: ts.Expression): boolean { + return !this.isUndefined(node) && isJsxExpression(node) && + !isStringLiteral(node.expression) && !isNumericLiteral(node.expression) && + !isTrueKeyword(node.expression) && !isFalseKeyword(node.expression); + } + + private isBoolean(node: ts.Expression): boolean { + if (isStringLiteral(node)) { + const propValue: string = node.text.toLowerCase(); + + return propValue === 'true' || propValue === 'false'; + } else if (isJsxExpression(node)) { + const expression: ts.Expression = node.expression; + + if (isStringLiteral(expression)) { + const propValue: string = expression.text.toLowerCase(); + + return propValue === 'true' || propValue === 'false'; + } else { + return isFalseKeyword(expression) || isTrueKeyword(expression); + } + } + + return false; + } + + private isMixed(node: ts.Expression): boolean { + if (isStringLiteral(node)) { + return node.text.toLowerCase() === 'mixed'; + } else if (isJsxExpression(node)) { + const expression: ts.Expression = node.expression; + + return isStringLiteral(expression) && expression.text.toLowerCase() === 'mixed'; + } + + return false; + } + + private isNumber(node: ts.Expression): boolean { + if (isStringLiteral(node)) { + return !isNaN(Number(node.text)); + } else if (isJsxExpression(node)) { + const expression: ts.Expression = node.expression; + + if (isStringLiteral(expression)) { + return !isNaN(Number(expression.text)); + } else { + return isNumericLiteral(expression); + } + } + + return false; + } + + private isString(node: ts.Expression): boolean { + return isStringLiteral(node) || (isJsxExpression(node) && isStringLiteral(node.expression)); + } +} diff --git a/src/utils/TypeGuard.ts b/src/utils/TypeGuard.ts index 1a37435f8..19094aa42 100644 --- a/src/utils/TypeGuard.ts +++ b/src/utils/TypeGuard.ts @@ -40,3 +40,13 @@ export function isJsxSelfClosingElement(node: ts.Node): node is ts.JsxSelfClosin export function isJsxOpeningElement(node: ts.Node): node is ts.JsxOpeningElement { return node && node.kind === ts.SyntaxKind.JsxOpeningElement; } + +export function isTrueKeyword(node: ts.Node): node is ts.LiteralExpression { + return node && node.kind === ts.SyntaxKind.TrueKeyword; +} +export function isFalseKeyword(node: ts.Node): node is ts.LiteralExpression { + return node && node.kind === ts.SyntaxKind.FalseKeyword; +} +export function isNullKeyword(node: ts.Node): node is ts.LiteralExpression { + return node && node.kind === ts.SyntaxKind.NullKeyword; +} diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/boolean.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/boolean.tsx new file mode 100644 index 000000000..b35b75dd0 --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/boolean.tsx @@ -0,0 +1,6 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/integer.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/integer.tsx new file mode 100644 index 000000000..dfed10936 --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/integer.tsx @@ -0,0 +1,6 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/notAllowUndefined.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/notAllowUndefined.tsx new file mode 100644 index 000000000..36fbed924 --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/notAllowUndefined.tsx @@ -0,0 +1,4 @@ +import React = require('react'); + +const a =
+const b =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/number.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/number.tsx new file mode 100644 index 000000000..7abe7aee0 --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/number.tsx @@ -0,0 +1,6 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/string.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/string.tsx new file mode 100644 index 000000000..edb52ee6f --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/string.tsx @@ -0,0 +1,5 @@ +import React = require('react'); + +const a =
+const b =
+const c =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/token.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/token.tsx new file mode 100644 index 000000000..7ecaeab1e --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/token.tsx @@ -0,0 +1,7 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
+const e =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/tokenlist.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/tokenlist.tsx new file mode 100644 index 000000000..9d2d61484 --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/tokenlist.tsx @@ -0,0 +1,8 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
+const e =
+const f =
diff --git a/test-data/ReactA11yProptypes/FailingTestInputs/tristate.tsx b/test-data/ReactA11yProptypes/FailingTestInputs/tristate.tsx new file mode 100644 index 000000000..c87a0c6b6 --- /dev/null +++ b/test-data/ReactA11yProptypes/FailingTestInputs/tristate.tsx @@ -0,0 +1,6 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
diff --git a/test-data/ReactA11yProptypes/PassingTestInputs/allowUndefined.tsx b/test-data/ReactA11yProptypes/PassingTestInputs/allowUndefined.tsx new file mode 100644 index 000000000..db0c2f9c7 --- /dev/null +++ b/test-data/ReactA11yProptypes/PassingTestInputs/allowUndefined.tsx @@ -0,0 +1,7 @@ +import React = require('react'); + +const a =
+const b =
+const c =
+const d =
+const e =
diff --git a/test-data/ReactA11yProptypes/PassingTestInputs/boolean.tsx b/test-data/ReactA11yProptypes/PassingTestInputs/boolean.tsx new file mode 100644 index 000000000..fb480ab11 --- /dev/null +++ b/test-data/ReactA11yProptypes/PassingTestInputs/boolean.tsx @@ -0,0 +1,8 @@ +import React = require('react'); + +const a =