Skip to content

Commit

Permalink
feat(lint/useAriaPropsSupportedByRole): add rule (#3644)
Browse files Browse the repository at this point in the history
Co-authored-by: Victorien Elvinger <[email protected]>
  • Loading branch information
ryo-ebata and Conaclos authored Aug 26, 2024
1 parent be09487 commit 80b9db5
Show file tree
Hide file tree
Showing 13 changed files with 1,066 additions and 54 deletions.
33 changes: 25 additions & 8 deletions crates/biome_aria/src/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,14 @@ impl<'a> AriaRoles {
"marquee" => &MarqueeRole as &dyn AriaRoleDefinition,
"math" => &MathRole as &dyn AriaRoleDefinition,
"menu" => &ListRole as &dyn AriaRoleDefinition,
"menuitem" => {
let type_values = attributes.get("type")?;
match type_values.first()?.as_str() {
"checkbox" => &MenuItemCheckboxRole as &dyn AriaRoleDefinition,
"radio" => &MenuItemRadioRole as &dyn AriaRoleDefinition,
_ => &MenuItemRole as &dyn AriaRoleDefinition,
}
}
"meter" => &MeterRole as &dyn AriaRoleDefinition,
"nav" => &NavigationRole as &dyn AriaRoleDefinition,
"ul" | "ol" => &ListRole as &dyn AriaRoleDefinition,
Expand Down Expand Up @@ -1311,6 +1319,22 @@ impl<'a> AriaRoles {
role_candidate.concepts_by_role()
}

/// 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.
pub fn get_role_by_element_name(
&self,
element_name: &str,
attributes: &FxHashMap<String, Vec<String>>,
) -> Option<&'static dyn AriaRoleDefinition> {
attributes
.get("role")
.and_then(|role| role.first())
.map_or_else(
|| self.get_implicit_role(element_name, attributes),
|r| self.get_role(r),
)
}

pub fn is_not_static_element(
&self,
element_name: &str,
Expand All @@ -1333,14 +1357,7 @@ impl<'a> AriaRoles {
return true;
}

// if the element has a interactive role, it is considered interactive.
let role_name = attributes
.get("role")
.and_then(|role| role.first())
.map_or_else(
|| self.get_implicit_role(element_name, attributes),
|r| self.get_role(r),
);
let role_name = self.get_role_by_element_name(element_name, attributes);

match role_name.map(|role| role.type_name()) {
Some("biome_aria::roles::PresentationRole" | "biome_aria::roles::GenericRole") => false,
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.

114 changes: 68 additions & 46 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 @@ -154,6 +154,7 @@ define_categories! {
"lint/nursery/noValueAtRule": "https://biomejs.dev/linter/rules/no-value-at-rule",
"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/useAriaPropsSupportedByRole": "https://biomejs.dev/linter/rules/use-aria-props-supported-by-role",
"lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment",
"lint/nursery/useConsistentBuiltinInstantiation": "https://biomejs.dev/linter/rules/use-consistent-new-builtin",
"lint/nursery/useConsistentCurlyBraces": "https://biomejs.dev/linter/rules/use-consistent-curly-braces",
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 @@ -22,6 +22,7 @@ pub mod no_useless_string_concat;
pub mod no_useless_undefined_initialization;
pub mod no_yoda_expression;
pub mod use_adjacent_overload_signatures;
pub mod use_aria_props_supported_by_role;
pub mod use_consistent_builtin_instantiation;
pub mod use_consistent_curly_braces;
pub mod use_date_now;
Expand Down Expand Up @@ -65,6 +66,7 @@ declare_lint_group! {
self :: no_useless_undefined_initialization :: NoUselessUndefinedInitialization ,
self :: no_yoda_expression :: NoYodaExpression ,
self :: use_adjacent_overload_signatures :: UseAdjacentOverloadSignatures ,
self :: use_aria_props_supported_by_role :: UseAriaPropsSupportedByRole ,
self :: use_consistent_builtin_instantiation :: UseConsistentBuiltinInstantiation ,
self :: use_consistent_curly_braces :: UseConsistentCurlyBraces ,
self :: use_date_now :: UseDateNow ,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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! {
/// Enforce that ARIA properties are valid for the roles that are supported by the element.
///
/// Invalid ARIA properties can make it difficult for users of assistive technologies to understand the purpose of the element.
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// <a href="#" aria-checked />
/// ```
///
/// ```jsx,expect_diagnostic
/// <img alt="foobar" aria-checked />
/// ```
///
/// ### Valid
///
/// ```js
/// <>
/// <a href="#" aria-expanded />
/// <img alt="foobar" aria-hidden />
/// <div role="heading" aria-level="1" />
/// </>
/// ```
///
pub UseAriaPropsSupportedByRole {
version: "next",
name: "useAriaPropsSupportedByRole",
language: "js",
sources: &[RuleSource::EslintJsxA11y("role-supports-aria-props")],
recommended: true,
}
}

impl Rule for UseAriaPropsSupportedByRole {
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();
let element_name = node.name().ok()?.as_jsx_name()?.value_token().ok()?;
let element_name = element_name.text_trimmed();
let aria_roles = ctx.aria_roles();
let attributes = ctx.extract_attributes(&node.attributes());
let attributes = ctx.convert_all_attribute_values(attributes);

if let Some(attributes) = &attributes {
let role_name = aria_roles.get_role_by_element_name(element_name, attributes)?;
for attribute in attributes.keys() {
if attribute.starts_with("aria-")
&& !is_valid_aria_props_supported_by_role(
role_name.type_name(),
attribute.as_str(),
)
{
return Some(attribute.clone());
}
}
}

None
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
let invalid_aria_prop = state;

Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"The ARIA attribute '"{invalid_aria_prop}"' is not supported by this element."
},
)
.note(markup! {
"Ensure that ARIA attributes are valid for the role of the element."
}),
)
}
}

fn is_valid_aria_props_supported_by_role(role_name: &'static str, aria_attribute: &str) -> bool {
if is_global_aria(aria_attribute) {
return true;
}

match role_name {
"biome_aria::roles::LinkRole" => {
matches!(
aria_attribute,
"aria-expanded" | "aria-haspopup" | "aria-current"
)
}
"biome_aria::roles::ButtonRole" => {
matches!(aria_attribute, "aria-expanded" | "aria-pressed")
}
"biome_aria::roles::CheckboxRole"
| "biome_aria::roles::RadioRole"
| "biome_aria::roles::MenuItemCheckboxRole"
| "biome_aria::roles::MenuItemRadioRole" => {
matches!(aria_attribute, "aria-checked")
}
"biome_aria::roles::ComboBoxRole" => {
matches!(aria_attribute, "aria-expanded")
}
"biome_aria::roles::SliderRole" => {
matches!(
aria_attribute,
"aria-valuemax" | "aria-valuemin" | "aria-valuenow"
)
}
"biome_aria::roles::ListRole" => {
matches!(aria_attribute, "aria-activedescendant")
}
"biome_aria::roles::HeadingRole" => matches!(aria_attribute, "aria-level"),
// This rule is not concerned with the abstract role
"biome_aria::roles::PresentationRole" | "biome_aria::roles::GenericRole" => true,
_ => false,
}
}

/// Check if the aria attribute is global
/// https://www.w3.org/TR/wai-aria-1.1/#global_states
///
/// However, aria-invalid and aria-haspopup are not included this list
/// Because every elements cannot have These attributes.
/// https://www.w3.org/TR/wai-aria-1.1/#aria-invalid
/// https://www.w3.org/TR/wai-aria-1.1/#aria-haspopup
fn is_global_aria(aria_attribute: &str) -> bool {
matches! {
aria_attribute,
"aria-atomic"
| "aria-busy"
| "aria-controls"
| "aria-describedby"
| "aria-disabled"
| "aria-dropeffect"
| "aria-flowto"
| "aria-grabbed"
| "aria-hidden"
| "aria-label"
| "aria-labelledby"
| "aria-live"
| "aria-owns"
| "aria-relevant"
| "aria-roledescription"
}
}
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,32 @@
<>
<a href="#" aria-checked />
<area href="#" aria-checked />
<img alt="foobar" aria-checked />
<menu type="toolbar" aria-checked />
<aside aria-checked />
<ul aria-expanded />
<details aria-expanded />
<dialog aria-expanded />
<aside aria-expanded />
<article aria-expanded />
<li aria-expanded />
<nav aria-expanded />
<ol aria-expanded />
<output aria-expanded />
<tbody aria-expanded />
<tfoot aria-expanded />
<thead aria-expanded />
<input type="radio" aria-invalid />
<input type="radio" aria-selected />
<input type="radio" aria-haspopup />
<input type="checkbox" aria-haspopup />
<input type="reset" aria-invalid />
<input type="submit" aria-invalid />
<input type="image" aria-invalid />
<input type="button" aria-invalid />
<menu type="toolbar" aria-haspopup />
<menu type="toolbar" aria-invalid />
<menu type="toolbar" aria-expanded />
<area href="#" aria-invalid />
<a href="#" aria-invalid />
</>
Loading

0 comments on commit 80b9db5

Please sign in to comment.