From 40218de17ee322c1d9b497d6d5d8842d972ecd0d Mon Sep 17 00:00:00 2001 From: connorshea <2977353+connorshea@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:03:23 +0000 Subject: [PATCH] fix(linter): Fix behavior of jsx-a11y/no-static-element-interactions rule. (#17817) AI Disclosure: I needed to reimplement [isNonInteractiveElement.js](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/8f75961d965e47afb88854d324bd32fafde7acfe/src/util/isNonInteractiveElement.js) in Rust, which was very annoying. I did this using Copilot's help and quite a bit of iteration/cross-referencing of the original logic/aria-query/axobject-query. I am fairly certain it's correct now, but I'm not 100% sure. At the very least, it's far more correct than it used to be. For reference on the original behavior, please see: - [Original source](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/8f75961d965e47afb88854d324bd32fafde7acfe/src/rules/no-static-element-interactions.js) - [Original tests](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/8f75961d965e47afb88854d324bd32fafde7acfe/__tests__/src/rules/no-static-element-interactions-test.js) 118 of the test cases were failing after porting everything over, many were dupes because the tests in the previous impl of this rule were based on a handful of the original tests, mixed together with hallucinated/new tests. I have exorcised those demons and fixed the rule behavior to match the upstream now, I believe. The only tests not ported over were the commented-out "strict mode" tests which I don't really know what to do with. I guess we could just add config settings for each of those tests that match the `handlers` array set in the original rule when using strict mode? See [here](https://github.com/oxc-project/oxc/issues/17816#issuecomment-3727148393) for that list. --- .../no_static_element_interactions.rs | 532 +++++++++++++----- ...x_a11y_no_static_element_interactions.snap | 335 +++++++++-- crates/oxc_linter/src/utils/react.rs | 238 +++++++- 3 files changed, 891 insertions(+), 214 deletions(-) diff --git a/crates/oxc_linter/src/rules/jsx_a11y/no_static_element_interactions.rs b/crates/oxc_linter/src/rules/jsx_a11y/no_static_element_interactions.rs index eb8a695b7891b..52d16fa2124c9 100644 --- a/crates/oxc_linter/src/rules/jsx_a11y/no_static_element_interactions.rs +++ b/crates/oxc_linter/src/rules/jsx_a11y/no_static_element_interactions.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use oxc_ast::{ AstKind, - ast::{JSXAttributeItem, JSXAttributeValue}, + ast::{JSXAttributeItem, JSXAttributeValue, JSXExpression}, }; use oxc_diagnostics::OxcDiagnostic; use oxc_macros::declare_oxc_lint; @@ -16,8 +16,9 @@ use crate::{ globals::HTML_TAG, rule::{DefaultRuleConfig, Rule}, utils::{ - get_element_type, has_jsx_prop, has_jsx_prop_ignore_case, is_hidden_from_screen_reader, - is_interactive_element, is_presentation_role, + get_element_type, get_prop_value, has_jsx_prop, has_jsx_prop_ignore_case, is_abstract_role, + is_hidden_from_screen_reader, is_interactive_element, is_interactive_role, + is_non_interactive_element, is_non_interactive_role, is_presentation_role, }, }; @@ -82,81 +83,6 @@ declare_oxc_lint!( config = NoStaticElementInteractionsConfig, ); -const INTERACTIVE_ROLES: [&str; 26] = [ - "button", - "checkbox", - "columnheader", - "combobox", - "gridcell", - "link", - "listbox", - "menu", - "menubar", - "menuitem", - "menuitemcheckbox", - "menuitemradio", - "option", - "radio", - "radiogroup", - "row", - "rowheader", - "scrollbar", - "searchbox", - "separator", - "slider", - "spinbutton", - "switch", - "tab", - "textbox", - "treeitem", -]; - -const NON_INTERACTIVE_ROLES: [&str; 43] = [ - "alert", - "alertdialog", - "application", - "article", - "banner", - "blockquote", - "caption", - "cell", - "complementary", - "contentinfo", - "definition", - "deletion", - "dialog", - "directory", - "document", - "feed", - "figure", - "form", - "group", - "heading", - "img", - "insertion", - "list", - "listitem", - "log", - "main", - "marquee", - "math", - "navigation", - "note", - "paragraph", - "region", - "row", - "rowgroup", - "search", - "status", - "table", - "tabpanel", - "term", - "time", - "timer", - "toolbar", - "tooltip", -]; - impl Rule for NoStaticElementInteractions { fn from_configuration(value: serde_json::Value) -> Result { serde_json::from_value::>(value).map(DefaultRuleConfig::into_inner) @@ -167,11 +93,18 @@ impl Rule for NoStaticElementInteractions { return; }; + // Note: We skip the handler if it exists with a `null` value, e.g. `
`. let has_handler = match &self.handlers { - Some(handlers) => { - handlers.iter().any(|handler| has_jsx_prop(jsx_el, handler.as_str()).is_some()) - } - None => DEFAULT_HANDLERS.iter().any(|handler| has_jsx_prop(jsx_el, handler).is_some()), + Some(handlers) => handlers.iter().any(|handler| { + has_jsx_prop(jsx_el, handler.as_str()) + .and_then(get_prop_value) + .is_some_and(|value| !is_null_value(value)) + }), + None => DEFAULT_HANDLERS.iter().any(|handler| { + has_jsx_prop(jsx_el, handler) + .and_then(get_prop_value) + .is_some_and(|value| !is_null_value(value)) + }), }; if !has_handler { @@ -180,6 +113,7 @@ impl Rule for NoStaticElementInteractions { let element_type = get_element_type(ctx, jsx_el); + // Do not test custom JSX elements. if !HTML_TAG.contains(element_type.as_ref()) { return; } @@ -192,6 +126,15 @@ impl Rule for NoStaticElementInteractions { return; } + if is_non_interactive_element(&element_type, jsx_el) { + return; + } + + // This rule has no opinion on abstract roles, so just ignore them. + if is_abstract_role(ctx, jsx_el) { + return; + } + let Some(JSXAttributeItem::Attribute(role_attr)) = has_jsx_prop_ignore_case(jsx_el, "role") else { ctx.diagnostic(no_static_element_interactions_diagnostic(jsx_el.name.span())); @@ -209,13 +152,10 @@ impl Rule for NoStaticElementInteractions { let roles: Vec<&str> = role_str.split_whitespace().collect(); if let Some(first_role) = roles.first() { - if INTERACTIVE_ROLES.contains(first_role) { + if is_interactive_role(first_role) { return; } - if NON_INTERACTIVE_ROLES.contains(first_role) { - ctx.diagnostic(no_static_element_interactions_diagnostic( - jsx_el.name.span(), - )); + if is_non_interactive_role(first_role) { return; } } @@ -232,35 +172,279 @@ impl Rule for NoStaticElementInteractions { } } +fn is_null_value(value: &JSXAttributeValue) -> bool { + matches!( + value, + JSXAttributeValue::ExpressionContainer(container) + if matches!(container.expression, JSXExpression::NullLiteral(_)) + ) +} + #[test] fn test() { use crate::tester::Tester; let pass = vec![ - (r"
;", None), - (r"
;", None), - (r"
{}} role='button' />;", None), - (r"
{}} role='button' />;", None), - (r"
{}} role='button' />;", None), - (r"