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

Commit

Permalink
feat(rule): Add new rule - react-a11y-media-captions (#850)
Browse files Browse the repository at this point in the history
* add new reactA11yMediaCaptionsRule

* add react-a11y-media-caption to readme

* add unit tests
fix behavior of validateMediaType

* fix according to comments
add unit tests

* modify get JsxExpression
text to look only for string literals

* modify to check string literal inside JsxExpression
add spread attributes logic to track elements

* replace getAttributeText with getStringLiteral

* add uni test to indicate no variable as expression

* git l
  • Loading branch information
noamyogev84 authored and IllusionMH committed Jul 29, 2019
1 parent 34f3347 commit 8f86920
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 0 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,15 @@ We recommend you specify exact versions of lint libraries, including `tslint-mic
</td>
<td>2.0.11</td>
</tr>
<tr>
<td>
<code>react-a11y-media-captions</code>
</td>
<td>
Enforce that video and audio elements have captions and descriptions.
</td>
<td>@next</td>
</tr>
<tr>
<td>
<code>react-a11y-meta</code>
Expand Down
1 change: 1 addition & 0 deletions configs/latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"rules": {
"detect-child-process": true,
"react-a11y-iframes": true,
"react-a11y-media-captions": true,
"react-a11y-mouse-event-has-key-event": true,
"void-zero": true
}
Expand Down
114 changes: 114 additions & 0 deletions src/reactA11yMediaCaptionsRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as ts from 'typescript';
import * as Lint from 'tslint';
import * as tsutils from 'tsutils';

import { ExtendedMetadata } from './utils/ExtendedMetadata';
import { getAllAttributesFromJsxElement, getStringLiteral } from './utils/JsxAttribute';

const VIDEO_ELEMENT_NAME: string = 'video';
const AUDIO_ELEMENT_NAME: string = 'audio';
const TRACK_ELEMENT_NAME: string = 'track';
const KIND_ATTRIBUTE_NAME: string = 'kind';
const CAPTIONS_KIND_NAME: string = 'captions';
const DESCRIPTION_KIND_NAME: string = 'description';
const NO_CAPTIONS_ERROR_MESSAGE: string = 'Video and audio elements must have a captions track';
const NO_AUDIO_DESCRIPTION_MESSAGE: string = 'Video elements must have an audio description track';

export class Rule extends Lint.Rules.AbstractRule {
public static metadata: ExtendedMetadata = {
ruleName: 'react-a11y-media-captions',
type: 'functionality',
description: 'Enforce that video and audio elements have captions and descriptions.',
options: null, // tslint:disable-line:no-null-keyword
optionsDescription: '',
typescriptOnly: false,
issueClass: 'Non-SDL',
issueType: 'Error',
severity: 'Important',
level: 'Opportunity for Excellence',
group: 'Accessibility'
};

public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
return sourceFile.languageVariant === ts.LanguageVariant.JSX ? this.applyWithFunction(sourceFile, walk) : [];
}
}

function walk(ctx: Lint.WalkContext<void>) {
function cb(node: ts.Node): void {
if (tsutils.isJsxSelfClosingElement(node) && (isVideoElement(node.tagName) || isAudioElement(node.tagName))) {
ctx.addFailureAtNode(node, NO_CAPTIONS_ERROR_MESSAGE);
}
if (tsutils.isJsxElement(node) && (isVideoElement(node.openingElement.tagName) || isAudioElement(node.openingElement.tagName))) {
if (!containsTrackElementWithSpreadAttributes(node.openingElement)) {
validateMediaType(node.openingElement, ctx);
return;
}
}
return ts.forEachChild(node, cb);
}
return ts.forEachChild(ctx.sourceFile, cb);
}

function isVideoElement(tagName: ts.JsxTagNameExpression): boolean {
const elementName = tagName.getText();
return elementName === VIDEO_ELEMENT_NAME;
}

function isAudioElement(tagName: ts.JsxTagNameExpression): boolean {
const elementName = tagName.getText();
return elementName === AUDIO_ELEMENT_NAME;
}

function hasSpreadAttributes(nodeArray: ts.NodeArray<ts.JsxAttributeLike> | undefined): boolean {
return !!(nodeArray && nodeArray.find(node => tsutils.isJsxSpreadAttribute(node)));
}

function isTrackElementNode(node: ts.Node): boolean {
return (
(tsutils.isJsxElement(node) && node.openingElement.tagName.getText() === TRACK_ELEMENT_NAME) ||
(tsutils.isJsxSelfClosingElement(node) && node.tagName.getText() === TRACK_ELEMENT_NAME)
);
}

function containsTrackElementWithSpreadAttributes(node: ts.JsxOpeningElement): boolean {
function cb(childNode: ts.Node): boolean {
if (isTrackElementNode(childNode)) {
const attributes = getAllAttributesFromJsxElement(childNode);
return hasSpreadAttributes(attributes);
}
return false;
}
return !!ts.forEachChild(node.parent, cb);
}

function validateMediaType(node: ts.JsxOpeningElement, ctx: Lint.WalkContext<void>): void {
const validateDescription = isVideoElement(node.tagName);
let foundCaptions = false;
let foundDescription = false;
function cb(childNode: ts.Node): void {
if (isTrackElementNode(childNode)) {
const attributes = getAllAttributesFromJsxElement(childNode);
if (attributes) {
const kindAttribute = <ts.JsxAttribute>(
attributes.find(att => tsutils.isJsxAttribute(att) && att.name.getText() === KIND_ATTRIBUTE_NAME)
);
if (!foundCaptions) {
foundCaptions = !!(kindAttribute && getStringLiteral(kindAttribute) === CAPTIONS_KIND_NAME);
}
if (!foundDescription && validateDescription) {
foundDescription = !!(kindAttribute && getStringLiteral(kindAttribute) === DESCRIPTION_KIND_NAME);
}
}
}
return ts.forEachChild(childNode, cb);
}
ts.forEachChild(node.parent, cb);

if (!foundCaptions) {
ctx.addFailureAtNode(node, NO_CAPTIONS_ERROR_MESSAGE);
}
if (!foundDescription && validateDescription) {
ctx.addFailureAtNode(node, NO_AUDIO_DESCRIPTION_MESSAGE);
}
}
77 changes: 77 additions & 0 deletions tests/react-a11y-media-captions/test.tsx.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@

const SomeComponent = () =>
<video width="320" height="240">
<source src="sitting_and_smiling_234.mp4" type="video/mp4"/>
<track src="subtitles_en.vtt" kind="subtitles" srclang="en" label="English"/>
<track src="sitting_and_smiling_234.vtt" kind="captions" srclang="en" label="English"/>
<track src="myvideo_en.vtt" kind="description" srclang="en" label="English"/>
</video>;

const SomeComponent = () =>
<video width="320" height="240">
<source src="sitting_and_smiling_232.mp4" type="video/mp4"/>
<track src="subtitles_en.vtt" kind={'subtitles'} srclang="en" label="English"/>
<track src="sitting_and_smiling_232.vtt" kind={'captions'} srclang="en" label="English"/>
<track src="myvideo_en.vtt" kind={'description'} srclang="en" label="English"/>
</video>;

const captionsKind = 'captions';
const SomeComponent = () =>
<audio>
~~~~~~~[Video and audio elements must have a captions track]
<source src="beck_loser.mp3" type="video/mp4"/>
<track src="beck_loser.vtt" kind={ captionsKind } srclang="en" label="English"/>
</audio>;

const SomeComponent = () =>
<audio>
<source src="beck_loser.mp3" type="video/mp4"/>
<track src="beck_loser.vtt" kind="captions" srclang="en" label="English"/>
</audio>;

const SomeComponent = () =>
<video width="320" height="240">
<source src="sitting_and_smiling_260.mp4" type="video/mp4"/>
<track {...props} />
<track src="subtitles_en.vtt" kind="subtitles" srclang="en" label="English"/>
</video>;

const SomeComponent = () =>

<video width="320" height="240">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Video elements must have an audio description track]
<source src="sitting_and_smiling_230.mp4" type="video/mp4"/>
<track src="subtitles_en.vtt" kind="subtitles" srclang="en" label="English"/>
<track src="sitting_and_smiling_230.vtt" kind="captions" srclang="en" label="English"/>
</video>;

const SomeComponent = () =>
<video width="320" height="240">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Video and audio elements must have a captions track]
<source src="sitting_and_smiling_236.mp4" type="video/mp4"/>
<track src="subtitles_en.vtt" kind="subtitles" srclang="en" label="English"/>
<track src="sitting_and_smiling_236.vtt" kind="description" srclang="en" label="English"/>
</video>;

const SomeComponent = () =>
<video width="320" height="240">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Video and audio elements must have a captions track]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Video elements must have an audio description track]
<source src="sitting_and_smiling_224.mp4" type="video/mp4"/>
<track src="subtitles_en.vtt" kind="subtitles" srclang="en" label="English"/>
</video>;

const SomeComponent = () =>
<audio>
~~~~~~~ [Video and audio elements must have a captions track]
<source src="beck_loser.mp3" type="video/mp4"/>
</audio>;s

const SomeComponent = () =>
<audio source src="rami-fortis-april-fools.mp3" type="mp3" />
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Video and audio elements must have a captions track]

const SomeComponent = () =>
<video source src="sitting_and_smiling_242.mp4" type="video/mp4" />
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Video and audio elements must have a captions track]

5 changes: 5 additions & 0 deletions tests/react-a11y-media-captions/tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"rules": {
"react-a11y-media-captions": true
}
}
1 change: 1 addition & 0 deletions tslint-warnings.csv
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ react-a11y-image-button-has-alt,Enforce that inputs element with type="image" mu
react-a11y-img-has-alt,"Enforce that an img element contains the non-empty alt attribute. For decorative images, using empty alt attribute and role="presentation".",TSLINT1OM69KS,tslint,Non-SDL,Warning,Important,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
react-a11y-input-elements,"For accessibility of your website, HTML input boxes and text areas must include default, place-holding characters.",TSLINTT7DC6U,tslint,Non-SDL,Warning,Moderate,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
react-a11y-lang,"For accessibility of your website, html elements must have a valid lang attribute.",TSLINTQ046RM,tslint,Non-SDL,Warning,Low,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
react-a11y-media-captions,Enforce that video and audio elements have captions and descriptions.,TSLINT1NMH5KU,tslint,Non-SDL,Error,Important,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
react-a11y-mouse-event-has-key-event,"For accessibility of your website, elements with mouseOver/mouseOut should be accompanied by onFocus/onBlur keyboard events.",TSLINT2DDJKM,tslint,Non-SDL,Error,Important,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
react-a11y-no-onchange,"For accessibility of your website, enforce usage of onBlur over onChange on select menus.",TSLINTNO0TDD,tslint,Non-SDL,Warning,Important,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
react-a11y-props,Enforce all `aria-*` attributes are valid. Elements cannot use an invalid `aria-*` attribute.,TSLINT1682S78,tslint,Non-SDL,Warning,Important,Opportunity for Excellence,See description on the tslint or tslint-microsoft-contrib website,TSLint Procedure,,
Expand Down

0 comments on commit 8f86920

Please sign in to comment.