Skip to content

Commit 3694a13

Browse files
fix(a11y/useAnchorContent): don't flag <a> elements used as render props (#10220)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 39e4fd5 commit 3694a13

6 files changed

Lines changed: 95 additions & 3 deletions

File tree

.changeset/thick-shoes-jog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [`useAnchorContent`](https://biomejs.dev/linter/rules/use-anchor-content/) false positive for `<a>` elements used as render prop values (e.g. `render={<a href="..." />}`), a pattern where the receiving component renders its children inside the anchor element.

crates/biome_js_analyze/src/lint/a11y/use_anchor_content.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ use biome_analyze::context::RuleContext;
22
use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, RuleSource, declare_lint_rule};
33
use biome_console::markup;
44
use biome_diagnostics::Severity;
5+
use biome_js_syntax::JsxAttribute;
6+
use biome_js_syntax::JsxAttributeInitializerClause;
7+
use biome_js_syntax::JsxAttributeList;
58
use biome_js_syntax::JsxElement;
9+
use biome_js_syntax::JsxExpressionAttributeValue;
10+
use biome_js_syntax::JsSyntaxKind;
611
use biome_js_syntax::jsx_ext::AnyJsxElement;
712
use biome_rowan::{AstNode, BatchMutationExt};
813
use biome_rule_options::use_anchor_content::UseAnchorContentOptions;
@@ -60,6 +65,14 @@ declare_lint_rule! {
6065
/// <a><div aria-hidden="true"></div>content</a>
6166
/// ```
6267
///
68+
/// The following is valid because `<a>` is used as a JSX attribute value on a custom
69+
/// component. The rule is suppressed for any such prop on a custom component, as the
70+
/// component may render the anchor as a content wrapper whose children supply the link text.
71+
///
72+
/// ```jsx
73+
/// <Button render={<a href="/home" aria-label="Home" />}>Home</Button>
74+
/// ```
75+
///
6376
/// ## Accessibility guidelines
6477
///
6578
/// - [WCAG 2.4.4](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context)
@@ -95,6 +108,10 @@ impl Rule for UseAnchorContent {
95108
return None;
96109
}
97110

111+
if is_jsx_attribute_anchor(node) {
112+
return None;
113+
}
114+
98115
match node {
99116
AnyJsxElement::JsxOpeningElement(opening_element) => {
100117
if !opening_element.has_accessible_child() {
@@ -173,3 +190,42 @@ fn has_valid_anchor_content(node: &AnyJsxElement) -> bool {
173190
})
174191
|| node.has_spread_prop()
175192
}
193+
194+
/// Returns true when the `<a>` element is the value of a JSX attribute on a custom component.
195+
///
196+
/// A custom component may use the anchor as a content wrapper, injecting its own children into
197+
/// it, so the final DOM can contain both the anchor's attributes and visible text — making the
198+
/// lint check a false positive.
199+
///
200+
/// Handles self-closing (`<a />`), open/close (`<a></a>`), and parenthesized
201+
/// (`render={(<a />)}`) forms. Native HTML elements are not exempted.
202+
fn is_jsx_attribute_anchor(node: &AnyJsxElement) -> bool {
203+
for ancestor in node.syntax().ancestors().skip(1) {
204+
if let Some(attr_value) = JsxExpressionAttributeValue::cast(ancestor.clone()) {
205+
return is_component_attribute(&attr_value).unwrap_or(false);
206+
}
207+
match ancestor.kind() {
208+
// Walk up through transparent wrapper nodes:
209+
// - JsxElement wraps JsxOpeningElement
210+
// - JsxTagExpression wraps JSX elements used as JS expressions
211+
// - JsParenthesizedExpression for render={(<a />)}
212+
JsSyntaxKind::JSX_ELEMENT
213+
| JsSyntaxKind::JSX_TAG_EXPRESSION
214+
| JsSyntaxKind::JS_PARENTHESIZED_EXPRESSION => {}
215+
_ => return false,
216+
}
217+
}
218+
false
219+
}
220+
221+
/// Returns `Some(true)` when `attr_value` is an attribute of a custom JSX component (i.e. an
222+
/// uppercase or member-expression name), `Some(false)` for native HTML elements, and `None`
223+
/// when the surrounding tree is malformed.
224+
fn is_component_attribute(attr_value: &JsxExpressionAttributeValue) -> Option<bool> {
225+
let initializer = JsxAttributeInitializerClause::cast(attr_value.syntax().parent()?)?;
226+
let attribute = JsxAttribute::cast(initializer.syntax().parent()?)?;
227+
let element = attribute
228+
.parent::<JsxAttributeList>()
229+
.and_then(|list| list.parent::<AnyJsxElement>())?;
230+
Some(element.is_custom_component())
231+
}

crates/biome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
<a><span aria-hidden={"true"}>content</span></a>
1212
<a><span aria-hidden={`true`}>content</span></a>
1313
<a><span aria-hidden={`${true}`}>content</span></a>
14+
<my-button render={<a />} />
1415
</>

crates/biome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx.snap

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ expression: invalid.jsx
1717
<a><span aria-hidden={"true"}>content</span></a>
1818
<a><span aria-hidden={`true`}>content</span></a>
1919
<a><span aria-hidden={`${true}`}>content</span></a>
20+
<my-button render={<a />} />
2021
</>
2122
2223
```
@@ -243,7 +244,7 @@ invalid.jsx:12:2 lint/a11y/useAnchorContent ━━━━━━━━━━━━
243244
> 12 │ <a><span aria-hidden={`true`}>content</span></a>
244245
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
245246
13 │ <a><span aria-hidden={`${true}`}>content</span></a>
246-
14 │ </>
247+
14 │ <my-button render={<a />} />
247248
248249
i All links on a page should have content that is accessible to screen readers.
249250
@@ -265,8 +266,31 @@ invalid.jsx:13:2 lint/a11y/useAnchorContent ━━━━━━━━━━━━
265266
12 │ <a><span aria-hidden={`true`}>content</span></a>
266267
> 13 │ <a><span aria-hidden={`${true}`}>content</span></a>
267268
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
268-
14 │ </>
269-
15 │
269+
14 │ <my-button render={<a />} />
270+
15 │ </>
271+
272+
i All links on a page should have content that is accessible to screen readers.
273+
274+
i Accessible content refers to digital content that is designed and structured in a way that makes it easy for people with disabilities to access, understand, and interact with using assistive technologies.
275+
276+
i Follow these links for more information,
277+
WCAG 2.4.4
278+
WCAG 4.1.2
279+
280+
281+
```
282+
283+
```
284+
invalid.jsx:14:21 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
285+
286+
× Provide screen reader accessible content when using `a` elements.
287+
288+
12 │ <a><span aria-hidden={`true`}>content</span></a>
289+
13 │ <a><span aria-hidden={`${true}`}>content</span></a>
290+
> 14 │ <my-button render={<a />} />
291+
│ ^^^^^
292+
15 │ </>
293+
16 │
270294
271295
i All links on a page should have content that is accessible to screen readers.
272296

crates/biome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@
1111
<a><span aria-hidden="false">content</span></a>
1212
<a>{content}</a>
1313
<a children={children} />
14+
<Button render={<a href="/home" aria-label="Home" />}>Home</Button>
15+
<Button render={<a href="/home" aria-label="Home"></a>}>Home</Button>
16+
<Button render={(<a href="/home" aria-label="Home" />)}>Home</Button>
1417
</>

crates/biome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ expression: valid.jsx
1717
<a><span aria-hidden="false">content</span></a>
1818
<a>{content}</a>
1919
<a children={children} />
20+
<Button render={<a href="/home" aria-label="Home" />}>Home</Button>
21+
<Button render={<a href="/home" aria-label="Home"></a>}>Home</Button>
22+
<Button render={(<a href="/home" aria-label="Home" />)}>Home</Button>
2023
</>
2124
2225
```

0 commit comments

Comments
 (0)