Skip to content

Commit 5ab9340

Browse files
feat(linter/jsx-a11y/anchor-has-content): add options to match eslint (#24571)
## Description Eslint supports [a `components` option](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/anchor-has-content.md) for `jsx-a11y/anchor-has-content` that specified additional components to treat as anchor elements. This adds equivalent support for that option. Note: I am not certain if this is something that should instead be read off of [`settings.jsx-a11y.components`](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html#settings-jsx-a11y-components), as that could list `{ MyAnchor: 'a' }`. Doing that would enable implicit application of rules to custom components based on rendered entity, but that may be the wrong use case, as the custom component definition itself could be responsible for rendering required children. ## AI disclosure I used GPT 5.6 Sol to assist in implementation of this fix. I've reviewed all generated code and test coverage. --------- Signed-off-by: Cameron <cameron.clark@hey.com> Co-authored-by: Cameron <cameron.clark@hey.com>
1 parent ebf7d18 commit 5ab9340

4 files changed

Lines changed: 95 additions & 6 deletions

File tree

apps/oxlint/src-js/package/config.generated.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@ export interface DummyRuleMap {
10471047
"jsdoc/require-yields-type"?: RuleNoConfig;
10481048
"jsx-a11y/alt-text"?: RuleNoConfig | [AllowWarnDeny, AltTextConfigSchema];
10491049
"jsx-a11y/anchor-ambiguous-text"?: RuleNoConfig | [AllowWarnDeny, AnchorAmbiguousTextConfig];
1050-
"jsx-a11y/anchor-has-content"?: RuleNoConfig;
1050+
"jsx-a11y/anchor-has-content"?: RuleNoConfig | [AllowWarnDeny, AnchorHasContentConfig];
10511051
"jsx-a11y/anchor-is-valid"?: RuleNoConfig | [AllowWarnDeny, AnchorIsValidConfig];
10521052
"jsx-a11y/aria-activedescendant-has-tabindex"?: RuleNoConfig;
10531053
"jsx-a11y/aria-props"?: RuleNoConfig;
@@ -2709,6 +2709,12 @@ export interface AnchorAmbiguousTextConfig {
27092709
*/
27102710
words?: string[];
27112711
}
2712+
export interface AnchorHasContentConfig {
2713+
/**
2714+
* Additional custom component names to treat as anchor elements.
2715+
*/
2716+
components?: string[];
2717+
}
27122718
export interface AnchorIsValidConfig {
27132719
/**
27142720
* Sub-rule aspects to run.

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

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
use schemars::JsonSchema;
2+
use serde::Deserialize;
3+
14
use oxc_ast::{
25
AstKind,
36
ast::{JSXAttributeItem, JSXChild, JSXElement},
47
};
58
use oxc_diagnostics::OxcDiagnostic;
69
use oxc_macros::declare_oxc_lint;
710
use oxc_span::Span;
11+
use oxc_str::CompactStr;
812

913
use crate::{
1014
AstNode,
1115
context::LintContext,
1216
fixer::{Fix, RuleFix},
13-
rule::Rule,
17+
rule::{DefaultRuleConfig, Rule},
1418
utils::{
1519
get_element_type, has_jsx_prop_ignore_case, is_hidden_from_screen_reader,
1620
object_has_accessible_child,
@@ -23,8 +27,23 @@ fn missing_content(span: Span) -> OxcDiagnostic {
2327
.with_label(span)
2428
}
2529

26-
#[derive(Debug, Default, Clone)]
27-
pub struct AnchorHasContent;
30+
#[derive(Debug, Default, Clone, Deserialize)]
31+
pub struct AnchorHasContent(Box<AnchorHasContentConfig>);
32+
33+
#[derive(Debug, Default, Clone, JsonSchema, Deserialize)]
34+
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
35+
pub struct AnchorHasContentConfig {
36+
/// Additional custom component names to treat as anchor elements.
37+
components: Vec<CompactStr>,
38+
}
39+
40+
impl std::ops::Deref for AnchorHasContent {
41+
type Target = AnchorHasContentConfig;
42+
43+
fn deref(&self) -> &Self::Target {
44+
&self.0
45+
}
46+
}
2847

2948
declare_oxc_lint!(
3049
/// ### What it does
@@ -58,17 +77,22 @@ declare_oxc_lint!(
5877
AnchorHasContent,
5978
jsx_a11y,
6079
correctness,
80+
config = AnchorHasContentConfig,
6181
conditional_suggestion,
6282
version = "0.0.18",
6383
short_description = "Enforce that anchors have content and that the content is accessible to screen readers.",
6484
);
6585

6686
impl Rule for AnchorHasContent {
87+
fn from_configuration(value: serde_json::Value) -> Result<Self, serde_json::error::Error> {
88+
serde_json::from_value::<DefaultRuleConfig<Self>>(value).map(DefaultRuleConfig::into_inner)
89+
}
90+
6791
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
6892
if let AstKind::JSXElement(jsx_el) = node.kind() {
6993
let name = get_element_type(ctx, &jsx_el.opening_element);
7094

71-
if name == "a" {
95+
if name == "a" || self.components.iter().any(|component| component == name.as_ref()) {
7296
if is_hidden_from_screen_reader(ctx, &jsx_el.opening_element) {
7397
return;
7498
}
@@ -124,6 +148,12 @@ fn remove_hidden_attributes(element: &JSXElement<'_>) -> RuleFix {
124148
fn test() {
125149
use crate::tester::Tester;
126150

151+
fn components() -> serde_json::Value {
152+
serde_json::json!([{
153+
"components": ["Anchor", "Link"],
154+
}])
155+
}
156+
127157
// https://raw.githubusercontent.com/jsx-eslint/eslint-plugin-jsx-a11y/main/__tests__/src/rules/anchor-has-content-test.js
128158
let pass = vec![
129159
(r"<div />;", None, None),
@@ -134,6 +164,11 @@ fn test() {
134164
(r#"<a dangerouslySetInnerHTML={{ __html: "foo" }} />"#, None, None),
135165
(r"<a children={children} />", None, None),
136166
(r"<Link />", None, None),
167+
(r"<Anchor>Anchor Content!</Anchor>", Some(components()), None),
168+
(r"<Anchor><TextWrapper /></Anchor>", Some(components()), None),
169+
(r#"<Anchor dangerouslySetInnerHTML={{ __html: "foo" }} />"#, Some(components()), None),
170+
(r"<Anchor title='foo' />", Some(components()), None),
171+
(r"<Anchor aria-label='foo' />", Some(components()), None),
137172
(
138173
r"<Link>foo</Link>",
139174
None,
@@ -166,6 +201,8 @@ fn test() {
166201
(r#"<a><input type="hidden" /></a>"#, None, None),
167202
(r"<a>{undefined}</a>", None, None),
168203
(r"<a>{null}</a>", None, None),
204+
(r"<Anchor />", Some(components()), None),
205+
(r"<Anchor><TextWrapper aria-hidden /></Anchor>", Some(components()), None),
169206
(
170207
r"<Link />",
171208
None,

crates/oxc_linter/src/snapshots/jsx_a11y_anchor_has_content.snap

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ source: crates/oxc_linter/src/tester.rs
4444
╰────
4545
help: Provide screen reader accessible content when using `a` elements.
4646

47+
jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
48+
╭─[anchor_has_content.tsx:1:1]
49+
1<Anchor />
50+
· ──────────
51+
╰────
52+
help: Provide screen reader accessible content when using `a` elements.
53+
54+
jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
55+
╭─[anchor_has_content.tsx:1:1]
56+
1 │ <Anchor><TextWrapper aria-hidden /></Anchor>
57+
· ────────────────────────────────────────────
58+
╰────
59+
help: Provide screen reader accessible content when using `a` elements.
60+
4761
jsx-a11y(anchor-has-content): Missing accessible content when using `a` elements.
4862
╭─[anchor_has_content.tsx:1:1]
4963
1<Link />

npm/oxlint/configuration_schema.json

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,21 @@
672672
},
673673
"additionalProperties": false
674674
},
675+
"AnchorHasContentConfig": {
676+
"type": "object",
677+
"properties": {
678+
"components": {
679+
"description": "Additional custom component names to treat as anchor elements.",
680+
"default": [],
681+
"type": "array",
682+
"items": {
683+
"type": "string"
684+
},
685+
"markdownDescription": "Additional custom component names to treat as anchor elements."
686+
}
687+
},
688+
"additionalProperties": false
689+
},
675690
"AnchorIsValidAspect": {
676691
"type": "string",
677692
"enum": [
@@ -3757,7 +3772,24 @@
37573772
]
37583773
},
37593774
"jsx-a11y/anchor-has-content": {
3760-
"$ref": "#/definitions/RuleNoConfig"
3775+
"anyOf": [
3776+
{
3777+
"$ref": "#/definitions/RuleNoConfig"
3778+
},
3779+
{
3780+
"type": "array",
3781+
"items": [
3782+
{
3783+
"$ref": "#/definitions/AllowWarnDeny"
3784+
},
3785+
{
3786+
"$ref": "#/definitions/AnchorHasContentConfig"
3787+
}
3788+
],
3789+
"maxItems": 2,
3790+
"minItems": 2
3791+
}
3792+
]
37613793
},
37623794
"jsx-a11y/anchor-is-valid": {
37633795
"anyOf": [

0 commit comments

Comments
 (0)