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(biome_js_analyze): implement useFocusableInteractive #2710

Merged
merged 4 commits into from
May 9, 2024
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
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.

33 changes: 27 additions & 6 deletions crates/biome_configuration/src/linter/rules.rs

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

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 @@ -142,6 +142,7 @@ define_categories! {
"lint/nursery/useConsistentBuiltinInstantiation": "https://biomejs.dev/linter/rules/use-consistent-new-builtin",
"lint/nursery/useDefaultSwitchClause": "https://biomejs.dev/linter/rules/use-default-switch-clause",
"lint/nursery/useExplicitLengthCheck": "https://biomejs.dev/linter/rules/use-explicit-length-check",
"lint/nursery/useFocusableInteractive": "https://biomejs.dev/linter/rules/use-focusable-interactive",
"lint/nursery/useGenericFontNames": "https://biomejs.dev/linter/rules/use-generic-font-names",
"lint/nursery/useImportRestrictions": "https://biomejs.dev/linter/rules/use-import-restrictions",
"lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes",
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.

104 changes: 104 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/use_focusable_interactive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use biome_analyze::{context::RuleContext, declare_rule, Rule, RuleDiagnostic, RuleSource};
use biome_aria::AriaRoles;
use biome_console::markup;
use biome_js_syntax::{jsx_ext::AnyJsxElement, AnyJsxAttributeValue};
use biome_rowan::AstNode;

use crate::services::aria::Aria;

declare_rule! {
/// Elements with an interactive role and interaction handlers must be focusable.
///
/// HTML elements with interactive roles must have `tabIndex` defined to ensure they are
/// focusable. Without tabIndex, assistive technologies may not recognize these elements as
/// interactive.
/// You could also consider switching from an interactive role to its semantic HTML element
/// instead.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// <div role="button" />
/// ```
///
/// ```js,expect_diagnostic
/// <div role="tab" />
/// ```
///
/// ### Valid
///
/// ```js
/// <div role="button" tabIndex={0} />
/// ```
///
/// ```jsx
/// <div />
/// ```
///
pub UseFocusableInteractive {
version: "next",
name: "useFocusableInteractive",
sources: &[RuleSource::EslintJsxA11y("interactive-support-focus")],
recommended: true,
}
}

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

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
if !node.is_element() {
return None;
}

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());

if aria_roles.is_not_interactive_element(element_name.text_trimmed(), attributes) {
let role_attribute = node.find_attribute_by_name("role");
if let Some(role_attribute) = role_attribute {
let tabindex_attribute = node.find_attribute_by_name("tabIndex");
let role_attribute_value = role_attribute.initializer()?.value().ok()?;
if attribute_has_interactive_role(&role_attribute_value, aria_roles)?
&& tabindex_attribute.is_none()
{
return Some(role_attribute_value.text());
}
}
}
None
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"The HTML element with the interactive role "<Emphasis>{state}</Emphasis>" is not focusable."
},
).note(markup! {
"A non-interactive HTML element that is not focusable may not be reachable for users that rely on keyboard navigation, even with an added role like "<Emphasis>{state}</Emphasis>"."
})
.note(markup! {
"Add a "<Emphasis>"tabIndex"</Emphasis>" attribute to make this element focusable."
}),
)
}
}

/// Checks if the given role attribute value is interactive or not based on ARIA roles.
fn attribute_has_interactive_role(
role_attribute_value: &AnyJsxAttributeValue,
aria_roles: &AriaRoles,
) -> Option<bool> {
Some(aria_roles.is_role_interactive(role_attribute_value.as_static_value()?.text()))
}
1 change: 1 addition & 0 deletions crates/biome_js_analyze/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ pub type UseExportType =
<lint::style::use_export_type::UseExportType as biome_analyze::Rule>::Options;
pub type UseFilenamingConvention = < lint :: style :: use_filenaming_convention :: UseFilenamingConvention as biome_analyze :: Rule > :: Options ;
pub type UseFlatMap = <lint::complexity::use_flat_map::UseFlatMap as biome_analyze::Rule>::Options;
pub type UseFocusableInteractive = < lint :: nursery :: use_focusable_interactive :: UseFocusableInteractive as biome_analyze :: Rule > :: Options ;
pub type UseForOf = <lint::style::use_for_of::UseForOf as biome_analyze::Rule>::Options;
pub type UseFragmentSyntax =
<lint::style::use_fragment_syntax::UseFragmentSyntax as biome_analyze::Rule>::Options;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div>
<div role="button" />
<div role="tab" />
</div>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: invalid.js
---
# Input
```jsx
<div>
<div role="button" />
<div role="tab" />
</div>;

```

# Diagnostics
```
invalid.js:2:2 lint/nursery/useFocusableInteractive ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! The HTML element with the interactive role "button" is not focusable.

1 │ <div>
> 2 │ <div role="button" />
│ ^^^^^^^^^^^^^^^^^^^^^
3 │ <div role="tab" />
4 │ </div>;

i A non-interactive HTML element that is not focusable may not be reachable for users that rely on keyboard navigation, even with an added role like "button".

i Add a tabIndex attribute to make this element focusable.


```

```
invalid.js:3:2 lint/nursery/useFocusableInteractive ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! The HTML element with the interactive role "tab" is not focusable.

1 │ <div>
2 │ <div role="button" />
> 3 │ <div role="tab" />
│ ^^^^^^^^^^^^^^^^^^
4 │ </div>;
5 │

i A non-interactive HTML element that is not focusable may not be reachable for users that rely on keyboard navigation, even with an added role like "tab".

i Add a tabIndex attribute to make this element focusable.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<div>
<div role="button" tabIndex={0} />

<div role="menu">
<div role="menuitem" tabIndex="0">
Open
</div>
<div role="menuitem" tabIndex="-1">
Save
</div>
<div role="menuitem" tabIndex="-1">
Close
</div>
</div>
<button />
<div role="h1" />
</div>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: valid.js
---
# Input
```jsx
<div>
<div role="button" tabIndex={0} />

<div role="menu">
<div role="menuitem" tabIndex="0">
Open
</div>
<div role="menuitem" tabIndex="-1">
Save
</div>
<div role="menuitem" tabIndex="-1">
Close
</div>
</div>
<button />
<div role="h1" />
</div>;

```
5 changes: 5 additions & 0 deletions packages/@biomejs/backend-jsonrpc/src/workspace.ts

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

7 changes: 7 additions & 0 deletions packages/@biomejs/biome/configuration_schema.json

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