Skip to content

Commit

Permalink
feat(lint): new UseValidAriaRoles
Browse files Browse the repository at this point in the history
  • Loading branch information
vasucp1207 authored and Conaclos committed Nov 13, 2023
1 parent 9dbb87b commit 17b0750
Show file tree
Hide file tree
Showing 14 changed files with 720 additions and 1 deletion.
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/aria_analyzers/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ use biome_analyze::declare_group;

pub(crate) mod no_interactive_element_to_noninteractive_role;
pub(crate) mod use_aria_activedescendant_with_tabindex;
pub(crate) mod use_valid_aria_role;

declare_group! {
pub (crate) Nursery {
name : "nursery" ,
rules : [
self :: no_interactive_element_to_noninteractive_role :: NoInteractiveElementToNoninteractiveRole ,
self :: use_aria_activedescendant_with_tabindex :: UseAriaActivedescendantWithTabindex ,
self :: use_valid_aria_role :: UseValidAriaRole ,
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
use std::str::FromStr;

use crate::{aria_services::Aria, JsRuleAction};
use biome_analyze::{
context::RuleContext, declare_rule, ActionCategory, FixKind, Rule, RuleDiagnostic,
};
use biome_console::markup;
use biome_deserialize::json::{report_unknown_map_key, VisitJsonNode};
use biome_deserialize::{DeserializationDiagnostic, VisitNode};
use biome_diagnostics::Applicability;
use biome_js_syntax::jsx_ext::AnyJsxElement;
use biome_json_syntax::JsonLanguage;
use biome_rowan::{AstNode, BatchMutationExt, SyntaxNode};
use bpaf::Bpaf;
use serde::{Deserialize, Serialize};

declare_rule! {
/// Elements with ARIA roles must use a valid, non-abstract ARIA role.
///
/// Source: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/aria-role.md
///
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// <div role="datepicker"></div>
/// ```
///
/// ```js,expect_diagnostic
/// <div role="range"></div>
/// ```
///
/// ```js,expect_diagnostic
/// <div role=""></div>
/// ```
///
/// ```js,expect_diagnostic
/// <Foo role="foo"></Foo>
/// ```
///
/// ### Valid
///
/// ```js
/// <>
/// <div role="button"></div>
/// <div role={role}></div>
/// <div></div>
/// </>
/// ```
///
/// ### Options
///
/// ```json
/// {
/// "//": "...",
/// "options": {
/// "allowInvalidRoles": ["invalid", "text"],
/// "nonIgnoreDom": true
/// }
/// }
/// ```
///
/// ## Accessibility guidelines
/// - [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value)
///
/// ## Resources
/// - [Chrome Audit Rules, AX_ARIA_01](https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules#ax_aria_01)
/// - [DPUB-ARIA roles](https://www.w3.org/TR/dpub-aria-1.0/)
/// - [MDN: Using ARIA: Roles, states, and properties](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques)
///
pub(crate) UseValidAriaRole {
version: "next",
name: "useValidAriaRole",
recommended: true,
fix_kind: FixKind::Unsafe,
}
}

#[derive(Default, Deserialize, Serialize, Eq, PartialEq, Debug, Clone, Bpaf)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ValidAriaRoleOptions {
#[bpaf(hide, argument::<String>("roles"), many)]
allowed_invalid_roles: Vec<String>,
#[bpaf(hide)]
ignore_non_dom: bool,
}

impl ValidAriaRoleOptions {
pub const ALLOWED_KEYS: &'static [&'static str] = &["allowInvalidRoles", "ignoreNonDom"];
}

impl FromStr for ValidAriaRoleOptions {
type Err = ();

fn from_str(_s: &str) -> Result<Self, Self::Err> {
Ok(ValidAriaRoleOptions::default())
}
}

impl VisitNode<JsonLanguage> for ValidAriaRoleOptions {
fn visit_map(
&mut self,
key: &SyntaxNode<JsonLanguage>,
value: &SyntaxNode<JsonLanguage>,
diagnostics: &mut Vec<DeserializationDiagnostic>,
) -> Option<()> {
let (name, value) = self.get_key_and_value(key, value)?;
let name_text = name.inner_string_text().ok()?;
let name_text = name_text.text();
match name_text {
"allowInvalidRoles" => {
self.allowed_invalid_roles =
self.map_to_array_of_strings(&value, name_text, diagnostics)?;
}
"ignoreNonDom" => {
self.ignore_non_dom = self.map_to_boolean(&value, name_text, diagnostics)?;
}
_ => {
report_unknown_map_key(&name, Self::ALLOWED_KEYS, diagnostics);
}
}

Some(())
}
}

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

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

let ignore_non_dom = options.ignore_non_dom;
let allowed_invalid_roles = &options.allowed_invalid_roles;

if ignore_non_dom && node.is_custom_component() {
return None;
}

let role_attribute = node.find_attribute_by_name("role")?;

let role_attribute_static_value = role_attribute.as_static_value()?;
let role_attribute_value = role_attribute_static_value.text();
let mut role_attribute_value = role_attribute_value.split(' ');

let is_valid = role_attribute_value.all(|val| {
let role_data = aria_roles.get_role(val);
allowed_invalid_roles.contains(&val.to_string()) || role_data.is_some()
});

if is_valid {
return None;
}

Some(())
}

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

Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"Enforce that elements with ARIA roles must use a valid, non-abstract ARIA role."
},
)
.note(markup! {
"Check "<Hyperlink href="https://www.w3.org/TR/wai-aria/#namefromauthor">"WAI-ARIA"</Hyperlink>" for valid roles or provide options accordingly."
})
)
}

fn action(ctx: &RuleContext<Self>, _: &Self::State) -> Option<JsRuleAction> {
let node = ctx.query();
let mut mutation = ctx.root().begin();
let role_attribute = node.find_attribute_by_name("role")?;

mutation.remove_node(role_attribute);

Some(JsRuleAction {
category: ActionCategory::QuickFix,
applicability: Applicability::MaybeIncorrect,
message:
markup! { "Remove the invalid "<Emphasis>"role"</Emphasis>" attribute.\n Check the list of all "<Hyperlink href="https://www.w3.org/TR/wai-aria/#role_definitions">"valid"</Hyperlink>" role attributes." }
.to_owned(),
mutation,
})
}
}
16 changes: 16 additions & 0 deletions crates/biome_js_analyze/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
use crate::analyzers::complexity::no_excessive_cognitive_complexity::{
complexity_options, ComplexityOptions,
};
use crate::aria_analyzers::nursery::use_valid_aria_role::{
valid_aria_role_options, ValidAriaRoleOptions,
};
use crate::semantic_analyzers::correctness::use_exhaustive_dependencies::{
hooks_options, HooksOptions,
};
Expand Down Expand Up @@ -34,6 +37,8 @@ pub enum PossibleOptions {
NamingConvention(#[bpaf(external(naming_convention_options), hide)] NamingConventionOptions),
/// Options for `noRestrictedGlobals` rule
RestrictedGlobals(#[bpaf(external(restricted_globals_options), hide)] RestrictedGlobalsOptions),
/// Options for `useValidAriaRole` rule
ValidAriaRole(#[bpaf(external(valid_aria_role_options), hide)] ValidAriaRoleOptions),
}

// Required by [Bpaf].
Expand All @@ -60,6 +65,7 @@ impl PossibleOptions {
"useNamingConvention" => {
Some(Self::NamingConvention(NamingConventionOptions::default()))
}
"useValidAriaRole" => Some(Self::ValidAriaRole(ValidAriaRoleOptions::default())),
_ => None,
}
}
Expand Down Expand Up @@ -94,6 +100,13 @@ impl PossibleOptions {
};
RuleOptions::new(options)
}
"useValidAriaRole" => {
let options = match self {
PossibleOptions::ValidAriaRole(options) => options.clone(),
_ => ValidAriaRoleOptions::default(),
};
RuleOptions::new(options)
}
// TODO: review error
_ => panic!("This rule {:?} doesn't have options", rule_key),
}
Expand All @@ -120,6 +133,9 @@ impl VisitNode<JsonLanguage> for PossibleOptions {
PossibleOptions::RestrictedGlobals(options) => {
options.visit_map(key, value, diagnostics)?;
}
PossibleOptions::ValidAriaRole(options) => {
options.visit_map(key, value, diagnostics)?;
}
}
Some(())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<>
<div role="range"></div>
<div role="datepicker"></div>
<div role=""></div>
<Foo role="Button" />
<Foo role="datepicker" />
<div role="unknown-invalid-role" />
<div role="tabpanel row foobar"></div>
<div role="doc-endnotes range"></div>
<div role={null}></div>
<Foo role="datepicker" />
</>
Loading

0 comments on commit 17b0750

Please sign in to comment.