Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ mod unicorn {

mod jsx_a11y {
pub mod alt_text;
pub mod anchor_has_content;
}

oxc_macros::declare_all_lint_rules! {
Expand Down Expand Up @@ -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,
}
196 changes: 196 additions & 0 deletions crates/oxc_linter/src/rules/jsx_a11y/anchor_has_content.rs
Original file line number Diff line number Diff line change
@@ -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();
}
48 changes: 48 additions & 0 deletions crates/oxc_linter/src/snapshots/anchor_has_content.snap
Original file line number Diff line number Diff line change
@@ -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>
· ──────────────────────────
Boshen marked this conversation as resolved.
Show resolved Hide resolved
╰────
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.


Loading