Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | |
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | 📋 | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | |
| [template-no-autoplay](docs/rules/template-no-autoplay.md) | disallow autoplay attribute on audio and video elements | | | |
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | |
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | 📋 | | |
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | 📋 | | |
Expand Down
81 changes: 81 additions & 0 deletions docs/rules/template-no-autoplay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# ember/template-no-autoplay

<!-- end auto-generated rule header -->

This rule disallows the `autoplay` attribute on `<audio>` elements, and on
`<video>` elements that are not also marked `muted`.

Autoplaying audio is disruptive for users with cognitive or sensory
sensitivities, can interfere with screen readers, and consumes bandwidth
without user consent. WCAG Success Criterion 1.4.2 requires users to be able
to pause, stop, or control audio that plays automatically for more than three
seconds. The [W3C ACT rule `aaa1bf`][act-aaa1bf] that operationalizes SC 1.4.2
is explicitly inapplicable when the media is muted or has no audio, so a
muted autoplaying `<video>` (e.g. GIF-style hero) is treated as allowed.

[act-aaa1bf]: https://www.w3.org/WAI/standards-guidelines/act/rules/aaa1bf/proposed/

## Examples

This rule **forbids** the following:

```hbs
<audio src='track.mp3' autoplay></audio>
<video src='clip.mp4' autoplay></video>
<audio src='track.mp3' autoplay muted></audio>
<video src='clip.mp4' autoplay muted={{false}}></video>
```

This rule **allows** the following:

```hbs
<audio src='track.mp3' controls></audio>
<video src='clip.mp4' controls></video>
<audio src='track.mp3' autoplay={{false}}></audio>
<video src='clip.mp4' autoplay muted></video>
<video src='clip.mp4' autoplay muted loop playsinline></video>
```

Dynamic values such as `autoplay={{this.shouldAutoplay}}` or
`muted={{this.isMuted}}` are not flagged at lint time — the lint pass can't
know the runtime value, and false positives are considered worse than false
negatives here.

The literal-boolean form `autoplay={{false}}` is treated as a reliable
opt-out (the mustache evaluates to a real JS `false`, the attribute is not
emitted, and the media will not auto-play). The string literal form
`autoplay={{"false"}}` is also treated as a falsy opt-out by this rule —
the rule checks for the exact string `"false"` (case-insensitive) and will
not flag the element. Note that this treatment is a deliberate allowance:
in raw HTML the `autoplay` attribute is boolean, so any presence — including
the string `"false"` — would normally mean the attribute is set. Glimmer,
however, passes `{{"false"}}` through as-is and many migration paths produce
this form intentionally as a no-op; the rule accepts it rather than generating
false positives.

## Configuration

- `additionalElements` (`string[]`): extra tag names to check beyond the default
`audio` / `video`. Useful if you render a custom element that also supports
autoplay.

```js
module.exports = {
rules: {
'ember/template-no-autoplay': ['error', { additionalElements: ['my-media'] }],
},
};
```

Note that the `muted` exemption applies **only to `<video>`**. Per WCAG 2.1
SC 1.4.2, auto-playing audible media is a WCAG failure regardless of the
tag; since `additionalElements` may cover custom tags (e.g. `<my-media>`)
whose mute semantics we can't statically verify, `muted` is not treated as
an exemption on those tags. `<audio muted autoplay>` is also flagged, by
the same reasoning.

## References

- [WCAG 2.1 SC 1.4.2: Audio Control](https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html)
- [MDN: HTMLMediaElement.autoplay](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/autoplay)
- Adapted from [`html-validate`'s `no-autoplay`](https://html-validate.org/rules/no-autoplay.html) (MIT).
116 changes: 116 additions & 0 deletions lib/rules/template-no-autoplay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict';

// See html-validate (https://html-validate.org/rules/no-autoplay.html) for the peer rule concept.

const DEFAULT_ELEMENTS = new Set(['audio', 'video']);

// Classify an attribute's value as "present at runtime" (truthy), "absent"
// (falsy), or "unknown" (dynamic). See ../../docs/glimmer-attribute-behavior.md
// for the empirical model — short version: only bare-mustache `{{false}}`
// causes Glimmer to omit the attribute. Bare-string literals (incl. `"false"`)
// and any concat form set the IDL property to truthy regardless of the
// literal value inside.
function classifyAttrValue(attr) {
if (!attr.value) {
return 'truthy';
}
if (attr.value.type === 'GlimmerTextNode') {
return 'truthy';
}
if (attr.value.type === 'GlimmerMustacheStatement' && attr.value.path) {
const path = attr.value.path;
if (path.type === 'GlimmerBooleanLiteral') {
return path.value ? 'truthy' : 'falsy';
}
if (path.type === 'GlimmerStringLiteral') {
return 'truthy';
}
return 'unknown';
}
if (attr.value.type === 'GlimmerConcatStatement') {
const parts = attr.value.parts || [];
const hasDynamicPart = parts.some(
(part) =>
part.type === 'GlimmerMustacheStatement' &&
part.path &&
part.path.type !== 'GlimmerBooleanLiteral' &&
part.path.type !== 'GlimmerStringLiteral'
);
if (hasDynamicPart) {
return 'unknown';
}
return 'truthy';
}
return 'truthy';
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow autoplay attribute on audio and video elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-autoplay.md',
templateMode: 'both',
},
fixable: null,
schema: [
{
type: 'object',
properties: {
additionalElements: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
noAutoplay:
'The `autoplay` attribute is disruptive for users and has accessibility concerns on `<{{tag}}>`',
},
},

create(context) {
const options = context.options[0] || {};
const extraElements = new Set(options.additionalElements || []);
const watched = new Set([...DEFAULT_ELEMENTS, ...extraElements]);

return {
GlimmerElementNode(node) {
if (!watched.has(node.tag)) {
return;
}
const autoplayAttr = node.attributes?.find((attr) => attr.name === 'autoplay');
if (!autoplayAttr) {
return;
}
const classification = classifyAttrValue(autoplayAttr);
if (classification === 'falsy' || classification === 'unknown') {
return;
}
// <video muted> is outside WCAG SC 1.4.2's audio-output scope (W3C ACT
// rule aaa1bf), so a muted autoplaying video is not an SC 1.4.2
// failure. Unknown mustache values for `muted` also skip, consistent
// with the rule's "false positives are worse than false negatives"
// stance. Limited to <video>: <audio muted autoplay> is
// spec-nonsensical, and additionalElements are opt-in user-land tags
// whose semantics we don't know.
if (node.tag === 'video') {
const mutedAttr = node.attributes?.find((attr) => attr.name === 'muted');
if (mutedAttr && classifyAttrValue(mutedAttr) !== 'falsy') {
return;
}
}
context.report({
node: autoplayAttr,
messageId: 'noAutoplay',
data: { tag: node.tag },
});
},
};
},
};
162 changes: 162 additions & 0 deletions tests/lib/rules/template-no-autoplay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
const rule = require('../../../lib/rules/template-no-autoplay');
const RuleTester = require('eslint').RuleTester;

const ERROR_AUDIO =
'The `autoplay` attribute is disruptive for users and has accessibility concerns on `<audio>`';
const ERROR_VIDEO =
'The `autoplay` attribute is disruptive for users and has accessibility concerns on `<video>`';

const validHbs = [
'<audio src="a.mp3"></audio>',
'<video src="a.mp4" controls></video>',
'<div autoplay></div>',
// Bare-mustache dynamic path — unknown at lint time, skip.
'<audio autoplay={{this.shouldAutoplay}}></audio>',
// Bare `{{false}}` is the only literal form that makes Glimmer omit the
// attribute (see docs/glimmer-attribute-behavior.md).
'<video autoplay={{false}}></video>',
// Concat with a dynamic part — unknown at lint time, skip.
'<audio autoplay="{{shouldPlay}}"></audio>',
'<audio autoplay="{{this.flag}}-suffix"></audio>',
'<audio autoplay="foo{{this.bar}}"></audio>',
// PascalCase component — not an HTML element.
'<AutoPlayer autoplay />',
// <video muted autoplay> is out of WCAG SC 1.4.2 scope (ACT rule aaa1bf).
// Per docs/glimmer-attribute-behavior.md, every form below sets the muted
// IDL property to true — including bare-string `{{"false"}}` and concat
// `"{{false}}"` (verified: <video muted="{{false}}"> → videoEl.muted ===
// true) — so the muted-autoplay exemption applies.
'<video autoplay muted></video>',
'<video autoplay muted loop playsinline></video>',
'<video autoplay muted=""></video>',
'<video autoplay muted="muted"></video>',
'<video autoplay muted="false"></video>',
'<video autoplay muted={{true}}></video>',
'<video autoplay muted={{"false"}}></video>',
'<video autoplay muted={{"true"}}></video>',
'<video autoplay muted="{{false}}"></video>',
'<video autoplay muted="{{true}}"></video>',
// Dynamic muted — unknown at lint time, skip (false positives > false
// negatives).
'<video autoplay muted={{this.isMuted}}></video>',
'<video autoplay muted="{{this.isMuted}}"></video>',
];

const invalidHbs = [
{ code: '<audio autoplay></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<video autoplay></video>', errors: [{ message: ERROR_VIDEO }] },
{ code: '<audio autoplay=""></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<audio autoplay="autoplay"></audio>', errors: [{ message: ERROR_AUDIO }] },
// HTML boolean-attribute presence — even `autoplay="false"` is autoplay=on.
{ code: '<audio autoplay="false"></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<video autoplay="false"></video>', errors: [{ message: ERROR_VIDEO }] },
// <audio> has no muted exception, so `autoplay muted="…"` still flags.
{ code: '<audio autoplay muted="false"></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<audio autoplay muted></audio>', errors: [{ message: ERROR_AUDIO }] },
// Bare boolean true autoplay → IDL set, plays.
{ code: '<video autoplay={{true}}></video>', errors: [{ message: ERROR_VIDEO }] },
// muted bare `{{false}}` is the only literal form that omits the attribute,
// so this is genuinely muted-off → no exemption (see
// docs/glimmer-attribute-behavior.md).
{ code: '<video autoplay muted={{false}}></video>', errors: [{ message: ERROR_VIDEO }] },
// Per docs/glimmer-attribute-behavior.md, bare-mustache string `"false"` is
// JS-truthy and concat with literals always sets the IDL property — so
// these are autoplay=on, regardless of what the literal value suggests.
{ code: '<audio autoplay={{"false"}}></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<audio autoplay="{{false}}"></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<audio autoplay="{{\'false\'}}"></audio>', errors: [{ message: ERROR_AUDIO }] },
{ code: '<audio autoplay="{{\'true\'}}"></audio>', errors: [{ message: ERROR_AUDIO }] },
];

// Plain valid cases that need no option — kept as named constants for reuse.
const noOptionValid = ['<audio autoplay={{false}}></audio>', '<div></div>'];

// Valid cases that explicitly exercise the `additionalElements` option.
const additionalElementsValid = [
// Custom element listed in additionalElements but without autoplay — no report.
{
code: '<custom-player src="a.mp4"></custom-player>',
options: [{ additionalElements: ['custom-player'] }],
},
];

// Opt-in `additionalElements` configured but the element doesn't carry
// autoplay — pins that the option wiring doesn't over-flag on its own.
const additionalElementsOptionValid = [
{ code: '<my-media></my-media>', options: [{ additionalElements: ['my-media'] }] },
];

const additionalElementsInvalid = [
{
code: '<my-media autoplay></my-media>',
options: [{ additionalElements: ['my-media'] }],
errors: [
{
message:
'The `autoplay` attribute is disruptive for users and has accessibility concerns on `<my-media>`',
},
],
},
// The `muted` exemption applies only to native <video>. Custom tags added
// via `additionalElements` still flag when they carry `autoplay`, even
// with `muted` present — we can't verify the tag's mute semantics.
{
code: '<my-media autoplay muted></my-media>',
options: [{ additionalElements: ['my-media'] }],
errors: [
{
message:
'The `autoplay` attribute is disruptive for users and has accessibility concerns on `<my-media>`',
},
],
},
];

const gjsValid = validHbs.map((code) => `<template>${code}</template>`);
const gjsInvalid = invalidHbs.map(({ code, errors }) => ({
code: `<template>${code}</template>`,
errors,
}));

const gjsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

gjsRuleTester.run('template-no-autoplay', rule, {
valid: [
...gjsValid,
...noOptionValid.map((code) => `<template>${code}</template>`),
...additionalElementsValid.map(({ code, options }) => ({
code: `<template>${code}</template>`,
options,
})),
...additionalElementsOptionValid.map(({ code, options }) => ({
code: `<template>${code}</template>`,
options,
})),
],
invalid: [
...gjsInvalid,
...additionalElementsInvalid.map(({ code, options, errors }) => ({
code: `<template>${code}</template>`,
options,
errors,
})),
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

hbsRuleTester.run('template-no-autoplay', rule, {
valid: [
...validHbs,
...noOptionValid,
...additionalElementsValid,
...additionalElementsOptionValid,
],
invalid: [...invalidHbs, ...additionalElementsInvalid],
});
Loading