@@ -2,7 +2,12 @@ use biome_analyze::context::RuleContext;
22use biome_analyze:: { Ast , FixKind , Rule , RuleDiagnostic , RuleSource , declare_lint_rule} ;
33use biome_console:: markup;
44use biome_diagnostics:: Severity ;
5+ use biome_js_syntax:: JsxAttribute ;
6+ use biome_js_syntax:: JsxAttributeInitializerClause ;
7+ use biome_js_syntax:: JsxAttributeList ;
58use biome_js_syntax:: JsxElement ;
9+ use biome_js_syntax:: JsxExpressionAttributeValue ;
10+ use biome_js_syntax:: JsSyntaxKind ;
611use biome_js_syntax:: jsx_ext:: AnyJsxElement ;
712use biome_rowan:: { AstNode , BatchMutationExt } ;
813use 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+ }
0 commit comments