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(lint/noStaticElementInteractions): add rule #2981

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d659393
feat(lint/noStaticElementInteractions): add rule
ryo-ebata May 25, 2024
4c71fa5
run just l
ryo-ebata May 25, 2024
82626b0
rename: elm → element_name
ryo-ebata May 30, 2024
f1ceb5e
refactor: inline has_truthy_ariahidden_attr method back into the main…
ryo-ebata May 30, 2024
494a4aa
refactor: add detail information
ryo-ebata May 30, 2024
aa03e21
refactor: change HashMap to FxHashMap
ryo-ebata May 30, 2024
ae05cca
refactor: delete elements have non-interactive role from valid test case
ryo-ebata Jun 2, 2024
9876632
refactor: Swapped variable declarations
ryo-ebata Jun 2, 2024
679fbab
Refactor: Remove redundant code
ryo-ebata Jun 2, 2024
3a83fab
fix: Modify AriaServices::extract_attributes to handle dynamic values
ryo-ebata Jun 2, 2024
2281933
refactor: Inline `is_interactive_handler_present` logic into `run` an…
ryo-ebata Jun 2, 2024
53745f3
refactor: modify markup text
ryo-ebata Jun 3, 2024
93e07f2
refactor: is role interactive check
ryo-ebata Jun 3, 2024
f4f65cb
add: code comment
ryo-ebata Jun 3, 2024
ae8d3c7
refactor: rule comments
ryo-ebata Jun 11, 2024
cc06ac5
Merge remote-tracking branch 'origin/main' into ryo-ebata/no-static-e…
ematipico Jun 12, 2024
7343ed2
rebase
ematipico Jun 12, 2024
74804bc
rebase and fix code blocks
ematipico Jun 12, 2024
71c5004
Update crates/biome_js_analyze/src/lint/nursery/no_static_element_int…
unvalley Jun 12, 2024
a9aa880
fix: Commented out unnecessary overhead.
ryo-ebata Jun 15, 2024
b330131
refactor: Improved event handler mappings by using simple arrays and …
ryo-ebata Jun 15, 2024
546fa4a
refactor: Use constant for SECTION_ARIA_ATTRIBUTES to improve efficiency
ryo-ebata Jun 15, 2024
f1d8524
refactor: use text_trimmed() for dynamic attribute values in extract_…
ryo-ebata Jun 15, 2024
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
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.

127 changes: 73 additions & 54 deletions crates/biome_configuration/src/linter/rules.rs

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,16 @@ define_categories! {
"lint/correctness/useValidForDirection": "https://biomejs.dev/linter/rules/use-valid-for-direction",
"lint/correctness/useYield": "https://biomejs.dev/linter/rules/use-yield",
"lint/nursery/colorNoInvalidHex": "https://biomejs.dev/linter/rules/color-no-invalid-hex",
"lint/nursery/useAdjacentOverloadSignatures": "https://biomejs.dev/linter/rules/use-adjacent-overload-signatures",
"lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex",
"lint/nursery/noConsole": "https://biomejs.dev/linter/rules/no-console",
"lint/nursery/noConstantMathMinMaxClamp": "https://biomejs.dev/linter/rules/no-constant-math-min-max-clamp",
"lint/nursery/noEmptyBlock": "https://biomejs.dev/linter/rules/no-empty-block",
"lint/nursery/noDoneCallback": "https://biomejs.dev/linter/rules/no-done-callback",
"lint/nursery/noDuplicateAtImportRules": "https://biomejs.dev/linter/rules/no-duplicate-at-import-rules",
"lint/nursery/noDuplicateElseIf": "https://biomejs.dev/linter/rules/no-duplicate-else-if",
"lint/nursery/noDuplicateFontNames": "https://biomejs.dev/linter/rules/no-font-family-duplicate-names",
"lint/nursery/noDuplicateJsonKeys": "https://biomejs.dev/linter/rules/no-duplicate-json-keys",
"lint/nursery/noDuplicateSelectorsKeyframeBlock": "https://biomejs.dev/linter/rules/no-duplicate-selectors-keyframe-block",
"lint/nursery/noEmptyBlock": "https://biomejs.dev/linter/rules/no-empty-block",
"lint/nursery/noEvolvingAny": "https://biomejs.dev/linter/rules/no-evolving-any",
"lint/nursery/noFlatMapIdentity": "https://biomejs.dev/linter/rules/no-flat-map-identity",
"lint/nursery/noImportantInKeyframe": "https://biomejs.dev/linter/rules/no-important-in-keyframe",
Expand All @@ -130,6 +129,7 @@ define_categories! {
"lint/nursery/noNodejsModules": "https://biomejs.dev/linter/rules/no-nodejs-modules",
"lint/nursery/noReactSpecificProps": "https://biomejs.dev/linter/rules/no-react-specific-props",
"lint/nursery/noRestrictedImports": "https://biomejs.dev/linter/rules/no-restricted-imports",
"lint/nursery/noStaticElementInteractions": "https://biomejs.dev/linter/rules/no-static-element-interactions",
"lint/nursery/noTypeOnlyImportAttributes": "https://biomejs.dev/linter/rules/no-type-only-import-attributes",
"lint/nursery/noUndeclaredDependencies": "https://biomejs.dev/linter/rules/no-undeclared-dependencies",
"lint/nursery/noUnknownFunction": "https://biomejs.dev/linter/rules/no-unknown-function",
Expand All @@ -141,6 +141,7 @@ define_categories! {
"lint/nursery/noUselessStringConcat": "https://biomejs.dev/linter/rules/no-useless-string-concat",
"lint/nursery/noUselessUndefinedInitialization": "https://biomejs.dev/linter/rules/no-useless-undefined-initialization",
"lint/nursery/noYodaExpression": "https://biomejs.dev/linter/rules/no-yoda-expression",
"lint/nursery/useAdjacentOverloadSignatures": "https://biomejs.dev/linter/rules/use-adjacent-overload-signatures",
"lint/nursery/useArrayLiterals": "https://biomejs.dev/linter/rules/use-array-literals",
"lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment",
"lint/nursery/useConsistentBuiltinInstantiation": "https://biomejs.dev/linter/rules/use-consistent-new-builtin",
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery.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,248 @@
use crate::services::aria::Aria;
use biome_analyze::context::RuleContext;
use biome_analyze::{declare_rule, Rule, RuleDiagnostic, RuleSource};
use biome_console::markup;
use biome_js_syntax::jsx_ext::AnyJsxElement;
use biome_rowan::AstNode;
use rustc_hash::FxHashMap;

declare_rule! {
/// Enforce that non-interactive, visible elements (such as `<div>`) that have click handlers use the role attribute.
///
unvalley marked this conversation as resolved.
Show resolved Hide resolved
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// <div onClick={()=>{})}></div>;
/// ```
/// ```jsx,expect_diagnostic
/// <span onClick={()=>{})}></span>;
/// ```
///
/// When `<a>` does not have "href" attribute, that is non-interactive.
/// ```jsx,expect_diagnostic
/// <a onClick={()=>{})}></a>
/// ```
///
/// ### Valid
///
/// ```jsx
/// <div role="button" onClick={()=>{})}></div>
/// <span role="link" onClick={()=>{})}></span>
/// <a href="http://example.com" onClick={()=>{})}></a>
/// ```
///
pub NoStaticElementInteractions {
version: "next",
name: "noStaticElementInteractions",
language: "js",
sources: &[RuleSource::EslintJsxA11y("no-static-element-interactions")],
recommended: false,
}
}

// These are interactions defined by eslint-plugin-jsx-a11y.
// ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/974275353598e9407c76bd4a50c331a953755cee/src/rules/no-static-element-interactions.js#L33-L37
// ref: https://github.com/jsx-eslint/jsx-ast-utils/blob/main/src/eventHandlers.js
lazy_static::lazy_static! {
static ref EVENT_TO_HANDLERS: FxHashMap<&'static str, Vec<&'static str>> = {
let mut m = FxHashMap::default();
m.insert("clipboard", vec!["onCopy", "onCut", "onPaste"]);
m.insert("composition", vec!["onCompositionEnd", "onCompositionStart", "onCompositionUpdate"]);
m.insert("keyboard", vec!["onKeyDown", "onKeyPress", "onKeyUp"]);
m.insert("focus", vec!["onFocus", "onBlur"]);
m.insert("form", vec!["onChange", "onInput", "onSubmit"]);
m.insert("mouse", vec![
"onClick", "onContextMenu", "onDblClick", "onDoubleClick", "onDrag", "onDragEnd",
"onDragEnter", "onDragExit", "onDragLeave", "onDragOver", "onDragStart", "onDrop",
"onMouseDown", "onMouseEnter", "onMouseLeave", "onMouseMove", "onMouseOut",
"onMouseOver", "onMouseUp"
]);
m.insert("selection", vec!["onSelect"]);
m.insert("touch", vec!["onTouchCancel", "onTouchEnd", "onTouchMove", "onTouchStart"]);
m.insert("ui", vec!["onScroll"]);
m.insert("wheel", vec!["onWheel"]);
m.insert("media", vec![
"onAbort", "onCanPlay", "onCanPlayThrough", "onDurationChange", "onEmptied",
"onEncrypted", "onEnded", "onError", "onLoadedData", "onLoadedMetadata", "onLoadStart",
"onPause", "onPlay", "onPlaying", "onProgress", "onRateChange", "onSeeked", "onSeeking",
"onStalled", "onSuspend", "onTimeUpdate", "onVolumeChange", "onWaiting"
]);
m.insert("image", vec!["onLoad", "onError"]);
m.insert("animation", vec!["onAnimationStart", "onAnimationEnd", "onAnimationIteration"]);
m.insert("transition", vec!["onTransitionEnd"]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need elements (and handlers) other than in CATEGORIES_TO_CHECK?
We only use focus, keyboard and mouse.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@unvalley
In ESlint, interactions are managed in independent files named eventHandlersByType.
ref: https://github.com/jsx-eslint/jsx-ast-utils/blob/main/src/eventHandlers.js

Among them, ESlint uses focus, keyboard and mouse by default.
ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/974275353598e9407c76bd4a50c331a953755cee/src/rules/no-static-element-interactions.js#L33-L37

In other words, the EVENT_TO_HANDLERS created in this PR was originally intended to be used for other rules, etc., but since it is currently unclear where to place them, they are implemented this way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. So, I guess it means we don't have any other rules that need this CATEGORIES_TO_CHECK yet.

Alright, we can locate the hashmap here, but I suggest commenting out elements that aren't used here. It may be a waste of overhead. Insertion for focus, keyboard, and mouse is enough.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@unvalley
I see, I understand!
I'll fix this.

Copy link
Member

@ematipico ematipico Jun 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to barge in during this review. I think this code would benefit from a refactor. I don't think it's a good custom to try to replicate JS code in Rust. For example, this code, while it's created once via lazy_static, still creates a bunch of vectors that are created at runtime (heap).

Instead, why not create simple arrays?

Also, I think we should explain what this code is. It's copied from another source, which is completely fine, but there's no comment that explains what are the keys and what are the values, how they are used and what they are meant to be.

For example composition -> [...]. What's composition? Where is it coming from? Is it an HTML element? Is it a role? Remember that in Biome, we want to aim for good standards, DX-wise and coding-wise.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@unvalley
fixed in this a9aa880

Copy link
Author

@ryo-ebata ryo-ebata Jun 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@unvalley
Sorry, I didn't address to additional comments, please leave them as they are.

Copy link
Author

@ryo-ebata ryo-ebata Jun 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ematipico @unvalley
Thank you for pointing this out.
As you said, it was causing overhead, so we defined it as a fixed-length array.

Also modified the comment to describe what is stored in this array and added the URL to the MDN reference describing each event handler

fixed in this commit b330131

unvalley marked this conversation as resolved.
Show resolved Hide resolved
m
};
}

const CATEGORIES_TO_CHECK: &[&str] = &["focus", "keyboard", "mouse"];

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

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let element_name = node.name().ok()?.as_jsx_name()?.value_token().ok()?;
let aria_roles = ctx.aria_roles();
let attributes = ctx.extract_attributes(&node.attributes());
let element_name = element_name.text_trimmed();

if let Some(attributes_ref) = attributes.as_ref() {
let has_interactive_handler = CATEGORIES_TO_CHECK.iter().any(|&category| {
if let Some(handlers) = EVENT_TO_HANDLERS.get(category) {
handlers.iter().any(|&handler| {
if let Some(values) = attributes_ref.get(handler) {
values.iter().any(|value| value != "null")
} else {
false
}
})
} else {
false
}
});

if !has_interactive_handler {
return None;
}
} else {
return None;
}

if node
.find_attribute_by_name("aria-hidden")
.map_or(false, |attr| {
attr.as_static_value()
.map_or(true, |val| val.text() == "true")
})
{
return None;
}

let is_valid_element = match element_name {
"section" => ["aria-label", "aria-labelledby"].iter().any(|&attr_name| {
unvalley marked this conversation as resolved.
Show resolved Hide resolved
node.find_attribute_by_name(attr_name)
.map_or(false, |attr| {
attr.as_static_value()
.map_or(false, |val| !val.text().is_empty())
})
unvalley marked this conversation as resolved.
Show resolved Hide resolved
}),
"a" => node.find_attribute_by_name("href").map_or(false, |attr| {
attr.as_static_value()
.map_or(false, |val| !val.text().is_empty())
}),
_ => {
(!aria_roles.is_not_interactive_element(element_name, attributes.clone())
&& !is_invalid_element(element_name))
|| is_valid_element(element_name)
}
};

if is_valid_element {
return None;
}

if let Some(attr) = node.find_attribute_by_name("role") {
let role_value = attr.as_static_value()?;
let role_text = role_value.text();

if aria_roles.is_role_interactive(role_text) {
return None;
}
} else {
return Some(());
}

Some(())
}

fn diagnostic(ctx: &RuleContext<Self>, _: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
Some(RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {{"Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element"}},
unvalley marked this conversation as resolved.
Show resolved Hide resolved
).note(
markup! {{"If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element"}}
unvalley marked this conversation as resolved.
Show resolved Hide resolved
))
}
}

/// This function disables interactive elements.
/// This is because this is an element that is disabled by eslint-plugin-jsx-a11y.
unvalley marked this conversation as resolved.
Show resolved Hide resolved
fn is_invalid_element(element_name: &str) -> bool {
match element_name {
// These cases are interactive with the is_not_interactive_element method,
// but is an invalid test case element for eslint-plugin-jsx-a11y.
"link" | "header" => true,
"area" | "b" | "bdi" | "bdo" | "hgroup" | "i" | "u" | "q" | "small" | "data" | "samp"
| "acronym" | "applet" | "base" | "big" | "blink" | "center" | "cite" | "col"
| "colgroup" | "content" | "font" | "frameset" | "head" | "kbd" | "keygen" | "map"
| "meta" | "noembed" | "noscript" | "object" | "param" | "picture" | "rp" | "rt"
| "rtc" | "s" | "script" | "source" | "spacer" | "strike" | "style" | "summary"
| "title" | "track" | "tt" | "var" | "wbr" | "xmp" => true,
_ => false,
}
}
unvalley marked this conversation as resolved.
Show resolved Hide resolved

/// This function ables non-interactive elements.
/// This is because this is an element that is abled by eslint-plugin-jsx-a11y.
fn is_valid_element(element_name: &str) -> bool {
unvalley marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "valid element" mean? Valid for what context? We should document it.

Plus, you would want to move this function inside biome_aria, as @unvalley suggested. Here's an example:

pub fn is_role_interactive(&self, role: &str) -> bool {
let role = self.get_role(role);
if let Some(role) = role {
role.is_interactive()
} else {
false
}
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ematipico cc @unvalley
Regarding this point, this method includes processing that considers elements generally deemed non-interactive as valid.
These elements were part of valid test cases in eslint-plugin-jsx-a11y.

When considering portability from ESLint, these test cases should be taken into account (meaning that if a project using ESLint transitions to Biome, many lint errors could occur). However, if we are overly concerned with the above point, we will never be able to lint according to Biome's rules.

Therefore, if we align with Biome's interactive criteria and adjust the rules accordingly, this method will become unnecessary. I would appreciate it if you could provide guidance on the direction we should take.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there was a misunderstanding; I'm sorry about it. I am not asking you to use the link I pasted. The link I pasted was an example. And I didn't ask to remove the logic you're porting from the plugin.

What I asked was to properly document the function. The phrase "This function ables non-interactive elements." doesn't provide any value to the function name, because it doesn't explain what is a valid element (or when it is valid).

I understand that English is not your first language, so I am willing to help if you explain a bit the logic of this function

Copy link
Member

@unvalley unvalley Jun 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can merge this PR after addressing this point.

Copy link
Author

@ryo-ebata ryo-ebata Jun 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ematipico cc @unvalley
Okay, thank you.
Then I need to see why Eslint is enabling these elements, but we have not yet found a clear reason.

So it may take a little time, but I may ask for your help in documenting it. Thank you in advance for your help at that time.

matches!(
element_name,
"input"
| "form"
| "iframe"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "ruby"
| "pre"
| "mark"
| "aside"
| "blockquote"
| "address"
| "article"
| "caption"
| "output"
| "p"
| "li"
| "ol"
| "ul"
| "nav"
| "table"
| "thead"
| "tbody"
| "tfoot"
| "time"
| "dfn"
| "main"
| "br"
| "details"
| "dd"
| "dir"
| "dl"
| "dt"
| "fieldset"
| "figcaption"
| "figure"
| "footer"
| "img"
| "label"
| "legend"
| "marquee"
| "menu"
| "meter"
| "optgroup"
| "progress"
| "th"
| "td"
)
}
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.

26 changes: 16 additions & 10 deletions crates/biome_js_analyze/src/services/aria.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,23 @@ impl AriaServices {
if let AnyJsxAttribute::JsxAttribute(attr) = attribute {
let name = attr.name().ok()?.syntax().text_trimmed().to_string();
// handle an attribute without values e.g. `<img aria-hidden />`
let Some(initializer) = attr.initializer() else {
defined_attributes
.entry(name)
.or_insert(vec!["true".to_string()]);
continue;
let values = if let Some(initializer) = attr.initializer() {
let initializer = initializer.value().ok()?;
if let Some(static_value) = initializer.as_static_value() {
// handle multiple values e.g. `<div class="wrapper document">`
static_value
.text()
.split(' ')
.map(|s| s.to_string())
.collect::<Vec<String>>()
} else {
// handle dynamic values e.g. `<div onClick={dynamicValue}>`
vec![initializer.syntax().text().to_string()]
unvalley marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
vec!["true".to_string()]
};
let initializer = initializer.value().ok()?;
let static_value = initializer.as_static_value()?;
// handle multiple values e.g. `<div class="wrapper document">`
let values = static_value.text().split(' ');
let values = values.map(|s| s.to_string()).collect::<Vec<String>>();

defined_attributes.entry(name).or_insert(values);
}
}
Expand Down
Loading