Skip to content
Draft
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
44 changes: 37 additions & 7 deletions docs/rules/template-require-iframe-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

## `<iframe>`

`<iframe>` elements must have a unique title property to indicate its content to the user.

This rule takes no arguments.
`<iframe>` elements must have a unique title property so assistive
technology can convey their content to the user. The normative
requirement is [WCAG SC 4.1.2 (Name, Role, Value)](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html);
the `title` attribute is _one sufficient technique_ for meeting it
(sufficient technique [H64](https://www.w3.org/WAI/WCAG21/Techniques/html/H64)).

## Examples

Expand All @@ -27,12 +29,40 @@ This rule **forbids** the following:
<template>
<iframe />
<iframe title='' />
<iframe title=' ' />
<iframe title={{null}} />
<iframe title={{undefined}} />
<iframe title={{42}} />
</template>
```

Whitespace-only `title` (`" "`) is flagged by default as an
authoring-hygiene check: HTML and ACCNAME technically permit it (step 2I
doesn't trim), but a whitespace-only accessible name is useless in
practice. Suppress this specific strictness via `allowWhitespaceOnlyTitle:
true` if your codebase needs it.

## Configuration

- `allowWhitespaceOnlyTitle` (`boolean`, default `false`): when `true`,
`<iframe title=" ">` is accepted. Empty-string `title=""` and
non-string mustache literals (`{{null}}`, `{{undefined}}`, `{{42}}`) are
still flagged.

```js
module.exports = {
rules: {
'ember/template-require-iframe-title': ['error', { allowWhitespaceOnlyTitle: true }],
},
};
```

## References

- [Deque University](https://dequeuniversity.com/rules/axe/1.1/frame-title)
- [Technique H65: Using the title attribute of the frame and iframe elements](https://www.w3.org/TR/2014/NOTE-WCAG20-TECHS-20140408/H64)
- [WCAG Success Criterion 2.4.1 - Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
- [WCAG Success Criterion 4.1.2 - Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
- [WCAG SC 4.1.2 — Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
— the normative requirement.
- [WCAG Technique H64 — Using the title attribute of the iframe element](https://www.w3.org/WAI/WCAG21/Techniques/html/H64)
— a sufficient technique for SC 4.1.2, not itself normative.
- [WCAG Success Criterion 2.4.1 — Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
- [ACCNAME 1.2 — accessible-name computation](https://www.w3.org/TR/accname-1.2/)
- [axe-core rule `frame-title`](https://dequeuniversity.com/rules/axe/4.10/frame-title)
95 changes: 84 additions & 11 deletions lib/rules/template-require-iframe-title.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
// Non-string literal AST nodes (boolean/null/undefined/number) don't represent
// a meaningful author-provided title. Even though they would coerce to strings
// at runtime (e.g. `true` → "true", `42` → "42"), those strings do not describe
// the frame's content — the rule rejects the literal forms.
const INVALID_LITERAL_TYPES = new Set([
'GlimmerBooleanLiteral',
'GlimmerNullLiteral',
'GlimmerUndefinedLiteral',
'GlimmerNumberLiteral',
]);

function isInvalidTitleLiteralPath(path) {
return INVALID_LITERAL_TYPES.has(path?.type);
}

function getInvalidLiteralType(path) {
if (!path) {
return undefined;
}
switch (path.type) {
case 'GlimmerBooleanLiteral': {
return 'boolean';
}
case 'GlimmerNullLiteral': {
return 'null';
}
case 'GlimmerUndefinedLiteral': {
return 'undefined';
}
case 'GlimmerNumberLiteral': {
return 'number';
}
default: {
return undefined;
}
}
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand All @@ -8,13 +46,24 @@ module.exports = {
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-iframe-title.md',
templateMode: 'both',
},
schema: [],
schema: [
{
type: 'object',
properties: {
allowWhitespaceOnlyTitle: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
messages: {
// Four messageIds (missingTitle, emptyTitle, dynamicFalseTitle,
// Four messageIds (missingTitle, emptyTitle, invalidTitleLiteral,
// duplicateTitle) for richer diagnostic detail.
missingTitle: '<iframe> elements must have a unique title property.',
emptyTitle: '<iframe> elements must have a unique title property.',
dynamicFalseTitle: '<iframe> elements must have a unique title property.',
invalidTitleLiteral:
'<iframe title> must be a non-empty string. Got a {{literalType}} literal, which does not describe the frame contents.',
duplicateTitleFirst: 'This title is not unique. #{{index}}',
duplicateTitleOther:
'<iframe> elements must have a unique title property. Value title="{{title}}" already used for different iframe. #{{index}}',
Expand All @@ -27,6 +76,14 @@ module.exports = {
},
},
create(context) {
// Whitespace-only `title=" "` is technically spec-compliant: ACCNAME
// 1.2 step 2I (Tooltip) does not whitespace-trim like step 2D
// (aria-label) does, so a 3-space accessible name is assigned. That is
// useless in practice but not a spec violation. Default behavior flags
// it as authoring hygiene; set `allowWhitespaceOnlyTitle: true` to
// align with spec/peer behavior.
const allowWhitespaceOnlyTitle = Boolean(context.options[0]?.allowWhitespaceOnlyTitle);

// Each entry: { value, node, index }
// - value: trimmed title string
// - node: original element node for the first occurrence
Expand Down Expand Up @@ -57,9 +114,15 @@ module.exports = {
if (titleAttr.value) {
switch (titleAttr.value.type) {
case 'GlimmerTextNode': {
const value = titleAttr.value.chars.trim();
const raw = titleAttr.value.chars;
const value = raw.trim();
if (value.length === 0) {
context.report({ node, messageId: 'emptyTitle' });
// Empty-string title always fails: no accessible name for screen readers.
// Whitespace-only titles are controlled by the `allowWhitespaceOnlyTitle`
// option (default false).
if (raw.length === 0 || !allowWhitespaceOnlyTitle) {
context.report({ node, messageId: 'emptyTitle' });
}
} else {
// Check for duplicate titles. Reports BOTH the first and the
// current occurrence on every collision, sharing a `#N` index
Expand Down Expand Up @@ -93,21 +156,31 @@ module.exports = {
break;
}
case 'GlimmerMustacheStatement': {
// title={{false}} → BooleanLiteral false is invalid
if (titleAttr.value.path?.type === 'GlimmerBooleanLiteral') {
context.report({ node, messageId: 'dynamicFalseTitle' });
// title={{false}} / title={{null}} / title={{undefined}} / title={{42}}
// — any literal that doesn't produce a meaningful accessible name.
if (isInvalidTitleLiteralPath(titleAttr.value.path)) {
context.report({
node,
messageId: 'invalidTitleLiteral',
data: { literalType: getInvalidLiteralType(titleAttr.value.path) },
});
}
break;
}
case 'GlimmerConcatStatement': {
// title="{{false}}" → ConcatStatement with single BooleanLiteral part
// title="{{false}}" / "{{undefined}}" / etc. — ConcatStatement
// with a single literal part that doesn't produce a name.
const parts = titleAttr.value.parts || [];
if (
parts.length === 1 &&
parts[0].type === 'GlimmerMustacheStatement' &&
parts[0].path?.type === 'GlimmerBooleanLiteral'
isInvalidTitleLiteralPath(parts[0].path)
) {
context.report({ node, messageId: 'dynamicFalseTitle' });
context.report({
node,
messageId: 'invalidTitleLiteral',
data: { literalType: getInvalidLiteralType(parts[0].path) },
});
}
break;
}
Expand Down
Loading
Loading