feat(linter): anchor-has-content for eslint-plugin-jsx-a11y

This commit is contained in:
zhangpeng 2023-11-19 22:38:07 +08:00 committed by GitHub
parent 6ee072aafd
commit 2ba69f1f01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 246 additions and 0 deletions

View file

@ -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,
}

View file

@ -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
///
/// ```
/// <a>Anchor Content!</a>
/// <a><TextWrapper /></a>
/// <a dangerouslySetInnerHTML={{ __html: 'foo' }} />
/// <a title='foo' />
/// <a aria-label='foo' />
/// ```
///
/// #### bad
///
/// ```
/// <a />
/// <a><TextWrapper aria-hidden /></a>
/// ```
///
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<JSXAttributeItem>) -> 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"<div />;", None),
(r"<a>Foo</a>", None),
(r"<a><Bar /></a>", None),
(r"<a>{foo}</a>", None),
(r"<a>{foo.bar}</a>", None),
(r#"<a dangerouslySetInnerHTML={{ __html: "foo" }} />"#, None),
(r"<a children={children} />", None),
// TODO:
// { code: '<Link>foo</Link>', settings: { 'jsx-a11y': { components: { Link: 'a' } } }, },
(r"<a title={title} />", None),
(r"<a aria-label={ariaLabel} />", None),
(r"<a title={title} aria-label={ariaLabel} />", None),
(r"<a><Bar aria-hidden />Foo</a>", None),
];
let fail = vec![
(r"<a />", None),
(r"<a><Bar aria-hidden /></a>", None),
(r"<a>{undefined}</a>", None),
// TODO:
// { code: '<Link />', errors: [expectedError], settings: { 'jsx-a11y': { components: { Link: 'a' } } }, },
(r"<a aria-hidden ></a>", None),
(r"<a>{null}</a>", None),
(r"<a title />", None),
];
Tester::new(AnchorHasContent::NAME, pass, fail).test_and_snapshot();
}

View file

@ -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 │ <a />
· ─
╰────
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 │ <a><Bar aria-hidden /></a>
· ──────────────────────────
╰────
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 │ <a>{undefined}</a>
· ──────────────────
╰────
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 │ <a aria-hidden ></a>
· ────────────────────
╰────
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 │ <a>{null}</a>
· ─────────────
╰────
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 │ <a title />
· ─
╰────
help: Provide screen reader accessible content when using `a` elements.