feat(html): port noAriaHiddenOnFocusable a11y rule to HTML#9496
feat(html): port noAriaHiddenOnFocusable a11y rule to HTML#9496aviraldua93 wants to merge 1 commit intobiomejs:nextfrom
Conversation
🦋 Changeset detectedLatest commit: 1ffe6b4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 13 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
WalkthroughThis PR introduces a new HTML accessibility lint rule Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs`:
- Around line 155-164: The check treating all inputs as natively focusable is
causing false positives for <input type="hidden">; update the conditional that
currently uses name_matches("input") (in the focusability check inside
no_aria_hidden_on_focusable.rs) to exclude inputs whose type attribute equals
"hidden" (case-insensitive). Implement the check inline or add a helper like
is_hidden_input(node) that reads the "type" attribute and returns true for
"hidden", and only treat the element as focusable when name_matches("input") &&
!is_hidden_input(node).
- Around line 171-180: has_contenteditable_true currently returns false when the
contenteditable attribute exists without a value or is an empty string; change
the logic to treat an attribute present with no value or an empty value as
truthy per the HTML spec: call element.find_attribute_by_name("contenteditable")
and match on the Option, returning false only when the attribute is None, but
when Some(attr) return true if attr.value() is None OR (attr.value() is Some(v)
and v.trim().eq_ignore_ascii_case("false") is false); update
has_contenteditable_true (and reference AnyHtmlElement/find_attribute_by_name
and attr.value()) accordingly so missing-value and empty-string cases are
detected as true.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7ff8e9ec-a6c9-43ea-b217-637a1aecbdcb
📒 Files selected for processing (3)
crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rscrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.htmlcrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html
crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs
Outdated
Show resolved
Hide resolved
crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs
Outdated
Show resolved
Hide resolved
a91f417 to
b57ecff
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (2)
crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs (2)
171-180:⚠️ Potential issue | 🟠 MajorTreat empty/missing
contenteditablevalue as truthy.At Line 175-179,
has_contenteditable_truemisses<div contenteditable ...>and<div contenteditable="" ...>, which should both be treated as editable in HTML. This creates false negatives.Proposed patch
fn has_contenteditable_true(element: &AnyHtmlElement) -> bool { element .find_attribute_by_name("contenteditable") - .is_some_and(|attr| { - attr.value().is_some_and(|value| { - let trimmed = value.trim(); - !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("false") - }) - }) + .is_some_and(|attr| match attr.value() { + None => true, + Some(value) => { + let trimmed = value.trim(); + trimmed.is_empty() || !trimmed.eq_ignore_ascii_case("false") + } + }) }Per HTML spec, what state does contenteditable have when present without a value, or with an empty string value?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs` around lines 171 - 180, The function has_contenteditable_true incorrectly treats elements like <div contenteditable> and <div contenteditable=""> as non-editable; update has_contenteditable_true to treat presence of the contenteditable attribute with no value or an empty string as truthy per the HTML spec by returning true when the attribute exists and its value is either missing or empty, and only return false when the attribute value is explicitly "false" (case-insensitive); locate has_contenteditable_true and adjust the attribute value check to consider attr.value() being None or Some("") as true while still treating "false" as false.
155-164:⚠️ Potential issue | 🟠 MajorExclude hidden inputs from native-focusable detection.
Line 157 currently treats all
<input>elements as focusable. That includestype="hidden", which should not trigger this rule, so this can raise false positives. Also add a fixture for<input type="hidden" aria-hidden="true">to lock this down.Proposed patch
- if name_matches("button") - || name_matches("input") - || name_matches("select") + if name_matches("button") + || name_matches("select") || name_matches("textarea") || name_matches("details") || name_matches("summary") { return true; } + + if name_matches("input") { + let is_hidden_input = element + .find_attribute_by_name("type") + .and_then(|attr| attr.value()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("hidden")); + return !is_hidden_input; + }In the HTML Standard, is <input type="hidden"> part of sequential keyboard focus navigation (tab order)?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs` around lines 155 - 164, The current native-focusable check treats all inputs as focusable (the name_matches("input") branch) which incorrectly includes <input type="hidden">; update the logic so the input case explicitly excludes inputs where the type attribute is "hidden" (e.g., check the element's "type" attribute or add an is_hidden_input helper and use it in the early-return condition), and add a fixture/test for <input type="hidden" aria-hidden="true"> to verify it no longer triggers the rule; ensure you update the same function containing the name_matches("input") check so hidden inputs are not considered focusable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs`:
- Around line 171-180: The function has_contenteditable_true incorrectly treats
elements like <div contenteditable> and <div contenteditable=""> as
non-editable; update has_contenteditable_true to treat presence of the
contenteditable attribute with no value or an empty string as truthy per the
HTML spec by returning true when the attribute exists and its value is either
missing or empty, and only return false when the attribute value is explicitly
"false" (case-insensitive); locate has_contenteditable_true and adjust the
attribute value check to consider attr.value() being None or Some("") as true
while still treating "false" as false.
- Around line 155-164: The current native-focusable check treats all inputs as
focusable (the name_matches("input") branch) which incorrectly includes <input
type="hidden">; update the logic so the input case explicitly excludes inputs
where the type attribute is "hidden" (e.g., check the element's "type" attribute
or add an is_hidden_input helper and use it in the early-return condition), and
add a fixture/test for <input type="hidden" aria-hidden="true"> to verify it no
longer triggers the rule; ensure you update the same function containing the
name_matches("input") check so hidden inputs are not considered focusable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 08de291f-12a3-42f6-85da-cbf9ffc788ea
⛔ Files ignored due to path filters (2)
crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.html.snapis excluded by!**/*.snapand included by**crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html.snapis excluded by!**/*.snapand included by**
📒 Files selected for processing (4)
.changeset/port-no-aria-hidden-on-focusable-html.mdcrates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rscrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.htmlcrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html
✅ Files skipped from review due to trivial changes (1)
- .changeset/port-no-aria-hidden-on-focusable-html.md
b57ecff to
31fb6dc
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.html`:
- Around line 9-10: The test snapshot for the noAriaHiddenOnFocusable spec is
out of sync because two new invalid inputs were added (contenteditable elements
with aria-hidden); regenerate and commit the updated snapshot for that fixture:
run the snapshot-update command used by this repo (e.g. the insta snapshot
update flow or the project’s test update command) for the
tests/specs/a11y/noAriaHiddenOnFocusable suite, verify the new diagnostics for
the two added lines are present, and commit the updated invalid.html.snap so the
fixture and expected output match.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 6d1b4aee-6ec5-4d0a-b339-dda8ee5672bc
⛔ Files ignored due to path filters (2)
crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.html.snapis excluded by!**/*.snapand included by**crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html.snapis excluded by!**/*.snapand included by**
📒 Files selected for processing (5)
.changeset/port-no-aria-hidden-on-focusable-html.mdcrates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rscrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.htmlcrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.htmlpr-body.md
🚧 Files skipped from review as they are similar to previous changes (2)
- .changeset/port-no-aria-hidden-on-focusable-html.md
- crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs
| <div contenteditable aria-hidden="true">editable bare attr</div> | ||
| <div contenteditable="" aria-hidden="true">editable empty string</div> |
There was a problem hiding this comment.
Update the spec snapshot for the new contenteditable cases.
Lines 9–10 add two new invalid inputs, but the existing invalid.html.snap (shown in context) doesn’t include their diagnostics yet. Please regenerate/update the snapshot so the fixture and expected output stay in sync.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.html`
around lines 9 - 10, The test snapshot for the noAriaHiddenOnFocusable spec is
out of sync because two new invalid inputs were added (contenteditable elements
with aria-hidden); regenerate and commit the updated snapshot for that fixture:
run the snapshot-update command used by this repo (e.g. the insta snapshot
update flow or the project’s test update command) for the
tests/specs/a11y/noAriaHiddenOnFocusable suite, verify the new diagnostics for
the two added lines are present, and commit the updated invalid.html.snap so the
fixture and expected output match.
ematipico
left a comment
There was a problem hiding this comment.
Thank you, there are a lot of missing tests:
- case sensitivity
- components
| "@biomejs/biome": minor | ||
| --- | ||
|
|
||
| # Port `noAriaHiddenOnFocusable` a11y lint rule to HTML |
There was a problem hiding this comment.
Refer to our contribution guide on how to write a changeset
| if let Some(tabindex_attr) = element.find_attribute_by_name("tabindex") { | ||
| if let Some(tabindex_value) = get_tabindex_value(&tabindex_attr) { |
There was a problem hiding this comment.
Use let chain. You didn't use clippy, did you ?
| /// Parses the tabindex attribute value as an integer. | ||
| fn get_tabindex_value(attribute: &HtmlAttribute) -> Option<i32> { | ||
| let value = attribute.value()?; | ||
| value.trim().parse::<i32>().ok() |
There was a problem hiding this comment.
Using i32 seems a bit much. Does the JSX rule use the same logic?
| value.trim().parse::<i32>().ok() | ||
| } | ||
|
|
||
| /// Checks if an element is natively focusable or has contenteditable. |
There was a problem hiding this comment.
docstring should explain the business logic of a function. Here, you want to explain when an element is focusable
| } | ||
|
|
||
| /// Checks if an element is natively focusable or has contenteditable. | ||
| fn is_focusable_element(element: &AnyHtmlElement, is_html: bool) -> bool { |
There was a problem hiding this comment.
Change the return type to Option<bool> so that you can use the try operator a reduce code branching
| if is_html { | ||
| element_name.eq_ignore_ascii_case(name) | ||
| } else { | ||
| element_name.text() == name |
Merging this PR will degrade performance by 23.76%
Performance Changes
Comparing Footnotes
|
Port the noAriaHiddenOnFocusable lint rule from JSX to HTML. The rule enforces that aria-hidden=true is not set on focusable elements. Addresses review feedback: - Use let-chains for tabindex checking (clippy-friendly) - Return Option<bool> from is_focusable_element with ? operator - Add business-logic doc comments to all helper functions - Exclude input type=hidden from focusability (HTML spec) - Fix contenteditable to match HTML spec enumerated attribute states: only true/empty/plaintext-only are editing hosts, invalid values map to Inherit state (not an editing host) - Add case sensitivity tests (BUTTON, INPUT uppercase) - Add component test (MyButton — not flagged) - Add area/details/summary test coverage - Changeset uses patch with example snippet Part of biomejs#8155. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
31fb6dc to
1ffe6b4
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html (1)
13-13: Consider adding an invalidcontenteditablevalue test case.Per the HTML spec, invalid values like
contenteditable="banana"fall back to the Inherit state (not an editing host). Adding a case like<div contenteditable="banana" aria-hidden="true">would document this edge case behaviour.Suggested addition
<div contenteditable="false" aria-hidden="true">not editable</div> +<div contenteditable="banana" aria-hidden="true">invalid value inherits</div> <BUTTON aria-hidden="true" tabindex="-1"></BUTTON>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html` at line 13, Add a test case showing the invalid contenteditable value behavior by including an element like <div contenteditable="banana" aria-hidden="true">not editable</div> in the valid.html tests for noAriaHiddenOnFocusable; this documents that invalid values fall back to Inherit (not an editing host) so aria-hidden on that element is acceptable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs`:
- Around line 165-173: The natively-focusable check (the block using
name_matches for "button", "select", "textarea", "details", "summary") should
also verify the element is not disabled before returning Some(true); update that
logic (e.g., call element.find_attribute_by_name("disabled").is_none()) so
disabled form controls are treated as unfocusable, and add corresponding tests
(e.g., fixtures with <button disabled aria-hidden="true">) to the valid cases to
cover this behavior for both the Rust rule and the JS equivalent.
---
Nitpick comments:
In
`@crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html`:
- Line 13: Add a test case showing the invalid contenteditable value behavior by
including an element like <div contenteditable="banana" aria-hidden="true">not
editable</div> in the valid.html tests for noAriaHiddenOnFocusable; this
documents that invalid values fall back to Inherit (not an editing host) so
aria-hidden on that element is acceptable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 6126c960-eb87-415d-b914-53e65ab076e4
📒 Files selected for processing (4)
.changeset/port-no-aria-hidden-on-focusable-html.mdcrates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rscrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.htmlcrates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html
✅ Files skipped from review due to trivial changes (1)
- .changeset/port-no-aria-hidden-on-focusable-html.md
| // These elements are always natively focusable | ||
| if name_matches("button") | ||
| || name_matches("select") | ||
| || name_matches("textarea") | ||
| || name_matches("details") | ||
| || name_matches("summary") | ||
| { | ||
| return Some(true); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if the JSX version handles disabled elements
rg -n -A 10 "disabled" crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rsRepository: biomejs/biome
Length of output: 39
🏁 Script executed:
# First, let's find the correct paths for both HTML and JS rules
fd -t f "no_aria_hidden_on_focusable" crates/Repository: biomejs/biome
Length of output: 257
🏁 Script executed:
# Let's also check the structure of the analyzers
ls -la crates/ | grep analyzeRepository: biomejs/biome
Length of output: 536
🏁 Script executed:
# Search for how disabled is handled in the HTML rule
rg -n "disabled" crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rsRepository: biomejs/biome
Length of output: 39
🏁 Script executed:
# Read the HTML rule implementation
wc -l crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rsRepository: biomejs/biome
Length of output: 131
🏁 Script executed:
# Read the JS rule implementation
wc -l crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rsRepository: biomejs/biome
Length of output: 129
🏁 Script executed:
# Check test specs for HTML rule
fd -t f "no_aria_hidden_on_focusable" crates/biome_html_analyze/tests/Repository: biomejs/biome
Length of output: 39
🏁 Script executed:
# Read the HTML rule implementation
cat -n crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs | head -100Repository: biomejs/biome
Length of output: 4364
🏁 Script executed:
# Read the JS rule implementation
cat -n crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rsRepository: biomejs/biome
Length of output: 6722
🏁 Script executed:
# Find test specs with broader search
find crates/biome_html_analyze/tests -type d | grep -i "no_aria\|focusable\|a11y"Repository: biomejs/biome
Length of output: 1914
🏁 Script executed:
# Read the rest of HTML rule to see the is_focusable_element function and lines 165-173
cat -n crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs | tail -n +100Repository: biomejs/biome
Length of output: 5374
🏁 Script executed:
# Check HTML test specs
ls -la crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/Repository: biomejs/biome
Length of output: 289
🏁 Script executed:
# Read HTML test spec files
cat crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.htmlRepository: biomejs/biome
Length of output: 799
🏁 Script executed:
cat crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.htmlRepository: biomejs/biome
Length of output: 738
🏁 Script executed:
# Check if there's a JS test spec for comparison
ls -la crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/ 2>/dev/null || echo "No JS test spec found"Repository: biomejs/biome
Length of output: 424
🏁 Script executed:
# Read JS test specs
cat crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.jsxRepository: biomejs/biome
Length of output: 367
🏁 Script executed:
cat crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.jsxRepository: biomejs/biome
Length of output: 284
🏁 Script executed:
# Search for "disabled" in both test directories
rg -n "disabled" crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/
rg -n "disabled" crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/Repository: biomejs/biome
Length of output: 39
Both rules should check the disabled attribute for natively focusable elements.
Per the HTML spec, disabled form controls (<button disabled>, <input disabled>, etc.) are not focusable and therefore shouldn't trigger this lint when paired with aria-hidden="true". Currently, neither the HTML nor the JS rule checks the disabled attribute—the HTML rule at lines 165–173 returns Some(true) for buttons/inputs without verifying they're enabled. Test coverage for disabled elements is also missing in both implementations.
Recommend adding a check: element.find_attribute_by_name("disabled").is_none() before marking these elements as focusable, and adding test cases like <button disabled aria-hidden="true"> to the valid fixtures.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs`
around lines 165 - 173, The natively-focusable check (the block using
name_matches for "button", "select", "textarea", "details", "summary") should
also verify the element is not disabled before returning Some(true); update that
logic (e.g., call element.find_attribute_by_name("disabled").is_none()) so
disabled form controls are treated as unfocusable, and add corresponding tests
(e.g., fixtures with <button disabled aria-hidden="true">) to the valid cases to
cover this behavior for both the Rust rule and the JS equivalent.
Summary
Ports the
noAriaHiddenOnFocusablelint rule from JSX to HTML, as part of the umbrella issue #8155.What the rule does
Enforces that
aria-hidden="true"is not set on focusable elements. A focusable element witharia-hiddencan be reached by keyboard but is invisible to screen readers, causing confusion.What counts as focusable
tabindex(e.g.,tabindex="0")<button>,<input>,<select>,<textarea>,<details>,<summary>,<a href="...">,<area href="...">contenteditable="true"Exemptions
aria-hidden="true"on non-focusable elements (div, span, etc.) is validtabindex="-1"witharia-hidden="true"is valid (intentionally removed from tab order)Fix
Provides an unsafe fix that removes the
aria-hiddenattribute.Test plan
valid.html: aria-hidden on non-focusable elements, focusable elements without aria-hidden, negative tabindex with aria-hiddeninvalid.html: button/a/input/select/textarea with aria-hidden, tabindex="0" with aria-hidden, contenteditable with aria-hiddenCloses part of #8155.