diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index eaf480f3d..278fa96c9 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -155,6 +155,10 @@ mod unicorn { pub mod throw_new_error; } +mod jsx_a11y { + pub mod alt_text; +} + oxc_macros::declare_all_lint_rules! { deepscan::bad_array_method_on_arguments, deepscan::bad_bitwise_operator, @@ -284,4 +288,5 @@ oxc_macros::declare_all_lint_rules! { import::named, import::no_cycle, import::no_self_import, + jsx_a11y::alt_text } diff --git a/crates/oxc_linter/src/rules/jsx_a11y/alt_text.rs b/crates/oxc_linter/src/rules/jsx_a11y/alt_text.rs new file mode 100644 index 000000000..ec80a839e --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/alt_text.rs @@ -0,0 +1,546 @@ +use oxc_ast::{ + ast::{ + JSXAttributeItem, JSXAttributeValue, JSXChild, JSXElement, JSXElementName, JSXExpression, + JSXExpressionContainer, JSXOpeningElement, + }, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::utils::has_jsx_prop_lowercase; +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Error, Diagnostic)] +enum AltTextDiagnostic { + // + #[error("eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute.")] + #[diagnostic(severity(warning), help("Must have `alt` prop, either with meaningful text, or an empty string for decorative images."))] + MissingAltProp(#[label] Span), + + #[error("eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value.")] + #[diagnostic( + severity(warning), + help("Must have meaningful value for `alt` prop. Use alt=\"\" for presentational images.") + )] + MissingAltValue(#[label] Span), + + #[error("eslint-plugin-jsx-a11y(alt-text): Missing value for aria-label attribute.")] + #[diagnostic(severity(warning), help("The aria-label attribute must have a value. The alt attribute is preferred over aria-label for images."))] + AriaLabelValue(#[label] Span), + + #[error("eslint-plugin-jsx-a11y(alt-text): Missing value for aria-labelledby attribute.")] + #[diagnostic( + severity(warning), + help("The alt attribute is preferred over aria-labelledby for images.") + )] + AriaLabelledByValue(#[label] Span), + + #[error("eslint-plugin-jsx-a11y(alt-text): ARIA used where native HTML could suffice.")] + #[diagnostic(severity(warning), help("Prefer alt=\"\" over presentational role. Native HTML attributes should be preferred for accessibility before resorting to ARIA attributes."))] + PreferAlt(#[label] Span), + + // + #[error("eslint-plugin-jsx-a11y(alt-text): Missing alternative text.")] + #[diagnostic(severity(warning), help("Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop."))] + Object(#[label] Span), + + // + #[error("eslint-plugin-jsx-a11y(alt-text): Missing alternative text.")] + #[diagnostic(severity(warning), help("Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop."))] + Area(#[label] Span), + + // + #[error("eslint-plugin-jsx-a11y(alt-text): Missing alternative text.")] + #[diagnostic(severity(warning), help(" elements with type=\"image\" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop."))] + InputTypeImage(#[label] Span), +} + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforce that all elements that require alternative text have meaningful + /// information to relay back to the end user. + /// + /// ### Why is this necessary? + /// + /// Alternative text is a critical component of accessibility for screen + /// reader users, enabling them to understand the content and function + /// of an element. + /// + /// ### What it checks + /// + /// This rule checks for alternative text on the following elements: + /// ``, ``, ``, and ``. + /// + /// ### How to fix it + /// + /// Ensure that the `alt` attribute is present and contains meaningful + /// text that describes the element's content or purpose. + /// + /// ### Example + /// ```javascript + /// // Bad + /// + /// + /// // Good + /// A close-up of a white daisy + /// ``` + AltText, + correctness +); + +#[derive(Debug, Clone)] +pub struct AltText { + img: Option>, + object: Option>, + area: Option>, + input_type_image: Option>, +} + +impl std::default::Default for AltText { + fn default() -> Self { + Self { + img: Some(vec![]), + object: Some(vec![]), + area: Some(vec![]), + input_type_image: Some(vec![]), + } + } +} + +impl Rule for AltText { + fn from_configuration(value: serde_json::Value) -> Self { + let mut alt_text = Self::default(); + if let Some(config) = value.get(0) { + if let Some(elements) = config.get("elements").and_then(|v| v.as_array()) { + alt_text = Self { img: None, object: None, area: None, input_type_image: None }; + for el in elements { + match el.as_str() { + Some("img") => alt_text.img = Some(vec![]), + Some("object") => alt_text.object = Some(vec![]), + Some("area") => alt_text.area = Some(vec![]), + Some("input[type=\"image\"]") => alt_text.input_type_image = Some(vec![]), + _ => {} + } + } + } + + for (tags, field) in [ + (&mut alt_text.img, "img"), + (&mut alt_text.object, "object"), + (&mut alt_text.area, "area"), + (&mut alt_text.input_type_image, "input[type=\"image\"]"), + ] { + if let (Some(tags), Some(elements)) = + (tags, config.get(field).and_then(|v| v.as_array())) + { + tags.extend( + elements.iter().filter_map(|v| v.as_str().map(ToString::to_string)), + ); + } + } + } + + alt_text + } + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_el) = node.kind() else { return }; + let JSXElementName::Identifier(iden) = &jsx_el.name else { return }; + let name = iden.name.as_str(); + + // + if let Some(custom_tags) = &self.img { + if name == "img" || custom_tags.iter().any(|i| i == name) { + img_rule(jsx_el, ctx); + return; + } + } + + // + if let Some(custom_tags) = &self.object { + if name == "object" || custom_tags.iter().any(|i| i == name) { + let maybe_parent = + ctx.nodes().parent_node(node.id()).map(oxc_semantic::AstNode::kind); + if let Some(AstKind::JSXElement(parent)) = maybe_parent { + object_rule(jsx_el, parent, ctx); + return; + } + } + } + + // + if let Some(custom_tags) = &self.area { + if name == "area" || custom_tags.iter().any(|i| i == name) { + area_rule(jsx_el, ctx); + return; + } + } + + // + if let Some(custom_tags) = &self.input_type_image { + let has_input_with_type_image = name.to_lowercase() == "input" + && has_jsx_prop_lowercase(jsx_el, "type") + .map_or(false, |v| get_literal_prop_value(v).map_or(false, |v| v == "image")); + if has_input_with_type_image || custom_tags.iter().any(|i| i == name) { + input_type_image_rule(jsx_el, ctx); + } + } + } +} + +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 get_literal_prop_value<'a>(item: &'a JSXAttributeItem<'_>) -> Option<&'a str> { + get_prop_value(item).and_then(|v| { + if let JSXAttributeValue::StringLiteral(s) = v { + Some(s.value.as_str()) + } else { + None + } + }) +} + +fn is_valid_alt_prop(item: &JSXAttributeItem<'_>) -> bool { + match get_prop_value(item) { + None => false, + Some(JSXAttributeValue::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expr), + .. + })) => !expr.is_null_or_undefined(), + _ => true, + } +} + +fn is_presentation_role<'a>(item: &'a JSXAttributeItem<'a>) -> bool { + get_literal_prop_value(item).map_or(false, |value| value == "presentation" || value == "none") +} + +fn aria_label_has_value<'a>(item: &'a JSXAttributeItem<'a>) -> bool { + match get_prop_value(item) { + None => false, + Some(JSXAttributeValue::StringLiteral(s)) if s.value.is_empty() => false, + Some(JSXAttributeValue::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expr), + .. + })) => !expr.is_undefined(), + _ => true, + } +} + +// ref: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/hasAccessibleChild.js +fn object_has_accessible_child(node: &JSXElement<'_>) -> bool { + node.children.iter().any(|child| match child { + JSXChild::Text(text) => !text.value.is_empty(), + JSXChild::Fragment(_) => true, + JSXChild::Element(el) => { + let is_hidden_from_screen_reader = + has_jsx_prop_lowercase(&el.opening_element, "aria-hidden").map_or(false, |v| { + match get_prop_value(v) { + None => true, + Some(JSXAttributeValue::StringLiteral(s)) if s.value == "true" => true, + _ => false, + } + }); + !is_hidden_from_screen_reader + } + JSXChild::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expr), + .. + }) => !expr.is_undefined(), + _ => false, + }) || has_jsx_prop_lowercase(&node.opening_element, "dangerouslySetInnerHTML").is_some() + || has_jsx_prop_lowercase(&node.opening_element, "children").is_some() +} + +fn img_rule<'a>(node: &'a JSXOpeningElement<'a>, ctx: &LintContext<'a>) { + if let Some(alt_prop) = has_jsx_prop_lowercase(node, "alt") { + if !is_valid_alt_prop(alt_prop) { + ctx.diagnostic(AltTextDiagnostic::MissingAltValue(node.span)); + } + return; + } + + if has_jsx_prop_lowercase(node, "role").map_or(false, is_presentation_role) { + ctx.diagnostic(AltTextDiagnostic::PreferAlt(node.span)); + return; + } + + if let Some(aria_label_prop) = has_jsx_prop_lowercase(node, "aria-label") { + if !aria_label_has_value(aria_label_prop) { + ctx.diagnostic(AltTextDiagnostic::AriaLabelValue(node.span)); + } + return; + } + + if let Some(aria_labelledby_prop) = has_jsx_prop_lowercase(node, "aria-labelledby") { + if !aria_label_has_value(aria_labelledby_prop) { + ctx.diagnostic(AltTextDiagnostic::AriaLabelledByValue(node.span)); + } + return; + } + + ctx.diagnostic(AltTextDiagnostic::MissingAltProp(node.span)); +} + +fn object_rule<'a>( + node: &'a JSXOpeningElement<'a>, + parent: &'a JSXElement<'a>, + ctx: &LintContext<'a>, +) { + let has_aria_label = + has_jsx_prop_lowercase(node, "aria-label").map_or(false, aria_label_has_value); + let has_aria_labeledby = + has_jsx_prop_lowercase(node, "aria-labelledby").map_or(false, aria_label_has_value); + let has_label = has_aria_label || has_aria_labeledby; + let has_title_attr = has_jsx_prop_lowercase(node, "title") + .and_then(get_literal_prop_value) + .map_or(false, |v| !v.is_empty()); + + if has_label || has_title_attr || object_has_accessible_child(parent) { + return; + } + ctx.diagnostic(AltTextDiagnostic::Object(node.span)); +} + +fn area_rule<'a>(node: &'a JSXOpeningElement<'a>, ctx: &LintContext<'a>) { + let has_aria_label = + has_jsx_prop_lowercase(node, "aria-label").map_or(false, aria_label_has_value); + let has_aria_labeledby = + has_jsx_prop_lowercase(node, "aria-labelledby").map_or(false, aria_label_has_value); + let has_label = has_aria_label || has_aria_labeledby; + if has_label { + return; + } + has_jsx_prop_lowercase(node, "alt").map_or_else( + || { + ctx.diagnostic(AltTextDiagnostic::Area(node.span)); + }, + |alt_prop| { + if !is_valid_alt_prop(alt_prop) { + ctx.diagnostic(AltTextDiagnostic::Area(node.span)); + } + }, + ); +} + +fn input_type_image_rule<'a>(node: &'a JSXOpeningElement<'a>, ctx: &LintContext<'a>) { + let has_aria_label = + has_jsx_prop_lowercase(node, "aria-label").map_or(false, aria_label_has_value); + let has_aria_labeledby = + has_jsx_prop_lowercase(node, "aria-labelledby").map_or(false, aria_label_has_value); + let has_label = has_aria_label || has_aria_labeledby; + if has_label { + return; + } + has_jsx_prop_lowercase(node, "alt").map_or_else( + || { + ctx.diagnostic(AltTextDiagnostic::InputTypeImage(node.span)); + }, + |alt_prop| { + if !is_valid_alt_prop(alt_prop) { + ctx.diagnostic(AltTextDiagnostic::InputTypeImage(node.span)); + } + }, + ); +} + +#[test] +fn test() { + use crate::tester::Tester; + + fn array() -> serde_json::Value { + serde_json::json!([{ + "img": ["Thumbnail", "Image"], + "object": ["Object"], + "area": ["Area"], + "input[type=\"image\"]": ["InputImage"], + }]) + } + + let pass = vec![ + (r#"foo;"#, None), + (r#"{"foo"};"#, None), + (r#"{alt};"#, None), + (r#"foo;"#, None), + (r#"{`This;"#, None), + (r#"foo;"#, None), + (r#"foo;"#, None), + (r#"foo"#, None), + (r#""#, None), + (r#"
"#, None), + (r#"{function(e)"#, None), + (r#"
"#, None), + (r#"{() void 0} />"#, None), + (r#""#, None), + (r#"test"#, None), + (r#"{alt"#, None), + (r#"{photo.caption};"#, None), + (r#"{bar()};"#, None), + (r#"{foo.bar"#, None), + (r#"{bar()"#, None), + (r#"{foo.bar()"#, None), + (r#""#, None), + (r#"{`${undefined}`}"#, None), + (r#" "#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#"this is lit..."#, None), + (r#"{error"#, None), + (r#"{undefined"#, None), + (r#"{plugin.name"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#"Foo"#, None), + (r#"

This is descriptive!

"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#"This is descriptive!"#, None), + (r#"{altText}"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + // TODO: When polymorphic components are supported + // (r#""#, None), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"
"#, Some(array())), + (r#" void 0} />"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"foo;"#, Some(array())), + (r#"{"foo"};"#, Some(array())), + (r#"{alt};"#, Some(array())), + (r#"foo;"#, Some(array())), + (r#"{`This;"#, Some(array())), + (r#"foo;"#, Some(array())), + (r#"foo;"#, Some(array())), + (r#"foo"#, Some(array())), + (r#""#, Some(array())), + (r#"{function(e)"#, Some(array())), + (r#"
"#, Some(array())), + (r#"{() void 0} />"#, Some(array())), + (r#""#, Some(array())), + (r#"{alt"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"Foo"#, Some(array())), + (r#"

This is descriptive!

"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"This is descriptive!"#, Some(array())), + (r#"{altText}"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + ]; + + let fail = vec![ + (r#";"#, None), + (r#";"#, None), + (r#"{undefined};"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + // TODO: Could support if get_prop_value could evaluate + // some logical expressions + // (r#"{false"#, None), + (r#"{undefined};"#, None), + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + // TODO: When polymorphic components are supported + // (r#""#, None), + (r#""#, None), + (r#"
"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#"{undefined}"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#"Foo"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#";"#, Some(array())), + (r#";"#, Some(array())), + (r#"{undefined};"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"
"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"{undefined}"#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#""#, Some(array())), + (r#"Foo"#, Some(array())), + (r#""#, Some(array())), + (r#""#, None), + ]; + + Tester::new(AltText::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/alt_text.snap b/crates/oxc_linter/src/snapshots/alt_text.snap new file mode 100644 index 000000000..ed2180b1c --- /dev/null +++ b/crates/oxc_linter/src/snapshots/alt_text.snap @@ -0,0 +1,446 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: alt_text +--- + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ─────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ─────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ {undefined}; + · ─────────────────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ─────────────────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ {undefined}; + · ─────────────────────────────────────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ─────────────────────────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): ARIA used where native HTML could suffice. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ─────────────────────────── + ╰──── + help: Prefer alt="" over presentational role. Native HTML attributes should be preferred for accessibility before resorting to ARIA attributes. + + ⚠ eslint-plugin-jsx-a11y(alt-text): ARIA used where native HTML could suffice. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ─────────────────── + ╰──── + help: Prefer alt="" over presentational role. Native HTML attributes should be preferred for accessibility before resorting to ARIA attributes. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing value for aria-label attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────── + ╰──── + help: The aria-label attribute must have a value. The alt attribute is preferred over aria-label for images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing value for aria-labelledby attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ─────────────────────────────────── + ╰──── + help: The alt attribute is preferred over aria-labelledby for images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing value for aria-label attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────── + ╰──── + help: The aria-label attribute must have a value. The alt attribute is preferred over aria-label for images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing value for aria-labelledby attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────── + ╰──── + help: The alt attribute is preferred over aria-labelledby for images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │
+ · ──────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────────────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ {undefined} + · ──────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ─────────────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ─────────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ Foo + · ──────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ───────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ───────────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ───────────────────────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ─────────────────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ───────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ ; + · ───────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Invalid `alt` value. + ╭─[alt_text.tsx:1:1] + 1 │ {undefined}; + · ───────────────────────── + ╰──── + help: Must have meaningful value for `alt` prop. Use alt="" for presentational images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ─────────────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing `alt` attribute. + ╭─[alt_text.tsx:1:1] + 1 │ + · ───────────────────────── + ╰──── + help: Must have `alt` prop, either with meaningful text, or an empty string for decorative images. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │
+ · ──────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────────── + ╰──── + help: Embedded elements must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ {undefined} + · ──────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ──────────────────────── + ╰──── + help: Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ Foo + · ──────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + ⚠ eslint-plugin-jsx-a11y(alt-text): Missing alternative text. + ╭─[alt_text.tsx:1:1] + 1 │ + · ────────────────────── + ╰──── + help: elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop. + + diff --git a/crates/oxc_linter/src/utils/react.rs b/crates/oxc_linter/src/utils/react.rs index 3f0fb7f00..af2e04c62 100644 --- a/crates/oxc_linter/src/utils/react.rs +++ b/crates/oxc_linter/src/utils/react.rs @@ -14,10 +14,10 @@ pub fn is_create_element_call(call_expr: &CallExpression) -> bool { false } -pub fn has_jsx_prop<'a>( - node: &'a JSXOpeningElement<'a>, - target_prop: &str, -) -> Option<&'a JSXAttributeItem<'a>> { +pub fn has_jsx_prop<'a, 'b>( + node: &'b JSXOpeningElement<'a>, + target_prop: &'b str, +) -> Option<&'b JSXAttributeItem<'a>> { node.attributes.iter().find(|attr| match attr { JSXAttributeItem::SpreadAttribute(_) => false, JSXAttributeItem::Attribute(attr) => { @@ -28,6 +28,20 @@ pub fn has_jsx_prop<'a>( }) } +pub fn has_jsx_prop_lowercase<'a, 'b>( + node: &'b JSXOpeningElement<'a>, + target_prop: &'b str, +) -> Option<&'b JSXAttributeItem<'a>> { + node.attributes.iter().find(|attr| match attr { + JSXAttributeItem::SpreadAttribute(_) => false, + JSXAttributeItem::Attribute(attr) => { + let JSXAttributeName::Identifier(name) = &attr.name else { return false }; + + name.name.as_str().to_lowercase() == target_prop + } + }) +} + const PRAGMA: &str = "React"; const CREATE_CLASS: &str = "createReactClass";