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

Add react-a11y-proptypes rule. #241

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
223 changes: 223 additions & 0 deletions src/reactA11yProptypesRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* 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 no aria-* attribute, skip it.
if (!aria[propName]) {
return;
}

const allowUndefined: boolean = aria[propName].allowUndefined !== undefined
? 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 (expression.kind === ts.SyntaxKind.Identifier) {
return expression.getText() === 'undefined';
} 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) &&
!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 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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do they have type like ts.TrueKeyworkd? If not, ignore it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They haven't ts.TrueKeyWord. Thanks.

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');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is aria-label={ } kind of undefined?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expression { } is undefined for our function isUndefined(), but there have an error "error TS17000: JSX attributes must only be assigned a non-empty 'expression'".
Is is tslint error or other error ? I don't know. @ipip2005 @HamletDRC

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems Typescript error, then skip this test.

From: bounces+848413-bc81-supersingerman=[email protected] [mailto:bounces+848413-bc81-supersingerman=[email protected]] On Behalf Of t-ligu
Sent: Tuesday, September 13, 2016 4:49 PM
To: Microsoft/tslint-microsoft-contrib [email protected]
Cc: Liaoliang Ye [email protected]; Mention [email protected]
Subject: Re: [Microsoft/tslint-microsoft-contrib] Add react-a11y-proptypes rule. (#241)

In test-data/ReactA11yProptypes/FailingTestInputs/notAllowUndefined.tsx #241 (comment) :

@@ -0,0 +1,4 @@
+import React = require('react');

The expression { } is undefined for our function isUndefined(), but there have an error "error TS17000: JSX attributes must only be assigned a non-empty 'expression'".
Is is tslint error or other error ? I don't know. @ipip2005 https://github.com/ipip2005 @HamletDRC https://github.com/HamletDRC


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub https://github.com/Microsoft/tslint-microsoft-contrib/pull/241/files/218c0185370898b09b2a0370a2940269bb2a459e#r78519606 , or mute the thread https://github.com/notifications/unsubscribe-auth/AHWr9x6OsIUHEclq2gAh6arm4sPrqocvks5qpmN6gaJpZM4J6hy3 . https://github.com/notifications/beacon/AHWr91ZwF982eUHTQvUIEyjnSTNBrlZRks5qpmN6gaJpZM4J6hy3.gif


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