diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 34bc03c3532c..9a77e7d69100 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -245,6 +245,7 @@ mod jsx_a11y { pub mod img_redundant_alt; pub mod lang; pub mod media_has_caption; + pub mod mouse_events_have_key_events; pub mod no_access_key; pub mod no_aria_hidden_on_focusable; pub mod no_autofocus; @@ -480,6 +481,7 @@ oxc_macros::declare_all_lint_rules! { jsx_a11y::iframe_has_title, jsx_a11y::img_redundant_alt, jsx_a11y::media_has_caption, + jsx_a11y::mouse_events_have_key_events, jsx_a11y::no_access_key, jsx_a11y::no_aria_hidden_on_focusable, jsx_a11y::no_autofocus, diff --git a/crates/oxc_linter/src/rules/jsx_a11y/mouse_events_have_key_events.rs b/crates/oxc_linter/src/rules/jsx_a11y/mouse_events_have_key_events.rs new file mode 100644 index 000000000000..3ae4b482615f --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/mouse_events_have_key_events.rs @@ -0,0 +1,274 @@ +use oxc_ast::{ + ast::{JSXAttributeValue, JSXExpression, JSXExpressionContainer}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::{self, Error}, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; + +use crate::{ + context::LintContext, + globals::HTML_TAG, + rule::Rule, + utils::{get_element_type, get_prop_value, has_jsx_prop}, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +enum MouseEventsHaveKeyEventsDiagnostic { + #[error("eslint-plugin-jsx-a11y(mouse-events-have-key-events): {1} must be accompanied by onFocus for accessibility.")] + #[diagnostic(severity(warning), help("Try to add onFocus."))] + MissOnFocus(#[label] Span, String), + + #[error("eslint-plugin-jsx-a11y(mouse-events-have-key-events): {1} must be accompanied by onBlur for accessibility.")] + #[diagnostic(severity(warning), help("Try to add onBlur."))] + MissOnBlur(#[label] Span, String), +} + +#[derive(Debug, Default, Clone)] +pub struct MouseEventsHaveKeyEvents(Box); + +#[derive(Debug, Clone)] +pub struct MouseEventsHaveKeyEventsConfig { + hover_in_handlers: Vec, + hover_out_handlers: Vec, +} + +impl Default for MouseEventsHaveKeyEventsConfig { + fn default() -> Self { + Self { + hover_in_handlers: vec!["onMouseOver".to_string()], + hover_out_handlers: vec!["onMouseOut".to_string()], + } + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforce onmouseover/onmouseout are accompanied by onfocus/onblur. + /// + /// ### Why is this bad? + /// + /// Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, + /// AT compatibility, and screenreader users. + /// + /// ### Example + /// ```jsx + /// // Good + ///
void 0} onFocus={() => void 0} /> + /// + /// // Bad + ///
void 0} /> + /// ``` + MouseEventsHaveKeyEvents, + correctness +); + +impl Rule for MouseEventsHaveKeyEvents { + fn from_configuration(value: serde_json::Value) -> Self { + let mut config = MouseEventsHaveKeyEventsConfig::default(); + + if let Some(hover_in_handlers_config) = value + .get(0) + .and_then(|v| v.get("hoverInHandlers")) + .and_then(serde_json::Value::as_array) + { + config.hover_in_handlers = hover_in_handlers_config + .iter() + .filter_map(serde_json::Value::as_str) + .map(ToString::to_string) + .collect(); + } + + if let Some(hover_out_handlers_config) = value + .get(0) + .and_then(|v| v.get("hoverOutHandlers")) + .and_then(serde_json::Value::as_array) + { + config.hover_out_handlers = hover_out_handlers_config + .iter() + .filter_map(serde_json::Value::as_str) + .map(ToString::to_string) + .collect(); + } + + Self(Box::new(config)) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_opening_el) = node.kind() else { + return; + }; + + let Some(el_type) = get_element_type(ctx, jsx_opening_el) else { + return; + }; + + if !HTML_TAG.contains(&el_type) { + return; + } + + for handler in &self.0.hover_in_handlers { + if let Some(jsx_attr) = has_jsx_prop(jsx_opening_el, handler) { + if get_prop_value(jsx_attr).is_none() { + continue; + } + + match has_jsx_prop(jsx_opening_el, "onFocus").and_then(get_prop_value) { + Some(JSXAttributeValue::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expr), + .. + })) => { + if expr.is_undefined() { + ctx.diagnostic(MouseEventsHaveKeyEventsDiagnostic::MissOnFocus( + jsx_attr.span(), + String::from(handler), + )); + } + } + None => { + ctx.diagnostic(MouseEventsHaveKeyEventsDiagnostic::MissOnFocus( + jsx_attr.span(), + String::from(handler), + )); + } + _ => {} + } + + break; + } + } + + for handler in &self.0.hover_out_handlers { + if let Some(jsx_attr) = has_jsx_prop(jsx_opening_el, handler) { + if get_prop_value(jsx_attr).is_none() { + continue; + } + + match has_jsx_prop(jsx_opening_el, "onBlur").and_then(get_prop_value) { + Some(JSXAttributeValue::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expr), + .. + })) => { + if expr.is_undefined() { + ctx.diagnostic(MouseEventsHaveKeyEventsDiagnostic::MissOnBlur( + jsx_attr.span(), + String::from(handler), + )); + } + } + None => { + ctx.diagnostic(MouseEventsHaveKeyEventsDiagnostic::MissOnBlur( + jsx_attr.span(), + String::from(handler), + )); + } + _ => {} + } + + break; + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ("
void 0} onFocus={() => void 0} />;", None), + ("
void 0} onFocus={() => void 0} {...props} />;", None), + ("
;", None), + ("
;", None), + ("
;", None), + ("
{}} />", None), + ("
{}} />", None), + ("
void 0} onBlur={() => void 0} />", None), + ("
void 0} onBlur={() => void 0} {...props} />", None), + ("
", None), + ("
", None), + ("", None), + (" {}} />", None), + (" {}} />", None), + (" {}} />", None), + (" {}} />", None), + (" {}} {...props} />", None), + (" {}} {...props} />", None), + (" {}} {...props} />", None), + (" {}} {...props} />", None), + ( + "
{}} onMouseOut={() => {}} />", + Some(serde_json::json!([{ "hoverInHandlers": [], "hoverOutHandlers": [] }])), + ), + ( + "
{}} onFocus={() => {}} />", + Some(serde_json::json!([{ "hoverInHandlers": ["onMouseOver"] }])), + ), + ( + "
{}} onFocus={() => {}} />", + Some(serde_json::json!([{ "hoverInHandlers": ["onMouseEnter"] }])), + ), + ( + "
{}} onBlur={() => {}} />", + Some(serde_json::json!([{ "hoverOutHandlers": ["onMouseOut"] }])), + ), + ( + "
{}} onBlur={() => {}} />", + Some(serde_json::json!([{ "hoverOutHandlers": ["onMouseLeave"] }])), + ), + ( + "
{}} onMouseOut={() => {}} />", + Some(serde_json::json!([ + { "hoverInHandlers": ["onPointerEnter"], "hoverOutHandlers": ["onPointerLeave"] }, + ])), + ), + ( + "
{}} />", + Some(serde_json::json!([{ "hoverOutHandlers": ["onPointerLeave"] }])), + ), + ]; + + let fail = vec![ + ("
void 0} />;", None), + ("
void 0} />", None), + ("
void 0} onFocus={undefined} />;", None), + ("
void 0} onBlur={undefined} />", None), + ("
void 0} {...props} />", None), + ("
void 0} {...props} />", None), + ( + "
{}} onMouseOut={() => {}} />", + Some(serde_json::json!([ + { "hoverInHandlers": ["onMouseOver"], "hoverOutHandlers": ["onMouseOut"] }, + ])), + ), + ( + "
{}} onPointerLeave={() => {}} />", + Some(serde_json::json!([ + { "hoverInHandlers": ["onPointerEnter"], "hoverOutHandlers": ["onPointerLeave"] }, + ])), + ), + ( + "
{}} />", + Some(serde_json::json!([{ "hoverInHandlers": ["onMouseOver"] }])), + ), + ( + "
{}} />", + Some(serde_json::json!([{ "hoverInHandlers": ["onPointerEnter"] }])), + ), + ( + "
{}} />", + Some(serde_json::json!([{ "hoverOutHandlers": ["onMouseOut"] }])), + ), + ( + "
{}} />", + Some(serde_json::json!([{ "hoverOutHandlers": ["onPointerLeave"] }])), + ), + ]; + + Tester::new(MouseEventsHaveKeyEvents::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/mouse_events_have_key_events.snap b/crates/oxc_linter/src/snapshots/mouse_events_have_key_events.snap new file mode 100644 index 000000000000..c7ef8d02938a --- /dev/null +++ b/crates/oxc_linter/src/snapshots/mouse_events_have_key_events.snap @@ -0,0 +1,103 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: mouse_events_have_key_events +--- + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOver must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
void 0} />; + · ────────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOut must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
void 0} /> + · ───────────────────────── + ╰──── + help: Try to add onBlur. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOver must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
void 0} onFocus={undefined} />; + · ────────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOut must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
void 0} onBlur={undefined} /> + · ───────────────────────── + ╰──── + help: Try to add onBlur. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOver must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
void 0} {...props} /> + · ────────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOut must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
void 0} {...props} /> + · ───────────────────────── + ╰──── + help: Try to add onBlur. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOver must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} onMouseOut={() => {}} /> + · ────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOut must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} onMouseOut={() => {}} /> + · ───────────────────── + ╰──── + help: Try to add onBlur. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onPointerEnter must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} onPointerLeave={() => {}} /> + · ───────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onPointerLeave must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} onPointerLeave={() => {}} /> + · ───────────────────────── + ╰──── + help: Try to add onBlur. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOver must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} /> + · ────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onPointerEnter must be accompanied by onFocus for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} /> + · ───────────────────────── + ╰──── + help: Try to add onFocus. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onMouseOut must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} /> + · ───────────────────── + ╰──── + help: Try to add onBlur. + + ⚠ eslint-plugin-jsx-a11y(mouse-events-have-key-events): onPointerLeave must be accompanied by onBlur for accessibility. + ╭─[mouse_events_have_key_events.tsx:1:1] + 1 │
{}} /> + · ───────────────────────── + ╰──── + help: Try to add onBlur. + +