Skip to content

Commit 548b47a

Browse files
committed
Rule: allow input nested in label (#1170)
- treat input nested in label as implicitly labeled - add tests for nested, multi-input, and edge cases - document the nested form Per HTML spec, a label's labeled control is either what for= points to, or its first labelable descendant. The rule previously only honored for/id matching. Fixes #1170
1 parent 4b74a34 commit 548b47a

3 files changed

Lines changed: 78 additions & 3 deletions

File tree

src/core/rules/input-requires-label.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ export default {
1010
col: number
1111
forValue?: string
1212
}> = []
13-
const inputTags: Array<{ event: Block; col: number; id?: string }> = []
13+
const inputTags: Array<{
14+
event: Block
15+
col: number
16+
id?: string
17+
nested: boolean
18+
}> = []
19+
let labelDepth = 0
1420

1521
parser.addListener('tagstart', (event) => {
1622
const tagName = event.tagName.toLowerCase()
@@ -20,20 +26,35 @@ export default {
2026
if (tagName === 'input') {
2127
// label is not required for hidden input
2228
if (mapAttrs['type'] !== 'hidden') {
23-
inputTags.push({ event: event, col: col, id: mapAttrs['id'] })
29+
inputTags.push({
30+
event: event,
31+
col: col,
32+
id: mapAttrs['id'],
33+
nested: labelDepth > 0,
34+
})
2435
}
2536
}
2637

2738
if (tagName === 'label') {
39+
// a self-closing <label/> opens no scope and emits no tagend
40+
if (!event.close) {
41+
labelDepth++
42+
}
2843
if ('for' in mapAttrs && mapAttrs['for'] !== '') {
2944
labelTags.push({ event: event, col: col, forValue: mapAttrs['for'] })
3045
}
3146
}
3247
})
3348

49+
parser.addListener('tagend', (event) => {
50+
if (event.tagName.toLowerCase() === 'label' && labelDepth > 0) {
51+
labelDepth--
52+
}
53+
})
54+
3455
parser.addListener('end', () => {
3556
inputTags.forEach((inputTag) => {
36-
if (!hasMatchingLabelTag(inputTag)) {
57+
if (!inputTag.nested && !hasMatchingLabelTag(inputTag)) {
3758
reporter.warn(
3859
'No matching [ label ] tag found.',
3960
inputTag.event.line,

test/rules/input-requires-label.spec.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@ describe(`Rules: ${ruleId}`, () => {
3838
const messages = HTMLHint.verify(code, ruleOptions)
3939
expect(messages.length).toBe(0)
4040
})
41+
42+
it('Input tag nested inside label tag should result in no error', () => {
43+
const code = '<label><input type="password" /></label>'
44+
const messages = HTMLHint.verify(code, ruleOptions)
45+
expect(messages.length).toBe(0)
46+
})
47+
48+
it('Input nested in label whose for points elsewhere should result in no error', () => {
49+
const code = '<label for="other-id"><input type="password" /></label>'
50+
const messages = HTMLHint.verify(code, ruleOptions)
51+
expect(messages.length).toBe(0)
52+
})
53+
54+
// Multiple inputs inside one label are all accepted. The HTML spec
55+
// associates only the first labelable descendant with the label, but
56+
// this rule is about "every input has a label nearby" for a11y, not
57+
// strict spec conformance.
58+
it('Multiple inputs nested inside one label should result in no error', () => {
59+
const code =
60+
'<label><input type="password" /><input type="text" /></label>'
61+
const messages = HTMLHint.verify(code, ruleOptions)
62+
expect(messages.length).toBe(0)
63+
})
64+
65+
it('Hidden input nested inside label should result in no error', () => {
66+
const code = '<label><input type="hidden" /></label>'
67+
const messages = HTMLHint.verify(code, ruleOptions)
68+
expect(messages.length).toBe(0)
69+
})
4170
})
4271

4372
describe('Error cases', () => {
@@ -81,5 +110,29 @@ describe(`Rules: ${ruleId}`, () => {
81110
expect(messages[0].col).toBe(7)
82111
expect(messages[0].type).toBe('warning')
83112
})
113+
114+
it('Input after a closed label should still result in error', () => {
115+
const code = '<label></label><input type="password" />'
116+
const messages = HTMLHint.verify(code, ruleOptions)
117+
expect(messages.length).toBe(1)
118+
expect(messages[0].rule.id).toBe(ruleId)
119+
expect(messages[0].type).toBe('warning')
120+
})
121+
122+
it('Input after self-closing label should still result in error', () => {
123+
const code = '<label /><input type="password" />'
124+
const messages = HTMLHint.verify(code, ruleOptions)
125+
expect(messages.length).toBe(1)
126+
expect(messages[0].rule.id).toBe(ruleId)
127+
expect(messages[0].type).toBe('warning')
128+
})
129+
130+
it('Unbalanced closing label tags should not break depth tracking', () => {
131+
const code = '</label></label><input type="password" />'
132+
const messages = HTMLHint.verify(code, ruleOptions)
133+
expect(messages.length).toBe(1)
134+
expect(messages[0].rule.id).toBe(ruleId)
135+
expect(messages[0].type).toBe('warning')
136+
})
84137
})
85138
})

website/src/content/docs/rules/input-requires-label.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Level: <Badge text="Warning" variant="caution" />
3333
```html
3434
<label for="some-id"/><input id="some-id" type="password" />
3535
<input id="some-id" type="password" /> <label for="some-id"/>
36+
<label><input type="password" /></label>
3637
```
3738

3839
### Why this rule is important

0 commit comments

Comments
 (0)