diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 37fba3467..7a211bc44 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -196,6 +196,7 @@ mod unicorn { mod jsx_a11y { pub mod alt_text; + pub mod anchor_has_content; } oxc_macros::declare_all_lint_rules! { @@ -367,4 +368,5 @@ oxc_macros::declare_all_lint_rules! { import::no_self_import, import::no_amd, jsx_a11y::alt_text, + jsx_a11y::anchor_has_content, } diff --git a/crates/oxc_linter/src/rules/jsx_a11y/anchor_has_content.rs b/crates/oxc_linter/src/rules/jsx_a11y/anchor_has_content.rs new file mode 100644 index 000000000..14c340d55 --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/anchor_has_content.rs @@ -0,0 +1,196 @@ +use oxc_ast::{ + ast::{ + Expression, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXElement, JSXElementName, + JSXExpression, + }, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use oxc_allocator::Vec; + +use crate::{context::LintContext, rule::Rule, utils::has_jsx_prop_lowercase, AstNode}; + +#[derive(Debug, Error, Diagnostic)] +enum AnchorHasContentDiagnostic { + #[error("eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.")] + #[diagnostic( + severity(warning), + help("Provide screen reader accessible content when using `a` elements.") + )] + MissingContent(#[label] Span), + + #[error("eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.")] + #[diagnostic(severity(warning), help("Remove the `aria-hidden` attribute to allow the anchor element and its content visible to assistive technologies."))] + RemoveAriaHidden(#[label] Span), +} + +#[derive(Debug, Default, Clone)] +pub struct AnchorHasContent; + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforce that anchors have content and that the content is accessible to screen readers. + /// Accessible means that it is not hidden using the `aria-hidden` prop. + /// + /// Alternatively, you may use the `title` prop or the `aria-label` prop. + /// + /// ### Why is this bad? + /// + /// + /// ### Example + /// + /// #### good + /// + /// ``` + /// Anchor Content! + /// + /// + /// + /// + /// ``` + /// + /// #### bad + /// + /// ``` + /// + /// + /// ``` + /// + AnchorHasContent, + correctness +); + +fn get_prop_value<'a, 'b>(item: &'b JSXAttributeItem<'a>) -> Option<&'b JSXAttributeValue<'a>> { + if let JSXAttributeItem::Attribute(attr) = item { + attr.0.value.as_ref() + } else { + None + } +} + +fn match_valid_prop(attr_items: &Vec) -> bool { + attr_items + .into_iter() + .any(|attr| matches!(get_prop_value(attr), Some(JSXAttributeValue::ExpressionContainer(_)))) +} + +fn check_has_accessible_child(jsx: &JSXElement, ctx: &LintContext) { + let children = &jsx.children; + if children.len() == 0 { + if let JSXElementName::Identifier(ident) = &jsx.opening_element.name { + ctx.diagnostic(AnchorHasContentDiagnostic::MissingContent(ident.span)); + return; + } + } + + // If each child is inaccessible, an error is reported + let mut diagnostic = AnchorHasContentDiagnostic::MissingContent(jsx.span); + let all_not_has_content = children.into_iter().all(|child| match child { + JSXChild::Text(text) => { + if text.value.trim() == "" { + return true; + } + false + } + JSXChild::ExpressionContainer(exp) => { + if let JSXExpression::Expression(jsexp) = &exp.expression { + if let Expression::Identifier(ident) = jsexp { + if ident.name == "undefined" { + return true; + } + } else if let Expression::NullLiteral(_) = jsexp { + return true; + } + }; + false + } + JSXChild::Element(ele) => { + let is_hidden = has_jsx_prop_lowercase(&ele.opening_element, "aria-hidden").is_some(); + if is_hidden { + diagnostic = AnchorHasContentDiagnostic::RemoveAriaHidden(jsx.span); + return true; + } + false + } + _ => false, + }); + + if all_not_has_content { + ctx.diagnostic(diagnostic); + } +} + +impl Rule for AnchorHasContent { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::JSXElement(jsx_el) = node.kind() { + let JSXElementName::Identifier(iden) = &jsx_el.opening_element.name else { return }; + let name = iden.name.as_str(); + if name == "a" { + // check self attr + if has_jsx_prop_lowercase(&jsx_el.opening_element, "aria-hidden").is_some() { + ctx.diagnostic(AnchorHasContentDiagnostic::RemoveAriaHidden(jsx_el.span)); + return; + } + + // check if self attr has title/aria-label + if (has_jsx_prop_lowercase(&jsx_el.opening_element, "title").is_some() + || has_jsx_prop_lowercase(&jsx_el.opening_element, "aria-label").is_some() + || has_jsx_prop_lowercase(&jsx_el.opening_element, "children").is_some() + || has_jsx_prop_lowercase(&jsx_el.opening_element, "dangerouslysetinnerhtml") + .is_some()) + && match_valid_prop(&jsx_el.opening_element.attributes) + { + // pass + return; + } + + // check content accessible + check_has_accessible_child(jsx_el, ctx); + } + } + + // custom component + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + // https://raw.githubusercontent.com/jsx-eslint/eslint-plugin-jsx-a11y/main/__tests__/src/rules/anchor-has-content-test.js + let pass = vec![ + (r"
;", None), + (r"Foo", None), + (r"", None), + (r"{foo}", None), + (r"{foo.bar}", None), + (r#""#, None), + (r"", None), + // TODO: + // { code: 'foo', settings: { 'jsx-a11y': { components: { Link: 'a' } } }, }, + (r"", None), + (r"", None), + (r"", None), + (r"Foo", None), + ]; + + let fail = vec![ + (r"", None), + (r"", None), + (r"{undefined}", None), + // TODO: + // { code: '', errors: [expectedError], settings: { 'jsx-a11y': { components: { Link: 'a' } } }, }, + (r"", None), + (r"{null}", None), + (r"", None), + ]; + + Tester::new(AnchorHasContent::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/anchor_has_content.snap b/crates/oxc_linter/src/snapshots/anchor_has_content.snap new file mode 100644 index 000000000..d631b39f7 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/anchor_has_content.snap @@ -0,0 +1,48 @@ +--- +source: crates/oxc_linter/src/tester.rs +assertion_line: 119 +expression: anchor_has_content +--- + ⚠ eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements. + ╭─[anchor_has_content.tsx:1:1] + 1 │ + · ─ + ╰──── + help: Provide screen reader accessible content when using `a` elements. + + ⚠ eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements. + ╭─[anchor_has_content.tsx:1:1] + 1 │ + · ────────────────────────── + ╰──── + help: Remove the `aria-hidden` attribute to allow the anchor element and its content visible to assistive technologies. + + ⚠ eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements. + ╭─[anchor_has_content.tsx:1:1] + 1 │ {undefined} + · ────────────────── + ╰──── + help: Provide screen reader accessible content when using `a` elements. + + ⚠ eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements. + ╭─[anchor_has_content.tsx:1:1] + 1 │ + · ──────────────────── + ╰──── + help: Remove the `aria-hidden` attribute to allow the anchor element and its content visible to assistive technologies. + + ⚠ eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements. + ╭─[anchor_has_content.tsx:1:1] + 1 │ {null} + · ───────────── + ╰──── + help: Provide screen reader accessible content when using `a` elements. + + ⚠ eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements. + ╭─[anchor_has_content.tsx:1:1] + 1 │ + · ─ + ╰──── + help: Provide screen reader accessible content when using `a` elements. + +