Skip to content

Commit 3e1f847

Browse files
authored
Add media-has-caption rule (#212)
#29 audio-has-caption ✅
1 parent 1c19f4f commit 3e1f847

File tree

5 files changed

+258
-16
lines changed

5 files changed

+258
-16
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/* eslint-env jest */
2+
/**
3+
* @fileoverview <audio> and <video> elements must have a <track> for captions.
4+
* @author Ethan Cohen
5+
*/
6+
7+
// -----------------------------------------------------------------------------
8+
// Requirements
9+
// -----------------------------------------------------------------------------
10+
11+
import { RuleTester } from 'eslint';
12+
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
13+
import rule from '../../../src/rules/media-has-caption';
14+
15+
// -----------------------------------------------------------------------------
16+
// Tests
17+
// -----------------------------------------------------------------------------
18+
19+
const ruleTester = new RuleTester();
20+
21+
const expectedError = {
22+
message: 'Media elements such as <audio> and <video> must have a <track> for captions.',
23+
type: 'JSXOpeningElement',
24+
};
25+
26+
const customSchema = [
27+
{
28+
audio: ['Audio'],
29+
video: ['Video'],
30+
track: ['Track'],
31+
},
32+
];
33+
34+
ruleTester.run('media-has-caption', rule, {
35+
valid: [
36+
{ code: '<div />;' },
37+
{ code: '<MyDiv />;' },
38+
{ code: '<audio><track kind="captions" /></audio>' },
39+
{ code: '<audio><track kind="Captions" /></audio>' },
40+
{
41+
code: '<audio><track kind="Captions" /><track kind="subtitles" /></audio>',
42+
},
43+
{ code: '<video><track kind="captions" /></video>' },
44+
{ code: '<video><track kind="Captions" /></video>' },
45+
{
46+
code: '<video><track kind="Captions" /><track kind="subtitles" /></video>',
47+
},
48+
{
49+
code: '<Audio><track kind="captions" /></Audio>',
50+
options: customSchema,
51+
},
52+
{
53+
code: '<audio><Track kind="captions" /></audio>',
54+
options: customSchema,
55+
},
56+
{
57+
code: '<Video><track kind="captions" /></Video>',
58+
options: customSchema,
59+
},
60+
{
61+
code: '<video><Track kind="captions" /></video>',
62+
options: customSchema,
63+
},
64+
{
65+
code: '<Audio><Track kind="captions" /></Audio>',
66+
options: customSchema,
67+
},
68+
{
69+
code: '<Video><Track kind="captions" /></Video>',
70+
options: customSchema,
71+
},
72+
].map(parserOptionsMapper),
73+
invalid: [
74+
{ code: '<audio><track /></audio>', errors: [expectedError] },
75+
{
76+
code: '<audio><track kind="subtitles" /></audio>',
77+
errors: [expectedError],
78+
},
79+
{ code: '<audio />', errors: [expectedError] },
80+
{ code: '<video><track /></video>', errors: [expectedError] },
81+
{
82+
code: '<video><track kind="subtitles" /></video>',
83+
errors: [expectedError],
84+
},
85+
{ code: '<video />', errors: [expectedError] },
86+
{ code: '<audio>Foo</audio>', errors: [expectedError] },
87+
{ code: '<video>Foo</video>', errors: [expectedError] },
88+
{ code: '<Audio />', options: customSchema, errors: [expectedError] },
89+
{ code: '<Video />', options: customSchema, errors: [expectedError] },
90+
{ code: '<audio><Track /></audio>', options: customSchema, errors: [expectedError] },
91+
{ code: '<video><Track /></video>', options: customSchema, errors: [expectedError] },
92+
{
93+
code: '<Audio><Track kind="subtitles" /></Audio>',
94+
options: customSchema,
95+
errors: [expectedError],
96+
},
97+
{
98+
code: '<Video><Track kind="subtitles" /></Video>',
99+
options: customSchema,
100+
errors: [expectedError],
101+
},
102+
].map(parserOptionsMapper),
103+
});

docs/rules/media-has-caption.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# media-has-caption
2+
3+
Providing captions for media is essential for deaf users to follow along. Captions should be a transcription or translation of the dialogue, sound effects, relevant musical cues, and other relevant audio information. Not only is this important for accessibility, but can also be useful for all users in the case that the media is unavailable (similar to `alt` text on an image when an image is unable to load).
4+
5+
The captions should contain all important and relevant information to understand the corresponding media. This may mean that the captions are not a 1:1 mapping of the dialogue in the media content.
6+
7+
### References
8+
9+
1.[aXe](https://dequeuniversity.com/rules/axe/2.1/audio-caption)
10+
1.[aXe](https://dequeuniversity.com/rules/axe/2.1/video-caption)
11+
12+
## Rule details
13+
14+
This rule takes one optional object argument of type object:
15+
16+
```json
17+
{
18+
"rules": {
19+
"jsx-a11y/media-has-caption": [ 2, {
20+
"audio": [ "Audio" ],
21+
"video": [ "Video" ],
22+
"track": [ "Track" ],
23+
}],
24+
}
25+
}
26+
```
27+
28+
For the `audio`, `video`, and `track` options, these strings determine which JSX elements (**always including** their corresponding DOM element) should be used for this rule. This is a good use case when you have a wrapper component that simply renders an `audio`, `video`, or `track` element (like in React):
29+
30+
### Succeed
31+
```jsx
32+
<audio><track kind="captions" {...props} /></audio>
33+
<video><track kind="captions" {...props} /></video>
34+
```
35+
36+
### Fail
37+
```jsx
38+
<audio {...props} />
39+
<video {...props} />
40+
```

src/index.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module.exports = {
1919
'interactive-supports-focus': require('./rules/interactive-supports-focus'),
2020
'label-has-for': require('./rules/label-has-for'),
2121
lang: require('./rules/lang'),
22+
'media-has-caption': require('./rules/media-has-caption'),
2223
'mouse-events-have-key-events': require('./rules/mouse-events-have-key-events'),
2324
'no-access-key': require('./rules/no-access-key'),
2425
'no-autofocus': require('./rules/no-autofocus'),
@@ -58,27 +59,48 @@ module.exports = {
5859
'jsx-a11y/img-redundant-alt': 'error',
5960
'jsx-a11y/interactive-supports-focus': 'error',
6061
'jsx-a11y/label-has-for': 'error',
62+
'jsx-a11y/media-has-caption': 'error',
6163
'jsx-a11y/mouse-events-have-key-events': 'error',
6264
'jsx-a11y/no-access-key': 'error',
6365
'jsx-a11y/no-autofocus': 'error',
6466
'jsx-a11y/no-distracting-elements': 'error',
67+
6568
'jsx-a11y/no-interactive-element-to-noninteractive-role': [
6669
'error',
6770
{
6871
tr: ['none', 'presentation'],
6972
},
7073
],
74+
7175
'jsx-a11y/no-noninteractive-element-interactions': 'error',
76+
7277
'jsx-a11y/no-noninteractive-element-to-interactive-role': [
7378
'error',
7479
{
75-
ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
76-
ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
80+
ul: [
81+
'listbox',
82+
'menu',
83+
'menubar',
84+
'radiogroup',
85+
'tablist',
86+
'tree',
87+
'treegrid',
88+
],
89+
ol: [
90+
'listbox',
91+
'menu',
92+
'menubar',
93+
'radiogroup',
94+
'tablist',
95+
'tree',
96+
'treegrid',
97+
],
7798
li: ['menuitem', 'option', 'row', 'tab', 'treeitem'],
7899
table: ['grid'],
79100
td: ['gridcell'],
80101
},
81102
],
103+
82104
'jsx-a11y/no-onchange': 'error',
83105
'jsx-a11y/no-redundant-roles': 'error',
84106
'jsx-a11y/no-static-element-interactions': 'warn',
@@ -111,6 +133,7 @@ module.exports = {
111133
'jsx-a11y/img-redundant-alt': 'error',
112134
'jsx-a11y/interactive-supports-focus': 'error',
113135
'jsx-a11y/label-has-for': 'error',
136+
'jsx-a11y/media-has-caption': 'error',
114137
'jsx-a11y/mouse-events-have-key-events': 'error',
115138
'jsx-a11y/no-access-key': 'error',
116139
'jsx-a11y/no-autofocus': 'error',

src/rules/media-has-caption.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @fileoverview <audio> and <video> elements must have a <track> for captions.
3+
* @author Ethan Cohen
4+
* @flow
5+
*/
6+
7+
// ----------------------------------------------------------------------------
8+
// Rule Definition
9+
// ----------------------------------------------------------------------------
10+
11+
import type { JSXElement, JSXOpeningElement } from 'ast-types-flow';
12+
import { elementType, getProp, getLiteralPropValue } from 'jsx-ast-utils';
13+
import { generateObjSchema, arraySchema } from '../util/schemas';
14+
15+
const errorMessage = 'Media elements such as <audio> and <video> must have a <track> for captions.';
16+
17+
const MEDIA_TYPES = ['audio', 'video'];
18+
19+
const schema = generateObjSchema({
20+
audio: arraySchema,
21+
video: arraySchema,
22+
track: arraySchema,
23+
});
24+
25+
const isMediaType = (context, type) => {
26+
const options = context.options[0] || {};
27+
return MEDIA_TYPES.map(mediaType => options[mediaType])
28+
.reduce((types, customComponent) => types.concat(customComponent), MEDIA_TYPES)
29+
.some(typeToCheck => typeToCheck === type);
30+
};
31+
32+
const isTrackType = (context, type) => {
33+
const options = context.options[0] || {};
34+
return ['track'].concat(options.track || []).some(typeToCheck => typeToCheck === type);
35+
};
36+
37+
module.exports = {
38+
meta: {
39+
docs: {},
40+
schema: [schema],
41+
},
42+
43+
create: (context: ESLintContext) => ({
44+
JSXElement: (node: JSXElement) => {
45+
const element: JSXOpeningElement = node.openingElement;
46+
const type = elementType(element);
47+
if (!isMediaType(context, type)) {
48+
return;
49+
}
50+
51+
// $FlowFixMe https://github.com/facebook/flow/issues/1414
52+
const trackChildren: Array<JSXElement> = node.children.filter((child: Node) => {
53+
if (child.type !== 'JSXElement') {
54+
return false;
55+
}
56+
57+
// $FlowFixMe https://github.com/facebook/flow/issues/1414
58+
return isTrackType(context, elementType(child.openingElement));
59+
});
60+
61+
if (trackChildren.length === 0) {
62+
context.report({
63+
node: element,
64+
message: errorMessage,
65+
});
66+
return;
67+
}
68+
69+
const hasCaption: boolean = trackChildren.some((track) => {
70+
const kindProp = getProp(track.openingElement.attributes, 'kind');
71+
const kindPropValue = getLiteralPropValue(kindProp) || '';
72+
return kindPropValue.toLowerCase() === 'captions';
73+
});
74+
75+
if (!hasCaption) {
76+
context.report({
77+
node: element,
78+
message: errorMessage,
79+
});
80+
}
81+
},
82+
}),
83+
};

yarn.lock

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,6 @@ [email protected]:
177177
version "0.8.12"
178178
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.8.12.tgz#a0d90e4351bb887716c83fd637ebf818af4adfcc"
179179

180-
181-
version "0.8.15"
182-
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.8.15.tgz#8eef0827f04dff0ec8857ba925abe3fea6194e52"
183-
184180
185181
version "0.9.6"
186182
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9"
@@ -215,6 +211,12 @@ aws4@^1.2.1:
215211
version "1.6.0"
216212
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
217213

214+
axobject-query@^0.1.0:
215+
version "0.1.0"
216+
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0"
217+
dependencies:
218+
ast-types-flow "0.0.7"
219+
218220
babel-cli@^6.14.0:
219221
version "6.24.1"
220222
resolved "https://registry.yarnpkg.com/babel-cli/-/babel-cli-6.24.1.tgz#207cd705bba61489b2ea41b5312341cf6aca2283"
@@ -3248,7 +3250,7 @@ readline2@^1.0.1:
32483250
is-fullwidth-code-point "^1.0.0"
32493251
mute-stream "0.0.5"
32503252

3251-
3253+
[email protected], recast@^0.10.10:
32523254
version "0.10.33"
32533255
resolved "https://registry.yarnpkg.com/recast/-/recast-0.10.33.tgz#942808f7aa016f1fa7142c461d7e5704aaa8d697"
32543256
dependencies:
@@ -3257,15 +3259,6 @@ [email protected]:
32573259
private "~0.1.5"
32583260
source-map "~0.5.0"
32593261

3260-
recast@^0.10.10:
3261-
version "0.10.43"
3262-
resolved "https://registry.yarnpkg.com/recast/-/recast-0.10.43.tgz#b95d50f6d60761a5f6252e15d80678168491ce7f"
3263-
dependencies:
3264-
ast-types "0.8.15"
3265-
esprima-fb "~15001.1001.0-dev-harmony-fb"
3266-
private "~0.1.5"
3267-
source-map "~0.5.0"
3268-
32693262
recast@^0.11.11, recast@^0.11.17:
32703263
version "0.11.23"
32713264
resolved "https://registry.yarnpkg.com/recast/-/recast-0.11.23.tgz#451fd3004ab1e4df9b4e4b66376b2a21912462d3"

0 commit comments

Comments
 (0)