Skip to content
This repository has been archived by the owner on Jul 15, 2023. It is now read-only.

Commit

Permalink
[Issue #245] new rule: react-a11y-proptypes
Browse files Browse the repository at this point in the history
closes #245
closes #241
  • Loading branch information
t-ligu authored and HamletDRC committed Sep 15, 2016
1 parent b15bd1c commit 39bad10
Show file tree
Hide file tree
Showing 22 changed files with 691 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 #. <br/>References:<br/>[WCAG Rule 38: Link text should be as least four 4 characters long](http://oaa-accessibility.org/wcag20/rule/38/)<br/>[WCAG Rule 39: Links with the same HREF should have the same link text](http://oaa-accessibility.org/wcag20/rule/39/)<br/>[WCAG Rule 41: Links that point to different HREFs should have different link text](http://oaa-accessibility.org/wcag20/rule/41/)<br/>[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/)<br/> | 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 the type of aria state and property values are correct. | 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. <br/>References:<br/>[ARIA Definition of Roles](https://www.w3.org/TR/wai-aria/roles#role_definitions)<br/>[WCAG Rule 90: Required properties and states should be defined](http://oaa-accessibility.org/wcag20/rule/90/)<br/>[WCAG Rule 91: Required properties and states must not be empty](http://oaa-accessibility.org/wcag20/rule/91/)<br/>| 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 `<a href='hrefValue' />`, 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). <br/>References: <br/>* [ARIA attributes can only be used with certain roles](http://oaa-accessibility.org/wcag20/rule/87/)<br/>* [Check aria properties and states for valid roles and properties](http://oaa-accessibility.org/wcag20/rule/84/)<br/>* [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:<br/>* [WCAG Rule 92: Role value must be valid](http://oaa-accessibility.org/wcag20/rule/92/)| 2.0.11
Expand Down
219 changes: 219 additions & 0 deletions src/reactA11yProptypesRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Enforce ARIA state and property values are valid.
*/

import * as ts from 'typescript';
import * as Lint from 'tslint/lib/lint';

import { AstUtils } from './utils/AstUtils';
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 no aria-* attribute, skip it.
if (!aria[propName]) {
return;
}

const allowUndefined: boolean = aria[propName].allowUndefined != null
? 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': return this.isInteger(propValueExpression);
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 (AstUtils.isUndefined(expression)) {
return true;
} else if (isNullKeyword(expression)) {
return true;
}
}

return false;
}

/**
* For this case <div prop={ x + 1 } />
* 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) && !AstUtils.isConstant(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 isInteger(node: ts.Expression): boolean {
if (isStringLiteral(node)) {
const value: number = Number(node.text);

return !isNaN(value) && Math.round(value) === value;
} else if (isJsxExpression(node)) {
const expression: ts.Expression = node.expression;

if (isStringLiteral(expression)) {
const value: number = Number(expression.text);

return !isNaN(value) && Math.round(value) === value;
} else if (isNumericLiteral(expression)) {
const value: number = Number(expression.text);

return Math.round(value) === value;
}

return false;
}

return false;
}

private isString(node: ts.Expression): boolean {
return isStringLiteral(node) || (isJsxExpression(node) && isStringLiteral(node.expression));
}
}
10 changes: 10 additions & 0 deletions src/utils/TypeGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/boolean.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React = require('react');

const a = <div aria-hidden='yes' />
const b = <div aria-hidden='no' />
const c = <div aria-hidden={ 0 } />
const d = <div aria-hidden={ 1 } />
9 changes: 9 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/integer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React = require('react');

const a = <div aria-level='yes' />
const b = <div aria-level='no' />
const c = <div aria-level={ true } />
const d = <div aria-level={ 'false' } />
const e = <div aria-level={ 1.1 } />
const f = <div aria-level='1.1' />
const g = <div aria-level={ '1.1' } />
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import React = require('react');

const a = <div aria-label />
const b = <div aria-label={ undefined } />
6 changes: 6 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/number.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React = require('react');

const a = <div aria-valuemax='yes' />
const b = <div aria-valuemax='no' />
const c = <div aria-valuemax={ true } />
const d = <div aria-valuemax={ 'false' } />
5 changes: 5 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/string.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React = require('react');

const a = <div aria-label={ true } />
const b = <div aria-label={ false } />
const c = <div aria-label={ 1234 } />
7 changes: 7 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/token.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React = require('react');

const a = <div aria-sort='' />
const b = <div aria-sort={ true } />
const c = <div aria-sort={ 123 } />
const d = <div aria-sort={ 'false' } />
const e = <div aria-sort='ascending descending' />
8 changes: 8 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/tokenlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React = require('react');

const a = <div aria-relevant='' />
const b = <div aria-relevant='foobar' />
const c = <div aria-relevant={ true } />
const d = <div aria-relevant={ 'false' } />
const e = <div aria-relevant='additions removalss' />
const f = <div aria-relevant='additions removalss ' />
6 changes: 6 additions & 0 deletions test-data/ReactA11yProptypes/FailingTestInputs/tristate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React = require('react');

const a = <div aria-checked={ undefined } />
const b = <div aria-checked='yes' />
const c = <div aria-checked='no' />
const d = <div aria-checked={ 1234 } />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React = require('react');

const a = <div aria-expanded />
const b = <div aria-grabbed />
const c = <div aria-selected />
const d = <div aria-expanded={ undefined } />
const e = <div aria-grabbed={ null } />
8 changes: 8 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/boolean.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React = require('react');

const a = <div aria-hidden='true' />
const b = <div aria-hidden='false' />
const c = <div aria-hidden={ 'true' } />
const d = <div aria-hidden={ 'false' } />
const e = <div aria-hidden={ true } />
const f = <div aria-hidden={ false } />
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React = require('react');

// We can't get the type of those tests until run time,
// so those tests will pass.
const num: number = 1;
const error: boolean = false;
const a = <div aria-hidden={ !true } />
const b = <div aria-hidden={ `${true}` } />
const c = <div aria-hidden={ 'tr' + 'ue' } />
const d = <div aria-hidden={ +123 } />
const e = <div aria-hidden={ -123 } />
const f = <div aria-hidden={ num } />
const g = <div aria-hidden={ this.props.hidden } />
const h = <div aria-hidden={ <div /> } />
const i = <input aria-hidden={ error ? 'true' : 'false' } />
9 changes: 9 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/integer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React = require('react');

const a = <div aria-level='123' />
const b = <div aria-level='+123' />
const c = <div aria-level='-123' />
const d = <div aria-level={ '123' } />
const e = <div aria-level={ '+123' } />
const f = <div aria-level={ '-123' } />
const g = <div aria-level={ 123 } />
10 changes: 10 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/number.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React = require('react');

const a = <div aria-valuemax='1.23' />
const b = <div aria-valuemax='+123' />
const c = <div aria-valuemax='-1.23' />
const d = <div aria-valuemax={ '1.23' } />
const e = <div aria-valuemax={ '+1.23' } />
const f = <div aria-valuemax={ '-1.23' } />
const g = <div aria-valuemax={ 123 } />
const h = <div aria-valuemax={ -1.23 } />
4 changes: 4 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/string.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import React = require('react');

const a = <div aria-label='Close' />
const b = <div aria-label={ 'Close' } />
11 changes: 11 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/token.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React = require('react');

const a = <div aria-sort='ascending' />
const b = <div aria-sort='ASCENDING' />
const c = <div aria-sort={ 'ascending' } />
const d = <div aria-sort='descending' />
const e = <div aria-sort={ 'descending' } />
const f = <div aria-sort='none' />
const g = <div aria-sort={ 'none' } />
const h = <div aria-sort='other' />
const i = <div aria-sort={ 'other' } />
10 changes: 10 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/tokenlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React = require('react');

const a = <div aria-relevant='additions' />
const b = <div aria-relevant={ 'additions' } />
const c = <div aria-relevant='additions removals' />
const d = <div aria-relevant='additions additions' />
const e = <div aria-relevant='additions removals text' />
const f = <div aria-relevant={ 'additions removals text' } />
const g = <div aria-relevant='additions removals text all' />
const h = <div aria-relevant={ 'additions removals text all' } />
8 changes: 8 additions & 0 deletions test-data/ReactA11yProptypes/PassingTestInputs/tristate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React = require('react');

const a = <div aria-checked='true' />
const b = <div aria-checked={ 'false' } />
const c = <div aria-checked='mixed' />
const d = <div aria-checked={ 'mixed' } />
const e = <div aria-checked={ true } />
const f = <div aria-checked={ false } />
Loading

0 comments on commit 39bad10

Please sign in to comment.