Skip to content

Commit 19e44c4

Browse files
committed
BUGFIX: template-require-iframe-title — flag invalid title literals + add allowWhitespaceOnlyTitle opt-out
Treat literal AST values that don't produce a meaningful accessible name as invalid `<iframe title>` values: - `GlimmerBooleanLiteral` / `GlimmerNullLiteral` / `GlimmerUndefinedLiteral` / `GlimmerNumberLiteral` → flagged with a new `invalidTitleLiteral` messageId. Coerce-to-string runtime values like "true" / "null" / "42" don't describe the frame contents, regardless of framework behavior. - `GlimmerStringLiteral` resolving to empty / whitespace → flagged as `emptyTitle` (resolution shared with the text-node case via a `processStaticTitle` helper). Closes a bypass that jsx-a11y already catches via `getLiteralPropValue`. Both literal classes apply to the bare-mustache `title={{x}}` form AND the single-part concat `title="{{x}}"` form. Whitespace-only static title (`title=" "`) is now opt-out via a new `allowWhitespaceOnlyTitle: true` schema option. ACCNAME 1.2 §4.3.2 step 2I (Tooltip) does not whitespace-trim — so a 3-space accessible name is spec-assigned. The check stays on by default as authoring hygiene; teams that want strict spec parity can opt out. Empty-string `title=""` and the non-string-literal cases above are not affected by this option — they are always flagged as correctness. Refs: - WCAG SC 4.1.2: https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html - ACCNAME 1.2 §4.3.2: https://www.w3.org/TR/accname-1.2/#computation-steps
1 parent c4e5929 commit 19e44c4

3 files changed

Lines changed: 309 additions & 54 deletions

File tree

docs/rules/template-require-iframe-title.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
## `<iframe>`
88

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

1315
## Examples
1416

@@ -27,12 +29,42 @@ This rule **forbids** the following:
2729
<template>
2830
<iframe />
2931
<iframe title='' />
32+
<iframe title=' ' />
33+
<iframe title={{null}} />
34+
<iframe title={{undefined}} />
35+
<iframe title={{true}} />
36+
<iframe title={{false}} />
37+
<iframe title={{42}} />
3038
</template>
3139
```
3240

41+
Whitespace-only `title` (`" "`) is flagged by default as an
42+
authoring-hygiene check: HTML and ACCNAME technically permit it (step 2I
43+
doesn't trim), but a whitespace-only accessible name is useless in
44+
practice. Suppress this specific strictness via
45+
`allowWhitespaceOnlyTitle: true` if your codebase needs it.
46+
47+
## Configuration
48+
49+
- `allowWhitespaceOnlyTitle` (`boolean`, default `false`): when `true`,
50+
`<iframe title=" ">` is accepted. Empty-string `title=""` and
51+
non-string mustache literals (`{{null}}`, `{{undefined}}`, `{{42}}`) are
52+
still flagged.
53+
54+
```js
55+
module.exports = {
56+
rules: {
57+
'ember/template-require-iframe-title': ['error', { allowWhitespaceOnlyTitle: true }],
58+
},
59+
};
60+
```
61+
3362
## References
3463

35-
- [Deque University](https://dequeuniversity.com/rules/axe/1.1/frame-title)
36-
- [Technique H65: Using the title attribute of the frame and iframe elements](https://www.w3.org/TR/2014/NOTE-WCAG20-TECHS-20140408/H64)
37-
- [WCAG Success Criterion 2.4.1 - Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
38-
- [WCAG Success Criterion 4.1.2 - Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
64+
- [WCAG SC 4.1.2 — Name, Role, Value](https://www.w3.org/TR/UNDERSTANDING-WCAG20/ensure-compat-rsv.html)
65+
— the normative requirement.
66+
- [WCAG Technique H64 — Using the title attribute of the iframe element](https://www.w3.org/WAI/WCAG21/Techniques/html/H64)
67+
— a sufficient technique for SC 4.1.2, not itself normative.
68+
- [WCAG Success Criterion 2.4.1 — Bypass Blocks](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-skip.html)
69+
- [ACCNAME 1.2 — accessible-name computation](https://www.w3.org/TR/accname-1.2/)
70+
- [axe-core rule `frame-title`](https://dequeuniversity.com/rules/axe/4.10/frame-title)

lib/rules/template-require-iframe-title.js

Lines changed: 139 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,43 @@
1+
'use strict';
2+
3+
// Non-string literal AST nodes (boolean/null/undefined/number) don't represent
4+
// a meaningful author-provided title. Even though they would coerce to strings
5+
// at runtime (e.g. `true` → "true", `42` → "42"), those strings do not describe
6+
// the frame's content — the rule rejects the literal forms.
7+
const INVALID_LITERAL_TYPES = new Set([
8+
'GlimmerBooleanLiteral',
9+
'GlimmerNullLiteral',
10+
'GlimmerUndefinedLiteral',
11+
'GlimmerNumberLiteral',
12+
]);
13+
14+
function isInvalidTitleLiteralPath(path) {
15+
return INVALID_LITERAL_TYPES.has(path?.type);
16+
}
17+
18+
function getInvalidLiteralType(path) {
19+
if (!path) {
20+
return undefined;
21+
}
22+
switch (path.type) {
23+
case 'GlimmerBooleanLiteral': {
24+
return 'boolean';
25+
}
26+
case 'GlimmerNullLiteral': {
27+
return 'null';
28+
}
29+
case 'GlimmerUndefinedLiteral': {
30+
return 'undefined';
31+
}
32+
case 'GlimmerNumberLiteral': {
33+
return 'number';
34+
}
35+
default: {
36+
return undefined;
37+
}
38+
}
39+
}
40+
141
/** @type {import('eslint').Rule.RuleModule} */
242
module.exports = {
343
meta: {
@@ -8,13 +48,25 @@ module.exports = {
848
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-iframe-title.md',
949
templateMode: 'both',
1050
},
11-
schema: [],
51+
fixable: null,
52+
schema: [
53+
{
54+
type: 'object',
55+
properties: {
56+
allowWhitespaceOnlyTitle: {
57+
type: 'boolean',
58+
},
59+
},
60+
additionalProperties: false,
61+
},
62+
],
1263
messages: {
13-
// Four messageIds (missingTitle, emptyTitle, dynamicFalseTitle,
14-
// duplicateTitle) for richer diagnostic detail.
64+
// Five messageIds (missingTitle, emptyTitle, invalidTitleLiteral,
65+
// duplicateTitleFirst, duplicateTitleOther) for richer diagnostic detail.
1566
missingTitle: '<iframe> elements must have a unique title property.',
1667
emptyTitle: '<iframe> elements must have a unique title property.',
17-
dynamicFalseTitle: '<iframe> elements must have a unique title property.',
68+
invalidTitleLiteral:
69+
'<iframe title> must be a non-empty string. Got {{literalType}} literal, which does not describe the frame contents.',
1870
duplicateTitleFirst: 'This title is not unique. #{{index}}',
1971
duplicateTitleOther:
2072
'<iframe> elements must have a unique title property. Value title="{{title}}" already used for different iframe. #{{index}}',
@@ -27,13 +79,59 @@ module.exports = {
2779
},
2880
},
2981
create(context) {
82+
// Whitespace-only `title=" "` is technically spec-compliant: ACCNAME
83+
// 1.2 step 2I (Tooltip) does not whitespace-trim like step 2D
84+
// (aria-label) does, so a 3-space accessible name is assigned. That is
85+
// useless in practice but not a spec violation. Default behavior flags
86+
// it as authoring hygiene; set `allowWhitespaceOnlyTitle: true` to
87+
// align with spec/peer behavior.
88+
const allowWhitespaceOnlyTitle = Boolean(context.options[0]?.allowWhitespaceOnlyTitle);
89+
3090
// Each entry: { value, node, index }
3191
// - value: trimmed title string
3292
// - node: original element node for the first occurrence
3393
// - index: duplicate-group index (1-based), assigned lazily on collision
3494
const knownTitles = [];
3595
let nextDuplicateIndex = 1;
3696

97+
// Process a statically-known title string (from a text node OR a
98+
// mustache string literal OR a single-part concat). Handles the empty /
99+
// whitespace / duplicate logic that's shared across those AST shapes.
100+
function processStaticTitle(node, raw) {
101+
const value = raw.trim();
102+
if (value.length === 0) {
103+
// Empty-string title always fails: no accessible name for screen readers.
104+
// Whitespace-only titles are controlled by `allowWhitespaceOnlyTitle`.
105+
if (raw.length === 0 || !allowWhitespaceOnlyTitle) {
106+
context.report({ node, messageId: 'emptyTitle' });
107+
}
108+
return;
109+
}
110+
// Duplicate check — reports BOTH the first and the current occurrence
111+
// on every collision, sharing a `#N` index so users can correlate them.
112+
// For three or more duplicates the first occurrence is therefore
113+
// re-reported once per collision.
114+
const existing = knownTitles.find((entry) => entry.value === value);
115+
if (existing) {
116+
if (existing.index === null) {
117+
existing.index = nextDuplicateIndex++;
118+
}
119+
const index = existing.index;
120+
context.report({
121+
node: existing.node,
122+
messageId: 'duplicateTitleFirst',
123+
data: { index: String(index) },
124+
});
125+
context.report({
126+
node,
127+
messageId: 'duplicateTitleOther',
128+
data: { title: raw, index: String(index) },
129+
});
130+
} else {
131+
knownTitles.push({ value, node, index: null });
132+
}
133+
}
134+
37135
return {
38136
GlimmerElementNode(node) {
39137
if (node.tag !== 'iframe') {
@@ -57,57 +155,55 @@ module.exports = {
57155
if (titleAttr.value) {
58156
switch (titleAttr.value.type) {
59157
case 'GlimmerTextNode': {
60-
const value = titleAttr.value.chars.trim();
61-
if (value.length === 0) {
62-
context.report({ node, messageId: 'emptyTitle' });
63-
} else {
64-
// Check for duplicate titles. Reports BOTH the first and the
65-
// current occurrence on every collision, sharing a `#N` index
66-
// so users can correlate them. For three or more duplicates
67-
// the first occurrence is therefore re-reported once per
68-
// collision.
69-
const existing = knownTitles.find((entry) => entry.value === value);
70-
if (existing) {
71-
if (existing.index === null) {
72-
existing.index = nextDuplicateIndex++;
73-
}
74-
const index = existing.index;
75-
76-
// Report on the first occurrence on every collision.
77-
context.report({
78-
node: existing.node,
79-
messageId: 'duplicateTitleFirst',
80-
data: { index: String(index) },
81-
});
82-
83-
// Report on the current (duplicate) occurrence.
84-
context.report({
85-
node,
86-
messageId: 'duplicateTitleOther',
87-
data: { title: titleAttr.value.chars, index: String(index) },
88-
});
89-
} else {
90-
knownTitles.push({ value, node, index: null });
91-
}
92-
}
158+
processStaticTitle(node, titleAttr.value.chars);
93159
break;
94160
}
95161
case 'GlimmerMustacheStatement': {
96-
// title={{false}} → BooleanLiteral false is invalid
97-
if (titleAttr.value.path?.type === 'GlimmerBooleanLiteral') {
98-
context.report({ node, messageId: 'dynamicFalseTitle' });
162+
// Non-string literal mustaches — boolean / null / undefined /
163+
// number — get a specific "invalidTitleLiteral" diagnostic
164+
// because the literal coerces to a string at runtime that
165+
// doesn't describe the frame contents.
166+
if (isInvalidTitleLiteralPath(titleAttr.value.path)) {
167+
context.report({
168+
node,
169+
messageId: 'invalidTitleLiteral',
170+
data: { literalType: getInvalidLiteralType(titleAttr.value.path) },
171+
});
172+
break;
173+
}
174+
// String-literal mustaches resolve to their static value — a
175+
// non-empty literal supplies an accessible name the same as a
176+
// text node. Empty / whitespace literals are flagged the same
177+
// way as `title=""` / `title=" "`.
178+
if (titleAttr.value.path?.type === 'GlimmerStringLiteral') {
179+
processStaticTitle(node, titleAttr.value.path.value);
99180
}
100181
break;
101182
}
102183
case 'GlimmerConcatStatement': {
103-
// title="{{false}}" → ConcatStatement with single BooleanLiteral part
104184
const parts = titleAttr.value.parts || [];
185+
// Single-part concat wrapping a non-string literal — same
186+
// diagnostic as the bare mustache form.
187+
if (
188+
parts.length === 1 &&
189+
parts[0].type === 'GlimmerMustacheStatement' &&
190+
isInvalidTitleLiteralPath(parts[0].path)
191+
) {
192+
context.report({
193+
node,
194+
messageId: 'invalidTitleLiteral',
195+
data: { literalType: getInvalidLiteralType(parts[0].path) },
196+
});
197+
break;
198+
}
199+
// Single-part concat wrapping a string literal — resolve to
200+
// the static value and apply the same checks as a text node.
105201
if (
106202
parts.length === 1 &&
107203
parts[0].type === 'GlimmerMustacheStatement' &&
108-
parts[0].path?.type === 'GlimmerBooleanLiteral'
204+
parts[0].path?.type === 'GlimmerStringLiteral'
109205
) {
110-
context.report({ node, messageId: 'dynamicFalseTitle' });
206+
processStaticTitle(node, parts[0].path.value);
111207
}
112208
break;
113209
}

0 commit comments

Comments
 (0)