Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): add noNoninteractiveElementInteractions rule #4358

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading