Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [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-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 | | | |

### Best Practices
Expand Down
53 changes: 53 additions & 0 deletions docs/rules/template-no-empty-headings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ember/template-no-empty-headings

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

Headings relay the structure of a webpage and provide a meaningful, hierarchical order of its content. If headings are empty or its text contents are inaccessible, this could confuse users or prevent them accessing sections of interest.

Disallow headings (h1, h2, etc.) with no accessible text content.

## Examples

This rule **forbids** the following:

```gjs
<template><h*></h*></template>
```

```gjs
<template><div role='heading' aria-level='1'></div></template>
```

```gjs
<template><h*><span aria-hidden='true'>Inaccessible text</span></h*></template>
```

This rule **allows** the following:

```gjs
<template><h*>Heading Content</h*></template>
```

```gjs
<template><h*><span>Text</span><h*></template>
```

```gjs
<template><div role='heading' aria-level='1'>Heading Content</div></template>
```

```gjs
<template><h* aria-hidden='true'>Heading Content</h*></template>
```

```gjs
<template><h* hidden>Heading Content</h*></template>
```

## Migration

If violations are found, remediation should be planned to ensure text content is present and visible and/or screen-reader accessible. Setting `aria-hidden="false"` or removing `hidden` attributes from the element(s) containing heading text may serve as a quickfix.

## References

- [WCAG SC 2.4.6 Headings and Labels](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html)
115 changes: 115 additions & 0 deletions lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);

function isHidden(node) {
if (!node.attributes) {
return false;
}
if (node.attributes.some((a) => a.name === 'hidden')) {
return true;
}
const ariaHidden = node.attributes.find((a) => a.name === 'aria-hidden');
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
return true;
}
return false;
}

function isComponent(node) {
if (node.type !== 'GlimmerElementNode') {
return false;
}
const tag = node.tag;
return /^[A-Z]/.test(tag) || tag.includes('::');
}

function isTextEmpty(text) {
// Treat &nbsp; (U+00A0) and regular whitespace as empty
return text.replaceAll(/\s/g, '').replaceAll('&nbsp;', '').length === 0;
}

function hasAccessibleContent(node) {
if (!node.children || node.children.length === 0) {
return false;
}

for (const child of node.children) {
// Text nodes — only counts if it has real visible characters
if (child.type === 'GlimmerTextNode') {
if (!isTextEmpty(child.chars)) {
return true;
}
continue;
}

// Mustache/block statements are dynamic content
if (child.type === 'GlimmerMustacheStatement' || child.type === 'GlimmerBlockStatement') {
return true;
}

// Element nodes
if (child.type === 'GlimmerElementNode') {
// Skip hidden elements entirely
if (isHidden(child)) {
continue;
}

// Component invocations count as content (they may render text)
if (isComponent(child)) {
return true;
}

// Recurse into non-hidden, non-component elements
if (hasAccessibleContent(child)) {
return true;
}
}
}
return false;
}

function isHeadingElement(node) {
if (HEADINGS.has(node.tag)) {
return true;
}
// Also detect <div role="heading" ...>
const roleAttr = node.attributes?.find((a) => a.name === 'role');
if (roleAttr?.value?.type === 'GlimmerTextNode' && roleAttr.value.chars === 'heading') {
return true;
}
return false;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow empty heading elements',
category: 'Accessibility',
strictGjs: true,
strictGts: true,
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-empty-headings.md',
},
schema: [],
messages: {
emptyHeading:
'Headings must contain accessible text content (or helper/component that provides text).',
},
},
create(context) {
return {
GlimmerElementNode(node) {
if (isHeadingElement(node)) {
// Skip if the heading itself is hidden
if (isHidden(node)) {
return;
}

if (!hasAccessibleContent(node)) {
context.report({ node, messageId: 'emptyHeading' });
}
}
},
};
},
};
131 changes: 131 additions & 0 deletions tests/lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
const rule = require('../../../lib/rules/template-no-empty-headings');
const RuleTester = require('eslint').RuleTester;

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

ruleTester.run('template-no-empty-headings', rule, {
valid: [
'<template><h1>Title</h1></template>',
'<template><h2>{{this.title}}</h2></template>',
'<template><h3><span>Text</span></h3></template>',
'<template><h4 hidden></h4></template>',
'<template><h1>Accessible Heading</h1></template>',
'<template><h1>Accessible&nbsp;Heading</h1></template>',
'<template><h1 aria-hidden="true">Valid Heading</h1></template>',
'<template><h1 aria-hidden="true"><span>Valid Heading</span></h1></template>',
'<template><h1 aria-hidden="false">Accessible Heading</h1></template>',
'<template><h1 hidden>Valid Heading</h1></template>',
'<template><h1 hidden><span>Valid Heading</span></h1></template>',
'<template><h1><span aria-hidden="true">Hidden text</span><span>Visible text</span></h1></template>',
'<template><h1><span aria-hidden="true">Hidden text</span>Visible text</h1></template>',
'<template><div role="heading" aria-level="1">Accessible Text</div></template>',
'<template><div role="heading" aria-level="1"><span>Accessible Text</span></div></template>',
'<template><div role="heading" aria-level="1"><span aria-hidden="true">Hidden text</span><span>Visible text</span></div></template>',
'<template><div role="heading" aria-level="1"><span aria-hidden="true">Hidden text</span>Visible text</div></template>',
'<template><div></div></template>',
'<template><p></p></template>',
'<template><span></span></template>',
'<template><header></header></template>',
'<template><h2><CustomComponent /></h2></template>',
'<template><h2>{{@title}}</h2></template>',
'<template><h2>{{#component}}{{/component}}</h2></template>',
'<template><h2><span>{{@title}}</span></h2></template>',
'<template><h2><div><CustomComponent /></div></h2></template>',
'<template><h2><div></div><CustomComponent /></h2></template>',
'<template><h2><div><span>{{@title}}</span></div></h2></template>',
'<template><h2><span>Some text{{@title}}</span></h2></template>',
'<template><h2><span><div></div>{{@title}}</span></h2></template>',
],
invalid: [
{
code: '<template><h1></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h2> </h2></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: `<template><h1>
&nbsp;</h1></template>`,
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span></span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: `<template><h1><span>
&nbsp;</span></h1></template>`,
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><div><span></span></div></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span></span><span></span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1> &nbsp; <div aria-hidden="true">Some hidden text</div></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span aria-hidden="true">Inaccessible text</span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span hidden>Inaccessible text</span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span hidden>{{@title}}</span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span hidden>{{#component}}Inaccessible text{{/component}}</span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span hidden><CustomComponent>Inaccessible text</CustomComponent></span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1><span aria-hidden="true">Hidden text</span><span aria-hidden="true">Hidden text</span></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><div role="heading" aria-level="1"></div></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><div role="heading" aria-level="1"><span aria-hidden="true">Inaccessible text</span></div></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><div role="heading" aria-level="1"><span hidden>Inaccessible text</span></div></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
],
});
Loading