From 2b7ca5962f7961adac92c5bc23741f65b419c037 Mon Sep 17 00:00:00 2001 From: Yuto Yoshino Date: Wed, 10 Jan 2024 00:21:11 +0900 Subject: [PATCH] feat(linter): eslint-plugin-jsx-a11y role-has-required-aria-props (#1881) partof: https://github.com/oxc-project/oxc/issues/1141 Based on: - doc: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/role-has-required-aria-props.md - code: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/role-has-required-aria-props.js - test: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/__tests__/src/rules/role-has-required-aria-props-test.js --- crates/oxc_linter/src/rules.rs | 2 + .../jsx_a11y/role_has_required_aria_props.rs | 136 +++++++++ .../role_has_required_aria_props.snap | 271 ++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 crates/oxc_linter/src/rules/jsx_a11y/role_has_required_aria_props.rs create mode 100644 crates/oxc_linter/src/snapshots/role_has_required_aria_props.snap diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 662909719357c..5fd2ef8e7a577 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -254,6 +254,7 @@ mod jsx_a11y { pub mod no_autofocus; pub mod no_distracting_elements; pub mod prefer_tag_over_role; + pub mod role_has_required_aria_props; pub mod role_support_aria_props; pub mod scope; pub mod tab_index_no_positive; @@ -509,6 +510,7 @@ oxc_macros::declare_all_lint_rules! { jsx_a11y::no_aria_hidden_on_focusable, jsx_a11y::no_autofocus, jsx_a11y::prefer_tag_over_role, + jsx_a11y::role_has_required_aria_props, jsx_a11y::scope, jsx_a11y::tab_index_no_positive, jsx_a11y::aria_role, diff --git a/crates/oxc_linter/src/rules/jsx_a11y/role_has_required_aria_props.rs b/crates/oxc_linter/src/rules/jsx_a11y/role_has_required_aria_props.rs new file mode 100644 index 0000000000000..a8c8187744a57 --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/role_has_required_aria_props.rs @@ -0,0 +1,136 @@ +use crate::{context::LintContext, rule::Rule, utils::has_jsx_prop_lowercase, AstNode}; +use oxc_ast::{ + ast::{JSXAttributeItem, JSXAttributeValue}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::{self, Error}, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; +use phf::{phf_map, phf_set}; + +#[derive(Debug, Error, Diagnostic)] +#[error( + "eslint-plugin-jsx-a11y(role-has-required-aria-props): `{role}` role is missing required aria props `{props}`." +)] +#[diagnostic( + severity(warning), + help("Add missing aria props `{props}` to the element with `{role}` role.") +)] +struct RoleHasRequiredAriaPropsDiagnostic { + #[label] + pub span: Span, + pub role: String, + pub props: String, +} + +#[derive(Debug, Default, Clone)] +pub struct RoleHasRequiredAriaProps; +declare_oxc_lint!( + /// ### What it does + /// Enforces that elements with ARIA roles must have all required attributes for that role. + /// + /// ### Why is this bad? + /// Certain ARIA roles require specific attributes to express necessary semantics for assistive technology. + /// + /// ### Example + /// ```javascript + /// // Bad + ///
+ /// + /// // Good + ///
+ /// ``` + RoleHasRequiredAriaProps, + correctness +); + +static ROLE_TO_REQUIRED_ARIA_PROPS: phf::Map<&'static str, phf::Set<&'static str>> = phf_map! { + "checkbox" => phf_set!{"aria-checked"}, + "radio" => phf_set!{"aria-checked"}, + "combobox" => phf_set!{"aria-controls", "aria-expanded"}, + "tab" => phf_set!{"aria-selected"}, + "slider" => phf_set!{"aria-valuemax", "aria-valuemin", "aria-valuenow"}, + "scrollbar" => phf_set!{"aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-orientation", "aria-controls"}, + "heading" => phf_set!{"aria-level"}, + "option" => phf_set!{"aria-selected"}, +}; + +impl Rule for RoleHasRequiredAriaProps { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::JSXOpeningElement(jsx_el) = node.kind() { + let Some(role_prop) = has_jsx_prop_lowercase(jsx_el, "role") else { return }; + let JSXAttributeItem::Attribute(attr) = role_prop else { return }; + let Some(JSXAttributeValue::StringLiteral(role_values)) = &attr.value else { return }; + let roles = role_values.value.split_whitespace(); + for role in roles { + if let Some(props) = ROLE_TO_REQUIRED_ARIA_PROPS.get(role) { + for prop in props { + if has_jsx_prop_lowercase(jsx_el, prop).is_none() { + ctx.diagnostic(RoleHasRequiredAriaPropsDiagnostic { + span: attr.span, + role: role.into(), + props: (*prop).into(), + }); + } + } + } + } + } + } +} + +#[test] +fn test() { + use crate::rules::RoleHasRequiredAriaProps; + use crate::tester::Tester; + + fn settings() -> serde_json::Value { + serde_json::json!({ + "jsx-a11y": { + "components": { + "MyComponent": "div", + } + } + }) + } + + let pass = vec![ + ("", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("", None, None, None), + ("", None, None, None), + ("", None, None, None), + ("", None, Some(settings()), None), + ]; + + let fail = vec![ + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("
", None, None, None), + ("", None, Some(settings()), None), + ]; + + Tester::new_with_settings(RoleHasRequiredAriaProps::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/role_has_required_aria_props.snap b/crates/oxc_linter/src/snapshots/role_has_required_aria_props.snap new file mode 100644 index 0000000000000..dde7b73253812 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/role_has_required_aria_props.snap @@ -0,0 +1,271 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: role_has_required_aria_props +--- + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `slider` role is missing required aria props `aria-valuenow`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-valuenow` to the element with `slider` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `slider` role is missing required aria props `aria-valuemin`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-valuemin` to the element with `slider` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `slider` role is missing required aria props `aria-valuemax`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-valuemax` to the element with `slider` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `slider` role is missing required aria props `aria-valuenow`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-valuenow` to the element with `slider` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `slider` role is missing required aria props `aria-valuemin`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-valuemin` to the element with `slider` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `slider` role is missing required aria props `aria-valuenow`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-valuenow` to the element with `slider` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `checkbox` role is missing required aria props `aria-checked`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-checked` to the element with `checkbox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `checkbox` role is missing required aria props `aria-checked`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-checked` to the element with `checkbox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `checkbox` role is missing required aria props `aria-checked`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-checked` to the element with `checkbox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `checkbox` role is missing required aria props `aria-checked`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │ + · ─────────────── + ╰──── + help: Add missing aria props `aria-checked` to the element with `checkbox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-expanded`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-expanded` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-expanded`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-expanded` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-expanded`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ─────────────── + ╰──── + help: Add missing aria props `aria-expanded` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-orientation`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-orientation` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuemax`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuemax` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuemin`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuemin` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuenow`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuenow` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-orientation`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-orientation` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuemin`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuemin` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuenow`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuenow` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-orientation`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-orientation` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuenow`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuenow` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-orientation`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-orientation` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuemin`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuemin` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-orientation`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-orientation` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-valuemax`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-valuemax` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `scrollbar` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ──────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `scrollbar` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `heading` role is missing required aria props `aria-level`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ────────────── + ╰──── + help: Add missing aria props `aria-level` to the element with `heading` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `option` role is missing required aria props `aria-selected`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │
+ · ───────────── + ╰──── + help: Add missing aria props `aria-selected` to the element with `option` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-controls`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │ + · ─────────────── + ╰──── + help: Add missing aria props `aria-controls` to the element with `combobox` role. + + ⚠ eslint-plugin-jsx-a11y(role-has-required-aria-props): `combobox` role is missing required aria props `aria-expanded`. + ╭─[role_has_required_aria_props.tsx:1:1] + 1 │ + · ─────────────── + ╰──── + help: Add missing aria props `aria-expanded` to the element with `combobox` role. + +