diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs
index 6629417b88e5..eda027acc8c3 100644
--- a/crates/biome_html_analyze/src/a11y.rs
+++ b/crates/biome_html_analyze/src/a11y.rs
@@ -1,22 +1,186 @@
+//! Shared accessibility helper functions for HTML lint rules.
+//!
+//! This module provides reusable utilities for checking accessibility-related
+//! attributes and element states. All helpers follow a layered architecture:
+//!
+//! 1. **Core helpers** (private): Low-level attribute value extraction and checks
+//! 2. **Element helpers** (public): Higher-level checks on HTML elements
+//! 3. **Type-specific variants** (public): Optimized versions that avoid cloning
+
use biome_html_syntax::element_ext::AnyHtmlTagElement;
+use biome_html_syntax::{AnyHtmlElement, HtmlAttribute};
+
+// ============================================================================
+// Core attribute value helpers (private)
+// ============================================================================
+
+/// Checks if an `aria-hidden` attribute has a truthy value.
+///
+/// Per ARIA spec, `aria-hidden` accepts only `"true"` or `"false"` as valid values.
+/// Returns `true` only for non-empty values that are not `"false"` (case-insensitive).
+/// Missing values, empty strings, and whitespace-only values are considered falsy.
+///
+/// Ref:
+fn is_aria_hidden_value_truthy(attribute: &HtmlAttribute) -> bool {
+ attribute.value().is_some_and(|value| {
+ let trimmed = value.trim();
+ !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("false")
+ })
+}
+
+/// Checks if an attribute value equals `"true"` exactly (case-sensitive).
+fn is_strict_true_value(attribute: &HtmlAttribute) -> bool {
+ attribute.value().is_some_and(|value| value == "true")
+}
+
+/// Checks if an attribute has a non-empty value after trimming whitespace.
+///
+/// Returns `false` for attributes with no value, empty strings, or whitespace-only values.
+fn has_non_empty_value(attribute: &HtmlAttribute) -> bool {
+ attribute
+ .value()
+ .is_some_and(|value| !value.trim().is_empty())
+}
-/// Check the element is hidden from screen reader.
+/// Checks if an attribute value matches the expected string (case-insensitive).
+///
+/// Useful for checking HTML attribute values like `type="hidden"` or `role="button"`
+/// where the comparison should be case-insensitive per HTML spec.
+pub(crate) fn attribute_value_equals_ignore_case(
+ attribute: &HtmlAttribute,
+ expected: &str,
+) -> bool {
+ attribute
+ .value()
+ .is_some_and(|value| value.eq_ignore_ascii_case(expected))
+}
+
+// ============================================================================
+// Element-level helpers (public)
+// ============================================================================
+
+/// Returns `true` if the element is hidden from assistive technologies.
+///
+/// An element is hidden from screen readers when:
+/// - It has a truthy `aria-hidden` attribute
+/// - It is an ` ` element
///
/// Ref:
-/// - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden
-/// - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden
-/// - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isHiddenFromScreenReader.js
+/// -
+/// -
+/// -
pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool {
- let is_aria_hidden = element.has_truthy_attribute("aria-hidden");
- if is_aria_hidden {
+ if element
+ .find_attribute_by_name("aria-hidden")
+ .is_some_and(|attr| is_aria_hidden_value_truthy(&attr))
+ {
return true;
}
match element.name_value_token().ok() {
Some(name) if name.text_trimmed() == "input" => element
.find_attribute_by_name("type")
- .and_then(|attribute| attribute.initializer()?.value().ok()?.string_value())
- .is_some_and(|value| value.text() == "hidden"),
+ .is_some_and(|attr| attribute_value_equals_ignore_case(&attr, "hidden")),
_ => false,
}
}
+
+/// Strict check: returns `true` only when `aria-hidden="true"` (case-sensitive).
+///
+/// Unlike [`is_aria_hidden_value_truthy`], this only matches the exact string `"true"`.
+///
+/// Ref:
+pub(crate) fn is_aria_hidden_true(element: &AnyHtmlElement) -> bool {
+ element
+ .find_attribute_by_name("aria-hidden")
+ .is_some_and(|attr| is_strict_true_value(&attr))
+}
+
+/// Returns the `aria-hidden` attribute if it has a truthy value.
+///
+/// Returns `Some` if present and not `"false"` (case-insensitive), `None` otherwise.
+/// Useful for code fixes that need to reference the attribute node.
+///
+/// Ref:
+pub(crate) fn get_truthy_aria_hidden_attribute(element: &AnyHtmlElement) -> Option {
+ let attribute = element.find_attribute_by_name("aria-hidden")?;
+ if is_aria_hidden_value_truthy(&attribute) {
+ Some(attribute)
+ } else {
+ None
+ }
+}
+
+/// Returns `true` if the element has the named attribute with a non-empty value.
+///
+/// Whitespace-only values are considered empty.
+pub(crate) fn has_non_empty_attribute(element: &AnyHtmlElement, name: &str) -> bool {
+ element
+ .find_attribute_by_name(name)
+ .is_some_and(|attr| has_non_empty_value(&attr))
+}
+
+/// Returns `true` if the element has an accessible name via `aria-label`,
+/// `aria-labelledby`, or `title` attributes.
+pub(crate) fn has_accessible_name(element: &AnyHtmlElement) -> bool {
+ has_non_empty_attribute(element, "aria-label")
+ || has_non_empty_attribute(element, "aria-labelledby")
+ || has_non_empty_attribute(element, "title")
+}
+
+/// Checks if an [`HtmlElement`] has a truthy `aria-hidden` attribute.
+///
+/// [`HtmlElement`]: biome_html_syntax::HtmlElement
+pub(crate) fn html_element_has_truthy_aria_hidden(
+ element: &biome_html_syntax::HtmlElement,
+) -> bool {
+ element
+ .find_attribute_by_name("aria-hidden")
+ .is_some_and(|attr| is_aria_hidden_value_truthy(&attr))
+}
+
+/// Checks if an [`HtmlSelfClosingElement`] has a truthy `aria-hidden` attribute.
+///
+/// [`HtmlSelfClosingElement`]: biome_html_syntax::HtmlSelfClosingElement
+pub(crate) fn html_self_closing_element_has_truthy_aria_hidden(
+ element: &biome_html_syntax::HtmlSelfClosingElement,
+) -> bool {
+ element
+ .find_attribute_by_name("aria-hidden")
+ .is_some_and(|attr| is_aria_hidden_value_truthy(&attr))
+}
+
+/// Checks if an [`HtmlSelfClosingElement`] has an accessible name via `aria-label`,
+/// `aria-labelledby`, or `title` attributes.
+///
+/// [`HtmlSelfClosingElement`]: biome_html_syntax::HtmlSelfClosingElement
+pub(crate) fn html_self_closing_element_has_accessible_name(
+ element: &biome_html_syntax::HtmlSelfClosingElement,
+) -> bool {
+ let has_aria_label = element
+ .find_attribute_by_name("aria-label")
+ .is_some_and(|attr| has_non_empty_value(&attr));
+ let has_aria_labelledby = element
+ .find_attribute_by_name("aria-labelledby")
+ .is_some_and(|attr| has_non_empty_value(&attr));
+ let has_title = element
+ .find_attribute_by_name("title")
+ .is_some_and(|attr| has_non_empty_value(&attr));
+ has_aria_label || has_aria_labelledby || has_title
+}
+
+/// Checks if an [`HtmlSelfClosingElement`] has the named attribute with a non-empty value.
+///
+/// [`HtmlSelfClosingElement`]: biome_html_syntax::HtmlSelfClosingElement
+pub(crate) fn html_self_closing_element_has_non_empty_attribute(
+ element: &biome_html_syntax::HtmlSelfClosingElement,
+ name: &str,
+) -> bool {
+ element
+ .find_attribute_by_name(name)
+ .is_some_and(|attr| has_non_empty_value(&attr))
+}
+
+#[cfg(test)]
+#[path = "a11y_tests.rs"]
+mod tests;
diff --git a/crates/biome_html_analyze/src/a11y_tests.rs b/crates/biome_html_analyze/src/a11y_tests.rs
new file mode 100644
index 000000000000..7c358767ada9
--- /dev/null
+++ b/crates/biome_html_analyze/src/a11y_tests.rs
@@ -0,0 +1,533 @@
+use super::*;
+use biome_html_parser::parse_html;
+use biome_html_syntax::HtmlRoot;
+use biome_rowan::AstNode;
+
+/// Helper to parse HTML and extract the first element
+fn parse_first_element(html: &str) -> AnyHtmlElement {
+ let parsed = parse_html(html, Default::default());
+ let root = HtmlRoot::cast(parsed.syntax()).unwrap();
+ root.syntax()
+ .descendants()
+ .find_map(AnyHtmlElement::cast)
+ .expect("No element found in parsed HTML")
+}
+
+// ============================================================================
+// Tests for is_aria_hidden_value_truthy (via get_truthy_aria_hidden_attribute)
+// ============================================================================
+
+mod aria_hidden_truthy {
+ use super::*;
+
+ #[test]
+ fn true_value_is_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_some());
+ }
+
+ #[test]
+ fn false_value_is_not_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn false_uppercase_is_not_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn false_mixed_case_is_not_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn empty_string_is_not_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn absent_attribute_returns_none() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn whitespace_only_is_not_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn arbitrary_string_is_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_some());
+ }
+
+ #[test]
+ fn numeric_string_is_truthy() {
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_some());
+ }
+
+ #[test]
+ fn false_with_whitespace_is_not_truthy() {
+ // " false " is trimmed to "false", so it's not truthy
+ let element = parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+
+ #[test]
+ fn on_self_closing_element() {
+ let element = parse_first_element(r#" "#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_some());
+ }
+
+ #[test]
+ fn other_attributes_ignored() {
+ let element =
+ parse_first_element(r#"
"#);
+ assert!(get_truthy_aria_hidden_attribute(&element).is_none());
+ }
+}
+
+// ============================================================================
+// Tests for is_aria_hidden_true (strict check)
+// ============================================================================
+
+mod aria_hidden_strict {
+ use super::*;
+
+ #[test]
+ fn exact_true_matches() {
+ let element = parse_first_element(r#"
"#);
+ assert!(is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn false_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn uppercase_true_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn mixed_case_true_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn empty_string_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn absent_attribute_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn true_with_whitespace_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn yes_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+
+ #[test]
+ fn one_does_not_match() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!is_aria_hidden_true(&element));
+ }
+}
+
+// ============================================================================
+// Tests for has_accessible_name
+// ============================================================================
+
+mod accessible_name {
+ use super::*;
+
+ #[test]
+ fn aria_label_provides_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(has_accessible_name(&element));
+ }
+
+ #[test]
+ fn aria_labelledby_provides_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(has_accessible_name(&element));
+ }
+
+ #[test]
+ fn title_provides_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(has_accessible_name(&element));
+ }
+
+ #[test]
+ fn empty_aria_label_no_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn whitespace_aria_label_no_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn tab_and_newline_only_no_name() {
+ let element = parse_first_element("
");
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn no_attributes_no_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn empty_title_no_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn empty_aria_labelledby_no_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn multiple_sources_first_wins() {
+ let element =
+ parse_first_element(r#"
"#);
+ assert!(has_accessible_name(&element));
+ }
+
+ #[test]
+ fn only_title_empty_others() {
+ let element =
+ parse_first_element(r#"
"#);
+ assert!(has_accessible_name(&element));
+ }
+
+ #[test]
+ fn unrelated_attributes_no_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_accessible_name(&element));
+ }
+
+ #[test]
+ fn on_self_closing_element() {
+ let element = parse_first_element(r#" "#);
+ assert!(has_accessible_name(&element));
+ }
+
+ #[test]
+ fn aria_label_with_only_leading_trailing_whitespace() {
+ let element = parse_first_element(r#"
"#);
+ assert!(has_accessible_name(&element));
+ }
+}
+
+// ============================================================================
+// Tests for attribute_value_equals_ignore_case
+// ============================================================================
+
+mod attribute_value_equals {
+ use super::*;
+
+ #[test]
+ fn exact_lowercase_match() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+
+ #[test]
+ fn uppercase_value_matches_lowercase() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+
+ #[test]
+ fn lowercase_value_matches_uppercase() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(attribute_value_equals_ignore_case(&attr, "HIDDEN"));
+ }
+
+ #[test]
+ fn mixed_case_matches() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+
+ #[test]
+ fn different_value_no_match() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(!attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+
+ #[test]
+ fn empty_value_matches_empty() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(attribute_value_equals_ignore_case(&attr, ""));
+ }
+
+ #[test]
+ fn empty_value_no_match_non_empty() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(!attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+
+ #[test]
+ fn partial_match_no_match() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(!attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+
+ #[test]
+ fn value_with_whitespace_no_match() {
+ let element = parse_first_element(r#" "#);
+ let attr = element.find_attribute_by_name("type").unwrap();
+ assert!(!attribute_value_equals_ignore_case(&attr, "hidden"));
+ }
+}
+
+// ============================================================================
+// Tests for has_non_empty_attribute
+// ============================================================================
+
+mod has_non_empty_attribute_tests {
+ use super::*;
+
+ #[test]
+ fn attribute_with_value() {
+ let element = parse_first_element(r#"
"#);
+ assert!(has_non_empty_attribute(&element, "data-test"));
+ }
+
+ #[test]
+ fn attribute_empty_value() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_non_empty_attribute(&element, "data-test"));
+ }
+
+ #[test]
+ fn attribute_whitespace_only() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_non_empty_attribute(&element, "data-test"));
+ }
+
+ #[test]
+ fn attribute_absent() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_non_empty_attribute(&element, "data-test"));
+ }
+
+ #[test]
+ fn different_attribute_name() {
+ let element = parse_first_element(r#"
"#);
+ assert!(!has_non_empty_attribute(&element, "data-test"));
+ }
+
+ #[test]
+ fn value_with_leading_trailing_whitespace() {
+ let element = parse_first_element(r#"
"#);
+ assert!(has_non_empty_attribute(&element, "data-test"));
+ }
+}
+
+// ============================================================================
+// Tests for is_hidden_from_screen_reader
+// ============================================================================
+
+mod hidden_from_screen_reader {
+ use super::*;
+ use biome_html_syntax::element_ext::AnyHtmlTagElement;
+
+ fn parse_tag_element(html: &str) -> AnyHtmlTagElement {
+ let parsed = parse_html(html, Default::default());
+ let root = HtmlRoot::cast(parsed.syntax()).unwrap();
+ root.syntax()
+ .descendants()
+ .find_map(AnyHtmlTagElement::cast)
+ .expect("No tag element found")
+ }
+
+ #[test]
+ fn aria_hidden_true_is_hidden() {
+ let element = parse_tag_element(r#"
"#);
+ assert!(is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn aria_hidden_false_not_hidden() {
+ let element = parse_tag_element(r#"
"#);
+ assert!(!is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn input_type_hidden_is_hidden() {
+ let element = parse_tag_element(r#" "#);
+ assert!(is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn input_type_hidden_case_insensitive() {
+ let element = parse_tag_element(r#" "#);
+ assert!(is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn input_type_text_not_hidden() {
+ let element = parse_tag_element(r#" "#);
+ assert!(!is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn input_without_type_not_hidden() {
+ let element = parse_tag_element(r#" "#);
+ assert!(!is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn div_without_aria_hidden_not_hidden() {
+ let element = parse_tag_element(r#"
"#);
+ assert!(!is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn non_input_with_type_hidden_not_hidden() {
+ let element = parse_tag_element(r#"
"#);
+ assert!(!is_hidden_from_screen_reader(&element));
+ }
+
+ #[test]
+ fn button_type_hidden_not_hidden() {
+ // type="hidden" only applies to input elements
+ let element = parse_tag_element(r#" "#);
+ assert!(!is_hidden_from_screen_reader(&element));
+ }
+}
+
+// ============================================================================
+// Tests for type-specific variants
+// ============================================================================
+
+mod type_specific_variants {
+ use super::*;
+ use biome_html_syntax::{HtmlElement, HtmlSelfClosingElement};
+
+ fn parse_html_element(html: &str) -> HtmlElement {
+ let parsed = parse_html(html, Default::default());
+ let root = HtmlRoot::cast(parsed.syntax()).unwrap();
+ root.syntax()
+ .descendants()
+ .find_map(HtmlElement::cast)
+ .expect("No HtmlElement found")
+ }
+
+ fn parse_self_closing_element(html: &str) -> HtmlSelfClosingElement {
+ let parsed = parse_html(html, Default::default());
+ let root = HtmlRoot::cast(parsed.syntax()).unwrap();
+ root.syntax()
+ .descendants()
+ .find_map(HtmlSelfClosingElement::cast)
+ .expect("No HtmlSelfClosingElement found")
+ }
+
+ #[test]
+ fn html_element_truthy_aria_hidden() {
+ let element = parse_html_element(r#"
"#);
+ assert!(html_element_has_truthy_aria_hidden(&element));
+ }
+
+ #[test]
+ fn html_element_false_aria_hidden() {
+ let element = parse_html_element(r#"
"#);
+ assert!(!html_element_has_truthy_aria_hidden(&element));
+ }
+
+ #[test]
+ fn html_element_no_aria_hidden() {
+ let element = parse_html_element(r#"
"#);
+ assert!(!html_element_has_truthy_aria_hidden(&element));
+ }
+
+ #[test]
+ fn self_closing_truthy_aria_hidden() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(html_self_closing_element_has_truthy_aria_hidden(&element));
+ }
+
+ #[test]
+ fn self_closing_false_aria_hidden() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(!html_self_closing_element_has_truthy_aria_hidden(&element));
+ }
+
+ #[test]
+ fn self_closing_has_accessible_name() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(html_self_closing_element_has_accessible_name(&element));
+ }
+
+ #[test]
+ fn self_closing_no_accessible_name() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(!html_self_closing_element_has_accessible_name(&element));
+ }
+
+ #[test]
+ fn self_closing_has_non_empty_attribute() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(html_self_closing_element_has_non_empty_attribute(
+ &element, "alt"
+ ));
+ }
+
+ #[test]
+ fn self_closing_empty_attribute() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(!html_self_closing_element_has_non_empty_attribute(
+ &element, "alt"
+ ));
+ }
+
+ #[test]
+ fn self_closing_missing_attribute() {
+ let element = parse_self_closing_element(r#" "#);
+ assert!(!html_self_closing_element_has_non_empty_attribute(
+ &element, "alt"
+ ));
+ }
+}
diff --git a/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs b/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs
index 3815042f473b..3b44c3098274 100644
--- a/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs
@@ -63,26 +63,9 @@ impl Rule for NoRedundantAlt {
return None;
}
- let aria_hidden_attribute = node.find_attribute_by_name("aria-hidden");
- if let Some(aria_hidden) = aria_hidden_attribute {
- let is_false = match aria_hidden.initializer()?.value().ok()? {
- AnyHtmlAttributeInitializer::HtmlSingleTextExpression(aria_hidden) => {
- aria_hidden
- .expression()
- .ok()?
- .html_literal_token()
- .ok()?
- .text_trimmed()
- == "false"
- }
- AnyHtmlAttributeInitializer::HtmlString(aria_hidden) => {
- aria_hidden.inner_string_text().ok()?.text() == "false"
- }
- };
-
- if !is_false {
- return None;
- }
+ // If aria-hidden is truthy (present and not "false"), skip the check
+ if node.has_truthy_attribute("aria-hidden") {
+ return None;
}
let alt = node
diff --git a/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs b/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs
index 0751b7c82a90..319b4593e680 100644
--- a/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs
@@ -6,6 +6,8 @@ use biome_rowan::AstNode;
use biome_rule_options::no_svg_without_title::NoSvgWithoutTitleOptions;
use biome_string_case::StrLikeExtension;
+use crate::a11y::is_aria_hidden_true;
+
const NAME_REQUIRED_ROLES: &[&str] = &["img", "image", "graphics-document", "graphics-symbol"];
declare_lint_rule! {
@@ -135,13 +137,8 @@ impl Rule for NoSvgWithoutTitle {
return None;
}
- if let Some(aria_hidden_attr) = node.find_attribute_by_name("aria-hidden")
- && let Some(attr_static_val) = aria_hidden_attr.initializer()
- {
- let attr_text = attr_static_val.value().ok()?.string_value()?;
- if attr_text == "true" {
- return None;
- }
+ if is_aria_hidden_true(node) {
+ return None;
}
// Checks if a `svg` element has a valid `title` element in a childlist
diff --git a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
index e0544bb6ebf3..f6d40e1d59cd 100644
--- a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
@@ -7,6 +7,10 @@ use biome_html_syntax::{AnyHtmlElement, HtmlFileSource};
use biome_rowan::{AstNode, TextRange};
use biome_rule_options::use_alt_text::UseAltTextOptions;
+use crate::a11y::{
+ attribute_value_equals_ignore_case, has_non_empty_attribute, is_aria_hidden_true,
+};
+
declare_lint_rule! {
/// Enforce that all elements that require alternative text have meaningful information to relay back to the end user.
///
@@ -118,9 +122,9 @@ impl Rule for UseAltText {
let is_html_file = file_source.is_html();
let has_alt = has_valid_alt_text(element);
- let has_aria_label = has_valid_label(element, "aria-label");
- let has_aria_labelledby = has_valid_label(element, "aria-labelledby");
- let aria_hidden = is_aria_hidden(element);
+ let has_aria_label = has_non_empty_attribute(element, "aria-label");
+ let has_aria_labelledby = has_non_empty_attribute(element, "aria-labelledby");
+ let aria_hidden = is_aria_hidden_true(element);
let name_matches = |name: &str| -> bool {
if is_html_file {
@@ -131,7 +135,7 @@ impl Rule for UseAltText {
};
if name_matches("object") {
- let has_title = has_valid_label(element, "title");
+ let has_title = has_non_empty_attribute(element, "title");
if !has_title && !has_aria_label && !has_aria_labelledby && !aria_hidden {
// For object elements, check if it has accessible child content
@@ -191,13 +195,7 @@ impl Rule for UseAltText {
fn has_type_image_attribute(element: &AnyHtmlElement) -> bool {
element
.find_attribute_by_name("type")
- .is_some_and(|attribute| {
- attribute
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|value| value.eq_ignore_ascii_case("image"))
- })
+ .is_some_and(|attr| attribute_value_equals_ignore_case(&attr, "image"))
}
/// Check if the element has a valid alt attribute
@@ -207,36 +205,3 @@ fn has_valid_alt_text(element: &AnyHtmlElement) -> bool {
// If there's an initializer with a value, any value is valid
element.find_attribute_by_name("alt").is_some()
}
-
-/// Check if the element has a valid label attribute (aria-label, aria-labelledby, or title)
-fn has_valid_label(element: &AnyHtmlElement, name_to_lookup: &str) -> bool {
- element
- .find_attribute_by_name(name_to_lookup)
- .is_some_and(|attribute| {
- // If no initializer, the attribute is present but empty - not valid for labels
- let Some(initializer) = attribute.initializer() else {
- return false;
- };
-
- // Check if the value is not empty
- initializer
- .value()
- .ok()
- .and_then(|value| value.string_value())
- .is_some_and(|value| !value.trim().is_empty())
- })
-}
-
-/// Check if the element has aria-hidden="true"
-fn is_aria_hidden(element: &AnyHtmlElement) -> bool {
- element
- .find_attribute_by_name("aria-hidden")
- .is_some_and(|attribute| {
- // Only aria-hidden="true" means hidden (must have initializer with value "true")
- attribute
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|value| value == "true")
- })
-}
diff --git a/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs b/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs
index ae78ede5fffe..ca6daae001f4 100644
--- a/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs
@@ -9,6 +9,12 @@ use biome_html_syntax::{
use biome_rowan::{AstNode, BatchMutationExt};
use crate::HtmlRuleAction;
+use crate::a11y::{
+ get_truthy_aria_hidden_attribute, has_accessible_name, html_element_has_truthy_aria_hidden,
+ html_self_closing_element_has_accessible_name,
+ html_self_closing_element_has_non_empty_attribute,
+ html_self_closing_element_has_truthy_aria_hidden,
+};
declare_lint_rule! {
/// Enforce that anchors have content and that the content is accessible to screen readers.
@@ -111,7 +117,7 @@ impl Rule for UseAnchorContent {
}
// Check if the anchor itself has aria-hidden attribute
- if let Some(aria_hidden_attr) = get_truthy_aria_hidden(node) {
+ if let Some(aria_hidden_attr) = get_truthy_aria_hidden_attribute(node) {
return Some(UseAnchorContentState {
aria_hidden_attribute: Some(aria_hidden_attr),
});
@@ -182,127 +188,33 @@ impl Rule for UseAnchorContent {
}
}
-/// Returns the aria-hidden attribute if it has a truthy value.
-fn get_truthy_aria_hidden(node: &AnyHtmlElement) -> Option {
- let attribute = node.find_attribute_by_name("aria-hidden")?;
- let is_truthy = attribute
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_none_or(|value| !value.eq_ignore_ascii_case("false"));
-
- if is_truthy { Some(attribute) } else { None }
-}
-
-/// Checks if the element has an accessible name via aria-label or title attribute.
-fn has_accessible_name(node: &AnyHtmlElement) -> bool {
- // Check aria-label attribute
- if let Some(attr) = node.find_attribute_by_name("aria-label")
- && attr
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|s| !s.trim().is_empty())
- {
- return true;
- }
-
- // Check title attribute
- if let Some(attr) = node.find_attribute_by_name("title")
- && attr
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|s| !s.trim().is_empty())
- {
- return true;
- }
-
- false
-}
-
-/// Checks if the given `HtmlElementList` has accessible content.
-/// Accessible content is either:
-/// - Non-empty text content
-/// - Child elements that don't have `aria-hidden="true"`
+/// Checks if `HtmlElementList` contains accessible content (non-empty text or visible elements).
fn has_accessible_content(html_child_list: &HtmlElementList) -> bool {
html_child_list.into_iter().any(|child| match &child {
AnyHtmlElement::AnyHtmlContent(content) => is_accessible_text_content(content),
AnyHtmlElement::HtmlElement(element) => {
- // Check if this child element has aria-hidden
- let has_aria_hidden =
- element
- .find_attribute_by_name("aria-hidden")
- .is_some_and(|attribute| {
- attribute
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_none_or(|value| !value.eq_ignore_ascii_case("false"))
- });
-
- if has_aria_hidden {
- // This element is hidden, check if there's other accessible content at this level
+ if html_element_has_truthy_aria_hidden(element) {
false
} else {
- // Element is not hidden, check if it has accessible content recursively
has_accessible_content(&element.children())
}
}
AnyHtmlElement::HtmlSelfClosingElement(element) => {
- // Check if element is hidden with aria-hidden
- let has_aria_hidden =
- element
- .find_attribute_by_name("aria-hidden")
- .is_some_and(|attribute| {
- attribute
- .initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_none_or(|value| !value.eq_ignore_ascii_case("false"))
- });
- if has_aria_hidden {
+ if html_self_closing_element_has_truthy_aria_hidden(element) {
return false;
}
- // Check for explicit accessible name via aria-label or title
- let has_aria_label = element
- .find_attribute_by_name("aria-label")
- .is_some_and(|attr| {
- attr.initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|s| !s.trim().is_empty())
- });
- if has_aria_label {
- return true;
- }
-
- let has_title = element.find_attribute_by_name("title").is_some_and(|attr| {
- attr.initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|s| !s.trim().is_empty())
- });
- if has_title {
+ if html_self_closing_element_has_accessible_name(element) {
return true;
}
- // Check tag-specific accessible content
let tag_name = element.name().ok().and_then(|n| n.value_token().ok());
let tag_text = tag_name.as_ref().map(|t| t.text_trimmed());
match tag_text {
- // requires non-empty alt attribute
Some(name) if name.eq_ignore_ascii_case("img") => {
- element.find_attribute_by_name("alt").is_some_and(|attr| {
- attr.initializer()
- .and_then(|init| init.value().ok())
- .and_then(|value| value.string_value())
- .is_some_and(|s| !s.trim().is_empty())
- })
+ html_self_closing_element_has_non_empty_attribute(element, "alt")
}
- // Void elements without meaningful content are not accessible
Some(name)
if name.eq_ignore_ascii_case("br")
|| name.eq_ignore_ascii_case("hr")
@@ -314,7 +226,6 @@ fn has_accessible_content(html_child_list: &HtmlElementList) -> bool {
{
false
}
- // is not accessible, other inputs may be
Some(name) if name.eq_ignore_ascii_case("input") => {
let is_hidden = element.find_attribute_by_name("type").is_some_and(|attr| {
attr.initializer()
@@ -324,11 +235,9 @@ fn has_accessible_content(html_child_list: &HtmlElementList) -> bool {
});
!is_hidden
}
- // Other self-closing elements without explicit accessible name are not accessible
_ => false,
}
}
- // Bogus elements and CDATA sections - treat as potentially accessible to avoid false positives
AnyHtmlElement::HtmlBogusElement(_) | AnyHtmlElement::HtmlCdataSection(_) => true,
})
}
diff --git a/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs b/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs
index 3fc06d74eb9d..706cf3b601fc 100644
--- a/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs
@@ -7,6 +7,8 @@ use biome_html_syntax::AnyHtmlElement;
use biome_rowan::{AstNode, TextRange};
use biome_rule_options::use_html_lang::UseHtmlLangOptions;
+use crate::a11y::has_non_empty_attribute;
+
declare_lint_rule! {
/// Enforce that `html` element has `lang` attribute.
///
@@ -55,12 +57,7 @@ impl Rule for UseHtmlLang {
return None;
}
- if let Some(lang_attribute) = element.find_attribute_by_name("lang")
- && let Some(initializer) = lang_attribute.initializer()
- && let Ok(value) = initializer.value()
- && let Some(value) = value.string_value()
- && !value.trim_ascii().is_empty()
- {
+ if has_non_empty_attribute(element, "lang") {
return None;
}
diff --git a/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs b/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs
index 120baa95caf0..bb38b7b7f215 100644
--- a/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs
@@ -7,6 +7,8 @@ use biome_html_syntax::AnyHtmlElement;
use biome_rowan::{AstNode, TextRange};
use biome_rule_options::use_iframe_title::UseIframeTitleOptions;
+use crate::a11y::has_non_empty_attribute;
+
declare_lint_rule! {
/// Enforces the usage of the attribute `title` for the element `iframe`.
///
@@ -63,12 +65,7 @@ impl Rule for UseIframeTitle {
return None;
}
- if let Some(title_attribute) = element.find_attribute_by_name("title")
- && let Some(initializer) = title_attribute.initializer()
- && let Ok(value) = initializer.value()
- && let Some(value) = value.string_value()
- && !value.trim_ascii().is_empty()
- {
+ if has_non_empty_attribute(element, "title") {
return None;
}
diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/astro/invalid.astro.snap b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/astro/invalid.astro.snap
index 7d58cad8f712..3a5cf1873e3e 100644
--- a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/astro/invalid.astro.snap
+++ b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/astro/invalid.astro.snap
@@ -104,33 +104,6 @@ invalid.astro:8:1 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━
```
-```
-invalid.astro:9:1 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
- × Provide screen reader accessible content when using a elements.
-
- 7 │
- 8 │ content
- > 9 │ content
- │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
- 10 │ content
- 11 │ nested content
-
- i All links on a page should have content that is accessible to screen readers.
-
- i Accessible content refers to digital content that is designed and structured in a way that makes it easy for people with disabilities to access, understand, and interact with using assistive technologies.
-
- i Follow these links for more information,
- WCAG 2.4.4
- WCAG 4.1.2
-
- i Unsafe fix: Remove the aria-hidden attribute to allow the anchor element and its content visible to assistive technologies.
-
- 9 │ content
- │ -----------
-
-```
-
```
invalid.astro:10:1 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/invalid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/invalid.html.snap
index 1fdc51de3e92..c6ac9c6abec3 100644
--- a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/invalid.html.snap
+++ b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/invalid.html.snap
@@ -178,32 +178,6 @@ invalid.html:16:1 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━
```
-```
-invalid.html:19:1 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
- × Provide screen reader accessible content when using a elements.
-
- 18 │
- > 19 │ content
- │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
- 20 │
- 21 │
-
- i All links on a page should have content that is accessible to screen readers.
-
- i Accessible content refers to digital content that is designed and structured in a way that makes it easy for people with disabilities to access, understand, and interact with using assistive technologies.
-
- i Follow these links for more information,
- WCAG 2.4.4
- WCAG 4.1.2
-
- i Unsafe fix: Remove the aria-hidden attribute to allow the anchor element and its content visible to assistive technologies.
-
- 19 │ content
- │ -----------
-
-```
-
```
invalid.html:22:1 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/svelte/invalid.svelte.snap b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/svelte/invalid.svelte.snap
index bf3b45cf93db..d97c337b854d 100644
--- a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/svelte/invalid.svelte.snap
+++ b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/svelte/invalid.svelte.snap
@@ -100,33 +100,6 @@ invalid.svelte:4:1 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━
```
-```
-invalid.svelte:5:1 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
- × Provide screen reader accessible content when using a elements.
-
- 3 │
- 4 │ content
- > 5 │ content
- │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
- 6 │ content
- 7 │ nested content
-
- i All links on a page should have content that is accessible to screen readers.
-
- i Accessible content refers to digital content that is designed and structured in a way that makes it easy for people with disabilities to access, understand, and interact with using assistive technologies.
-
- i Follow these links for more information,
- WCAG 2.4.4
- WCAG 4.1.2
-
- i Unsafe fix: Remove the aria-hidden attribute to allow the anchor element and its content visible to assistive technologies.
-
- 5 │ content
- │ -----------
-
-```
-
```
invalid.svelte:6:1 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/vue/invalid.vue.snap b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/vue/invalid.vue.snap
index 45e51a3b05d7..459c31674613 100644
--- a/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/vue/invalid.vue.snap
+++ b/crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/vue/invalid.vue.snap
@@ -103,33 +103,6 @@ invalid.vue:5:3 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━━
```
-```
-invalid.vue:6:3 lint/a11y/useAnchorContent FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
- × Provide screen reader accessible content when using a elements.
-
- 4 │
- 5 │ content
- > 6 │ content
- │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
- 7 │ content
- 8 │ nested content
-
- i All links on a page should have content that is accessible to screen readers.
-
- i Accessible content refers to digital content that is designed and structured in a way that makes it easy for people with disabilities to access, understand, and interact with using assistive technologies.
-
- i Follow these links for more information,
- WCAG 2.4.4
- WCAG 4.1.2
-
- i Unsafe fix: Remove the aria-hidden attribute to allow the anchor element and its content visible to assistive technologies.
-
- 6 │ ··content
- │ -----------
-
-```
-
```
invalid.vue:7:3 lint/a11y/useAnchorContent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
diff --git a/crates/biome_html_analyze/tests/specs/nursery/noAmbiguousAnchorText/invalid.html.snap b/crates/biome_html_analyze/tests/specs/nursery/noAmbiguousAnchorText/invalid.html.snap
index 56ddbf814fa5..5c99de9ba4cb 100644
--- a/crates/biome_html_analyze/tests/specs/nursery/noAmbiguousAnchorText/invalid.html.snap
+++ b/crates/biome_html_analyze/tests/specs/nursery/noAmbiguousAnchorText/invalid.html.snap
@@ -416,25 +416,6 @@ invalid.html:39:1 lint/nursery/noAmbiguousAnchorText ━━━━━━━━━
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
-```
-
-```
-invalid.html:41:1 lint/nursery/noAmbiguousAnchorText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
- i No ambiguous anchor descriptions allowed.
-
- 39 │ click here
- 40 │
- > 41 │ more text learn more
- │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- 42 │
- 43 │ more text learn more
-
- i Ambiguous anchor descriptions do not provide sufficient context for screen reader users. Provide more context to these users.
-
- i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
-
-
```
```
diff --git a/crates/biome_html_syntax/src/attr_ext.rs b/crates/biome_html_syntax/src/attr_ext.rs
index 5734ea10d26b..735d85a58cf9 100644
--- a/crates/biome_html_syntax/src/attr_ext.rs
+++ b/crates/biome_html_syntax/src/attr_ext.rs
@@ -33,6 +33,17 @@ impl HtmlAttributeList {
}
}
+impl HtmlAttribute {
+ /// Extracts the value from an attribute's initializer.
+ ///
+ /// Returns `None` if the attribute has no initializer or the value cannot be extracted.
+ pub fn value(&self) -> Option {
+ self.initializer()
+ .and_then(|init| init.value().ok())
+ .and_then(|value| value.string_value())
+ }
+}
+
impl HtmlAttributeName {
/// Returns the token text of the attribute name.
pub fn token_text(&self) -> Option {