-
-
Notifications
You must be signed in to change notification settings - Fork 964
feat(linter): add noNonInteractiveTabIndex for HTML #9306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ematipico
merged 8 commits into
biomejs:next
from
viraxslot:feat-html-no-noninteractive-tabindex
Apr 17, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a4a1e3a
feat(linter): add noNonInteractiveTabIndex for HTML
viraxslot 7b14d21
[autofix.ci] apply automated fixes
autofix-ci[bot] f32efb7
fix(linter): collapsed if statement
viraxslot 63c30ae
Merge branch 'feat-html-no-noninteractive-tabindex' of github.com:vir…
viraxslot be17084
chore: add aria service and complete rule
ematipico 5b4a056
Merge remote-tracking branch 'origin/next' into feat-html-no-noninter…
ematipico a911f56
merge, fmt and clippy
ematipico caf9531
address feedback
ematipico File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| "@biomejs/biome": minor | ||
| --- | ||
|
|
||
| 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. | ||
|
|
||
| ```html | ||
| <div tabindex="0">Invalid: non-interactive element</div>` | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
146 changes: 146 additions & 0 deletions
146
crates/biome_html_analyze/src/lint/a11y/no_noninteractive_tabindex.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| use biome_analyze::context::RuleContext; | ||
| use biome_analyze::{FixKind, Rule, RuleDiagnostic, RuleSource, declare_lint_rule}; | ||
| use biome_aria_metadata::AriaRole; | ||
| use biome_console::markup; | ||
| use biome_diagnostics::Severity; | ||
| use biome_html_syntax::{HtmlAttribute, element_ext::AnyHtmlTagElement}; | ||
| use biome_rowan::{AstNode, BatchMutationExt, TextRange, TokenText}; | ||
| use biome_rule_options::no_noninteractive_tabindex::NoNoninteractiveTabindexOptions; | ||
|
|
||
| use crate::{Aria, HtmlRuleAction}; | ||
|
|
||
| declare_lint_rule! { | ||
| /// Enforce that `tabindex` is not assigned to non-interactive HTML elements. | ||
| /// | ||
| /// When using the tab key to navigate a webpage, limit it to interactive elements. | ||
| /// You don't need to add tabindex to items in an unordered list as assistive technology can navigate through the HTML. | ||
| /// Keep the tab ring small, which is the order of elements when tabbing, for a more efficient and accessible browsing experience. | ||
| /// | ||
| /// ## Examples | ||
| /// | ||
| /// ### Invalid | ||
| /// | ||
| /// ```html,expect_diagnostic | ||
| /// <div tabindex="0"></div> | ||
| /// ``` | ||
| /// | ||
| /// ```html,expect_diagnostic | ||
| /// <div role="article" tabindex="0"></div> | ||
| /// ``` | ||
| /// | ||
| /// ```html,expect_diagnostic | ||
| /// <article tabindex="0"></article> | ||
| /// ``` | ||
| /// | ||
| /// ### Valid | ||
| /// | ||
| /// ```html | ||
| /// <div></div> | ||
| /// ``` | ||
| /// | ||
| /// ```html | ||
| /// <button tabindex="0"></button> | ||
| /// ``` | ||
| /// | ||
| /// ```html | ||
| /// <article tabindex="-1"></article> | ||
| /// ``` | ||
| /// | ||
| pub NoNoninteractiveTabindex { | ||
| version: "next", | ||
| name: "noNoninteractiveTabindex", | ||
| language: "html", | ||
| sources: &[RuleSource::EslintJsxA11y("no-noninteractive-tabindex").same()], | ||
| recommended: true, | ||
| severity: Severity::Error, | ||
| fix_kind: FixKind::Unsafe, | ||
| } | ||
| } | ||
|
|
||
| pub struct RuleState { | ||
| attribute_range: TextRange, | ||
| attribute: HtmlAttribute, | ||
| element_name: TokenText, | ||
| } | ||
|
|
||
| impl Rule for NoNoninteractiveTabindex { | ||
| type Query = Aria<AnyHtmlTagElement>; | ||
| type State = RuleState; | ||
| type Signals = Option<Self::State>; | ||
| type Options = NoNoninteractiveTabindexOptions; | ||
|
|
||
| fn run(ctx: &RuleContext<Self>) -> Self::Signals { | ||
| let node = ctx.query(); | ||
|
|
||
| if !ctx.aria_roles().is_not_interactive_element(node) { | ||
| return None; | ||
| } | ||
|
|
||
| let tabindex_attribute = node.find_attribute_by_name("tabindex")?; | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| let element_name = node.tag_name()?; | ||
| let attribute_range = tabindex_attribute.range(); | ||
|
|
||
| Some(RuleState { | ||
| attribute_range, | ||
| attribute: tabindex_attribute, | ||
| element_name, | ||
| }) | ||
| } | ||
|
|
||
| fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> { | ||
| let element_name = state.element_name.text(); | ||
| Some( | ||
| RuleDiagnostic::new( | ||
| rule_category!(), | ||
| state.attribute_range, | ||
| markup! { | ||
| "The HTML element "<Emphasis>{element_name}</Emphasis>" is non-interactive. Do not use "<Emphasis>"tabindex"</Emphasis>"." | ||
| }, | ||
| ) | ||
| .note(markup! { | ||
| "Adding non-interactive elements to the keyboard navigation flow can confuse users." | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<HtmlRuleAction> { | ||
| let mut mutation = ctx.root().begin(); | ||
| mutation.remove_node(state.attribute.clone()); | ||
|
|
||
| Some(HtmlRuleAction::new( | ||
| ctx.metadata().action_category(ctx.category(), ctx.group()), | ||
| ctx.metadata().applicability(), | ||
| markup! { "Remove the "<Emphasis>"tabindex"</Emphasis>" attribute." }.to_owned(), | ||
| mutation, | ||
| )) | ||
| } | ||
| } | ||
|
|
||
| /// Returns `true` only for valid integers strictly less than 0. | ||
| fn is_negative_tabindex(number_like_string: &str) -> bool { | ||
| matches!(number_like_string.trim().parse::<i64>(), Ok(n) if n < 0) | ||
| } | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| use biome_analyze::{ | ||
| AddVisitor, FromServices, Phase, Phases, QueryKey, Queryable, RuleKey, RuleMetadata, | ||
| ServiceBag, ServicesDiagnostic, SyntaxVisitor, | ||
| }; | ||
| use biome_aria::AriaRoles; | ||
| use biome_html_syntax::{HtmlLanguage, HtmlRoot, HtmlSyntaxNode}; | ||
| use biome_rowan::AstNode; | ||
| use std::sync::Arc; | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub struct AriaServices { | ||
| pub(crate) roles: Arc<AriaRoles>, | ||
| } | ||
|
|
||
| impl AriaServices { | ||
| pub fn aria_roles(&self) -> &AriaRoles { | ||
| &self.roles | ||
| } | ||
| } | ||
|
|
||
| impl FromServices for AriaServices { | ||
| fn from_services( | ||
| rule_key: &RuleKey, | ||
| _rule_metadata: &RuleMetadata, | ||
| services: &ServiceBag, | ||
| ) -> Result<Self, ServicesDiagnostic> { | ||
| let roles: &Arc<AriaRoles> = services | ||
| .get_service() | ||
| .ok_or_else(|| ServicesDiagnostic::new(rule_key.rule_name(), &["AriaRoles"]))?; | ||
| Ok(Self { | ||
| roles: roles.clone(), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl Phase for AriaServices { | ||
| fn phase() -> Phases { | ||
| Phases::Syntax | ||
| } | ||
| } | ||
|
|
||
| #[derive(Clone)] | ||
| pub struct Aria<N>(pub N); | ||
|
|
||
| impl<N> Queryable for Aria<N> | ||
| where | ||
| N: AstNode<Language = HtmlLanguage> + 'static, | ||
| { | ||
| type Input = HtmlSyntaxNode; | ||
| type Output = N; | ||
|
|
||
| type Language = HtmlLanguage; | ||
| type Services = AriaServices; | ||
|
|
||
| fn build_visitor(analyzer: &mut impl AddVisitor<HtmlLanguage>, _: &HtmlRoot) { | ||
| analyzer.add_visitor(Phases::Syntax, SyntaxVisitor::default); | ||
| } | ||
|
|
||
| fn key() -> QueryKey<Self::Language> { | ||
| QueryKey::Syntax(N::KIND_SET) | ||
| } | ||
|
|
||
| fn unwrap_match(_: &ServiceBag, node: &Self::Input) -> Self::Output { | ||
| N::unwrap_cast(node.clone()) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| pub mod aria; | ||
| pub mod module_graph; |
5 changes: 5 additions & 0 deletions
5
crates/biome_html_analyze/tests/specs/a11y/noNoninteractiveTabindex/invalid.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <!-- should generate diagnostics --> | ||
| <div tabindex="0"></div> | ||
| <div role="article" tabindex="0"></div> | ||
| <article tabindex="0"></article> | ||
| <span tabindex="1"></span> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: biomejs/biome
Length of output: 1387
🏁 Script executed:
rg "fn from_roles" --type rs -A 2Repository: biomejs/biome
Length of output: 83
🏁 Script executed:
Repository: biomejs/biome
Length of output: 39
🏁 Script executed:
rg "fn from_roles" --type rust -A 3Repository: biomejs/biome
Length of output: 363
🏁 Script executed:
rg "struct AriaRole|enum AriaRole" --type rust -A 5Repository: biomejs/biome
Length of output: 1835
🏁 Script executed:
Repository: biomejs/biome
Length of output: 84
🏁 Script executed:
Repository: biomejs/biome
Length of output: 300
🏁 Script executed:
Repository: biomejs/biome
Length of output: 76
🏁 Script executed:
Repository: biomejs/biome
Length of output: 598
Avoid exiting early on malformed
roleattributes.At line 91, the
?chain exits the function when aroleattribute exists but lacks a usable value, suppressing the violation report. Since malformed roles aren't interactive roles, the lint should still fire. Useand_thento keeproleas anOption, then only skip when it's both present and interactive.Suggested fix
Remember to run
just gen-rulesbefore opening the PR.📝 Committable suggestion
🤖 Prompt for AI Agents