mirror of
https://github.com/danbulant/oxc
synced 2026-05-19 12:19:15 +00:00
feat(linter): eslint-plugin-jsx-a11y click-events-have-key-events (#1976)
( #1974 was lost due to mishandling. 🙈 ) Part of #1141 ### Refs -4c7e7815c1/docs/rules/click-events-have-key-events.md-4c7e7815c1/src/rules/click-events-have-key-events.js-4c7e7815c1/__tests__/src/rules/click-events-have-key-events-test.js--------- Co-authored-by: Boshen <boshenc@gmail.com>
This commit is contained in:
parent
ead4e8df1f
commit
04540f749d
4 changed files with 283 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// <div onClick={() => void 0} onKeyDown={() => void 0} />
|
||||
///
|
||||
/// // Bad
|
||||
/// <div onClick={() => 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"<div onClick={() => void 0} onKeyDown={foo}/>;", None, None, None),
|
||||
(r"<div onClick={() => void 0} onKeyUp={foo} />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} onKeyPress={foo}/>;", None, None, None),
|
||||
(r"<div onClick={() => void 0} onKeyDown={foo} onKeyUp={bar} />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} onKeyDown={foo} {...props} />;", None, None, None),
|
||||
(r#"<div className="foo" />;"#, None, None, None),
|
||||
(r"<div onClick={() => void 0} aria-hidden />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} aria-hidden={true} />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} aria-hidden={false} onKeyDown={foo} />;", None, None, None),
|
||||
(
|
||||
r"<div onClick={() => void 0} onKeyDown={foo} aria-hidden={undefined} />;",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(r#"<input type="text" onClick={() => void 0} />"#, None, None, None),
|
||||
(r"<input onClick={() => void 0} />", None, None, None),
|
||||
(r#"<button onClick={() => void 0} className="foo" />"#, None, None, None),
|
||||
(r#"<select onClick={() => void 0} className="foo" />"#, None, None, None),
|
||||
(r#"<textarea onClick={() => void 0} className="foo" />"#, None, None, None),
|
||||
(r#"<a onClick={() => void 0} href="http://x.y.z" />"#, None, None, None),
|
||||
(r#"<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />"#, None, None, None),
|
||||
(r#"<input onClick={() => void 0} type="hidden" />;"#, None, None, None),
|
||||
(r#"<div onClick={() => void 0} role="presentation" />;"#, None, None, None),
|
||||
(r#"<div onClick={() => void 0} role="none" />;"#, None, None, None),
|
||||
(r"<TestComponent onClick={doFoo} />", None, None, None),
|
||||
(r"<Button onClick={doFoo} />", None, None, None),
|
||||
(r"<Footer onClick={doFoo} />", None, None, None),
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
(r"<div onClick={() => void 0} />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} role={undefined} />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} {...props} />;", None, None, None),
|
||||
(r"<section onClick={() => void 0} />;", None, None, None),
|
||||
(r"<main onClick={() => void 0} />;", None, None, None),
|
||||
(r"<article onClick={() => void 0} />;", None, None, None),
|
||||
(r"<header onClick={() => void 0} />;", None, None, None),
|
||||
(r"<footer onClick={() => void 0} />;", None, None, None),
|
||||
(r"<div onClick={() => void 0} aria-hidden={false} />;", None, None, None),
|
||||
(r"<a onClick={() => void 0} />", None, None, None),
|
||||
(r#"<a tabIndex="0" onClick={() => void 0} />"#, None, None, None),
|
||||
(
|
||||
r"<Footer onClick={doFoo} />",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"jsx-a11y": {
|
||||
"components": {
|
||||
"Footer": "footer",
|
||||
}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
Tester::new(ClickEventsHaveKeyEvents::NAME, pass, fail).test_and_snapshot();
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
source: crates/oxc_linter/src/tester.rs
|
||||
expression: click_events_have_key_events
|
||||
---
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <div onClick={() => void 0} />;
|
||||
· ──────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <div onClick={() => void 0} role={undefined} />;
|
||||
· ───────────────────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <div onClick={() => void 0} {...props} />;
|
||||
· ─────────────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <section onClick={() => void 0} />;
|
||||
· ──────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <main onClick={() => void 0} />;
|
||||
· ───────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <article onClick={() => void 0} />;
|
||||
· ──────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <header onClick={() => void 0} />;
|
||||
· ─────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <footer onClick={() => void 0} />;
|
||||
· ─────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <div onClick={() => void 0} aria-hidden={false} />;
|
||||
· ──────────────────────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <a onClick={() => void 0} />
|
||||
· ────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <a tabIndex="0" onClick={() => void 0} />
|
||||
· ─────────────────────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
|
||||
╭─[click_events_have_key_events.tsx:1:1]
|
||||
1 │ <Footer onClick={doFoo} />
|
||||
· ──────────────────────────
|
||||
╰────
|
||||
help: Visible, non-interactive elements with click handlers must have one of keyup, keydown, or keypress listener.
|
||||
|
||||
|
||||
|
|
@ -112,6 +112,48 @@ pub fn object_has_accessible_child(node: &JSXElement<'_>) -> bool {
|
|||
|| has_jsx_prop_lowercase(&node.opening_element, "children").is_some()
|
||||
}
|
||||
|
||||
pub fn is_presentation_role(jsx_opening_el: &JSXOpeningElement) -> bool {
|
||||
let Some(role) = has_jsx_prop(jsx_opening_el, "role") else {
|
||||
return false;
|
||||
};
|
||||
let Some("presentation" | "none") = get_string_literal_prop_value(role) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// TODO: Should re-implement
|
||||
// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/4c7e7815c12a797587bb8e3cdced7f3003848964/src/util/isInteractiveElement.js
|
||||
// with `oxc-project/aria-query` which is currently W.I.P.
|
||||
//
|
||||
// Until then, use simplified version by https://html.spec.whatwg.org/multipage/dom.html#interactive-content
|
||||
pub fn is_interactive_element(element_type: &str, jsx_opening_el: &JSXOpeningElement) -> bool {
|
||||
// Interactive contents are...
|
||||
// - button, details, embed, iframe, label, select, textarea
|
||||
// - input (if the `type` attribute is not in the Hidden state)
|
||||
// - a (if the `href` attribute is present)
|
||||
// - audio, video (if the `controls` attribute is present)
|
||||
// - img (if the `usemap` attribute is present)
|
||||
match element_type {
|
||||
"button" | "details" | "embed" | "iframe" | "label" | "select" | "textarea" => true,
|
||||
"input" => {
|
||||
if let Some(input_type) = has_jsx_prop(jsx_opening_el, "type") {
|
||||
if get_string_literal_prop_value(input_type)
|
||||
.is_some_and(|val| val.to_uppercase() == "HIDDEN")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
"a" => has_jsx_prop(jsx_opening_el, "href").is_some(),
|
||||
"audio" | "video" => has_jsx_prop(jsx_opening_el, "controls").is_some(),
|
||||
"img" => has_jsx_prop(jsx_opening_el, "usemap").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
const PRAGMA: &str = "React";
|
||||
const CREATE_CLASS: &str = "createReactClass";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue