Skip to content

Commit ed2a963

Browse files
ShivaniNRcoliff
andauthored
Rule: allow input nested in label (#1170) (#1852)
* 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 * fix: only treat nesting as implicit label when for is absent - Labels with a for attribute target the referenced control, not nested inputs. Only increment labelDepth for labels without for. - Move <label for="other-id"><input> test to error cases. - Swap doc section headers (were backwards) and add visible text to nested label example. Addresses review feedback on #1852. --------- Co-authored-by: Christian Oliff <christianoliff@pm.me>
1 parent 6a431fc commit ed2a963

4 files changed

Lines changed: 100 additions & 8 deletions

File tree

dist/core/rules/input-requires-label.js

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

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

Lines changed: 25 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,36 @@ 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') {
2839
if ('for' in mapAttrs && mapAttrs['for'] !== '') {
40+
// explicit label: associates with the referenced control, not nested ones
2941
labelTags.push({ event: event, col: col, forValue: mapAttrs['for'] })
42+
} else if (!event.close) {
43+
// implicit label (no `for`): nesting labels the input
44+
// a self-closing <label/> opens no scope and emits no tagend
45+
labelDepth++
3046
}
3147
}
3248
})
3349

50+
parser.addListener('tagend', (event) => {
51+
if (event.tagName.toLowerCase() === 'label' && labelDepth > 0) {
52+
labelDepth--
53+
}
54+
})
55+
3456
parser.addListener('end', () => {
3557
inputTags.forEach((inputTag) => {
36-
if (!hasMatchingLabelTag(inputTag)) {
58+
if (!inputTag.nested && !hasMatchingLabelTag(inputTag)) {
3759
reporter.warn(
3860
'No matching [ label ] tag found.',
3961
inputTag.event.line,

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ 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+
// Multiple inputs inside one label are all accepted. The HTML spec
49+
// associates only the first labelable descendant with the label, but
50+
// this rule is about "every input has a label nearby" for a11y, not
51+
// strict spec conformance.
52+
it('Multiple inputs nested inside one label should result in no error', () => {
53+
const code =
54+
'<label><input type="password" /><input type="text" /></label>'
55+
const messages = HTMLHint.verify(code, ruleOptions)
56+
expect(messages.length).toBe(0)
57+
})
58+
59+
it('Hidden input nested inside label should result in no error', () => {
60+
const code = '<label><input type="hidden" /></label>'
61+
const messages = HTMLHint.verify(code, ruleOptions)
62+
expect(messages.length).toBe(0)
63+
})
4164
})
4265

4366
describe('Error cases', () => {
@@ -81,5 +104,37 @@ describe(`Rules: ${ruleId}`, () => {
81104
expect(messages[0].col).toBe(7)
82105
expect(messages[0].type).toBe('warning')
83106
})
107+
108+
it('Input nested in label with for pointing elsewhere should result in error', () => {
109+
const code = '<label for="other-id"><input type="password" /></label>'
110+
const messages = HTMLHint.verify(code, ruleOptions)
111+
expect(messages.length).toBe(1)
112+
expect(messages[0].rule.id).toBe(ruleId)
113+
expect(messages[0].type).toBe('warning')
114+
})
115+
116+
it('Input after a closed label should still result in error', () => {
117+
const code = '<label></label><input type="password" />'
118+
const messages = HTMLHint.verify(code, ruleOptions)
119+
expect(messages.length).toBe(1)
120+
expect(messages[0].rule.id).toBe(ruleId)
121+
expect(messages[0].type).toBe('warning')
122+
})
123+
124+
it('Input after self-closing label should still result in error', () => {
125+
const code = '<label /><input type="password" />'
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].type).toBe('warning')
130+
})
131+
132+
it('Unbalanced closing label tags should not break depth tracking', () => {
133+
const code = '</label></label><input type="password" />'
134+
const messages = HTMLHint.verify(code, ruleOptions)
135+
expect(messages.length).toBe(1)
136+
expect(messages[0].rule.id).toBe(ruleId)
137+
expect(messages[0].type).toBe('warning')
138+
})
84139
})
85140
})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Level: <Badge text="Warning" variant="caution" />
1919
- `true`: enable rule
2020
- `false`: disable rule
2121

22-
### The following patterns are **not** considered rule violations
22+
### The following patterns are considered rule violations
2323

2424
```html
2525
<input type="password">
@@ -28,11 +28,12 @@ Level: <Badge text="Warning" variant="caution" />
2828
<input type="password" /> <label for="something"/>
2929
```
3030

31-
### The following patterns are considered rule violations
31+
### The following patterns are **not** considered rule violations
3232

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>Password <input type="password" /></label>
3637
```
3738

3839
### Why this rule is important

0 commit comments

Comments
 (0)