From a17a9cf10352d7ea1aa952ec2cd987437d602348 Mon Sep 17 00:00:00 2001 From: Yuji Sugiura <6259812+leaysgur@users.noreply.github.com> Date: Sat, 13 Jan 2024 18:27:26 +0900 Subject: [PATCH] feat(linter): eslint-plugin-jsx-a11y click-events-have-key-events (#1976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ( #1974 was lost due to mishandling. ๐Ÿ™ˆ ) Part of #1141 ### Refs - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/4c7e7815c12a797587bb8e3cdced7f3003848964/docs/rules/click-events-have-key-events.md - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/4c7e7815c12a797587bb8e3cdced7f3003848964/src/rules/click-events-have-key-events.js - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/4c7e7815c12a797587bb8e3cdced7f3003848964/__tests__/src/rules/click-events-have-key-events-test.js --------- Co-authored-by: Boshen --- crates/oxc_linter/src/rules.rs | 2 + .../jsx_a11y/click_events_have_key_events.rs | 150 ++++++++++++++++++ .../click_events_have_key_events.snap | 89 +++++++++++ crates/oxc_linter/src/utils/react.rs | 42 +++++ 4 files changed, 283 insertions(+) create mode 100644 crates/oxc_linter/src/rules/jsx_a11y/click_events_have_key_events.rs create mode 100644 crates/oxc_linter/src/snapshots/click_events_have_key_events.snap diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 0f9bb3fc6aa48..969eb8e12f0fe 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -247,6 +247,7 @@ mod jsx_a11y { pub mod aria_role; pub mod aria_unsupported_elements; pub mod autocomplete_valid; + pub mod click_events_have_key_events; pub mod heading_has_content; pub mod html_has_lang; pub mod iframe_has_title; @@ -508,6 +509,7 @@ oxc_macros::declare_all_lint_rules! { jsx_a11y::anchor_is_valid, jsx_a11y::aria_props, jsx_a11y::aria_unsupported_elements, + jsx_a11y::click_events_have_key_events, jsx_a11y::heading_has_content, jsx_a11y::html_has_lang, jsx_a11y::lang, diff --git a/crates/oxc_linter/src/rules/jsx_a11y/click_events_have_key_events.rs b/crates/oxc_linter/src/rules/jsx_a11y/click_events_have_key_events.rs new file mode 100644 index 0000000000000..951117bdfb925 --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/click_events_have_key_events.rs @@ -0,0 +1,150 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + globals::HTML_TAG, + rule::Rule, + utils::{ + get_element_type, has_jsx_prop, is_hidden_from_screen_reader, is_interactive_element, + is_presentation_role, + }, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.")] +#[diagnostic(severity(warning), help("Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener."))] +struct ClickEventsHaveKeyEventsDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct ClickEventsHaveKeyEvents; + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforce onClick is accompanied by at least one of the following: onKeyUp, onKeyDown, onKeyPress. + /// + /// ### 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. + /// This does not apply for interactive or hidden elements. + /// + /// ### Example + /// ```jsx + /// // Good + ///
void 0} onKeyDown={() => void 0} /> + /// + /// // Bad + ///
void 0} /> + /// ``` + ClickEventsHaveKeyEvents, + correctness +); + +impl Rule for ClickEventsHaveKeyEvents { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_opening_el) = node.kind() else { + return; + }; + + if has_jsx_prop(jsx_opening_el, "onClick").is_none() { + return; + }; + + // Check only native DOM elements or custom component via settings + let Some(element_type) = get_element_type(ctx, jsx_opening_el) else { + return; + }; + if !HTML_TAG.contains(&element_type) { + return; + }; + + if is_hidden_from_screen_reader(jsx_opening_el) || is_presentation_role(jsx_opening_el) { + return; + } + + if is_interactive_element(&element_type, jsx_opening_el) { + return; + } + + if ["onKeyUp", "onKeyDown", "onKeyPress"] + .iter() + .find_map(|prop| has_jsx_prop(jsx_opening_el, prop)) + .is_some() + { + return; + } + + ctx.diagnostic(ClickEventsHaveKeyEventsDiagnostic(jsx_opening_el.span)); + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + (r"
void 0} onKeyDown={foo}/>;", None, None, None), + (r"
void 0} onKeyUp={foo} />;", None, None, None), + (r"
void 0} onKeyPress={foo}/>;", None, None, None), + (r"
void 0} onKeyDown={foo} onKeyUp={bar} />;", None, None, None), + (r"
void 0} onKeyDown={foo} {...props} />;", None, None, None), + (r#"
;"#, None, None, None), + (r"
void 0} aria-hidden />;", None, None, None), + (r"
void 0} aria-hidden={true} />;", None, None, None), + (r"
void 0} aria-hidden={false} onKeyDown={foo} />;", None, None, None), + ( + r"
void 0} onKeyDown={foo} aria-hidden={undefined} />;", + None, + None, + None, + ), + (r#" void 0} />"#, None, None, None), + (r" void 0} />", None, None, None), + (r#"