Skip to content

feat(linter): add noNonInteractiveTabIndex for HTML#9306

Merged
ematipico merged 8 commits intobiomejs:nextfrom
viraxslot:feat-html-no-noninteractive-tabindex
Apr 17, 2026
Merged

feat(linter): add noNonInteractiveTabIndex for HTML#9306
ematipico merged 8 commits intobiomejs:nextfrom
viraxslot:feat-html-no-noninteractive-tabindex

Conversation

@viraxslot
Copy link
Copy Markdown
Contributor

Summary

This PR was created with AI assistance (Claude Code).

Implements the noNoninteractiveTabindex a11y rule for HTML files, porting the existing JSX/TSX rule to the HTML analyzer.

This rule enforces that tabindex is not used on non-interactive elements, as it can cause usability issues for keyboard users. The rule allows tabindex on interactive elements (like buttons and links) and permits negative tabindex values for managing focus programmatically.

Part of #8155

Test Plan

  • Added test fixtures in tests/specs/a11y/noNoninteractiveTabindex/
  • invalid.html contains 3 test cases that should trigger the rule:
    • <div tabindex="0"> - non-interactive element with positive tabindex
    • <div role="article" tabindex="0"> - non-interactive role with positive tabindex
    • <article tabindex="0"> - non-interactive semantic element with positive tabindex
  • valid.html contains 3 valid cases:
    • <div> - no tabindex attribute
    • <button tabindex="0"> - interactive element with tabindex
    • <article tabindex="-1"> - negative tabindex for programmatic focus management
  • All tests pass with cargo test -p biome_html_analyze no_noninteractive_tabindex
  • Snapshots generated and reviewed with cargo insta review

Docs

Documentation is included in the rule's rustdoc comments with examples of both invalid and valid cases.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 3, 2026

🦋 Changeset detected

Latest commit: caf9531

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@biomejs/biome Minor
@biomejs/cli-win32-x64 Minor
@biomejs/cli-win32-arm64 Minor
@biomejs/cli-darwin-x64 Minor
@biomejs/cli-darwin-arm64 Minor
@biomejs/cli-linux-x64 Minor
@biomejs/cli-linux-arm64 Minor
@biomejs/cli-linux-x64-musl Minor
@biomejs/cli-linux-arm64-musl Minor
@biomejs/wasm-web Minor
@biomejs/wasm-bundler Minor
@biomejs/wasm-nodejs Minor
@biomejs/backend-jsonrpc Patch
@biomejs/js-api Major

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

@github-actions github-actions bot added A-Linter Area: linter L-HTML Language: HTML and super languages labels Mar 3, 2026
}
}

fn is_not_interactive_element(node: &AnyHtmlElement) -> Option<bool> {
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.

I'm using a simplified is_not_interactive_element check here. I found the full implementation in biome_aria::roles::AriaRoles::is_not_interactive_element that handles more edge cases, but I wasn't sure how to make it work with HTML AST.

Could you please suggest how to expose/reuse that logic for HTML rules? Or is the simplified approach acceptable for now?

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.

implement the Element and Attribute traits that it uses for html elements and attributes. then you can pass those in to the is_not_interactive_element function

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 3, 2026

Merging this PR will degrade performance by 64.45%

❌ 4 regressed benchmarks
✅ 63 untouched benchmarks
⏩ 189 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
html_analyzer[index_1033418810622582172.html] 474.9 µs 991.6 µs -52.11%
html_analyzer[real/wikipedia-JavaScript.html] 192.8 ms 542.4 ms -64.45%
html_analyzer[real/wikipedia-fr-Guerre_de_Canudos.html] 468.1 ms 1,075.4 ms -56.47%
html_analyzer[real/wikipedia-Unix.html] 169.6 ms 447.7 ms -62.12%

Comparing viraxslot:feat-html-no-noninteractive-tabindex (caf9531) with next (8bbf54b)

Open in CodSpeed

Footnotes

  1. 189 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 3, 2026

Walkthrough

Adds a new HTML accessibility lint rule NoNoninteractiveTabindex to biome_html_analyze that flags non-interactive elements declaring non‑negative tabindex, suppresses diagnostics for negative values and interactive ARIA roles, and offers an unsafe fix that removes the tabindex attribute. Introduces ARIA services and query support, integrates biome_aria trait impls for elements/attributes in html_syntax, adds tests (valid/invalid) and a changelog entry for the rule.

Possibly related PRs

Suggested labels

A-Parser

Suggested reviewers

  • dyc3
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a new HTML accessibility lint rule for non-interactive elements with tabindex attributes.
Description check ✅ Passed The description clearly relates to the changeset, explaining the rule's purpose, test plan, and documentation, with disclosure of AI assistance.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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_noninteractive_tabindex.rs`:
- Around line 154-166: The match arm that classifies elements by interactivity
is wrong for <header> and anchors/areas; change the "header" arm to return
Some(true) (non-interactive), and replace the combined "a" | "area" arm with
separate handling for "a" and "area" that inspects the node via
node.find_attribute_by_name("href") (using the existing
initializer()/value().ok()/string_value() chain) and only treats the element as
interactive (Some(false)) when a non-empty href is present, otherwise treat it
as non-interactive (Some(true)); keep existing logic for other elements and the
input/type:hidden check using the same node.find_attribute_by_name flow.
- Around line 89-99: The current early-return `?` chain when reading a `role`
attribute (in the block using role_attribute,
role_attr.initializer()?.value().ok()?.string_value()?) causes the lint to exit
on malformed `role` values; change this to use option-preserving combinators
(e.g., replace the `?` chain with and_then/map calls) so you get an
Option<String> from role_attr.initializer()/value()/string_value() instead of
returning, then call AriaRole::from_roles on that Option and only return None
when an aria_role is present and aria_role.is_interactive(); otherwise allow the
lint to continue reporting. Ensure you update the block around role_attribute,
role_attr, AriaRole::from_roles, and aria_role.is_interactive(), and run `just
gen-rules` before the PR.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b805cd2 and a4a1e3a.

⛔ Files ignored due to path filters (2)
  • crates/biome_html_analyze/tests/specs/a11y/noNoninteractiveTabindex/invalid.html.snap is excluded by !**/*.snap and included by **
  • crates/biome_html_analyze/tests/specs/a11y/noNoninteractiveTabindex/valid.html.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (4)
  • .changeset/html-no-noninteractive-tab-index.md
  • crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs
  • crates/biome_html_analyze/tests/specs/a11y/noNoninteractiveTabindex/invalid.html
  • crates/biome_html_analyze/tests/specs/a11y/noNoninteractiveTabindex/valid.html

Comment on lines +89 to +99
let role_attribute = node.find_attribute_by_name("role");
if let Some(role_attr) = role_attribute {
let role_value = role_attr.initializer()?.value().ok()?.string_value()?;
let role = AriaRole::from_roles(role_value.trim());

if let Some(aria_role) = role {
if aria_role.is_interactive() {
return None;
}
}
}
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs | sed -n '75,110p'

Repository: biomejs/biome

Length of output: 1387


🏁 Script executed:

rg "fn from_roles" --type rs -A 2

Repository: biomejs/biome

Length of output: 83


🏁 Script executed:

fd "Cargo.toml" | head -1 | xargs grep -A 10 "^\[package\]" | grep -E "(rust-version|edition)"

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

rg "fn from_roles" --type rust -A 3

Repository: biomejs/biome

Length of output: 363


🏁 Script executed:

rg "struct AriaRole|enum AriaRole" --type rust -A 5

Repository: biomejs/biome

Length of output: 1835


🏁 Script executed:

find . -name "Cargo.toml" -path "*/biome_html_analyze/*" | head -1 | xargs cat | grep -E "rust-version|edition"

Repository: biomejs/biome

Length of output: 84


🏁 Script executed:

find . -name "Cargo.toml" -path "./Cargo.toml" | xargs cat | grep -A 5 "^\[workspace\]"

Repository: biomejs/biome

Length of output: 300


🏁 Script executed:

grep -A 20 "^\[workspace\]" Cargo.toml | grep -E "edition|rust-version"

Repository: biomejs/biome

Length of output: 76


🏁 Script executed:

rg "is_some_and" --type rust | head -5

Repository: biomejs/biome

Length of output: 598


Avoid exiting early on malformed role attributes.

At line 91, the ? chain exits the function when a role attribute exists but lacks a usable value, suppressing the violation report. Since malformed roles aren't interactive roles, the lint should still fire. Use and_then to keep role as an Option, then only skip when it's both present and interactive.

Suggested fix
-        let role_attribute = node.find_attribute_by_name("role");
-        if let Some(role_attr) = role_attribute {
-            let role_value = role_attr.initializer()?.value().ok()?.string_value()?;
-            let role = AriaRole::from_roles(role_value.trim());
-
-            if let Some(aria_role) = role {
-                if aria_role.is_interactive() {
-                    return None;
-                }
-            }
-        }
+        if let Some(role_attr) = node.find_attribute_by_name("role") {
+            let role = role_attr
+                .initializer()
+                .and_then(|init| init.value().ok())
+                .and_then(|value| value.string_value())
+                .and_then(|value| AriaRole::from_roles(value.trim()));
+
+            if role.is_some_and(|aria_role| aria_role.is_interactive()) {
+                return None;
+            }
+        }

Remember to run just gen-rules before opening the PR.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let role_attribute = node.find_attribute_by_name("role");
if let Some(role_attr) = role_attribute {
let role_value = role_attr.initializer()?.value().ok()?.string_value()?;
let role = AriaRole::from_roles(role_value.trim());
if let Some(aria_role) = role {
if aria_role.is_interactive() {
return None;
}
}
}
if let Some(role_attr) = node.find_attribute_by_name("role") {
let role = role_attr
.initializer()
.and_then(|init| init.value().ok())
.and_then(|value| value.string_value())
.and_then(|value| AriaRole::from_roles(value.trim()));
if role.is_some_and(|aria_role| aria_role.is_interactive()) {
return None;
}
}
🤖 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_noninteractive_tabindex.rs` around
lines 89 - 99, The current early-return `?` chain when reading a `role`
attribute (in the block using role_attribute,
role_attr.initializer()?.value().ok()?.string_value()?) causes the lint to exit
on malformed `role` values; change this to use option-preserving combinators
(e.g., replace the `?` chain with and_then/map calls) so you get an
Option<String> from role_attr.initializer()/value()/string_value() instead of
returning, then call AriaRole::from_roles on that Option and only return None
when an aria_role is present and aria_role.is_interactive(); otherwise allow the
lint to continue reporting. Ensure you update the block around role_attribute,
role_attr, AriaRole::from_roles, and aria_role.is_interactive(), and run `just
gen-rules` before the PR.

Comment on lines +154 to +166
match element_name {
"header" => Some(false),
"a" | "area" | "button" | "select" | "textarea" => Some(false),
"svg" => Some(true),
"body" | "br" | "details" | "dir" | "frame" | "iframe" | "label" | "mark" | "marquee"
| "menu" | "meter" | "optgroup" | "pre" | "progress" | "ruby" => Some(true),
"input" => Some(
node.find_attribute_by_name("type")
.and_then(|attr| attr.initializer())
.and_then(|init| init.value().ok())
.and_then(|val| val.string_value())
.is_some_and(|value| value == "hidden"),
),
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's see the full context of the function containing these lines
cat -n crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs | head -200 | tail -100

Repository: biomejs/biome

Length of output: 4255


🏁 Script executed:

# Let's also see the function signature and any documentation
cat -n crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs | head -180

Repository: biomejs/biome

Length of output: 6825


🏁 Script executed:

# Check the full file to understand the rule intent
wc -l crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs

Repository: biomejs/biome

Length of output: 130


Fix element interactivity classification for correct rule coverage.

Line 155 marks <header> as interactive—incorrect, since it's a semantic container like <article> (shown invalid in rule docs). Line 156 treats all <a> and <area> elements as interactive regardless of href presence, missing non-interactive variants. Update line 155 to Some(true) and split line 156 to check href attribute for anchors/areas:

Suggested fix
     match element_name {
-        "header" => Some(false),
-        "a" | "area" | "button" | "select" | "textarea" => Some(false),
+        "header" => Some(true),
+        "a" | "area" => Some(node.find_attribute_by_name("href").is_none()),
+        "button" | "select" | "textarea" => Some(false),
         "svg" => Some(true),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
match element_name {
"header" => Some(false),
"a" | "area" | "button" | "select" | "textarea" => Some(false),
"svg" => Some(true),
"body" | "br" | "details" | "dir" | "frame" | "iframe" | "label" | "mark" | "marquee"
| "menu" | "meter" | "optgroup" | "pre" | "progress" | "ruby" => Some(true),
"input" => Some(
node.find_attribute_by_name("type")
.and_then(|attr| attr.initializer())
.and_then(|init| init.value().ok())
.and_then(|val| val.string_value())
.is_some_and(|value| value == "hidden"),
),
match element_name {
"header" => Some(true),
"a" | "area" => Some(node.find_attribute_by_name("href").is_none()),
"button" | "select" | "textarea" => Some(false),
"svg" => Some(true),
"body" | "br" | "details" | "dir" | "frame" | "iframe" | "label" | "mark" | "marquee"
| "menu" | "meter" | "optgroup" | "pre" | "progress" | "ruby" => Some(true),
"input" => Some(
node.find_attribute_by_name("type")
.and_then(|attr| attr.initializer())
.and_then(|init| init.value().ok())
.and_then(|val| val.string_value())
.is_some_and(|value| value == "hidden"),
),
🤖 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_noninteractive_tabindex.rs` around
lines 154 - 166, The match arm that classifies elements by interactivity is
wrong for <header> and anchors/areas; change the "header" arm to return
Some(true) (non-interactive), and replace the combined "a" | "area" arm with
separate handling for "a" and "area" that inspects the node via
node.find_attribute_by_name("href") (using the existing
initializer()/value().ok()/string_value() chain) and only treats the element as
interactive (Some(false)) when a non-empty href is present, otherwise treat it
as non-interactive (Some(true)); keep existing logic for other elements and the
input/type:hidden check using the same node.find_attribute_by_name flow.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs (2)

89-99: ⚠️ Potential issue | 🟠 Major

Don’t let malformed role values bypass the lint.

On Line [91], the ? chain exits run(...) when role exists but cannot be parsed, so invalid non-interactive elements can slip through. A broken role shouldn’t get a free pass.

Suggested fix
-        let role_attribute = node.find_attribute_by_name("role");
-        if let Some(role_attr) = role_attribute {
-            let role_value = role_attr.initializer()?.value().ok()?.string_value()?;
-            let role = AriaRole::from_roles(role_value.trim());
-
-            if let Some(aria_role) = role
-                && aria_role.is_interactive()
-            {
-                return None;
-            }
-        }
+        if let Some(role_attr) = node.find_attribute_by_name("role") {
+            let role = role_attr
+                .initializer()
+                .and_then(|init| init.value().ok())
+                .and_then(|value| value.string_value())
+                .and_then(|value| AriaRole::from_roles(value.trim()));
+
+            if role.is_some_and(|aria_role| aria_role.is_interactive()) {
+                return None;
+            }
+        }
🤖 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_noninteractive_tabindex.rs` around
lines 89 - 99, The current code in run(...) uses the ? operator on role parsing
(role_attr.initializer()?.value().ok()?.string_value()?) which causes the
function to exit early on malformed role values and thus skip the lint; change
these ? chains to explicit Option/Result handling so parsing failures do not
short-circuit the function. Locate the role_attribute / role_attr handling and
replace the chained ? calls with a safe extraction (e.g.,
initializer().and_then(...).and_then(...) or nested if let / match) to get
role_value only when all steps succeed, then call
AriaRole::from_roles(role_value.trim()) and only return None if Some(aria_role)
exists and aria_role.is_interactive(); malformed or unparseable roles should be
treated as non-interactive (i.e., do not return early).

154-166: ⚠️ Potential issue | 🟠 Major

Interactivity mapping is incorrect for <header>, <a>, and <area>.

Line [155] currently treats <header> as interactive, and Line [156] treats all anchors/areas as interactive regardless of href. That under-reports violations.

Suggested fix
-        "header" => Some(false),
-        "a" | "area" | "button" | "select" | "textarea" => Some(false),
+        "header" => Some(true),
+        "a" | "area" => Some(
+            node.find_attribute_by_name("href")
+                .and_then(|attr| attr.initializer())
+                .and_then(|init| init.value().ok())
+                .and_then(|val| val.string_value())
+                .is_none_or(|value| value.trim().is_empty()),
+        ),
+        "button" | "select" | "textarea" => Some(false),
🤖 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_noninteractive_tabindex.rs` around
lines 154 - 166, The current element interactivity mapping misclassifies
elements: change the mapping so "header" is treated as non-interactive and make
"a" and "area" interactive only when they have an actual href attribute; replace
the current simple match arm ("a" | "area" | "button" | ...) with logic that
checks node.find_attribute_by_name("href").and_then(|attr|
attr.initializer()).and_then(|init| init.value().ok()).and_then(|v|
v.string_value()).is_some() for "a" and "area" (use that predicate to return the
interactive boolean), while keeping "button", "select", "textarea" handled as
before and ensure the "header" arm returns the non-interactive value used
elsewhere in this match (adjust the boolean to match the function's
interactive/non-interactive convention).
🤖 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_noninteractive_tabindex.rs`:
- Around line 81-87: The code currently returns early for missing or unparseable
tabindex values and treats any non-string/malformed value as "negative"; update
the tabindex handling so that you only skip (return None) when the attribute has
a valid string that parses to an integer and that integer is negative.
Concretely, in the tabindex handling around tabindex_attribute.initializer(),
.value(), and .string_value() and in the similar block at lines ~140-147, do not
use the ?-early-return for missing initializer/value/string; instead check
presence and get the string, attempt to parse it to an integer (e.g., via
parse::<i32>()), and only call is_negative_tabindex (or replace with a simple
parsed_int < 0 check) when parsing succeeds—if parsing fails or parts are
missing, treat it as non-negative so the rule continues to lint rather than
skipping.

---

Duplicate comments:
In `@crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs`:
- Around line 89-99: The current code in run(...) uses the ? operator on role
parsing (role_attr.initializer()?.value().ok()?.string_value()?) which causes
the function to exit early on malformed role values and thus skip the lint;
change these ? chains to explicit Option/Result handling so parsing failures do
not short-circuit the function. Locate the role_attribute / role_attr handling
and replace the chained ? calls with a safe extraction (e.g.,
initializer().and_then(...).and_then(...) or nested if let / match) to get
role_value only when all steps succeed, then call
AriaRole::from_roles(role_value.trim()) and only return None if Some(aria_role)
exists and aria_role.is_interactive(); malformed or unparseable roles should be
treated as non-interactive (i.e., do not return early).
- Around line 154-166: The current element interactivity mapping misclassifies
elements: change the mapping so "header" is treated as non-interactive and make
"a" and "area" interactive only when they have an actual href attribute; replace
the current simple match arm ("a" | "area" | "button" | ...) with logic that
checks node.find_attribute_by_name("href").and_then(|attr|
attr.initializer()).and_then(|init| init.value().ok()).and_then(|v|
v.string_value()).is_some() for "a" and "area" (use that predicate to return the
interactive boolean), while keeping "button", "select", "textarea" handled as
before and ensure the "header" arm returns the non-interactive value used
elsewhere in this match (adjust the boolean to match the function's
interactive/non-interactive convention).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a4a1e3a and 63c30ae.

⛔ Files ignored due to path filters (2)
  • packages/@biomejs/backend-jsonrpc/src/workspace.ts is excluded by !**/backend-jsonrpc/src/workspace.ts and included by **
  • packages/@biomejs/biome/configuration_schema.json is excluded by !**/configuration_schema.json and included by **
📒 Files selected for processing (1)
  • crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs

Comment on lines +81 to +87
let initializer = tabindex_attribute.initializer()?;
let value = initializer.value().ok()?;
let string_value = value.string_value()?;

if is_negative_tabindex(&string_value) {
return None;
}
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.

⚠️ Potential issue | 🟠 Major

Only skip when tabindex is a valid negative integer.

Right now, malformed/unparseable tabindex values are treated as “negative” (Line [146]) and skipped; plus missing initialisers are skipped via ? on Line [81]-[83]. Both create false negatives.

Suggested fix
-        let initializer = tabindex_attribute.initializer()?;
-        let value = initializer.value().ok()?;
-        let string_value = value.string_value()?;
-
-        if is_negative_tabindex(&string_value) {
+        let is_negative = tabindex_attribute
+            .initializer()
+            .and_then(|init| init.value().ok())
+            .and_then(|value| value.string_value())
+            .is_some_and(|value| is_negative_tabindex(&value));
+
+        if is_negative {
             return None;
         }
-/// Verifies if number string is an integer less than 0.
-/// Non-integer numbers are considered valid.
+/// Returns `true` only for valid integers strictly less than 0.
 fn is_negative_tabindex(number_like_string: &str) -> bool {
-    let number_string_result = number_like_string.trim().parse::<i32>();
-
-    match number_string_result {
-        Ok(number) => number < 0,
-        Err(_) => true,
-    }
+    matches!(number_like_string.trim().parse::<i64>(), Ok(number) if number < 0)
 }

Also applies to: 140-147

🤖 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_noninteractive_tabindex.rs` around
lines 81 - 87, The code currently returns early for missing or unparseable
tabindex values and treats any non-string/malformed value as "negative"; update
the tabindex handling so that you only skip (return None) when the attribute has
a valid string that parses to an integer and that integer is negative.
Concretely, in the tabindex handling around tabindex_attribute.initializer(),
.value(), and .string_value() and in the similar block at lines ~140-147, do not
use the ?-early-return for missing initializer/value/string; instead check
presence and get the string, attempt to parse it to an integer (e.g., via
parse::<i32>()), and only call is_negative_tabindex (or replace with a simple
parsed_int < 0 check) when parsing succeeds—if parsing fails or parts are
missing, treat it as non-negative so the rule continues to lint rather than
skipping.


Added the [`noNoninteractiveTabindex`](https://biomejs.dev/linter/rules/no-noninteractive-tabindex/) lint rule for HTML. This rule enforces that `tabindex` is not used on non-interactive elements, as it can cause usability issues for keyboard users.

`<div tabindex="0">Invalid: non-interactive element</div>`
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.

nit: use a block snippet

}
}

fn is_not_interactive_element(node: &AnyHtmlElement) -> Option<bool> {
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.

implement the Element and Attribute traits that it uses for html elements and attributes. then you can pass those in to the is_not_interactive_element function

@Netail Netail mentioned this pull request Mar 25, 2026
32 tasks
@ematipico
Copy link
Copy Markdown
Member

ematipico commented Apr 11, 2026

@viraxslot, do you plan to finish this PR?

@viraxslot
Copy link
Copy Markdown
Contributor Author

@ematipico I'm not sure I'll have enough time in the nearest future, sorry. You can take over it if needed.

Comment on lines +151 to +166
let element_name_token = node.name()?;
let element_name = element_name_token.text();

match element_name {
"header" => Some(false),
"a" | "area" | "button" | "select" | "textarea" => Some(false),
"svg" => Some(true),
"body" | "br" | "details" | "dir" | "frame" | "iframe" | "label" | "mark" | "marquee"
| "menu" | "meter" | "optgroup" | "pre" | "progress" | "ruby" => Some(true),
"input" => Some(
node.find_attribute_by_name("type")
.and_then(|attr| attr.initializer())
.and_then(|init| init.value().ok())
.and_then(|val| val.string_value())
.is_some_and(|value| value == "hidden"),
),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tags in HTML are case insensitive, while in case sensitive in Astro, Svelte & Vue. Let's make use is_html_tag() from utils.rs :)

}

impl Rule for NoNoninteractiveTabindex {
type Query = Ast<AnyHtmlElement>;
Copy link
Copy Markdown
Member

@Netail Netail Apr 17, 2026

Choose a reason for hiding this comment

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

Might be good to query on AnyHtmlTagElement instead, limits the scope a bit (OpeningElement & SelfClosingElement)

@ematipico ematipico self-assigned this Apr 17, 2026
@ematipico
Copy link
Copy Markdown
Member

I'll take over the rule

@github-actions github-actions bot added the A-Parser Area: parser label Apr 17, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
crates/biome_html_analyze/src/services/aria.rs (1)

10-19: Nit: Arc<AriaRoles> around a zero-sized type is overkill.

AriaRoles is a ZST (Debug + Default); you could hold it by value and derive Clone/Default on AriaServices, skipping the allocation and the service-bag indirection. Perfectly fine as-is though — it mirrors the JS analyzer's pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_html_analyze/src/services/aria.rs` around lines 10 - 19,
AriaRoles is a zero-sized type so wrapping it in Arc is unnecessary; change
AriaServices to store AriaRoles by value (replace pub(crate) roles:
Arc<AriaRoles> with pub(crate) roles: AriaRoles), update the aria_roles(&self)
-> &AriaRoles method to return a reference to the inner value, derive Clone and
Default (or implement) for AriaServices so callers can clone without allocation,
and remove any Arc imports/usages related to AriaRoles.
crates/biome_html_syntax/Cargo.toml (1)

17-22: Tiny ordering nit.

Grouping biome_aria next to the other biome_* entries (top of the list) would keep the manifest tidy, but nothing functional here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_html_syntax/Cargo.toml` around lines 17 - 22, Move the
biome_aria dependency line so it's grouped with the other biome_* entries at the
top of the dependency list; specifically, locate the existing biome_rowan,
biome_string_case (and other biome_* lines) and place the biome_aria = {
workspace = true } line adjacent to them to keep the manifest consistently
ordered.
🤖 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_syntax/src/attr_ext.rs`:
- Around line 24-31: The attribute name accessor returns token.token_text(),
which preserves leading/trailing trivia and causes mismatches; update
AnyHtmlAttribute::name (the match arm for AnyHtmlAttribute::HtmlAttribute that
calls attr.name().ok()?.value_token().ok().map(|token| token.token_text())) to
use the trimmed form of the token text (e.g., token.token_text_trimmed() or the
equivalent text_trimmed helper used elsewhere) so returned TokenText has trivia
removed and matches Element::find_attribute_by_name comparisons.
- Around line 13-20: The trait impl for fn value(&self) in AnyHtmlAttribute uses
as_static_value(), which only extracts HtmlString cases and misses single-text
expressions; change the extraction to use the inherent string_value() path used
by AnyHtmlAttribute::value() (i.e., replace the
attr.initializer()?.value().ok()?.as_static_value() call with the equivalent
call that uses string_value() so both HtmlString and
HtmlAttributeSingleTextExpression are returned), updating the match arm inside
value(&self) for AnyHtmlAttribute::HtmlAttribute(attr) accordingly.

---

Nitpick comments:
In `@crates/biome_html_analyze/src/services/aria.rs`:
- Around line 10-19: AriaRoles is a zero-sized type so wrapping it in Arc is
unnecessary; change AriaServices to store AriaRoles by value (replace pub(crate)
roles: Arc<AriaRoles> with pub(crate) roles: AriaRoles), update the
aria_roles(&self) -> &AriaRoles method to return a reference to the inner value,
derive Clone and Default (or implement) for AriaServices so callers can clone
without allocation, and remove any Arc imports/usages related to AriaRoles.

In `@crates/biome_html_syntax/Cargo.toml`:
- Around line 17-22: Move the biome_aria dependency line so it's grouped with
the other biome_* entries at the top of the dependency list; specifically,
locate the existing biome_rowan, biome_string_case (and other biome_* lines) and
place the biome_aria = { workspace = true } line adjacent to them to keep the
manifest consistently ordered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8ac81b4c-de4e-4319-9ec1-4fd482989424

📥 Commits

Reviewing files that changed from the base of the PR and between 63c30ae and be17084.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock and included by **
📒 Files selected for processing (10)
  • .changeset/html-no-noninteractive-tab-index.md
  • crates/biome_html_analyze/Cargo.toml
  • crates/biome_html_analyze/src/lib.rs
  • crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs
  • crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs
  • crates/biome_html_analyze/src/services/aria.rs
  • crates/biome_html_analyze/src/services/mod.rs
  • crates/biome_html_syntax/Cargo.toml
  • crates/biome_html_syntax/src/attr_ext.rs
  • crates/biome_html_syntax/src/element_ext.rs
✅ Files skipped from review due to trivial changes (3)
  • crates/biome_html_analyze/src/services/mod.rs
  • crates/biome_html_analyze/Cargo.toml
  • .changeset/html-no-noninteractive-tab-index.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs

Comment thread crates/biome_html_syntax/src/attr_ext.rs Outdated
Comment thread crates/biome_html_syntax/src/attr_ext.rs Outdated
@ematipico ematipico requested review from Netail and dyc3 April 17, 2026 13:04
@ematipico ematipico merged commit afd57a6 into biomejs:next Apr 17, 2026
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Linter Area: linter A-Parser Area: parser L-HTML Language: HTML and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants