feat(rome_js_analyzer): rule `useKeyWithClickEvents`
MODIFY
crates/rome_diagnostics_categories/src/categories.rs
crates/rome_diagnostics_categories/src/categories.rs
@@ -57,6 +57,7 @@ define_dategories! {
"lint/nursery/useAnchorContent": "https://rome.tools/docs/lint/rules/useAnchorContent",
"lint/nursery/useBlankTarget": "https://rome.tools/docs/lint/rules/useBlankTarget",
"lint/nursery/useValidAnchor": "https://rome.tools/docs/lint/rules/useValidAnchor",
+ "lint/nursery/useKeyWithClickEvents": "https://rome.tools/docs/lint/rules/useKeyWithClickEvents",
"lint/nursery/noRestrictedGlobals": "https://rome.tools/docs/lint/rules/noRestrictedGlobals",
"lint/nursery/noPositiveTabindex": "https://rome.tools/docs/lint/rules/noPositiveTabindex",
"lint/nursery/noExplicitAny": "https://rome.tools/docs/lint/rules/noExplicitAny",

MODIFY
crates/rome_js_analyze/src/analyzers/nursery.rs
crates/rome_js_analyze/src/analyzers/nursery.rs
@@ -6,6 +6,7 @@ mod no_new_symbol;
mod no_unreachable;
mod use_anchor_content;
mod use_blank_target;
+mod use_key_with_click_events;
mod use_optional_chain;
mod use_valid_anchor;
-declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_auto_focus :: NoAutoFocus , self :: no_new_symbol :: NoNewSymbol , self :: no_unreachable :: NoUnreachable , self :: use_anchor_content :: UseAnchorContent , self :: use_blank_target :: UseBlankTarget , self :: use_optional_chain :: UseOptionalChain , self :: use_valid_anchor :: UseValidAnchor ,] } }
+declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_auto_focus :: NoAutoFocus , self :: no_new_symbol :: NoNewSymbol , self :: no_unreachable :: NoUnreachable , self :: use_anchor_content :: UseAnchorContent , self :: use_blank_target :: UseBlankTarget , self :: use_key_with_click_events :: UseKeyWithClickEvents , self :: use_optional_chain :: UseOptionalChain , self :: use_valid_anchor :: UseValidAnchor ,] } }

ADD
crates/rome_js_analyze/src/analyzers/nursery/use_key_with_click_events.rs
+use rome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic};
+use rome_console::markup;
+use rome_js_syntax::{JsxOpeningElement, JsxSelfClosingElement};
+use rome_rowan::{declare_node_union, AstNode};
+
+declare_rule! {
+ /// Enforce to have the `onClick` mouse event with the `onKeyUp`, the `onKeyDown`, or the `noKeyPress` keyboard event.
+ ///
+ /// ## Examples
+ ///
+ /// ### Invalid
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <div onClick={() => {}} />
+ /// ```
+ ///
+ /// ```jsx,expect_diagnostic
+ /// <div onClick={() => {}} ></div>
+ /// ```
+ ///
+ /// ### Valid
+ ///
+ /// ```jsx
+ /// <div onClick={() => {}} onKeyDown={handleKeyDown} />
+ ///```
+ ///
+ /// ```jsx
+ /// <div onClick={() => {}} onKeyUp={handleKeyUp} />
+ ///```
+ ///
+ /// ```jsx
+ /// <div onClick={() => {}} onKeyPress={handleKeyPress} />
+ ///```
+ ///
+ /// ```jsx
+ /// // this rule doesn't apply to user created component
+ /// <MyComponent onClick={() => {}} />
+ ///```
+ pub(crate) UseKeyWithClickEvents {
+ version: "10.0.0",
+ name: "useKeyWithClickEvents",
+ recommended: false,
+ }
+}
+
+declare_node_union! {
+ pub(crate) JsxAnyElement = JsxOpeningElement | JsxSelfClosingElement
+}
+
+impl UseKeyWithClickEvents {
+ const REQUIRED_PROPS: [&str; 3] = ["onKeyDown", "onKeyUp", "onKeyPress"];
+}
+
+impl Rule for UseKeyWithClickEvents {
+ type Query = Ast<JsxAnyElement>;
+ type State = ();
+ type Signals = Option<Self::State>;
+ type Options = ();
+
+ fn run(ctx: &RuleContext<Self>) -> Self::Signals {
+ let node = ctx.query();
+
+ match node {
+ JsxAnyElement::JsxOpeningElement(element) => {
+ element.name().ok()?.as_jsx_name()?;
+ element.find_attribute_by_name("onClick").ok()??;
+
+ for attr in element.attributes().into_iter() {
+ let name = attr
+ .as_jsx_attribute()?
+ .name()
+ .ok()?
+ .as_jsx_name()?
+ .syntax()
+ .text_trimmed()
+ .to_string();
+
+ if Self::REQUIRED_PROPS.contains(&name.as_str()) {
+ return None;
+ }
+ }
+
+ Some(())
+ }
+ JsxAnyElement::JsxSelfClosingElement(element) => {
+ element.name().ok()?.as_jsx_name()?;
+ element.find_attribute_by_name("onClick").ok()??;
+
+ for attr in element.attributes().into_iter() {
+ let name = attr
+ .as_jsx_attribute()?
+ .name()
+ .ok()?
+ .as_jsx_name()?
+ .syntax()
+ .text_trimmed()
+ .to_string();
+
+ if Self::REQUIRED_PROPS.contains(&name.as_str()) {
+ return None;
+ }
+ }
+
+ Some(())
+ }
+ }
+ }
+
+ fn diagnostic(ctx: &RuleContext<Self>, _: &Self::State) -> Option<RuleDiagnostic> {
+ let node = ctx.query();
+
+ Some(RuleDiagnostic::new(
+ rule_category!(),
+ node.range(),
+ markup! {
+ "Enforce to have the "<Emphasis>"onClick"</Emphasis>" mouse event with the "<Emphasis>"onKeyUp"</Emphasis>", the "<Emphasis>"onKeyDown"</Emphasis>", or the "<Emphasis>"onKeyPress"</Emphasis>" keyboard event."
+ },
+ ).footer_note(markup! {
+ "Actions triggered using mouse events should have corresponding keyboard events to account for keyboard-only navigation."
+ }))
+ }
+}

ADD
crates/rome_js_analyze/tests/specs/nursery/useKeyWithClickEvents/useKeyWithClickEventsInvalid.jsx
+<>
+ <div onClick={() => {}} />
+</>
\ No newline at end of file

ADD
crates/rome_js_analyze/tests/specs/nursery/useKeyWithClickEvents/useKeyWithClickEventsValid.jsx
+<>
+ <div onClick={() => {}} onKeyUp={handleKeyUp} />
+ <div onClick={() => {}} onKeyDown={handleKeyDown} />
+ <div onClick={() => {}} onKeyPress={handleKeyPress} />
+ <MyComponent onClick={() => {}} />
+</>
\ No newline at end of file

ADD
crates/rome_js_analyze/tests/specs/nursery/useKeyWithClickEvents/useKeyWithClickEventsValid.jsx.snap
+---
+source: crates/rome_js_analyze/tests/spec_tests.rs
+expression: useKeyWithClickEventsValid.jsx
+---
+# Input
+```js
+<>
+ <div onClick={() => {}} onKeyUp={handleKeyUp} />
+ <div onClick={() => {}} onKeyDown={handleKeyDown} />
+ <div onClick={() => {}} onKeyPress={handleKeyPress} />
+ <MyComponent onClick={() => {}} />
+</>
+```
+
+

MODIFY
crates/rome_service/src/configuration/linter/rules.rs
crates/rome_service/src/configuration/linter/rules.rs
@@ -406,13 +406,14 @@ struct NurserySchema {
use_button_type: Option<RuleConfiguration>,
use_camel_case: Option<RuleConfiguration>,
use_fragment_syntax: Option<RuleConfiguration>,
+ use_key_with_click_events: Option<RuleConfiguration>,
use_key_with_mouse_events: Option<RuleConfiguration>,
use_optional_chain: Option<RuleConfiguration>,
use_valid_anchor: Option<RuleConfiguration>,
}
impl Nursery {
const CATEGORY_NAME: &'static str = "nursery";
- pub(crate) const CATEGORY_RULES: [&'static str; 23] = [
+ pub(crate) const CATEGORY_RULES: [&'static str; 24] = [
"noArrayIndexKey",
"noAutofocus",
"noChildrenProp",
@@ -433,6 +434,7 @@ impl Nursery {
"useButtonType",
"useCamelCase",
"useFragmentSyntax",
+ "useKeyWithClickEvents",
"useKeyWithMouseEvents",
"useOptionalChain",
"useValidAnchor",

MODIFY
editors/vscode/configuration_schema.json
editors/vscode/configuration_schema.json
}
]
},
+ "useKeyWithClickEvents": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/RuleConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"useKeyWithMouseEvents": {
"anyOf": [
{

MODIFY
npm/backend-jsonrpc/src/workspace.ts
npm/backend-jsonrpc/src/workspace.ts
@@ -179,6 +179,7 @@ export interface Nursery {
useButtonType?: RuleConfiguration;
useCamelCase?: RuleConfiguration;
useFragmentSyntax?: RuleConfiguration;
+ useKeyWithClickEvents?: RuleConfiguration;
useKeyWithMouseEvents?: RuleConfiguration;
useOptionalChain?: RuleConfiguration;
useValidAnchor?: RuleConfiguration;
@@ -319,6 +320,7 @@ export type Category =
| "lint/nursery/useAnchorContent"
| "lint/nursery/useBlankTarget"
| "lint/nursery/useValidAnchor"
+ | "lint/nursery/useKeyWithClickEvents"
| "lint/nursery/noRestrictedGlobals"
| "lint/nursery/noPositiveTabindex"
| "lint/nursery/noExplicitAny"

MODIFY
rome.json
rome.json
"noNewSymbol": "error",
"noVoidElementsWithChildren": "error",
"noDangerouslySetInnerHtmlWithChildren": "error",
- "noAutofocus": "error"
+ "noAutofocus": "error",
+ "useKeyWithClickEvents": "error"
}
}
}

MODIFY
website/playground/src/LineWidthInput.tsx
website/playground/src/LineWidthInput.tsx
@@ -28,6 +28,7 @@ export default function LineWidthInput({ lineWidth, setLineWidth }: Props) {
<button
aria-label="Set line width to 80 characters"
onClick={() => setLineWidth(80)}
+ onKeyDown={() => setLineWidth(80)}
disabled={lineWidth === 80}
className="bg-slate-500 m-2 text-sm w-[80px] p-1 rounded text-slate-50 disabled:bg-slate-300 transition"
>
@@ -36,6 +37,7 @@ export default function LineWidthInput({ lineWidth, setLineWidth }: Props) {
<button
aria-label="Set line width to 120 characters"
onClick={() => setLineWidth(120)}
+ onKeyDown={() => setLineWidth(120)}
disabled={lineWidth === 120}
className="bg-slate-500 m-2 text-sm w-[80px] p-1 rounded text-slate-50 disabled:bg-slate-300 transition"
>

MODIFY
website/src/docs/lint/rules/index.md
website/src/docs/lint/rules/index.md
@@ -395,6 +395,13 @@ Enforce camel case naming convention.
This rule enforces the use of <code>&lt;&gt;...&lt;/&gt;</code> over <code>&lt;Fragment&gt;...&lt;/Fragment&gt;</code>.
</div>
<div class="rule">
+<h3 data-toc-exclude id="useKeyWithClickEvents">
+ <a href="/docs/lint/rules/useKeyWithClickEvents">useKeyWithClickEvents (since v10.0.0)</a>
+ <a class="header-anchor" href="#useKeyWithClickEvents"></a>
+</h3>
+Enforce to have the <code>onClick</code> mouse event with the <code>onKeyUp</code>, the <code>onKeyDown</code>, or the <code>noKeyPress</code> keyboard event.
+</div>
+<div class="rule">
<h3 data-toc-exclude id="useKeyWithMouseEvents">
<a href="/docs/lint/rules/useKeyWithMouseEvents">useKeyWithMouseEvents (since v10.0.0)</a>
<a class="header-anchor" href="#useKeyWithMouseEvents"></a>

feat(rome_js_analyzer): rule `useKeyWithClickEvents` (#3426)