Skip to content

Commit

Permalink
feat(linter): add noNoninteractiveElementInteractions rule (#4358)
Browse files Browse the repository at this point in the history
Co-authored-by: Emanuele Stoppa <[email protected]>
  • Loading branch information
tunamaguro and ematipico authored Jan 3, 2025
1 parent 6ea885f commit a658a29
Show file tree
Hide file tree
Showing 16 changed files with 2,130 additions and 90 deletions.
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.

2 changes: 1 addition & 1 deletion crates/biome_aria/src/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ impl AriaRoles {

/// Given an element name and attributes, it returns the role associated with that element.
/// If no explicit role attribute is present, an implicit role is returned.
fn get_role_by_element_name(&self, element: &impl Element) -> Option<AriaRole> {
pub fn get_role_by_element_name(&self, element: &impl Element) -> Option<AriaRole> {
element
.find_attribute_by_name(|name| name == "role")
.as_ref()
Expand Down
2 changes: 1 addition & 1 deletion crates/biome_aria_metadata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ fn is_valid_html_id(id: &str) -> bool {
}

impl AriaRole {
/// Returns the first valid role from `values`, a space-separated list of roles.
/// Returns the first valid role from `roles`, a space-separated list of roles.
///
/// If a role attribute has multiple values, the first valid role (specified role) will be used.
/// See <https://www.w3.org/TR/2014/REC-wai-aria-implementation-20140320/#mapping_role>
Expand Down
10 changes: 10 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

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

184 changes: 102 additions & 82 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ define_categories! {
"lint/nursery/noMissingGenericFamilyKeyword": "https://biomejs.dev/linter/rules/no-missing-generic-family-keyword",
"lint/nursery/noMissingVarFunction": "https://biomejs.dev/linter/rules/no-missing-var-function",
"lint/nursery/noNestedTernary": "https://biomejs.dev/linter/rules/no-nested-ternary",
"lint/nursery/noNoninteractiveElementInteractions": "https://biomejs.dev/linter/rules/no-noninteractive-element-interactions",
"lint/nursery/noOctalEscape": "https://biomejs.dev/linter/rules/no-octal-escape",
"lint/nursery/noProcessEnv": "https://biomejs.dev/linter/rules/no-process-env",
"lint/nursery/noProcessGlobal": "https://biomejs.dev/linter/rules/no-process-global",
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod no_head_import_in_document;
pub mod no_img_element;
pub mod no_irregular_whitespace;
pub mod no_nested_ternary;
pub mod no_noninteractive_element_interactions;
pub mod no_octal_escape;
pub mod no_process_env;
pub mod no_process_global;
Expand Down Expand Up @@ -63,6 +64,7 @@ declare_lint_group! {
self :: no_img_element :: NoImgElement ,
self :: no_irregular_whitespace :: NoIrregularWhitespace ,
self :: no_nested_ternary :: NoNestedTernary ,
self :: no_noninteractive_element_interactions :: NoNoninteractiveElementInteractions ,
self :: no_octal_escape :: NoOctalEscape ,
self :: no_process_env :: NoProcessEnv ,
self :: no_process_global :: NoProcessGlobal ,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
use crate::services::aria::Aria;
use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource};
use biome_console::markup;
use biome_js_syntax::jsx_ext::AnyJsxElement;
use biome_rowan::AstNode;

declare_lint_rule! {
/// Disallow use event handlers on non-interactive elements.
///
/// Non-interactive HTML elements indicate _content_ and _containers_ in the user interface.
/// Non-interactive elements include `<main>`, `<area>`, `<h1>` (,`<h2>`, etc), `<img>`, `<li>`, `<ul>` and `<ol>`.
///
/// A Non-interactive element does not support event handlers(mouse and key handlers).
///
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// <div onClick={() => {}}>button</div>
/// ```
///
/// ### Valid
///
/// ```jsx
/// <button onClick={() => { }}>button</button>
/// ```
///
/// ```jsx
/// // Adding a role to element does not add behavior.
/// // If not used semantic HTML elements like `button`, developers need to implement the expected behavior for role(like focusability and key press support)
/// // See https://www.w3.org/WAI/ARIA/apg/
/// <div role="button" onClick={() => { }}>button</div>
/// ```
///
/// ```jsx
/// // The role="presentation" attribute removes the semantic meaning of an element, indicating that it should be ignored by assistive technologies.
/// // Therefore, it's acceptable to add event handlers to elements with role="presentation" for visual effects or other purposes,
/// // but users relying on assistive technologies may not be able to interact with these elements.
/// <div role="presentation" onClick={() => { }}>button</div>
/// ```
///
/// ```jsx
/// // Hidden from screen reader.
/// <div onClick={() => {}} aria-hidden />
/// ```
///
/// ```jsx
/// // Custom component is not checked.
/// <SomeComponent onClick={() => {}}>button</SomeComponent>
/// ```
///
/// ```jsx
/// // Spread attributes is not supported.
/// <div {...{"onClick":() => {}}}>button</div>
/// ```
///
/// ## Accessibility guidelines
///
/// - [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value)
///
/// ### Resources
///
/// - [WAI-ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#usage_intro)
/// - [WAI-ARIA Authoring Practices Guide - Design Patterns and Widgets](https://www.w3.org/TR/wai-aria-practices-1.1/#aria_ex)
/// - [Fundamental Keyboard Navigation Conventions](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_generalnav)
/// - [Mozilla Developer Network - ARIA Techniques](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_button_role#Keyboard_and_focus)
///
pub NoNoninteractiveElementInteractions {
version: "next",
name: "noNoninteractiveElementInteractions",
language: "jsx",
sources: &[RuleSource::EslintJsxA11y("no-noninteractive-element-interactions")],
recommended: false,
}
}

impl Rule for NoNoninteractiveElementInteractions {
type Query = Aria<AnyJsxElement>;
type State = ();
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let element = ctx.query();

// Custom components are not checked because we do not know what DOM will be used.
if element.is_custom_component() {
return None;
}

let aria_roles = ctx.aria_roles();
let role = aria_roles.get_role_by_element_name(element);
let has_interactive_role = role.is_some_and(|role| role.is_interactive());

if !has_handler_props(element)
|| is_content_editable(element)
|| has_presentation_role(element)
|| is_hidden_from_screen_reader(element)?
|| has_interactive_role
{
return None;
}

// Non-interactive elements what contains event handler should be reported.
if has_handler_props(element) && aria_roles.is_not_interactive_element(element) {
return Some(());
};

None
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"Non-interactive element should not have event handler."
},
)
.note(markup! {
"Consider replace semantically interactive element like "<Emphasis>"<button/>"</Emphasis>" or "<Emphasis>"<a href/>"</Emphasis>"."
})
)
}
}

/// Ref: https://github.com/jsx-eslint/jsx-ast-utils/blob/v3.3.5/src/eventHandlers.js
const INTERACTIVE_HANDLERS: &[&str] = &[
"onClick",
"onContextMenu",
"onDblClick",
"onDoubleClick",
"onDrag",
"onDragEnd",
"onDragEnter",
"onDragExit",
"onDragLeave",
"onDragOver",
"onDragStart",
"onDrop",
"onMouseDown",
"onMouseEnter",
"onMouseLeave",
"onMouseMove",
"onMouseOut",
"onMouseOver",
"onKeyDown",
"onKeyPress",
"onKeyUp",
"onFocus",
"onBlur",
"onLoad",
"onError",
];

/// Check if the element contains event handler
fn has_handler_props(element: &AnyJsxElement) -> bool {
INTERACTIVE_HANDLERS
.iter()
.any(|handler| element.find_attribute_by_name(handler).is_some())
}

/// Check if the element's implicit ARIA semantics have been removed.
/// See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/presentation_role
///
/// Ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isPresentationRole.js
fn has_presentation_role(element: &AnyJsxElement) -> bool {
if let Some(attribute) = element.find_attribute_by_name("role") {
let value = attribute.as_static_value();
if let Some(value) = value {
return matches!(value.as_string_constant(), Some("presentation" | "none"));
}
}
false
}

/// Check the element is hidden from screen reader.
/// See
/// - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden
/// - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden
///
/// Ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isHiddenFromScreenReader.js
fn is_hidden_from_screen_reader(element_name: &AnyJsxElement) -> Option<bool> {
let is_aria_hidden = element_name.has_truthy_attribute("aria-hidden");

let name = element_name.name_value_token().ok()?;

let is_input_hidden = if name.text_trimmed() == "input" {
element_name
.find_attribute_by_name("type")
.and_then(|attribute| attribute.as_static_value())
.and_then(|value| value.as_string_constant().map(|value| value == "hidden"))
.unwrap_or_default()
} else {
false
};

Some(is_aria_hidden || is_input_hidden)
}

/// Check if the element is `contentEditable`
/// See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable
///
/// Ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isContentEditable.js
fn is_content_editable(element: &AnyJsxElement) -> bool {
element
.find_attribute_by_name("contentEditable")
.and_then(|attribute| attribute.as_static_value())
.and_then(|value| value.as_string_constant().map(|value| value == "true"))
.unwrap_or_default()
}
1 change: 1 addition & 0 deletions crates/biome_js_analyze/src/options.rs

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<>
<div onClick={() => { }} />
<main onClick={() => void 0} />
<article onClick={() => { }} />
<aside onClick={() => { }} />
<blockquote onClick={() => { }} />
<body onClick={() => { }} />
<br onClick={() => { }} />
<caption onClick={() => { }} />
<dd onClick={() => { }} />
<details onClick={() => { }} />
<dfn onClick={() => { }} />
<dl onClick={() => { }} />
<dir onClick={() => { }} />
<dt onClick={() => { }} />
<fieldset onClick={() => { }} />
<figcaption onClick={() => { }} />
<figure onClick={() => { }} />
<footer onClick={() => { }} />
<form onClick={() => { }} />
<frame onClick={() => { }} />
<h1 onClick={() => { }} />
<h2 onClick={() => { }} />
<h3 onClick={() => { }} />
<h4 onClick={() => { }} />
<h5 onClick={() => { }} />
<h6 onClick={() => { }} />
<iframe onClick={() => { }} />
<img onClick={() => { }} />
<label onClick={() => { }} />
<legend onClick={() => { }} />
<li onClick={() => { }} />
<mark onClick={() => { }} />
<marquee onClick={() => { }} />
<menu onClick={() => { }} />
<meter onClick={() => { }} />
<nav onClick={() => { }} />
<ol onClick={() => { }} />
<optgroup onClick={() => { }} />
<output onClick={() => { }} />
<p onClick={() => { }} />
<pre onClick={() => { }} />
<progress onClick={() => { }} />
<ruby onClick={() => { }} />
<section onClick={() => { }} aria-label="Aardvark" />
<section onClick={() => { }} aria-labelledby="js_1" />
<table onClick={() => { }} />
<tbody onClick={() => { }} />
<td onClick={() => { }} />
<tfoot onClick={() => { }} />
<thead onClick={() => { }} />
<time onClick={() => { }} />
<ul onClick={() => { }} />
<ul contentEditable="false" onClick={() => { }} />
<div role="alert" onClick={() => { }} />
<div role="alertdialog" onClick={() => { }} />
<div role="application" onClick={() => { }} />
<div role="banner" onClick={() => { }} />
<div role="cell" onClick={() => { }} />
<div role="complementary" onClick={() => { }} />
<div role="contentinfo" onClick={() => { }} />
<div role="definition" onClick={() => { }} />
<div role="dialog" onClick={() => { }} />
<div role="directory" onClick={() => { }} />
<div role="document" onClick={() => { }} />
<div role="feed" onClick={() => { }} />
<div role="figure" onClick={() => { }} />
<div role="form" onClick={() => { }} />
<div role="group" onClick={() => { }} />
<div role="heading" onClick={() => { }} />
<div role="img" onClick={() => { }} />
<div role="list" onClick={() => { }} />
<div role="listitem" onClick={() => { }} />
<div role="log" onClick={() => { }} />
<div role="main" onClick={() => { }} />
<div role="marquee" onClick={() => { }} />
<div role="math" onClick={() => { }} />
<div role="navigation" onClick={() => { }} />
<div role="note" onClick={() => { }} />
<div role="region" onClick={() => { }} />
<div role="rowgroup" onClick={() => { }} />
<div role="search" onClick={() => { }} />
<div role="status" onClick={() => { }} />
<div role="table" onClick={() => { }} />
<div role="tabpanel" onClick={() => { }} />
<div role="term" onClick={() => { }} />
<div role="timer" onClick={() => { }} />
<div role="tooltip" onClick={() => { }} />
<div role="progressbar" onClick={() => { }} />
</>
Loading

0 comments on commit a658a29

Please sign in to comment.