diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cbf402b28..e77577c711b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ output. [#4405](https://github.com/rome/tools/pull/4405) - [`useGroupedTypeImport`](https://docs.rome.tools/lint/rules/useGroupedTypeImport/) - [`noUselessConstructor`](https://docs.rome.tools/lint/rules/noUselessConstructor/) - [`useLiteralEnumMembers`](https://docs.rome.tools/lint/rules/useLiteralEnumMembers/) +- [`useHeadingContent`](https://docs.rome.tools/lint/rules/useHeadingContent/) #### Other changes diff --git a/crates/rome_diagnostics_categories/src/categories.rs b/crates/rome_diagnostics_categories/src/categories.rs index 39ad0dc7c1d..b8da3b54856 100644 --- a/crates/rome_diagnostics_categories/src/categories.rs +++ b/crates/rome_diagnostics_categories/src/categories.rs @@ -105,10 +105,10 @@ define_categories! { "lint/nursery/noConsoleLog": "https://docs.rome.tools/lint/rules/noConsoleLog", "lint/nursery/noForEach": "https://docs.rome.tools/lint/rules/noForEach", "lint/nursery/useLiteralKeys": "https://docs.rome.tools/lint/rules/useLiteralKeys", -"lint/nursery/noUselessConstructor": "https://docs.rome.tools/lint/rules/noUselessConstructor", -"lint/nursery/useLiteralEnumMembers": "https://docs.rome.tools/lint/rules/useLiteralEnumMembers", -"lint/nursery/useGroupedTypeImport": "https://docs.rome.tools/lint/rules/useGroupedTypeImport", - // Insert new nursery rule here + "lint/nursery/noUselessConstructor": "https://docs.rome.tools/lint/rules/noUselessConstructor", + "lint/nursery/useLiteralEnumMembers": "https://docs.rome.tools/lint/rules/useLiteralEnumMembers", + "lint/nursery/useGroupedTypeImport": "https://docs.rome.tools/lint/rules/useGroupedTypeImport", + "lint/nursery/useHeadingContent": "https://docs.rome.tools/lint/rules/useHeadingContent", "lint/nursery/noRedeclare": "https://docs.rome.tools/lint/rules/noRedeclare", "lint/nursery/useNamespaceKeyword": "https://docs.rome.tools/lint/rules/useNamespaceKeyword", "lint/nursery/noRedundantRoles": "https://docs.rome.tools/lint/rules/noRedundantRoles", diff --git a/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs b/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs index a5abc044357..8159f455606 100644 --- a/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs +++ b/crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs @@ -1,16 +1,15 @@ use rome_analyze::context::RuleContext; use rome_analyze::{declare_rule, Ast, Rule, RuleDiagnostic}; use rome_console::markup; -use rome_js_syntax::{ - AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement, AnyJsxAttributeValue, - AnyJsxChild, JsxAttribute, JsxElement, JsxReferenceIdentifier, JsxSelfClosingElement, -}; -use rome_rowan::{declare_node_union, AstNode, AstNodeList}; +use rome_js_syntax::jsx_ext::AnyJsxElement; +use rome_js_syntax::JsxElement; +use rome_rowan::AstNode; declare_rule! { /// Enforce that anchors have content and that the content is accessible to screen readers. /// - /// Accessible means that it is not hidden using the aria-hidden prop. Refer to the references to learn about why this is important. + /// Accessible means the content is not hidden using the `aria-hidden` attribute. + /// Refer to the references to learn about why this is important. /// /// ## Examples /// @@ -69,91 +68,52 @@ declare_rule! { } } -declare_node_union! { - pub(crate) UseAnchorContentNode = JsxElement | JsxSelfClosingElement -} - -impl UseAnchorContentNode { - /// Check if the current element is an anchor - fn is_anchor(&self) -> Option { - Some(match self { - UseAnchorContentNode::JsxElement(element) => { - element.opening_element().ok()?.name().ok()?.text() == "a" - } - UseAnchorContentNode::JsxSelfClosingElement(element) => { - element.name().ok()?.text() == "a" - } - }) - } - - /// Check if the `a` element has the `aria-hidden` attribute set to true. - fn is_hidden_from_screen_reader(&self) -> bool { - match self { - UseAnchorContentNode::JsxElement(element) => { - if let Ok(opening_element) = element.opening_element() { - match opening_element.find_attribute_by_name("aria-hidden") { - Ok(Some(aria_hidden_attribute)) => { - is_aria_hidden_truthy(aria_hidden_attribute).unwrap_or(false) - } - _ => false, - } - } else { - false - } - } - UseAnchorContentNode::JsxSelfClosingElement(element) => { - match element.find_attribute_by_name("aria-hidden") { - Ok(Some(aria_hidden_attribute)) => { - is_aria_hidden_truthy(aria_hidden_attribute).unwrap_or(false) - } - _ => false, - } - } - } - } - - /// Check if the `a` element has content accessible to screen readers. - /// Accessible means that the content is not hidden using the `aria-hidden` attribute. - fn has_accessible_child(&self) -> Option { - Some(match self { - UseAnchorContentNode::JsxElement(element) => element - .children() - .into_iter() - .any(|child| is_accessible_to_screen_reader(child).unwrap_or(true)), - UseAnchorContentNode::JsxSelfClosingElement(element) => element - .find_attribute_by_name("dangerouslySetInnerHTML") - .ok()? - .is_some(), - }) - } -} - impl Rule for UseAnchorContent { - type Query = Ast; + type Query = Ast; type State = (); type Signals = Option; type Options = (); fn run(ctx: &RuleContext) -> Self::Signals { let node = ctx.query(); - if !node.is_anchor()? { - return None; - } + let name = node.name().ok()?.name_value_token()?; + + if name.text_trimmed() == "a" { + if node.has_truthy_attribute("aria-hidden") { + return Some(()); + } - // If there's no `aria-hidden` attribute on the `a` element, - // proceed to check the accessibility of its child elements - if !node.is_hidden_from_screen_reader() && node.has_accessible_child()? { - return None; + if has_valid_anchor_content(node) { + return None; + } + + match node { + AnyJsxElement::JsxOpeningElement(_) => { + let children = node.parent::()?.children(); + if !children + .into_iter() + .any(|child| child.is_accessible_node().unwrap_or(true)) + { + return Some(()); + } + } + AnyJsxElement::JsxSelfClosingElement(_) => return Some(()), + } } - Some(()) + None } fn diagnostic(ctx: &RuleContext, _state: &Self::State) -> Option { - let node = ctx.query(); + let range = match ctx.query() { + AnyJsxElement::JsxOpeningElement(node) => { + node.parent::()?.syntax().text_range() + } + AnyJsxElement::JsxSelfClosingElement(node) => node.syntax().text_trimmed_range(), + }; Some(RuleDiagnostic::new( rule_category!(), - node.syntax().text_trimmed_range(), + range, markup! { "Provide screen reader accessible content when using ""`a`"" elements." } @@ -165,113 +125,19 @@ impl Rule for UseAnchorContent { } } -/// Check if the element is a text content for screen readers, -/// or it is not hidden using the `aria-hidden` attribute -fn is_accessible_to_screen_reader(element: AnyJsxChild) -> Option { - Some(match element { - AnyJsxChild::JsxText(text) => { - let value_token = text.value_token().ok()?; - value_token.text_trimmed().trim() != "" - } - AnyJsxChild::JsxElement(element) => { - let opening_element = element.opening_element().ok()?; - - // We don't check if a component (e.g. ) is using the `aria-hidden` property, - // since we don't have enough information about how the property is used. - let element_name = opening_element.name().ok()?; - if JsxReferenceIdentifier::can_cast(element_name.syntax().kind()) { - return None; - } - - let aria_hidden_attribute = opening_element - .find_attribute_by_name("aria-hidden") - .ok()??; - !is_aria_hidden_truthy(aria_hidden_attribute)? - } - AnyJsxChild::JsxSelfClosingElement(element) => { - // We don't check if a component (e.g. ) is using the `aria-hidden` property, - // since we don't have enough information about how the property is used. - let element_name = element.name().ok()?; - if JsxReferenceIdentifier::can_cast(element_name.syntax().kind()) { - return None; - } - - let aria_hidden_attribute = element.find_attribute_by_name("aria-hidden").ok()??; - !is_aria_hidden_truthy(aria_hidden_attribute)? - } - AnyJsxChild::JsxExpressionChild(expression) => { - let expression = expression.expression()?; - match expression { - AnyJsExpression::AnyJsLiteralExpression( - AnyJsLiteralExpression::JsNullLiteralExpression(_), - ) => false, - AnyJsExpression::JsIdentifierExpression(identifier) => { - let text = identifier.name().ok()?.value_token().ok()?; - return Some(text.text_trimmed() != "undefined"); - } - _ => true, - } - } - _ => true, - }) -} - -/// Check if the `aria-hidden` attribute is present or the value is true. -fn is_aria_hidden_truthy(aria_hidden_attribute: JsxAttribute) -> Option { - let initializer = aria_hidden_attribute.initializer(); - if initializer.is_none() { - return Some(true); - } - let attribute_value = initializer?.value().ok()?; - Some(match attribute_value { - AnyJsxAttributeValue::JsxExpressionAttributeValue(attribute_value) => { - let expression = attribute_value.expression().ok()?; - is_expression_truthy(expression)? - } - AnyJsxAttributeValue::AnyJsxTag(_) => false, - AnyJsxAttributeValue::JsxString(aria_hidden_string) => { - let quoted_string = aria_hidden_string.inner_string_text().ok()?; - quoted_string.text() == "true" - } - }) -} - -/// Check if the expression contains only one boolean literal `true` -/// or one string literal `"true"` -fn is_expression_truthy(expression: AnyJsExpression) -> Option { - Some(match expression { - AnyJsExpression::AnyJsLiteralExpression(literal_expression) => { - if let AnyJsLiteralExpression::JsBooleanLiteralExpression(boolean_literal) = - literal_expression - { - let text = boolean_literal.value_token().ok()?; - text.text_trimmed() == "true" - } else if let AnyJsLiteralExpression::JsStringLiteralExpression(string_literal) = - literal_expression - { - let quoted_string = string_literal.inner_string_text().ok()?; - quoted_string.text() == "true" - } else { - false - } - } - AnyJsExpression::JsTemplateExpression(template) => { - let mut iter = template.elements().iter(); - if iter.len() != 1 { - return None; - } - match iter.next() { - Some(AnyJsTemplateElement::JsTemplateChunkElement(element)) => { - let template_token = element.template_chunk_token().ok()?; - template_token.text_trimmed() == "true" +/// check if the node has a valid anchor attribute +fn has_valid_anchor_content(node: &AnyJsxElement) -> bool { + node.find_attribute_by_name("dangerouslySetInnerHTML") + .is_some() + || node + .find_attribute_by_name("children") + .map_or(false, |attribute| { + if attribute.initializer().is_none() { + return false; } - Some(AnyJsTemplateElement::JsTemplateElement(element)) => { - let expression = element.expression().ok()?; - is_expression_truthy(expression)? - } - _ => false, - } - } - _ => false, - }) + attribute + .as_static_value() + .map_or(true, |attribute| !attribute.is_falsy()) + }) + || node.has_spread_prop() } diff --git a/crates/rome_js_analyze/src/analyzers/nursery.rs b/crates/rome_js_analyze/src/analyzers/nursery.rs index 6b17b07d623..1da94b8df2e 100644 --- a/crates/rome_js_analyze/src/analyzers/nursery.rs +++ b/crates/rome_js_analyze/src/analyzers/nursery.rs @@ -33,10 +33,11 @@ mod no_useless_rename; mod no_useless_switch_case; mod no_with; mod use_grouped_type_import; +mod use_heading_content; mod use_is_nan; mod use_literal_enum_members; mod use_literal_keys; mod use_media_caption; mod use_namespace_keyword; mod use_yield; -declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_assign_in_expressions :: NoAssignInExpressions , self :: no_banned_types :: NoBannedTypes , self :: no_comma_operator :: NoCommaOperator , self :: no_confusing_arrow :: NoConfusingArrow , self :: no_confusing_labels :: NoConfusingLabels , self :: no_duplicate_case :: NoDuplicateCase , self :: no_duplicate_class_members :: NoDuplicateClassMembers , self :: no_duplicate_jsx_props :: NoDuplicateJsxProps , self :: no_extra_labels :: NoExtraLabels , self :: no_extra_semicolons :: NoExtraSemicolons , self :: no_for_each :: NoForEach , self :: no_global_object_calls :: NoGlobalObjectCalls , self :: no_inferrable_types :: NoInferrableTypes , self :: no_inner_declarations :: NoInnerDeclarations , self :: no_invalid_constructor_super :: NoInvalidConstructorSuper , self :: no_namespace :: NoNamespace , self :: no_parameter_properties :: NoParameterProperties , self :: no_prototype_builtins :: NoPrototypeBuiltins , self :: no_redundant_alt :: NoRedundantAlt , self :: no_self_assign :: NoSelfAssign , self :: no_self_compare :: NoSelfCompare , self :: no_svg_without_title :: NoSvgWithoutTitle , self :: no_switch_declarations :: NoSwitchDeclarations , self :: no_unreachable_super :: NoUnreachableSuper , self :: no_unsafe_optional_chaining :: NoUnsafeOptionalChaining , self :: no_unused_labels :: NoUnusedLabels , self :: no_useless_catch :: NoUselessCatch , self :: no_useless_constructor :: NoUselessConstructor , self :: no_useless_rename :: NoUselessRename , self :: no_useless_switch_case :: NoUselessSwitchCase , self :: no_with :: NoWith , self :: use_grouped_type_import :: UseGroupedTypeImport , self :: use_is_nan :: UseIsNan , self :: use_literal_enum_members :: UseLiteralEnumMembers , self :: use_literal_keys :: UseLiteralKeys , self :: use_media_caption :: UseMediaCaption , self :: use_namespace_keyword :: UseNamespaceKeyword , self :: use_yield :: UseYield ,] } } +declare_group! { pub (crate) Nursery { name : "nursery" , rules : [self :: no_assign_in_expressions :: NoAssignInExpressions , self :: no_banned_types :: NoBannedTypes , self :: no_comma_operator :: NoCommaOperator , self :: no_confusing_arrow :: NoConfusingArrow , self :: no_confusing_labels :: NoConfusingLabels , self :: no_duplicate_case :: NoDuplicateCase , self :: no_duplicate_class_members :: NoDuplicateClassMembers , self :: no_duplicate_jsx_props :: NoDuplicateJsxProps , self :: no_extra_labels :: NoExtraLabels , self :: no_extra_semicolons :: NoExtraSemicolons , self :: no_for_each :: NoForEach , self :: no_global_object_calls :: NoGlobalObjectCalls , self :: no_inferrable_types :: NoInferrableTypes , self :: no_inner_declarations :: NoInnerDeclarations , self :: no_invalid_constructor_super :: NoInvalidConstructorSuper , self :: no_namespace :: NoNamespace , self :: no_parameter_properties :: NoParameterProperties , self :: no_prototype_builtins :: NoPrototypeBuiltins , self :: no_redundant_alt :: NoRedundantAlt , self :: no_self_assign :: NoSelfAssign , self :: no_self_compare :: NoSelfCompare , self :: no_svg_without_title :: NoSvgWithoutTitle , self :: no_switch_declarations :: NoSwitchDeclarations , self :: no_unreachable_super :: NoUnreachableSuper , self :: no_unsafe_optional_chaining :: NoUnsafeOptionalChaining , self :: no_unused_labels :: NoUnusedLabels , self :: no_useless_catch :: NoUselessCatch , self :: no_useless_constructor :: NoUselessConstructor , self :: no_useless_rename :: NoUselessRename , self :: no_useless_switch_case :: NoUselessSwitchCase , self :: no_with :: NoWith , self :: use_grouped_type_import :: UseGroupedTypeImport , self :: use_heading_content :: UseHeadingContent , self :: use_is_nan :: UseIsNan , self :: use_literal_enum_members :: UseLiteralEnumMembers , self :: use_literal_keys :: UseLiteralKeys , self :: use_media_caption :: UseMediaCaption , self :: use_namespace_keyword :: UseNamespaceKeyword , self :: use_yield :: UseYield ,] } } diff --git a/crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs b/crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs new file mode 100644 index 00000000000..752cc99d88f --- /dev/null +++ b/crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs @@ -0,0 +1,127 @@ +use rome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic}; +use rome_console::markup; +use rome_js_syntax::{jsx_ext::AnyJsxElement, JsxElement}; +use rome_rowan::AstNode; + +declare_rule! { + /// Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. + /// Accessible means that it is not hidden using the aria-hidden prop. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + ///

+ /// ``` + /// + /// ```jsx,expect_diagnostic + ///

+ /// ``` + /// + /// ```jsx,expect_diagnostic + ///

+ /// ``` + /// + /// ## Valid + /// + /// ```jsx + ///

heading

+ /// ``` + /// + /// ```jsx + ///

visible content

+ /// ``` + /// + /// ```jsx + ///

+ /// ``` + /// + /// ```jsx + ///

visible content

+ /// ``` + /// + /// ## Accessibility guidelines + /// + /// - [WCAG 2.4.6](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html) + /// + pub(crate) UseHeadingContent { + version: "next", + name: "useHeadingContent", + recommended: false, + } +} + +const HEADING_ELEMENTS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"]; + +impl Rule for UseHeadingContent { + type Query = Ast; + type State = (); + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let node = ctx.query(); + let name = node.name().ok()?.name_value_token()?; + + if HEADING_ELEMENTS.contains(&name.text_trimmed()) { + if node.has_truthy_attribute("aria-hidden") { + return Some(()); + } + + if has_valid_heading_content(node) { + return None; + } + + match node { + AnyJsxElement::JsxOpeningElement(_) => { + let children = node.parent::()?.children(); + if !children + .into_iter() + .any(|child| child.is_accessible_node().unwrap_or(true)) + { + return Some(()); + } + } + AnyJsxElement::JsxSelfClosingElement(_) => return Some(()), + } + } + + None + } + + fn diagnostic(ctx: &RuleContext, _: &Self::State) -> Option { + let range = match ctx.query() { + AnyJsxElement::JsxOpeningElement(node) => { + node.parent::()?.syntax().text_range() + } + AnyJsxElement::JsxSelfClosingElement(node) => node.syntax().text_trimmed_range(), + }; + Some(RuleDiagnostic::new( + rule_category!(), + range, + markup! { + "Provide screen reader accessible content when using ""heading"" elements." + }, + ).note( + "All headings on a page should have content that is accessible to screen readers." + )) + } +} + +/// check if the node has a valid heading attribute +fn has_valid_heading_content(node: &AnyJsxElement) -> bool { + node.find_attribute_by_name("dangerouslySetInnerHTML") + .is_some() + || node + .find_attribute_by_name("children") + .map_or(false, |attribute| { + if attribute.initializer().is_none() { + return false; + } + attribute + .as_static_value() + .map_or(true, |attribute| !attribute.is_falsy()) + }) + || node.has_spread_prop() +} diff --git a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx index 9927b79e654..3c9fed1caec 100644 --- a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx +++ b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx @@ -6,8 +6,8 @@ {undefined} content - content - content - content - content + content + content + content + content diff --git a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx.snap b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx.snap index 0c709641767..16b61105674 100644 --- a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx.snap +++ b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/invalid.jsx.snap @@ -12,10 +12,10 @@ expression: invalid.jsx {undefined} content - content - content - content - content + content + content + content + content ``` @@ -115,7 +115,7 @@ invalid.jsx:7:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━ > 7 │ content │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ 8 │ - 9 │ content + 9 │ content i All links on a page should have content that is accessible to screen readers. @@ -131,8 +131,8 @@ invalid.jsx:8:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━ 7 │ content > 8 │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 9 │ content - 10 │ content + 9 │ content + 10 │ content i All links on a page should have content that is accessible to screen readers. @@ -140,16 +140,16 @@ invalid.jsx:8:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━ ``` ``` -invalid.jsx:9:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.jsx:9:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ! Provide screen reader accessible content when using `a` elements. 7 │ content 8 │ - > 9 │ content - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 10 │ content - 11 │ content + > 9 │ content + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 10 │ content + 11 │ content i All links on a page should have content that is accessible to screen readers. @@ -157,16 +157,16 @@ invalid.jsx:9:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━ ``` ``` -invalid.jsx:10:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.jsx:10:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ! Provide screen reader accessible content when using `a` elements. 8 │ - 9 │ content - > 10 │ content - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 11 │ content - 12 │ content + 9 │ content + > 10 │ content + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 11 │ content + 12 │ content i All links on a page should have content that is accessible to screen readers. @@ -174,15 +174,15 @@ invalid.jsx:10:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━ ``` ``` -invalid.jsx:11:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.jsx:11:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ! Provide screen reader accessible content when using `a` elements. - 9 │ content - 10 │ content - > 11 │ content - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 12 │ content + 9 │ content + 10 │ content + > 11 │ content + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │ content 13 │ i All links on a page should have content that is accessible to screen readers. @@ -191,14 +191,14 @@ invalid.jsx:11:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━ ``` ``` -invalid.jsx:12:5 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.jsx:12:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ! Provide screen reader accessible content when using `a` elements. - 10 │ content - 11 │ content - > 12 │ content - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 10 │ content + 11 │ content + > 12 │ content + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 13 │ 14 │ diff --git a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx index caf99cb3cdb..d12cdc4ab11 100644 --- a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx +++ b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx @@ -8,4 +8,5 @@ content content {content} + diff --git a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx.snap b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx.snap index 1bcbde34047..fca0647e97c 100644 --- a/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx.snap +++ b/crates/rome_js_analyze/tests/specs/a11y/useAnchorContent/valid.jsx.snap @@ -14,6 +14,7 @@ expression: valid.jsx content content {content} + ``` diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx new file mode 100644 index 00000000000..932f08a07b4 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx @@ -0,0 +1,18 @@ +<> +

+

+
+

+

+

+ <> +

+

content

+

+

{undefined}

+

+

+

+

+

+; diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx.snap b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx.snap new file mode 100644 index 00000000000..621a4ba1844 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/invalid.jsx.snap @@ -0,0 +1,238 @@ +--- +source: crates/rome_js_analyze/tests/spec_tests.rs +expression: invalid.jsx +--- +# Input +```js +<> +

+

+
+

+

+

+ <> +

+

content

+

+

{undefined}

+

+

+

+

+

+; + +``` + +# Diagnostics +``` +invalid.jsx:2:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 1 │ <> + > 2 │

+ │ ^^^^^^ + 3 │

+ 4 │
+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:3:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 1 │ <> + 2 │

+ > 3 │

+ │ ^^^^ + > 4 │
+ > 5 │

+ │ ^^^^^ + 6 │

+ 7 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:6:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 4 │
+ 5 │

+ > 6 │

+ │ ^^^^^^^^^ + 7 │

+ 8 │ <> + + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:7:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 5 │

+ 6 │

+ > 7 │

+ │ ^^^^ + > 8 │ <> + > 9 │

+ │ ^^^^^ + 10 │

content

+ 11 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:10:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 8 │ <> + 9 │

+ > 10 │

content

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 11 │

+ 12 │

{undefined}

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:11:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 9 │

+ 10 │

content

+ > 11 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │

{undefined}

+ 13 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:12:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 10 │

content

+ 11 │

+ > 12 │

{undefined}

+ │ ^^^^^^^^^^^^^^^^^^^^ + 13 │

+ 14 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:13:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 11 │

+ 12 │

{undefined}

+ > 13 │

+ │ ^^^^^^^^^^^^^^^ + 14 │

+ 15 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:14:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 12 │

{undefined}

+ 13 │

+ > 14 │

+ │ ^^^^^^^^^^^^^^^^^^^^ + 15 │

+ 16 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:15:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 13 │

+ 14 │

+ > 15 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^ + 16 │

+ 17 │

+ + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:16:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 14 │

+ 15 │

+ > 16 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 17 │

+ 18 │ ; + + i All headings on a page should have content that is accessible to screen readers. + + +``` + +``` +invalid.jsx:17:2 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Provide screen reader accessible content when using heading elements. + + 15 │

+ 16 │

+ > 17 │

+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 18 │ ; + 19 │ + + i All headings on a page should have content that is accessible to screen readers. + + +``` + + diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx new file mode 100644 index 00000000000..526b1497d6b --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx @@ -0,0 +1,49 @@ +<> +
+

heading

+

heading

+

heading

+

heading

+
heading
+
heading
+

+ +

+

+ +

+

+ +

+

+ +

+

{foo}

+

{foo.bar}

+

+ visible content +

+

+ +

+

+

+

+

+
+ visible content +

+

+

+

+

+

+

+

+ visible content +

+

+ <>heading +

+; diff --git a/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx.snap b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx.snap new file mode 100644 index 00000000000..e5a36a22091 --- /dev/null +++ b/crates/rome_js_analyze/tests/specs/nursery/useHeadingContent/valid.jsx.snap @@ -0,0 +1,59 @@ +--- +source: crates/rome_js_analyze/tests/spec_tests.rs +expression: valid.jsx +--- +# Input +```js +<> +
+

heading

+

heading

+

heading

+

heading

+
heading
+
heading
+

+ +

+

+ +

+

+ +

+

+ +

+

{foo}

+

{foo.bar}

+

+ visible content +

+

+ +

+

+

+

+

+
+ visible content +

+

+

+

+

+

+

+

+ visible content +

+

+ <>heading +

+; + +``` + + diff --git a/crates/rome_js_syntax/src/jsx_ext.rs b/crates/rome_js_syntax/src/jsx_ext.rs index bb84990e2ce..4be5d592dcc 100644 --- a/crates/rome_js_syntax/src/jsx_ext.rs +++ b/crates/rome_js_syntax/src/jsx_ext.rs @@ -2,8 +2,9 @@ use std::collections::HashSet; use crate::{ static_value::{QuotedString, StaticValue}, - AnyJsxAttribute, AnyJsxAttributeName, AnyJsxAttributeValue, AnyJsxElementName, JsSyntaxToken, - JsxAttribute, JsxAttributeList, JsxName, JsxOpeningElement, JsxSelfClosingElement, JsxString, + AnyJsxAttribute, AnyJsxAttributeName, AnyJsxAttributeValue, AnyJsxChild, AnyJsxElementName, + JsSyntaxToken, JsxAttribute, JsxAttributeList, JsxName, JsxOpeningElement, + JsxSelfClosingElement, JsxString, }; use rome_rowan::{declare_node_union, AstNode, AstNodeList, SyntaxResult}; @@ -380,6 +381,15 @@ impl AnyJsxElement { } } } + + pub fn has_truthy_attribute(&self, name_to_lookup: &str) -> bool { + self.find_attribute_by_name(name_to_lookup) + .map_or(false, |attribute| { + attribute.as_static_value().map_or(true, |value| { + !(value.is_falsy() || value.is_string_constant("false")) + }) && !self.has_trailing_spread_prop(attribute) + }) + } } impl JsxAttribute { @@ -418,3 +428,40 @@ impl AnyJsxAttributeValue { } } } + +impl AnyJsxChild { + /// Check if jsx child node is accessible for screen readers + pub fn is_accessible_node(&self) -> Option { + Some(match self { + AnyJsxChild::JsxText(text) => { + let value_token = text.value_token().ok()?; + value_token.text_trimmed().trim() != "" + } + AnyJsxChild::JsxExpressionChild(expression) => { + let expression = expression.expression()?; + expression + .as_static_value() + .map_or(true, |value| !value.is_falsy()) + } + AnyJsxChild::JsxElement(element) => { + let opening_element = element.opening_element().ok()?; + let jsx_element = AnyJsxElement::cast(opening_element.syntax().clone())?; + + // We don't check if a component (e.g. ) is using the `aria-hidden` property, + // since we don't have enough information about how the property is used. + jsx_element.is_custom_component() + || !jsx_element.has_truthy_attribute("aria-hidden") + } + AnyJsxChild::JsxSelfClosingElement(element) => { + let jsx_element = AnyJsxElement::cast(element.syntax().clone())?; + jsx_element.is_custom_component() + || !jsx_element.has_truthy_attribute("aria-hidden") + } + AnyJsxChild::JsxFragment(fragment) => fragment + .children() + .into_iter() + .any(|child| child.is_accessible_node().unwrap_or(true)), + _ => true, + }) + } +} diff --git a/crates/rome_js_syntax/src/static_value.rs b/crates/rome_js_syntax/src/static_value.rs index 8d11f114680..c4278b09693 100644 --- a/crates/rome_js_syntax/src/static_value.rs +++ b/crates/rome_js_syntax/src/static_value.rs @@ -1,7 +1,4 @@ -use crate::{ - JsSyntaxKind::{IDENT, JSX_IDENT, JSX_STRING_LITERAL, JS_STRING_LITERAL}, - JsSyntaxToken, -}; +use crate::JsSyntaxToken; use std::ops::Deref; @@ -11,11 +8,6 @@ pub struct QuotedString(JsSyntaxToken); /// A string literal that is wrapped in quotes impl QuotedString { pub fn new(token: JsSyntaxToken) -> Self { - assert!(matches!( - token.kind(), - IDENT | JSX_IDENT | JS_STRING_LITERAL | JSX_STRING_LITERAL - )); - Self(token) } diff --git a/crates/rome_service/src/configuration/linter/rules.rs b/crates/rome_service/src/configuration/linter/rules.rs index 220d235aa0c..5ddbca74c8a 100644 --- a/crates/rome_service/src/configuration/linter/rules.rs +++ b/crates/rome_service/src/configuration/linter/rules.rs @@ -1561,6 +1561,10 @@ pub struct Nursery { )] #[serde(skip_serializing_if = "Option::is_none")] pub use_grouped_type_import: Option, + #[doc = "Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop."] + #[bpaf(long("use-heading-content"), argument("on|off|warn"), optional, hide)] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_heading_content: Option, #[doc = "Enforce that all React hooks are being called from the Top Level component functions."] #[bpaf(long("use-hook-at-top-level"), argument("on|off|warn"), optional, hide)] #[serde(skip_serializing_if = "Option::is_none")] @@ -1609,7 +1613,7 @@ pub struct Nursery { } impl Nursery { const GROUP_NAME: &'static str = "nursery"; - pub(crate) const GROUP_RULES: [&'static str; 56] = [ + pub(crate) const GROUP_RULES: [&'static str; 57] = [ "noAriaUnsupportedElements", "noAssignInExpressions", "noBannedTypes", @@ -1656,6 +1660,7 @@ impl Nursery { "useCamelCase", "useExhaustiveDependencies", "useGroupedTypeImport", + "useHeadingContent", "useHookAtTopLevel", "useIframeTitle", "useIsNan", @@ -1752,16 +1757,16 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[49]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[50]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[51]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56]), ]; - const ALL_RULES_AS_FILTERS: [RuleFilter<'static>; 56] = [ + const ALL_RULES_AS_FILTERS: [RuleFilter<'static>; 57] = [ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), @@ -1818,6 +1823,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56]), ]; pub(crate) fn is_recommended(&self) -> bool { !matches!(self.recommended, Some(false)) } pub(crate) const fn is_not_recommended(&self) -> bool { @@ -2057,56 +2063,61 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45])); } } - if let Some(rule) = self.use_hook_at_top_level.as_ref() { + if let Some(rule) = self.use_heading_content.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46])); } } - if let Some(rule) = self.use_iframe_title.as_ref() { + if let Some(rule) = self.use_hook_at_top_level.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47])); } } - if let Some(rule) = self.use_is_nan.as_ref() { + if let Some(rule) = self.use_iframe_title.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48])); } } - if let Some(rule) = self.use_literal_enum_members.as_ref() { + if let Some(rule) = self.use_is_nan.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[49])); } } - if let Some(rule) = self.use_literal_keys.as_ref() { + if let Some(rule) = self.use_literal_enum_members.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[50])); } } - if let Some(rule) = self.use_media_caption.as_ref() { + if let Some(rule) = self.use_literal_keys.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[51])); } } - if let Some(rule) = self.use_namespace_keyword.as_ref() { + if let Some(rule) = self.use_media_caption.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52])); } } - if let Some(rule) = self.use_valid_aria_props.as_ref() { + if let Some(rule) = self.use_namespace_keyword.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53])); } } - if let Some(rule) = self.use_valid_lang.as_ref() { + if let Some(rule) = self.use_valid_aria_props.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_yield.as_ref() { + if let Some(rule) = self.use_valid_lang.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } + if let Some(rule) = self.use_yield.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -2341,56 +2352,61 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45])); } } - if let Some(rule) = self.use_hook_at_top_level.as_ref() { + if let Some(rule) = self.use_heading_content.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46])); } } - if let Some(rule) = self.use_iframe_title.as_ref() { + if let Some(rule) = self.use_hook_at_top_level.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47])); } } - if let Some(rule) = self.use_is_nan.as_ref() { + if let Some(rule) = self.use_iframe_title.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48])); } } - if let Some(rule) = self.use_literal_enum_members.as_ref() { + if let Some(rule) = self.use_is_nan.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[49])); } } - if let Some(rule) = self.use_literal_keys.as_ref() { + if let Some(rule) = self.use_literal_enum_members.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[50])); } } - if let Some(rule) = self.use_media_caption.as_ref() { + if let Some(rule) = self.use_literal_keys.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[51])); } } - if let Some(rule) = self.use_namespace_keyword.as_ref() { + if let Some(rule) = self.use_media_caption.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52])); } } - if let Some(rule) = self.use_valid_aria_props.as_ref() { + if let Some(rule) = self.use_namespace_keyword.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53])); } } - if let Some(rule) = self.use_valid_lang.as_ref() { + if let Some(rule) = self.use_valid_aria_props.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_yield.as_ref() { + if let Some(rule) = self.use_valid_lang.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } + if let Some(rule) = self.use_yield.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -2402,7 +2418,7 @@ impl Nursery { pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 45] { Self::RECOMMENDED_RULES_AS_FILTERS } - pub(crate) fn all_rules_as_filters() -> [RuleFilter<'static>; 56] { Self::ALL_RULES_AS_FILTERS } + pub(crate) fn all_rules_as_filters() -> [RuleFilter<'static>; 57] { Self::ALL_RULES_AS_FILTERS } #[doc = r" Select preset rules"] pub(crate) fn collect_preset_rules( &self, @@ -2470,6 +2486,7 @@ impl Nursery { "useCamelCase" => self.use_camel_case.as_ref(), "useExhaustiveDependencies" => self.use_exhaustive_dependencies.as_ref(), "useGroupedTypeImport" => self.use_grouped_type_import.as_ref(), + "useHeadingContent" => self.use_heading_content.as_ref(), "useHookAtTopLevel" => self.use_hook_at_top_level.as_ref(), "useIframeTitle" => self.use_iframe_title.as_ref(), "useIsNan" => self.use_is_nan.as_ref(), diff --git a/crates/rome_service/src/configuration/parse/json/rules.rs b/crates/rome_service/src/configuration/parse/json/rules.rs index 537de67850a..2f9065b2cd3 100644 --- a/crates/rome_service/src/configuration/parse/json/rules.rs +++ b/crates/rome_service/src/configuration/parse/json/rules.rs @@ -957,6 +957,7 @@ impl VisitNode for Nursery { "useCamelCase", "useExhaustiveDependencies", "useGroupedTypeImport", + "useHeadingContent", "useHookAtTopLevel", "useIframeTitle", "useIsNan", @@ -1814,6 +1815,24 @@ impl VisitNode for Nursery { )); } }, + "useHeadingContent" => match value { + AnyJsonValue::JsonStringValue(_) => { + let mut configuration = RuleConfiguration::default(); + self.map_to_known_string(&value, name_text, &mut configuration, diagnostics)?; + self.use_heading_content = Some(configuration); + } + AnyJsonValue::JsonObjectValue(_) => { + let mut configuration = RuleConfiguration::default(); + self.map_to_object(&value, name_text, &mut configuration, diagnostics)?; + self.use_heading_content = Some(configuration); + } + _ => { + diagnostics.push(DeserializationDiagnostic::new_incorrect_type( + "object or string", + value.range(), + )); + } + }, "useHookAtTopLevel" => match value { AnyJsonValue::JsonStringValue(_) => { let mut configuration = RuleConfiguration::default(); diff --git a/editors/vscode/configuration_schema.json b/editors/vscode/configuration_schema.json index 78ab7361fb0..bbbfcc415fc 100644 --- a/editors/vscode/configuration_schema.json +++ b/editors/vscode/configuration_schema.json @@ -816,6 +816,13 @@ { "type": "null" } ] }, + "useHeadingContent": { + "description": "Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "useHookAtTopLevel": { "description": "Enforce that all React hooks are being called from the Top Level component functions.", "anyOf": [ diff --git a/npm/backend-jsonrpc/src/workspace.ts b/npm/backend-jsonrpc/src/workspace.ts index 4bf3e4668b4..51bc4d87e31 100644 --- a/npm/backend-jsonrpc/src/workspace.ts +++ b/npm/backend-jsonrpc/src/workspace.ts @@ -576,6 +576,10 @@ export interface Nursery { * Enforce the use of import type when an import only has specifiers with type qualifier. */ useGroupedTypeImport?: RuleConfiguration; + /** + * Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop. + */ + useHeadingContent?: RuleConfiguration; /** * Enforce that all React hooks are being called from the Top Level component functions. */ @@ -1021,6 +1025,7 @@ export type Category = | "lint/nursery/noUselessConstructor" | "lint/nursery/useLiteralEnumMembers" | "lint/nursery/useGroupedTypeImport" + | "lint/nursery/useHeadingContent" | "lint/nursery/noRedeclare" | "lint/nursery/useNamespaceKeyword" | "lint/nursery/noRedundantRoles" diff --git a/npm/rome/configuration_schema.json b/npm/rome/configuration_schema.json index 78ab7361fb0..bbbfcc415fc 100644 --- a/npm/rome/configuration_schema.json +++ b/npm/rome/configuration_schema.json @@ -816,6 +816,13 @@ { "type": "null" } ] }, + "useHeadingContent": { + "description": "Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. Accessible means that it is not hidden using the aria-hidden prop.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "useHookAtTopLevel": { "description": "Enforce that all React hooks are being called from the Top Level component functions.", "anyOf": [ diff --git a/website/src/pages/lint/rules/index.mdx b/website/src/pages/lint/rules/index.mdx index e8c94fee760..f28cacf3879 100644 --- a/website/src/pages/lint/rules/index.mdx +++ b/website/src/pages/lint/rules/index.mdx @@ -911,6 +911,13 @@ Enforce all dependencies are correctly specified. Enforce the use of import type when an import only has specifiers with type qualifier.
+

+ useHeadingContent +

+Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. +Accessible means that it is not hidden using the aria-hidden prop. +
+

useHookAtTopLevel

diff --git a/website/src/pages/lint/rules/useAnchorContent.md b/website/src/pages/lint/rules/useAnchorContent.md index 61ac426942b..65d2154f33f 100644 --- a/website/src/pages/lint/rules/useAnchorContent.md +++ b/website/src/pages/lint/rules/useAnchorContent.md @@ -9,7 +9,8 @@ parent: lint/rules/index Enforce that anchors have content and that the content is accessible to screen readers. -Accessible means that it is not hidden using the aria-hidden prop. Refer to the references to learn about why this is important. +Accessible means the content is not hidden using the `aria-hidden` attribute. +Refer to the references to learn about why this is important. ## Examples diff --git a/website/src/pages/lint/rules/useHeadingContent.md b/website/src/pages/lint/rules/useHeadingContent.md new file mode 100644 index 00000000000..6b31f39ea56 --- /dev/null +++ b/website/src/pages/lint/rules/useHeadingContent.md @@ -0,0 +1,88 @@ +--- +title: Lint Rule useHeadingContent +parent: lint/rules/index +--- + +# useHeadingContent (since vnext) + +Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers. +Accessible means that it is not hidden using the aria-hidden prop. + +## Examples + +### Invalid + +```jsx +

+``` + +
nursery/useHeadingContent.js:1:1 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Provide screen reader accessible content when using heading  elements.
+  
+  > 1 │ <h1 />
+   ^^^^^^
+    2 │ 
+  
+   All headings on a page should have content that is accessible to screen readers.
+  
+
+ +```jsx +

+``` + +
nursery/useHeadingContent.js:1:1 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Provide screen reader accessible content when using heading  elements.
+  
+  > 1 │ <h1><div aria-hidden /></h1>
+   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+    2 │ 
+  
+   All headings on a page should have content that is accessible to screen readers.
+  
+
+ +```jsx +

+``` + +
nursery/useHeadingContent.js:1:1 lint/nursery/useHeadingContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Provide screen reader accessible content when using heading  elements.
+  
+  > 1 │ <h1></h1>
+   ^^^^^^^^^
+    2 │ 
+  
+   All headings on a page should have content that is accessible to screen readers.
+  
+
+ +## Valid + +```jsx +

heading

+``` + +```jsx +

visible content

+``` + +```jsx +

+``` + +```jsx +

visible content

+``` + +## Accessibility guidelines + +- [WCAG 2.4.6](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html) + +## Related links + +- [Disable a rule](/linter/#disable-a-lint-rule) +- [Rule options](/linter/#rule-options)