Skip to content

Commit 52d2486

Browse files
committed
feat(linter/jsx-a11y): add fixer for anchor-has-content
Add a conditional fix that removes `aria-hidden` from an anchor's child if there is only a single child. This PR also fixes a false positive on hidden anchors. It should report visible anchors with hidden content, not hidden anchors.
1 parent 8e8fcd0 commit 52d2486

File tree

3 files changed

+97
-11
lines changed

3 files changed

+97
-11
lines changed

crates/oxc_linter/src/fixer/fix.rs

+10
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ macro_rules! impl_from {
145145
// but this breaks when implementing `From<RuleFix<'a>> for CompositeFix<'a>`.
146146
impl_from!(CompositeFix<'a>, Fix<'a>, Option<Fix<'a>>, Vec<Fix<'a>>);
147147

148+
impl<'a> FromIterator<Fix<'a>> for RuleFix<'a> {
149+
fn from_iter<T: IntoIterator<Item = Fix<'a>>>(iter: T) -> Self {
150+
Self {
151+
kind: FixKind::SafeFix,
152+
message: None,
153+
fix: iter.into_iter().collect::<Vec<_>>().into(),
154+
}
155+
}
156+
}
157+
148158
impl<'a> From<RuleFix<'a>> for CompositeFix<'a> {
149159
#[inline]
150160
fn from(val: RuleFix<'a>) -> Self {

crates/oxc_linter/src/rules/jsx_a11y/anchor_has_content.rs

+66-11
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
use oxc_ast::AstKind;
1+
use oxc_ast::{
2+
ast::{JSXAttributeItem, JSXChild, JSXElement},
3+
AstKind,
4+
};
25
use oxc_diagnostics::OxcDiagnostic;
36
use oxc_macros::declare_oxc_lint;
47
use oxc_span::Span;
58

69
use crate::{
710
context::LintContext,
11+
fixer::{Fix, RuleFix},
812
rule::Rule,
913
utils::{
1014
get_element_type, has_jsx_prop_ignore_case, is_hidden_from_screen_reader,
@@ -19,12 +23,6 @@ fn missing_content(span0: Span) -> OxcDiagnostic {
1923
.with_label(span0)
2024
}
2125

22-
fn remove_aria_hidden(span0: Span) -> OxcDiagnostic {
23-
OxcDiagnostic::warn("Missing accessible content when using `a` elements.")
24-
.with_help("Remove the `aria-hidden` attribute to allow the anchor element and its content visible to assistive technologies.")
25-
.with_label(span0)
26-
}
27-
2826
#[derive(Debug, Default, Clone)]
2927
pub struct AnchorHasContent;
3028

@@ -59,7 +57,8 @@ declare_oxc_lint!(
5957
/// ```
6058
///
6159
AnchorHasContent,
62-
correctness
60+
correctness,
61+
conditional_suggestion
6362
);
6463

6564
impl Rule for AnchorHasContent {
@@ -70,7 +69,6 @@ impl Rule for AnchorHasContent {
7069
};
7170
if name == "a" {
7271
if is_hidden_from_screen_reader(ctx, &jsx_el.opening_element) {
73-
ctx.diagnostic(remove_aria_hidden(jsx_el.span));
7472
return;
7573
}
7674

@@ -84,12 +82,43 @@ impl Rule for AnchorHasContent {
8482
};
8583
}
8684

87-
ctx.diagnostic(missing_content(jsx_el.span));
85+
let diagnostic = missing_content(jsx_el.span);
86+
if jsx_el.children.len() == 1 {
87+
let child = &jsx_el.children[0];
88+
if let JSXChild::Element(child) = child {
89+
ctx.diagnostic_with_suggestion(diagnostic, |_fixer| {
90+
remove_hidden_attributes(child)
91+
});
92+
return;
93+
}
94+
}
95+
96+
ctx.diagnostic(diagnostic);
8897
}
8998
}
9099
}
91100
}
92101

102+
fn remove_hidden_attributes<'a>(element: &JSXElement<'a>) -> RuleFix<'a> {
103+
element
104+
.opening_element
105+
.attributes
106+
.iter()
107+
.filter_map(JSXAttributeItem::as_attribute)
108+
.filter_map(|attr| {
109+
attr.name.as_identifier().and_then(|name| {
110+
if name.name.eq_ignore_ascii_case("aria-hidden")
111+
|| name.name.eq_ignore_ascii_case("hidden")
112+
{
113+
Some(Fix::delete(attr.span))
114+
} else {
115+
None
116+
}
117+
})
118+
})
119+
.collect()
120+
}
121+
93122
#[test]
94123
fn test() {
95124
use crate::tester::Tester;
@@ -114,12 +143,28 @@ fn test() {
114143
(r"<a title={title} />", None, None),
115144
(r"<a aria-label={ariaLabel} />", None, None),
116145
(r"<a title={title} aria-label={ariaLabel} />", None, None),
146+
(r#"<a><Bar aria-hidden="false" /></a>"#, None, None),
147+
// anchors can be hidden
148+
(r"<a aria-hidden>Foo</a>", None, None),
149+
(r#"<a aria-hidden="true">Foo</a>"#, None, None),
150+
(r"<a hidden>Foo</a>", None, None),
151+
(r"<a aria-hidden><span aria-hidden>Foo</span></a>", None, None),
152+
(r#"<a hidden="true">Foo</a>"#, None, None),
153+
(r#"<a hidden="">Foo</a>"#, None, None),
154+
// TODO: should these be failing?
155+
(r"<a><div hidden /></a>", None, None),
156+
(r"<a><Bar hidden /></a>", None, None),
157+
(r#"<a><Bar hidden="" /></a>"#, None, None),
158+
(r#"<a><Bar hidden="until-hidden" /></a>"#, None, None),
117159
];
118160

119161
let fail = vec![
120162
(r"<a />", None, None),
121163
(r"<a><Bar aria-hidden /></a>", None, None),
164+
(r#"<a><Bar aria-hidden="true" /></a>"#, None, None),
165+
(r#"<a><input type="hidden" /></a>"#, None, None),
122166
(r"<a>{undefined}</a>", None, None),
167+
(r"<a>{null}</a>", None, None),
123168
(
124169
r"<Link />",
125170
None,
@@ -129,5 +174,15 @@ fn test() {
129174
),
130175
];
131176

132-
Tester::new(AnchorHasContent::NAME, pass, fail).test_and_snapshot();
177+
let fix = vec![
178+
(r"<a><Bar aria-hidden /></a>", "<a><Bar /></a>"),
179+
(r"<a><Bar aria-hidden>Can't see me</Bar></a>", r"<a><Bar >Can't see me</Bar></a>"),
180+
(r"<a><Bar aria-hidden={true}>Can't see me</Bar></a>", r"<a><Bar >Can't see me</Bar></a>"),
181+
(
182+
r#"<a><Bar aria-hidden="true">Can't see me</Bar></a>"#,
183+
r"<a><Bar >Can't see me</Bar></a>",
184+
),
185+
];
186+
187+
Tester::new(AnchorHasContent::NAME, pass, fail).expect_fix(fix).test_and_snapshot();
133188
}

crates/oxc_linter/src/snapshots/anchor_has_content.snap

+21
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,34 @@ source: crates/oxc_linter/src/tester.rs
1515
╰────
1616
help: Provide screen reader accessible content when using `a` elements.
1717

18+
eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
19+
╭─[anchor_has_content.tsx:1:1]
20+
1 │ <a><Bar aria-hidden="true" /></a>
21+
· ─────────────────────────────────
22+
╰────
23+
help: Provide screen reader accessible content when using `a` elements.
24+
25+
eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
26+
╭─[anchor_has_content.tsx:1:1]
27+
1 │ <a><input type="hidden" /></a>
28+
· ──────────────────────────────
29+
╰────
30+
help: Provide screen reader accessible content when using `a` elements.
31+
1832
eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
1933
╭─[anchor_has_content.tsx:1:1]
2034
1 │ <a>{undefined}</a>
2135
· ──────────────────
2236
╰────
2337
help: Provide screen reader accessible content when using `a` elements.
2438

39+
eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
40+
╭─[anchor_has_content.tsx:1:1]
41+
1 │ <a>{null}</a>
42+
· ─────────────
43+
╰────
44+
help: Provide screen reader accessible content when using `a` elements.
45+
2546
eslint-plugin-jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
2647
╭─[anchor_has_content.tsx:1:1]
2748
1<Link />

0 commit comments

Comments
 (0)