Skip to content

fix: warn when implicit label has no visible text#1864

Open
ShivaniNR wants to merge 2 commits intohtmlhint:mainfrom
ShivaniNR:dev/shivaninr/empty-label-text-warning
Open

fix: warn when implicit label has no visible text#1864
ShivaniNR wants to merge 2 commits intohtmlhint:mainfrom
ShivaniNR:dev/shivaninr/empty-label-text-warning

Conversation

@ShivaniNR
Copy link
Copy Markdown
Contributor

Short description of what this resolves

Closes #1860

An implicit label wrapping an input (<label><input /></label>) without any visible text is inaccessible; screen readers find the input but have no name to announce. After #1852 added implicit label support, this case was silently accepted.

Proposed changes

  • Added a labelHasText flag that resets when an implicit <label> opens
  • Added a text event listener that sets the flag when non-whitespace text is found inside the label
  • On </label> close, if no text was found, nested inputs are marked as unlabeled, so the existing warning fires
  • Updated tests: empty/whitespace-only labels now warn, labels with visible text pass

@ShivaniNR ShivaniNR requested a review from coliff as a code owner April 22, 2026 23:18
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request updates the input-requires-label rule to ensure that labels with nested inputs contain descriptive, non-whitespace text. While the changes introduce a text listener and updated tests, a logic bug was identified where the use of a single boolean flag incorrectly tracks text presence across multiple or nested labels. The reviewer provides actionable feedback and code suggestions to replace this logic with a state stack, ensuring correct validation for each label's scope.

Comment thread src/core/rules/input-requires-label.ts Outdated
nested: boolean
}> = []
let labelDepth = 0
let labelHasText = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using a single boolean labelHasText and a global loop over inputTags in the tagend listener is problematic for two reasons:

  1. False Positives: When an empty label is encountered, the current code resets the nested flag for all inputs found so far in the document, even those correctly nested in previous valid labels.
  2. Nested Labels: A nested label will reset the labelHasText state for its parent label, potentially causing the parent label to be treated as empty even if it contains text.

To fix this, use a stack to track the state (text presence and starting input index) for each label depth.

Suggested change
let labelHasText = false
const labelStateStack: Array<{ hasText: boolean; inputStartIndex: number }> = []

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.

Done. Replaced let labelHasText = false with const labelStateStack: Array<{ hasText: boolean; inputStartIndex: number }> = [] to track state per label depth

Comment thread src/core/rules/input-requires-label.ts Outdated
// implicit label (no `for`): nesting labels the input
// a self-closing <label/> opens no scope and emits no tagend
labelDepth++
labelHasText = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Push the initial state for the new label context onto the stack.

          labelStateStack.push({
            hasText: false,
            inputStartIndex: inputTags.length,
          })

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.

Done. Now pushing { hasText: false, inputStartIndex: inputTags.length } on label open instead of resetting a global boolean.

Comment thread src/core/rules/input-requires-label.ts Outdated
Comment on lines 54 to 61
if (!labelHasText) {
inputTags.forEach((input) => {
if (input.nested) {
input.nested = false
}
})
}
labelDepth--
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Pop the state for the current label and only reset the nested flag for inputs that were added within this specific label's scope. Also, propagate the text presence to the parent label if applicable.

        const state = labelStateStack.pop()
        if (state && !state.hasText) {
          for (let i = state.inputStartIndex; i < inputTags.length; i++) {
            inputTags[i].nested = false
          }
        } else if (state && state.hasText && labelStateStack.length > 0) {
          labelStateStack[labelStateStack.length - 1].hasText = true
        }
        labelDepth--

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.

Done. The tagend listener now pops the stack, only resets nested for inputs within the current label's scope (from inputStartIndex), and propagates hasText to the parent label. Also added two tests covering the multi-label and nested-label edge cases.

Comment thread src/core/rules/input-requires-label.ts Outdated

parser.addListener('text', (event) => {
if (labelDepth > 0 && event.raw && !/^\s*$/.test(event.raw)) {
labelHasText = true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the text presence flag for the current label context in the stack.

Suggested change
labelHasText = true
labelStateStack[labelStateStack.length - 1].hasText = true

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.

Done.

Comment thread dist/core/rules/input-requires-label.js Outdated
const labelTags = [];
const inputTags = [];
let labelDepth = 0;
let labelHasText = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

This compiled file contains the same logic bug as the source. It resets the nested flag for all inputs globally when any label is empty. Use a state stack instead.

Suggested change
let labelHasText = false;
const labelStateStack = [];

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.

Done.

Comment thread dist/core/rules/input-requires-label.js Outdated
}
else if (!event.close) {
labelDepth++;
labelHasText = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Initialize the state for the current label context.

Suggested change
labelHasText = false;
labelStateStack.push({ hasText: false, inputStartIndex: inputTags.length });

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.

Done.

Comment thread dist/core/rules/input-requires-label.js Outdated
Comment on lines 37 to 44
if (!labelHasText) {
inputTags.forEach((input) => {
if (input.nested) {
input.nested = false;
}
});
}
labelDepth--;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Only reset inputs belonging to the current label context and propagate text presence to the parent.

                const state = labelStateStack.pop();
                if (state && !state.hasText) {
                    for (let i = state.inputStartIndex; i < inputTags.length; i++) {
                        inputTags[i].nested = false;
                    }
                }
                else if (state && state.hasText && labelStateStack.length > 0) {
                    labelStateStack[labelStateStack.length - 1].hasText = true;
                }
                labelDepth--;

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.

Done.

Comment thread dist/core/rules/input-requires-label.js Outdated
});
parser.addListener('text', (event) => {
if (labelDepth > 0 && event.raw && !/^\s*$/.test(event.raw)) {
labelHasText = true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the state stack when text is found.

Suggested change
labelHasText = true;
labelStateStack[labelStateStack.length - 1].hasText = true;

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.

Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

input-requires-label: warn when label wraps input but contains no visible text

1 participant