Skip to content

Commit 4c13ef9

Browse files
authored
Add new rule: frame-title-require (#1629)
1 parent dfda7fa commit 4c13ef9

File tree

7 files changed

+223
-2
lines changed

7 files changed

+223
-2
lines changed

.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"idclassaddisabled",
3333
"Infima",
3434
"invision",
35+
"Labelledby",
3536
"langtag",
3637
"mingo",
3738
"msapplication",

dist/core/rules/index.js

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/rules/frame-title-require.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Rule } from '../types'
2+
3+
export default {
4+
id: 'frame-title-require',
5+
description: 'A <frame> or <iframe> element must have an accessible name.',
6+
init(parser, reporter) {
7+
parser.addListener('tagstart', (event) => {
8+
const tagName = event.tagName.toLowerCase()
9+
const mapAttrs = parser.getMapAttrs(event.attrs)
10+
const col = event.col + tagName.length + 1
11+
12+
if (tagName === 'frame' || tagName === 'iframe') {
13+
// Check if element has role="presentation" or role="none"
14+
const role = mapAttrs['role']
15+
if (role === 'presentation' || role === 'none') {
16+
return // Rule passes - element semantics are overridden
17+
}
18+
19+
// Check for accessible name attributes
20+
const hasAriaLabel =
21+
'aria-label' in mapAttrs && mapAttrs['aria-label'].trim() !== ''
22+
const hasAriaLabelledby =
23+
'aria-labelledby' in mapAttrs &&
24+
mapAttrs['aria-labelledby'].trim() !== ''
25+
const hasTitle = 'title' in mapAttrs && mapAttrs['title'].trim() !== ''
26+
27+
if (!hasAriaLabel && !hasAriaLabelledby && !hasTitle) {
28+
reporter.warn(
29+
`A <${tagName}> element must have an accessible name.`,
30+
event.line,
31+
col,
32+
this,
33+
event.raw
34+
)
35+
}
36+
}
37+
})
38+
},
39+
} as Rule

src/core/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { default as buttonTypeRequire } from './button-type-require'
1212
export { default as doctypeFirst } from './doctype-first'
1313
export { default as doctypeHTML5 } from './doctype-html5'
1414
export { default as emptyTagNotSelfClosed } from './empty-tag-not-self-closed'
15+
export { default as frameTitleRequire } from './frame-title-require'
1516
export { default as h1Require } from './h1-require'
1617
export { default as headScriptDisabled } from './head-script-disabled'
1718
export { default as hrefAbsOrRel } from './href-abs-or-rel'
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
const HTMLHint = require('../../dist/htmlhint.js').HTMLHint
2+
3+
const ruleId = 'frame-title-require'
4+
const ruleOptions = {}
5+
6+
ruleOptions[ruleId] = true
7+
8+
describe(`Rules: ${ruleId}`, () => {
9+
it('Frame with aria-label should not result in an error', () => {
10+
const code = '<frame src="test.html" aria-label="Test frame">'
11+
const messages = HTMLHint.verify(code, ruleOptions)
12+
expect(messages.length).toBe(0)
13+
})
14+
15+
it('Iframe with aria-label should not result in an error', () => {
16+
const code = '<iframe src="test.html" aria-label="Test iframe"></iframe>'
17+
const messages = HTMLHint.verify(code, ruleOptions)
18+
expect(messages.length).toBe(0)
19+
})
20+
21+
it('Frame with aria-labelledby should not result in an error', () => {
22+
const code = '<frame src="test.html" aria-labelledby="label1">'
23+
const messages = HTMLHint.verify(code, ruleOptions)
24+
expect(messages.length).toBe(0)
25+
})
26+
27+
it('Iframe with aria-labelledby should not result in an error', () => {
28+
const code = '<iframe src="test.html" aria-labelledby="label1"></iframe>'
29+
const messages = HTMLHint.verify(code, ruleOptions)
30+
expect(messages.length).toBe(0)
31+
})
32+
33+
it('Frame with title should not result in an error', () => {
34+
const code = '<frame src="test.html" title="Test frame">'
35+
const messages = HTMLHint.verify(code, ruleOptions)
36+
expect(messages.length).toBe(0)
37+
})
38+
39+
it('Iframe with title should not result in an error', () => {
40+
const code = '<iframe src="test.html" title="Test iframe"></iframe>'
41+
const messages = HTMLHint.verify(code, ruleOptions)
42+
expect(messages.length).toBe(0)
43+
})
44+
45+
it('Frame with role="presentation" should not result in an error', () => {
46+
const code = '<frame src="test.html" role="presentation">'
47+
const messages = HTMLHint.verify(code, ruleOptions)
48+
expect(messages.length).toBe(0)
49+
})
50+
51+
it('Iframe with role="none" should not result in an error', () => {
52+
const code = '<iframe src="test.html" role="none"></iframe>'
53+
const messages = HTMLHint.verify(code, ruleOptions)
54+
expect(messages.length).toBe(0)
55+
})
56+
57+
it('Frame without accessible name should result in an error', () => {
58+
const code = '<frame src="test.html">'
59+
const messages = HTMLHint.verify(code, ruleOptions)
60+
expect(messages.length).toBe(1)
61+
expect(messages[0].rule.id).toBe(ruleId)
62+
expect(messages[0].line).toBe(1)
63+
expect(messages[0].col).toBe(7)
64+
expect(messages[0].type).toBe('warning')
65+
expect(messages[0].message).toBe(
66+
'A <frame> element must have an accessible name.'
67+
)
68+
})
69+
70+
it('Iframe without accessible name should result in an error', () => {
71+
const code = '<iframe src="test.html"></iframe>'
72+
const messages = HTMLHint.verify(code, ruleOptions)
73+
expect(messages.length).toBe(1)
74+
expect(messages[0].rule.id).toBe(ruleId)
75+
expect(messages[0].line).toBe(1)
76+
expect(messages[0].col).toBe(8)
77+
expect(messages[0].type).toBe('warning')
78+
expect(messages[0].message).toBe(
79+
'A <iframe> element must have an accessible name.'
80+
)
81+
})
82+
83+
it('Frame with empty aria-label should result in an error', () => {
84+
const code = '<frame src="test.html" aria-label="">'
85+
const messages = HTMLHint.verify(code, ruleOptions)
86+
expect(messages.length).toBe(1)
87+
expect(messages[0].rule.id).toBe(ruleId)
88+
expect(messages[0].line).toBe(1)
89+
expect(messages[0].col).toBe(7)
90+
expect(messages[0].type).toBe('warning')
91+
})
92+
93+
it('Iframe with empty title should result in an error', () => {
94+
const code = '<iframe src="test.html" title=""></iframe>'
95+
const messages = HTMLHint.verify(code, ruleOptions)
96+
expect(messages.length).toBe(1)
97+
expect(messages[0].rule.id).toBe(ruleId)
98+
expect(messages[0].line).toBe(1)
99+
expect(messages[0].col).toBe(8)
100+
expect(messages[0].type).toBe('warning')
101+
})
102+
103+
it('Frame with whitespace-only aria-label should result in an error', () => {
104+
const code = '<frame src="test.html" aria-label=" ">'
105+
const messages = HTMLHint.verify(code, ruleOptions)
106+
expect(messages.length).toBe(1)
107+
expect(messages[0].rule.id).toBe(ruleId)
108+
expect(messages[0].line).toBe(1)
109+
expect(messages[0].col).toBe(7)
110+
expect(messages[0].type).toBe('warning')
111+
})
112+
113+
it('Iframe with whitespace-only aria-labelledby should result in an error', () => {
114+
const code = '<iframe src="test.html" aria-labelledby=" "></iframe>'
115+
const messages = HTMLHint.verify(code, ruleOptions)
116+
expect(messages.length).toBe(1)
117+
expect(messages[0].rule.id).toBe(ruleId)
118+
expect(messages[0].line).toBe(1)
119+
expect(messages[0].col).toBe(8)
120+
expect(messages[0].type).toBe('warning')
121+
})
122+
123+
it('Multiple iframes - one valid, one invalid should result in one error', () => {
124+
const code =
125+
'<iframe src="test1.html" title="Valid frame"></iframe><iframe src="test2.html"></iframe>'
126+
const messages = HTMLHint.verify(code, ruleOptions)
127+
expect(messages.length).toBe(1)
128+
expect(messages[0].rule.id).toBe(ruleId)
129+
expect(messages[0].line).toBe(1)
130+
expect(messages[0].col).toBe(62)
131+
expect(messages[0].type).toBe('warning')
132+
})
133+
134+
it('Other elements should not be affected', () => {
135+
const code = '<div><p>Content</p></div>'
136+
const messages = HTMLHint.verify(code, ruleOptions)
137+
expect(messages.length).toBe(0)
138+
})
139+
})

website/src/content/docs/list-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ draft: true
3131
- [`attr-value-single-quotes`](/rules/attr-value-single-quotes): Attribute values must be in single quotes.
3232
- [`attr-whitespace`](/rules/attr-whitespace): No leading or trailing spaces in attribute values.
3333
- [`button-type-require`](/rules/button-type-require): The type attribute of a button element must be present with a valid value: "button", "submit", or "reset".
34+
- [`frame-title-require`](/rules/frame-title-require): A `<frame>` or `<iframe>` element must have an accessible name.
3435
- [`input-requires-label`](/rules/input-requires-label): All [ input ] tags must have a corresponding [ label ] tag.
3536

3637
## Tags
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
id: frame-title-require
3+
title: frame-title-require
4+
description: Requires that frame and iframe elements have an accessible name for screen readers and assistive technologies.
5+
sidebar:
6+
hidden: true
7+
badge: New
8+
---
9+
10+
import { Badge } from '@astrojs/starlight/components';
11+
12+
A `<frame>` or `<iframe>` element must have an accessible name.
13+
14+
Level: <Badge text="Warning" variant="caution" />
15+
16+
## Config value
17+
18+
- `true`: enable rule
19+
- `false`: disable rule
20+
21+
### The following patterns are **not** considered rule violations
22+
23+
```html
24+
<iframe src="content.html" aria-label="Interactive content"></iframe>
25+
<iframe src="content.html" aria-labelledby="frame-heading"></iframe>
26+
<iframe src="content.html" title="Embedded content"></iframe>
27+
<iframe src="content.html" role="presentation"></iframe>
28+
<frame src="content.html" title="Navigation frame">
29+
```
30+
31+
### The following patterns are considered rule violations
32+
33+
```html
34+
<iframe src="content.html"></iframe>
35+
<iframe src="content.html" aria-label=""></iframe>
36+
<iframe src="content.html" title=""></iframe>
37+
<frame src="content.html">
38+
```

0 commit comments

Comments
 (0)