diff --git a/README.md b/README.md index 65c2f8835..60471a327 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Rule Name | Description | Since `prefer-type-cast` | Prefer the tradition type casts instead of the new 'as-cast' syntax. For example, prefer 'myVariable' instead of 'myVariable as string'. Rule ignores any file ending in .tsx. If you prefer the opposite and want to see the 'as type' casts, then enable the tslint rule named 'no-angle-bracket-type-assertion'| 2.0.4 `promise-must-complete` | When a Promise instance is created, then either the reject() or resolve() parameter must be called on it within all code branches in the scope. For more examples see the [feature request](https://github.com/Microsoft/tslint-microsoft-contrib/issues/34). | 1.0 `react-iframe-missing-sandbox` | React iframes must specify a sandbox attribute. If specified as an empty string, this attribute enables extra restrictions on the content that can appear in the inline frame. The value of the attribute can either be an empty string (all the restrictions are applied), or a space-separated list of tokens that lift particular restrictions. You many not use both allow-scripts and allow-same-origin at the same time, as that allows the embedded document to programmatically remove the sandbox attribute in some scenarios. | 2.0.10 +`react-a11y-aria-unsupported-elements` | For accessibility of your website, enforce that elements that do not support ARIA roles, states, and properties do not have those attributes. | 2.0.11 `react-a11y-img-has-alt` | Enforce that an `img` element contains the `alt` attribute or `role='presentation'` for decorative image. All images must have `alt` text to convey their purpose and meaning to **screen reader users**. Besides, the `alt` attribute specifies an alternate text for an image, if the image cannot be displayed. This rule accepts as a parameter a string array for tag names other than img to also check. For example, if you use a custom tag named 'Image' then configure the rule with: `[true, ['Image']]`
References:
[Web Content Accessibility Guidelines 1.0](https://www.w3.org/TR/WCAG10/wai-pageauth.html#tech-text-equivalent)
[ARIA Presentation Role](https://www.w3.org/TR/wai-aria/roles#presentation) | 2.0.11 `react-a11y-lang` | For accessibility of your website, HTML elements must have a lang attribute and the attribute must be a valid language code.
References:
* [H58: Using language attributes to identify changes in the human language](https://www.w3.org/TR/WCAG20-TECHS/H58.html)
* [lang attribute must have a valid value](https://dequeuniversity.com/rules/axe/1.1/valid-lang)
[List of ISO 639-1 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) | 2.0.11 `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 diff --git a/src/reactA11yAriaUnsupportedElementsRule.ts b/src/reactA11yAriaUnsupportedElementsRule.ts new file mode 100644 index 000000000..9a02592a4 --- /dev/null +++ b/src/reactA11yAriaUnsupportedElementsRule.ts @@ -0,0 +1,81 @@ +/** + * Enforce that elements that do not support ARIA roles, states, and properties do not have those attributes. + */ +import * as ts from 'typescript'; +import * as Lint from 'tslint/lib/lint'; + +import { ExtendedMetadata } from './utils/ExtendedMetadata'; +import { getJsxAttributesFromJsxElement} from './utils/JsxAttribute'; +import { IDom } from './utils/attributes/IDom'; +import { IAria } from './utils/attributes/IAria'; + +// tslint:disable:no-require-imports no-var-requires +const DOM_SCHEMA: IDom[] = require('./utils/attributes/domSchema.json'); +const ARIA_SCHEMA: IAria[] = require('./utils/attributes/ariaSchema.json'); +// tslint:enable:no-require-imports no-var-requires + +export function getFailureString(tagName: string, ariaAttributeNames: string[]): string { + return `This element ${tagName} does not support ARIA roles, states and properties. ` + + `Try removing attribute(s): ${ariaAttributeNames.join(', ')}.`; +} + +export class Rule extends Lint.Rules.AbstractRule { + public static metadata: ExtendedMetadata = { + ruleName: 'react-a11y-aria-unsupported-elements', + type: 'maintainability', + description: 'Enforce that elements that do not support ARIA roles, states, and properties do not have those attributes.', + 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 ReactA11yAriaUnsupportedElementsWalker(sourceFile, this.getOptions())) + : []; + } +} + +class ReactA11yAriaUnsupportedElementsWalker extends Lint.RuleWalker { + public visitJsxElement(node: ts.JsxElement): void { + this.checkJsxOpeningElement(node.openingElement); + super.visitJsxElement(node); + } + + public visitJsxSelfClosingElement(node: ts.JsxSelfClosingElement): void { + this.checkJsxOpeningElement(node); + super.visitJsxSelfClosingElement(node); + } + + private checkJsxOpeningElement(node: ts.JsxOpeningElement): void { + const tagName: string = node.tagName.getText(); + + if (!DOM_SCHEMA[tagName]) { + return; + } + + const unsupportedAria: boolean = DOM_SCHEMA[tagName].unsupportedAria !== undefined + ? DOM_SCHEMA[tagName].unsupportedAria + : false; + + if (!unsupportedAria) { + return; + } + + const checkAttributeNames: string[] = Object.keys(ARIA_SCHEMA).concat('role'); + const attributes: { [propName: string]: ts.JsxAttribute } = getJsxAttributesFromJsxElement(node); + const invalidAttributeNames: string[] = + checkAttributeNames.filter((attributeName: string): boolean => !!attributes[attributeName]); + + if (invalidAttributeNames.length > 0) { + this.addFailure(this.createFailure( + node.getStart(), + node.getWidth(), + getFailureString(tagName, invalidAttributeNames) + )); + } + } +} diff --git a/src/utils/attributes/IDom.ts b/src/utils/attributes/IDom.ts new file mode 100644 index 000000000..855343dd0 --- /dev/null +++ b/src/utils/attributes/IDom.ts @@ -0,0 +1,6 @@ +/** + * Interface of dom + */ +export interface IDom { + unsupportedAria: boolean; +} diff --git a/src/utils/attributes/domSchema.json b/src/utils/attributes/domSchema.json new file mode 100644 index 000000000..63d8a2386 --- /dev/null +++ b/src/utils/attributes/domSchema.json @@ -0,0 +1,145 @@ +{ + "a": {}, + "abbr": {}, + "address": {}, + "area": {}, + "article": {}, + "aside": {}, + "audio": {}, + "b": {}, + "base": { + "unsupportedAria": true + }, + "bdi": {}, + "bdo": {}, + "big": {}, + "blockquote": {}, + "body": {}, + "br": {}, + "button": {}, + "canvas": {}, + "caption": {}, + "cite": {}, + "code": {}, + "col": { + "unsupportedAria": true + }, + "colgroup": { + "unsupportedAria": true + }, + "data": {}, + "datalist": {}, + "dd": {}, + "del": {}, + "details": {}, + "dfn": {}, + "dialog": {}, + "div": {}, + "dl": {}, + "dt": {}, + "em": {}, + "embed": {}, + "fieldset": {}, + "figcaption": {}, + "figure": {}, + "footer": {}, + "form": {}, + "h1": {}, + "h2": {}, + "h3": {}, + "h4": {}, + "h5": {}, + "h6": {}, + "head": { + "unsupportedAria": true + }, + "header": {}, + "hgroup": {}, + "hr": {}, + "html": { + "unsupportedAria": true + }, + "i": {}, + "iframe": {}, + "img": {}, + "input": {}, + "ins": {}, + "kbd": {}, + "keygen": {}, + "label": {}, + "legend": {}, + "li": {}, + "link": { + "unsupportedAria": true + }, + "main": {}, + "map": {}, + "mark": {}, + "menu": {}, + "menuitem": {}, + "meta": { + "unsupportedAria": true + }, + "meter": {}, + "nav": {}, + "noscript": { + "unsupportedAria": true + }, + "object": {}, + "ol": {}, + "optgroup": {}, + "option": {}, + "output": {}, + "p": {}, + "param": { + "unsupportedAria": true + }, + "picture": { + "unsupportedAria": true + }, + "pre": {}, + "progress": {}, + "q": {}, + "rp": {}, + "rt": {}, + "ruby": {}, + "s": {}, + "samp": {}, + "script": { + "unsupportedAria": true + }, + "section": {}, + "select": {}, + "small": {}, + "source": { + "unsupportedAria": true + }, + "span": {}, + "strong": {}, + "style": { + "unsupportedAria": true + }, + "sub": {}, + "summary": {}, + "sup": {}, + "table": {}, + "tbody": {}, + "td": {}, + "textarea": {}, + "tfoot": {}, + "th": {}, + "thead": {}, + "time": {}, + "title": { + "unsupportedAria": true + }, + "tr": {}, + "track": { + "unsupportedAria": true + }, + "u": {}, + "ul": {}, + "var": {}, + "video": {}, + "wbr": {} +} diff --git a/tests/ReactA11yAriaUnsupportedElementsRuleTest.ts b/tests/ReactA11yAriaUnsupportedElementsRuleTest.ts new file mode 100644 index 000000000..199d3db26 --- /dev/null +++ b/tests/ReactA11yAriaUnsupportedElementsRuleTest.ts @@ -0,0 +1,56 @@ +import { TestHelper } from './TestHelper'; +import { getFailureString } from '../src/reactA11yAriaUnsupportedElementsRule'; + +/** + * Unit test for react-a11y-aria-unsupported-elements rule. + */ +describe('reactA11yAriaUnsupportedElementsRule', () => { + const ruleName: string = 'react-a11y-aria-unsupported-elements'; + + it('should pass when tag name is not dom elements', (): void => { + const script: string = ` + import React = require('react); + + const a =
; + const b =
; + `; + TestHelper.assertNoViolation(ruleName, script); + }); + + it('should pass when tag name is supported aria element', (): void => { + const script: string = ` + import React = require('react); + + const a =
; + const b =
; + `; + TestHelper.assertNoViolation(ruleName, script); + }); + + it('should fail when unsupported aria elements have aria-* or role attributes', (): void => { + const script: string = ` + import React = require('react'); + + const a = ; + const b = ; + `; + TestHelper.assertViolations( + ruleName, + script, + [ + { + name: 'file.tsx', + ruleName: ruleName, + startPosition: { character: 23, line: 4 }, + failure: getFailureString('base', ['aria-label', 'role']) + }, + { + name: 'file.tsx', + ruleName: ruleName, + startPosition: { character: 23, line: 5 }, + failure: getFailureString('base', ['aria-label', 'role']) + } + ] + ); + }); +}); diff --git a/tslint.json b/tslint.json index 452e1eb4d..0c13e9323 100644 --- a/tslint.json +++ b/tslint.json @@ -1,5 +1,6 @@ { "rules": { + "react-a11y-aria-unsupported-elements": true, "react-a11y-img-has-alt": true, "react-a11y-props": true, "react-a11y-role-has-required-aria-props": true,