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
20 changes: 17 additions & 3 deletions dist/core/rules/input-requires-label.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 25 additions & 3 deletions src/core/rules/input-requires-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ export default {
col: number
forValue?: string
}> = []
const inputTags: Array<{ event: Block; col: number; id?: string }> = []
const inputTags: Array<{
event: Block
col: number
id?: string
nested: boolean
}> = []
let labelDepth = 0

parser.addListener('tagstart', (event) => {
const tagName = event.tagName.toLowerCase()
Expand All @@ -20,20 +26,36 @@ export default {
if (tagName === 'input') {
// label is not required for hidden input
if (mapAttrs['type'] !== 'hidden') {
inputTags.push({ event: event, col: col, id: mapAttrs['id'] })
inputTags.push({
event: event,
col: col,
id: mapAttrs['id'],
nested: labelDepth > 0,
})
}
}

if (tagName === 'label') {
if ('for' in mapAttrs && mapAttrs['for'] !== '') {
// explicit label: associates with the referenced control, not nested ones
labelTags.push({ event: event, col: col, forValue: mapAttrs['for'] })
} else if (!event.close) {
// implicit label (no `for`): nesting labels the input
// a self-closing <label/> opens no scope and emits no tagend
labelDepth++
}
Comment on lines 38 to 46
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

labelDepth is incremented for every non-self-closing <label>, including labels with a for attribute. Per the HTML spec, implicit labeling via nesting only applies when for is not specified; if for exists, the label targets the referenced control and a nested <input> is not labeled by that <label>. This will incorrectly suppress warnings for <label for="other-id"><input ...></label> (and also for for=""). Track depth only for labels that can provide implicit labeling (e.g., for not present), or store a stack of label scopes with a flag indicating whether they are implicit.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. labelDepth now only increments for labels without a for attribute. Labels with for go to labelTags for explicit matching only, so correctly warns.

}
})

parser.addListener('tagend', (event) => {
if (event.tagName.toLowerCase() === 'label' && labelDepth > 0) {
labelDepth--
}
})

parser.addListener('end', () => {
inputTags.forEach((inputTag) => {
if (!hasMatchingLabelTag(inputTag)) {
if (!inputTag.nested && !hasMatchingLabelTag(inputTag)) {
reporter.warn(
Comment on lines 57 to 59
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new nested short-circuit (if (!inputTag.nested && !hasMatchingLabelTag(...))) means any input seen while labelDepth > 0 will skip validation entirely. Once depth tracking is limited to implicit labels (no for), consider whether inputs inside explicit labels should still be validated against labelTags (they typically still need their own matching label or other accessible name).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by the fix. Since labelDepth no longer increments for labels with for, inputs inside explicit labels won't be marked as nested and will go through normal hasMatchingLabelTag validation.

'No matching [ label ] tag found.',
inputTag.event.line,
Expand Down
55 changes: 55 additions & 0 deletions test/rules/input-requires-label.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ describe(`Rules: ${ruleId}`, () => {
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})

it('Input tag nested inside label tag should result in no error', () => {
const code = '<label><input type="password" /></label>'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})

// Multiple inputs inside one label are all accepted. The HTML spec
// associates only the first labelable descendant with the label, but
// this rule is about "every input has a label nearby" for a11y, not
// strict spec conformance.
it('Multiple inputs nested inside one label should result in no error', () => {
const code =
'<label><input type="password" /><input type="text" /></label>'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})

it('Hidden input nested inside label should result in no error', () => {
const code = '<label><input type="hidden" /></label>'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})
})

describe('Error cases', () => {
Expand Down Expand Up @@ -81,5 +104,37 @@ describe(`Rules: ${ruleId}`, () => {
expect(messages[0].col).toBe(7)
expect(messages[0].type).toBe('warning')
})

it('Input nested in label with for pointing elsewhere should result in error', () => {
const code = '<label for="other-id"><input type="password" /></label>'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].type).toBe('warning')
})

it('Input after a closed label should still result in error', () => {
const code = '<label></label><input type="password" />'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].type).toBe('warning')
})

it('Input after self-closing label should still result in error', () => {
const code = '<label /><input type="password" />'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].type).toBe('warning')
})

it('Unbalanced closing label tags should not break depth tracking', () => {
const code = '</label></label><input type="password" />'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].type).toBe('warning')
})
})
})
5 changes: 3 additions & 2 deletions website/src/content/docs/rules/input-requires-label.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Level: <Badge text="Warning" variant="caution" />
- `true`: enable rule
- `false`: disable rule

### The following patterns are **not** considered rule violations
### The following patterns are considered rule violations

```html
<input type="password">
Expand All @@ -28,11 +28,12 @@ Level: <Badge text="Warning" variant="caution" />
<input type="password" /> <label for="something"/>
```

### The following patterns are considered rule violations
### The following patterns are **not** considered rule violations

```html
<label for="some-id"/><input id="some-id" type="password" />
<input id="some-id" type="password" /> <label for="some-id"/>
<label>Password <input type="password" /></label>
```
Comment on lines 33 to 37
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested-input example was added under “considered rule violations”, but this PR changes the rule to allow an <input> nested in a <label> without needing for/id. Move this example to the non-violation section (and ensure the violation/non-violation examples match the rule’s actual behavior).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

@ShivaniNR ShivaniNR Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed along with the header swap mentioned above.


### Why this rule is important
Expand Down
Loading