Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions PR_REPLY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# PR #18229 Feedback - Summary of Changes

Hi @Sysix! I have addressed all the review feedback in the latest commit:

1. **Reverted Formatter Changes**: Reverted all unrelated changes in `oxc_formatter`. I will extract the regex support for import sorting into a separate PR as suggested.
2. **Centralized Accessibility Utilities**: Moved `INTERACTIVE_ROLES`, `NON_INTERACTIVE_ROLES`, and `is_interactive_role` to `crates/oxc_linter/src/utils/react.rs`.
3. **Refactored `interactive-supports-focus`**:
- Added check for `disabled` and `aria-disabled` props.
- Implemented dual diagnostics (generic and element-specific) to match upstream.
- Improved `tabIndex` validation (handles numeric/string literals and `undefined`).
- Cleaned up help message casing.
4. **Implementation of `no-interactive-element-to-noninteractive-role`**: Added this rule and verified it locally.
5. **Testing**: Greatly expanded the test suite for `interactive-supports-focus` using cases from the upstream ESLint plugin.

Verified all changes locally with `cargo check` and `cargo test`. All snapshots are updated. Ready for a re-review! CC @Boshen
11 changes: 11 additions & 0 deletions crates/oxc_linter/src/generated/rule_runner_impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1756,6 +1756,12 @@ impl RuleRunner for crate::rules::jsx_a11y::img_redundant_alt::ImgRedundantAlt {
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run;
}

impl RuleRunner for crate::rules::jsx_a11y::interactive_supports_focus::InteractiveSupportsFocus {
const NODE_TYPES: Option<&AstTypesBitset> =
Some(&AstTypesBitset::from_types(&[AstType::JSXOpeningElement]));
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run;
}

impl RuleRunner
for crate::rules::jsx_a11y::label_has_associated_control::LabelHasAssociatedControl
{
Expand Down Expand Up @@ -1806,6 +1812,11 @@ impl RuleRunner for crate::rules::jsx_a11y::no_distracting_elements::NoDistracti
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run;
}

impl RuleRunner for crate::rules::jsx_a11y::no_interactive_element_to_noninteractive_role::NoInteractiveElementToNoninteractiveRole {
const NODE_TYPES: Option<&AstTypesBitset> = Some(&AstTypesBitset::from_types(&[AstType::JSXOpeningElement]));
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run;
}

impl RuleRunner for crate::rules::jsx_a11y::no_noninteractive_tabindex::NoNoninteractiveTabindex {
const NODE_TYPES: Option<&AstTypesBitset> =
Some(&AstTypesBitset::from_types(&[AstType::JSXOpeningElement]));
Expand Down
4 changes: 4 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ pub(crate) mod jsx_a11y {
pub mod html_has_lang;
pub mod iframe_has_title;
pub mod img_redundant_alt;
pub mod interactive_supports_focus;
pub mod label_has_associated_control;
pub mod lang;
pub mod media_has_caption;
Expand All @@ -563,6 +564,7 @@ pub(crate) mod jsx_a11y {
pub mod no_aria_hidden_on_focusable;
pub mod no_autofocus;
pub mod no_distracting_elements;
pub mod no_interactive_element_to_noninteractive_role;
pub mod no_noninteractive_tabindex;
pub mod no_redundant_roles;
pub mod no_static_element_interactions;
Expand Down Expand Up @@ -993,10 +995,12 @@ oxc_macros::declare_all_lint_rules! {
jsx_a11y::html_has_lang,
jsx_a11y::iframe_has_title,
jsx_a11y::img_redundant_alt,
jsx_a11y::interactive_supports_focus,
jsx_a11y::label_has_associated_control,
jsx_a11y::lang,
jsx_a11y::media_has_caption,
jsx_a11y::mouse_events_have_key_events,
jsx_a11y::no_interactive_element_to_noninteractive_role,
jsx_a11y::no_noninteractive_tabindex,
jsx_a11y::no_static_element_interactions,
jsx_a11y::no_access_key,
Expand Down
209 changes: 209 additions & 0 deletions crates/oxc_linter/src/rules/jsx_a11y/interactive_supports_focus.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
use oxc_ast::{
AstKind,
ast::{Expression, JSXAttributeItem, JSXAttributeValue},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};

use crate::{
AstNode,
context::LintContext,
globals::HTML_TAG,
rule::Rule,
utils::{
get_element_type, get_string_literal_prop_value, has_jsx_prop,
is_hidden_from_screen_reader, is_interactive_element, is_presentation_role,
},
};

fn interactive_supports_focus_diagnostic(span: Span, role: &str) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Elements with the '{role}' interactive role must be focusable."))
.with_help("Interactive elements must be able to receive focus. In JSX, add a valid tabIndex prop.")
.with_label(span)
}

fn interactive_supports_focus_non_interactive_diagnostic(span: Span, element: &str, role: &str) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("The '{element}' element with the '{role}' interactive role must be focusable."))
.with_help("Interactive elements must be able to receive focus. In JSX, add a valid tabIndex prop.")
.with_label(span)
}

#[derive(Debug, Default, Clone)]
pub struct InteractiveSupportsFocus;

declare_oxc_lint!(
/// ### What it does
///
/// Enforces that elements with interactive roles are focusable.
///
/// ### Why is this bad?
///
/// Interactive elements that are not focusable cannot be accessed by keyboard users,
/// making them inaccessible to users with disabilities who rely on keyboard navigation.
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```jsx
/// <div role="button" onClick={() => {}} />
/// <span role="checkbox" aria-checked="false" onClick={() => {}} />
/// ```
///
/// Examples of **correct** code for this rule:
/// ```jsx
/// <div role="button" onClick={() => {}} tabIndex="0" />
/// <button onClick={() => {}} />
/// <input type="text" />
/// ```
InteractiveSupportsFocus,
jsx_a11y,
correctness,
fix = pending
);

impl Rule for InteractiveSupportsFocus {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
let AstKind::JSXOpeningElement(jsx_opening_el) = node.kind() else {
return;
};

let element_type = get_element_type(ctx, jsx_opening_el);

if !HTML_TAG.contains(element_type.as_ref()) {
return;
}

if is_hidden_from_screen_reader(ctx, jsx_opening_el) || is_presentation_role(jsx_opening_el) {
Copy link
Member

Choose a reason for hiding this comment

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

return;
}

let has_interactive_props = INTERACTIVE_PROPS
.iter()
.any(|prop| has_jsx_prop(jsx_opening_el, prop).is_some());

if !has_interactive_props {
return;
}

if has_jsx_prop(jsx_opening_el, "disabled").is_some()
|| has_jsx_prop(jsx_opening_el, "aria-disabled").is_some_and(|attr| {
get_string_literal_prop_value(attr).is_some_and(|val| val == "true")
})
{
return;
}

if is_interactive_element(&element_type, jsx_opening_el) {
return;
}

let role = has_jsx_prop(jsx_opening_el, "role");
Copy link
Member

Choose a reason for hiding this comment

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

Please refactor this code block into is_interactive_role utils function in react, where is_interactive_element is there too :)

let Some(role_attr) = role else {
return;
};

let role_val = get_string_literal_prop_value(role_attr);
let is_interactive = if let Some(val) = role_val {
crate::utils::is_interactive_role(val)
} else {
false
};

if !is_interactive {
return;
}

// Check for `tabIndex`.
match has_jsx_prop(jsx_opening_el, "tabIndex") {
Some(JSXAttributeItem::Attribute(attr)) => {
match &attr.value {
Some(JSXAttributeValue::StringLiteral(s)) => {
if s.value.parse::<i32>().is_err() {
if element_type == "div" || element_type == "span" {
ctx.diagnostic(interactive_supports_focus_diagnostic(role_attr.span(), role_val.unwrap()));
} else {
ctx.diagnostic(interactive_supports_focus_non_interactive_diagnostic(role_attr.span(), &element_type, role_val.unwrap()));
}
}
}
Some(JSXAttributeValue::ExpressionContainer(container)) => {
if let Some(expr) = container.expression.as_expression() {
match expr {
Expression::NumericLiteral(_) => {}
Expression::UnaryExpression(unary) => {
if let Expression::NumericLiteral(_) = &unary.argument {
// Valid
} else {
// Unknown, assume valid
}
}
Expression::Identifier(id) if id.name == "undefined" => {
if element_type == "div" || element_type == "span" {
ctx.diagnostic(interactive_supports_focus_diagnostic(role_attr.span(), role_val.unwrap()));
} else {
ctx.diagnostic(interactive_supports_focus_non_interactive_diagnostic(role_attr.span(), &element_type, role_val.unwrap()));
}
}
_ => {}
}
}
}
_ => {}
}
}
Some(JSXAttributeItem::SpreadAttribute(_)) => {}
None => {
if element_type == "div" || element_type == "span" {
ctx.diagnostic(interactive_supports_focus_diagnostic(role_attr.span(), role_val.unwrap()));
} else {
ctx.diagnostic(interactive_supports_focus_non_interactive_diagnostic(role_attr.span(), &element_type, role_val.unwrap()));
}
}
}
}
}

const INTERACTIVE_PROPS: [&str; 6] = [
"onClick",
"onMouseDown",
"onMouseUp",
"onKeyPress",
"onKeyDown",
"onKeyUp",
];
Comment on lines +167 to +174
Copy link
Member

Choose a reason for hiding this comment

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

I did provide you with the exact references to the interactive props, they are still missing some.
#18229 (comment)


#[test]
fn test() {
use crate::tester::Tester;

let pass = vec![
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

I know that the upstream tests are complex with JS execution code, but the half of "copy paste" tests are still missing.

r#"<div role="button" onClick={() => {}} tabIndex="0" />"#,
r#"<div role="checkbox" onClick={() => {}} tabIndex="-1" />"#,
r#"<button onClick={() => {}} />"#,
r#"<input type="text" onClick={() => {}} />"#,
r#"<a href="foo" onClick={() => {}} />"#,
r#"<div />"#,
r#"<div role="presentation" />"#,
r#"<div role="button" onClick={() => {}} tabIndex={0} />"#,
r#"<MyButton onClick={() => {}} />"#,
r#"<div role="button" />"#, // Valid because no handler
r#"<div role="button" onClick={() => {}} aria-disabled="true" />"#, // Valid becuse disabled
r#"<div role="button" onClick={() => {}} disabled />"#, // Valid becuse disabled check
];

let fail = vec![
r#"<div role="button" onClick={() => {}} />"#,
r#"<div role="checkbox" onClick={() => {}} />"#,
r#"<div role="link" onClick={() => {}} />"#,
r#"<span role="slider" onClick={() => {}} />"#,
r#"<div role="button" onClick={() => {}} tabIndex={undefined} />"#,
r#"<section role="button" onClick={() => {}} />"#,
r#"<main role="button" onClick={() => {}} />"#,
r#"<article role="button" onClick={() => {}} />"#,
r#"<header role="button" onClick={() => {}} />"#,
r#"<footer role="button" onClick={() => {}} />"#,
];

Tester::new(InteractiveSupportsFocus::NAME, InteractiveSupportsFocus::PLUGIN, pass, fail).test_and_snapshot();
}
Loading