Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 172 additions & 8 deletions crates/biome_html_analyze/src/a11y.rs
Original file line number Diff line number Diff line change
@@ -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: <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden>
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")
})
Comment on lines +17 to +28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

ARIA aria-hidden attribute specification valid values

💡 Result:

Per the WAI-ARIA specification, aria-hidden accepts these valid values (tokens):

  • "true" — element is hidden from the accessibility API
  • "false" — element is exposed to the accessibility API as if rendered
  • "undefined" (default) — user agent determines hidden state based on whether it is rendered (same effect as omitting the attribute) [1][2]

Any other value is invalid. [1]

Sources: WAI-ARIA 1.2 spec [1], MDN reference [2].


🏁 Script executed:

# Check the complete function and surrounding context
cd crates/biome_html_analyze/src && cat -n a11y.rs | head -50

Repository: biomejs/biome

Length of output: 2689


🏁 Script executed:

# Search for tests related to aria-hidden to understand intended behaviour
rg "aria-hidden" --type rust -C 3

Repository: biomejs/biome

Length of output: 50371


Align docs with the lax validation approach.

The implementation intentionally accepts any non-empty value other than "false" (per the tests accepting "yes" and "1"), but the doc comment claims the ARIA spec accepts only "true"/"false". Whilst the spec does define those two values, the function is more permissive. Update the docs to clarify this intentional leniency rather than implying strict spec compliance:

Suggested doc adjustment
-/// 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).
+/// `aria-hidden` is defined with `"true"`/`"false"` values per ARIA spec.
+/// This helper is permissive: treats any non-empty value other than `"false"` as truthy.
🤖 Prompt for AI Agents
In `@crates/biome_html_analyze/src/a11y.rs` around lines 17 - 28, The doc comment
for is_aria_hidden_value_truthy inaccurately implies strict ARIA spec
validation; update it to state that the function intentionally uses a permissive
check (accepts any non-empty value except case-insensitive "false", e.g., "yes"
or "1" are treated truthy) while noting the spec defines "true"/"false". Modify
the comment to describe this lenient behavior, explain which inputs are
considered falsy (missing, empty, whitespace-only, or "false"), and keep or add
the MDN ref for context; reference the function name is_aria_hidden_value_truthy
so reviewers can locate the change.

}

/// 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 `<input type="hidden">` 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
/// - <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,
}
}
Comment on lines +62 to 86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's understand what name_value_token() returns and how it's used
# Check the AnyHtmlTagElement implementation and name_value_token method
fd -e rs crates/biome_html_analyze | head -20

Repository: biomejs/biome

Length of output: 462


🏁 Script executed:

# Search for name_value_token implementation and usage patterns
rg "name_value_token" --type rs -A 3 -B 1

Repository: biomejs/biome

Length of output: 83


🏁 Script executed:

# Look for other tag name comparisons in the a11y.rs file and related files
rg "== [\"']input[\"']|== [\"'].*[\"']" crates/biome_html_analyze/src/ --type rs -B 2 -A 2

Repository: biomejs/biome

Length of output: 83


🏁 Script executed:

# Check how HTML tag names are handled elsewhere in the codebase - look for case-insensitive patterns
rg "eq_ignore_ascii_case|to_lowercase|to_lowercase_ascii" crates/biome_html_analyze/ --type rs -B 1 -A 1

Repository: biomejs/biome

Length of output: 83


🏁 Script executed:

# Find Rust files in biome_html_analyze
fd . crates/biome_html_analyze -e rs | head -20

Repository: biomejs/biome

Length of output: 1196


🏁 Script executed:

# Search for name_value_token usage and implementation
rg "name_value_token" crates/biome_html_analyze/ -A 3 -B 1

Repository: biomejs/biome

Length of output: 485


🏁 Script executed:

# Look for other tag name equality checks
rg '== "input"' crates/biome_html_analyze/ -B 2 -A 2

Repository: biomejs/biome

Length of output: 485


🏁 Script executed:

# Check for case-insensitive comparisons in HTML analysis code
rg "eq_ignore_ascii_case|to_lowercase" crates/biome_html_analyze/ -B 1 -A 1

Repository: biomejs/biome

Length of output: 8518


🏁 Script executed:

# Search for how other HTML linters/analyzers handle tag names - look at related implementations
rg 'text_trimmed|name.*==' crates/biome_html_analyze/src/a11y.rs -B 2 -A 2

Repository: biomejs/biome

Length of output: 295


Make the tag name check case-insensitive to match codebase patterns.

HTML tag names are case-insensitive. Every other HTML tag check in the codebase uses eq_ignore_ascii_case() (e.g. use_anchor_content.rs uses it for "input" checks), but this function uses case-sensitive comparison. Source code preserves the original case, so <INPUT> won't match.

Suggested fix
-        Some(name) if name.text_trimmed() == "input" => element
+        Some(name) if name.text_trimmed().eq_ignore_ascii_case("input") => element
🤖 Prompt for AI Agents
In `@crates/biome_html_analyze/src/a11y.rs` around lines 62 - 86, The tag-name
check in is_hidden_from_screen_reader is case-sensitive and should be made
case-insensitive; update the match arm that currently does `name.text_trimmed()
== "input"` to use `name.text_trimmed().eq_ignore_ascii_case("input")` so
variants like `<INPUT>` are detected; keep the surrounding logic (the subsequent
check of the "type" attribute via attribute_value_equals_ignore_case and the
aria-hidden branch) unchanged.


/// 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: <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden>
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: <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden>
pub(crate) fn get_truthy_aria_hidden_attribute(element: &AnyHtmlElement) -> Option<HtmlAttribute> {
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;
Loading