Skip to content
Closed
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
29 changes: 25 additions & 4 deletions lib/rules/template-require-iframe-title.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Mustache path nodes that produce no accessible name. Booleans, null, undefined
// all coerce to empty-ish strings; numeric literals ("42") are accepted by HTML
// but provide no useful title for assistive tech.
function isInvalidTitleLiteral(path) {
if (!path) {
return false;
}
if (path.type === 'GlimmerBooleanLiteral') {
return true;
}
if (path.type === 'GlimmerNullLiteral' || path.type === 'GlimmerUndefinedLiteral') {
return true;
}
if (path.type === 'GlimmerNumberLiteral') {
return true;
}
return false;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand Down Expand Up @@ -93,19 +112,21 @@ module.exports = {
break;
}
case 'GlimmerMustacheStatement': {
// title={{false}} → BooleanLiteral false is invalid
if (titleAttr.value.path?.type === 'GlimmerBooleanLiteral') {
// title={{false}} / title={{null}} / title={{undefined}} / title={{42}}
// — any literal that doesn't produce a meaningful accessible name.
if (isInvalidTitleLiteral(titleAttr.value.path)) {
context.report({ node, messageId: 'dynamicFalseTitle' });
}
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'
isInvalidTitleLiteral(parts[0].path)
) {
context.report({ node, messageId: 'dynamicFalseTitle' });
}
Expand Down
209 changes: 209 additions & 0 deletions tests/audit/iframe-title/peer-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Audit fixture — peer-plugin parity for `ember/template-require-iframe-title`.
// These tests are NOT part of the main suite and do not run in CI. They encode
// the CURRENT behavior of our rule so that running this file reports pass.
// Each divergence from an upstream plugin is annotated as "DIVERGENCE —".
//
// Source files (context/ checkouts):
// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/iframe-has-title-test.js
// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/iframe-has-title.test.ts
// - eslint-plugin-lit-a11y/tests/lib/rules/iframe-title.js

'use strict';

const rule = require('../../../lib/rules/template-require-iframe-title');
const RuleTester = require('eslint').RuleTester;

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

ruleTester.run('audit:iframe-title (gts)', rule, {
valid: [
// === Upstream parity — basic cases ===
// jsx-a11y / vue-a11y / lit-a11y: valid (no iframe, or titled iframe).
'<template><div /></template>',
'<template><iframe title="Unique title" /></template>',

// Dynamic title — jsx-a11y treats `title={foo}` as valid (expression is
// assumed to yield a truthy string). vue-a11y: valid for `:title="foo"`.
// lit-a11y: valid for `title=${foo}`. Ours: valid for `{{someValue}}`.
'<template><iframe title={{someValue}} /></template>',

// Ours: valid for concat-mustache (dynamic in concat).
// No direct jsx-a11y analogue because JSX has no string-interpolation; but
// the equivalent `title={`${foo}`}` is treated as valid by jsx-a11y.
'<template><iframe title="{{someValue}}" /></template>',

// === OUR behavior (no upstream peer equivalent) — exemptions ===
// Our rule skips iframes that are aria-hidden or hidden.
// - jsx-a11y: does NOT exempt aria-hidden; `<iframe aria-hidden />`
// without a title is still flagged.
// - vue-a11y / lit-a11y: same — no aria-hidden/hidden exemption.
// Intentional: matches ember-template-lint upstream behavior.
'<template><iframe aria-hidden="true" /></template>',
'<template><iframe hidden /></template>',
'<template><iframe title="" aria-hidden /></template>',
'<template><iframe title="" hidden /></template>',

// === Remaining divergence — `title={{""}}` ===
// jsx-a11y flags `<iframe title={""} />` via getLiteralPropValue.
// Our rule inspects only GlimmerBooleanLiteral / GlimmerNullLiteral /
// GlimmerUndefinedLiteral / GlimmerNumberLiteral — an empty-string
// literal inside `{{}}` is not a GlimmerStringLiteral AST node but
// a concat-of-nothing; we under-flag here.
'<template><iframe title={{""}} /></template>',

// === Disambiguation — distinct titles across iframes (all valid) ===
'<template><iframe title="foo" /><iframe title="bar" /></template>',
],

invalid: [
// === Upstream parity — missing title ===
{
code: '<template><iframe /></template>',
output: null,
errors: [{ messageId: 'missingTitle' }],
},
{
code: '<template><iframe src="/content"></iframe></template>',
output: null,
errors: [{ messageId: 'missingTitle' }],
},

// === Upstream parity — empty title string ===
// jsx-a11y, vue-a11y, lit-a11y all flag `title=""`.
{
code: '<template><iframe title="" /></template>',
output: null,
errors: [{ messageId: 'emptyTitle' }],
},
// Whitespace-only title — ours trims then flags empty.
// jsx-a11y: `getLiteralPropValue(" ")` yields " " which is truthy, so
// jsx-a11y would NOT flag. vue-a11y similarly does not trim. Ours trims.
// DIVERGENCE — we over-flag whitespace-only titles.
{
code: '<template><iframe title=" " /></template>',
output: null,
errors: [{ messageId: 'emptyTitle' }],
},

// === Parity — title is literal boolean / null / undefined / number ===
// jsx-a11y: INVALID for any non-string literal (via getLiteralPropValue
// truthiness + string-check). vue-a11y: same. Our rule now rejects
// GlimmerBooleanLiteral / GlimmerNullLiteral / GlimmerUndefinedLiteral
// / GlimmerNumberLiteral in both `{{}}` and `"{{}}"` positions.
{
code: '<template><iframe title={{false}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title={{true}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title={{null}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title={{undefined}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title={{42}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
// Concat form — `title="{{false}}"` / etc. also flagged.
{
code: '<template><iframe title="{{false}}" /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title="{{null}}" /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title="{{42}}" /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},

// === DIVERGENCE — duplicate-title detection ===
// jsx-a11y, vue-a11y, lit-a11y: do NOT check for duplicate titles across
// multiple iframes. Our rule does (inherited from ember-template-lint).
// Captured here as one of our OVER-flagging cases (intentional extension).
{
code: '<template><iframe title="foo" /><iframe title="foo" /></template>',
output: null,
errors: [
{ message: 'This title is not unique. #1' },
{
message:
'<iframe> elements must have a unique title property. Value title="foo" already used for different iframe. #1',
},
],
},
],
});

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

hbsRuleTester.run('audit:iframe-title (hbs)', rule, {
valid: [
'<iframe title="Welcome" />',
'<iframe title={{someValue}} />',
// DIVERGENCE — exempted (see gts section)
'<iframe aria-hidden="true" />',
'<iframe hidden />',
],
invalid: [
{
code: '<iframe />',
output: null,
errors: [{ messageId: 'missingTitle' }],
},
{
code: '<iframe title="" />',
output: null,
errors: [{ messageId: 'emptyTitle' }],
},
// Parity — non-string literal titles.
{
code: '<iframe title={{false}} />',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<iframe title={{undefined}} />',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<iframe title={{42}} />',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
// DIVERGENCE — duplicate detection (upstream does not check).
{
code: '<iframe title="foo" /><iframe title="foo" />',
output: null,
errors: [
{ message: 'This title is not unique. #1' },
{
message:
'<iframe> elements must have a unique title property. Value title="foo" already used for different iframe. #1',
},
],
},
],
});
32 changes: 32 additions & 0 deletions tests/lib/rules/template-require-iframe-title.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,38 @@ ruleTester.run('template-require-iframe-title', rule, {
output: null,
errors: [{ messageId: 'emptyTitle' }],
},

// Mustache literals that don't coerce to a useful accessible name.
{
code: '<template><iframe title={{null}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title={{undefined}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title={{42}} /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title="{{null}}" /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title="{{undefined}}" /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
{
code: '<template><iframe title="{{42}}" /></template>',
output: null,
errors: [{ messageId: 'dynamicFalseTitle' }],
},
],
});

Expand Down
Loading