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
13 changes: 13 additions & 0 deletions .changeset/port-no-aria-hidden-on-focusable-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@biomejs/biome": minor
---

Added HTML support for the [`noAriaHiddenOnFocusable`](https://biomejs.dev/linter/rules/no-aria-hidden-on-focusable/) accessibility lint rule, which enforces that `aria-hidden="true"` is not set on focusable elements. Focusable elements include native interactive elements (`<button>`, `<input>`, `<select>`, `<textarea>`), elements with `href` (`<a>`, `<area>`), elements with `tabindex >= 0`, and editing hosts (`contenteditable`). Includes an unsafe fix to remove the `aria-hidden` attribute.

```html
<!-- Invalid: aria-hidden on a focusable element -->
<button aria-hidden="true">Submit</button>

<!-- Valid: aria-hidden on a non-focusable element -->
<div aria-hidden="true">decorative content</div>
```
212 changes: 212 additions & 0 deletions crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
use biome_analyze::{
Ast, FixKind, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_html_syntax::{AnyHtmlElement, HtmlAttribute, HtmlFileSource};
use biome_rowan::{AstNode, BatchMutationExt};
use biome_rule_options::no_aria_hidden_on_focusable::NoAriaHiddenOnFocusableOptions;

use crate::HtmlRuleAction;
use crate::a11y::get_truthy_aria_hidden_attribute;

declare_lint_rule! {
/// Enforce that aria-hidden="true" is not set on focusable elements.
///
/// `aria-hidden="true"` can be used to hide purely decorative content from screen reader users.
/// A focusable element with `aria-hidden="true"` can be reached by keyboard.
/// This can lead to confusion or unexpected behavior for screen reader users.
///
/// ## Examples
///
/// ### Invalid
///
/// ```html,expect_diagnostic
/// <div aria-hidden="true" tabindex="0"></div>
/// ```
///
/// ```html,expect_diagnostic
/// <a href="/" aria-hidden="true">link</a>
/// ```
///
/// ### Valid
///
/// ```html
/// <div aria-hidden="true"></div>
/// ```
///
/// ```html
/// <button aria-hidden="true" tabindex="-1"></button>
/// ```
///
/// ## Resources
///
/// - [aria-hidden elements do not contain focusable elements](https://dequeuniversity.com/rules/axe/html/4.4/aria-hidden-focus)
/// - [Element with aria-hidden has no content in sequential focus navigation](https://www.w3.org/WAI/standards-guidelines/act/rules/6cfa84/proposed/)
/// - [MDN aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden)
///
pub NoAriaHiddenOnFocusable {
version: "next",
name: "noAriaHiddenOnFocusable",
language: "html",
sources: &[RuleSource::EslintJsxA11y("no-aria-hidden-on-focusable").same()],
recommended: true,
severity: Severity::Error,
fix_kind: FixKind::Unsafe,
}
}

pub struct NoAriaHiddenOnFocusableState {
aria_hidden_attribute: HtmlAttribute,
}

impl Rule for NoAriaHiddenOnFocusable {
type Query = Ast<AnyHtmlElement>;
type State = NoAriaHiddenOnFocusableState;
type Signals = Option<Self::State>;
type Options = NoAriaHiddenOnFocusableOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let element = ctx.query();
let aria_hidden_attr = get_truthy_aria_hidden_attribute(element)?;
let file_source = ctx.source_type::<HtmlFileSource>();
let is_html = file_source.is_html();

// Tabindex overrides native focusability: negative removes from tab order,
// non-negative makes the element focusable regardless of element type.
if let Some(tabindex_attr) = element.find_attribute_by_name("tabindex")
&& let Some(tabindex_value) = get_tabindex_value(&tabindex_attr)
{
if tabindex_value < 0 {
return None;
}
return Some(NoAriaHiddenOnFocusableState {
aria_hidden_attribute: aria_hidden_attr,
});
}

// Check if element is natively focusable or has contenteditable
if is_focusable_element(element, is_html)? {
return Some(NoAriaHiddenOnFocusableState {
aria_hidden_attribute: aria_hidden_attr,
});
}

None
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
Some(
RuleDiagnostic::new(
rule_category!(),
node.syntax().text_trimmed_range(),
markup! {
"Incorrect use of "<Emphasis>"aria-hidden=\"true\""</Emphasis>" detected."
},
)
.note(markup! {
""<Emphasis>"aria-hidden"</Emphasis>" should not be set to "<Emphasis>"true"</Emphasis>" on focusable elements because this can lead to confusing behavior for screen reader users."
}),
)
}

fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<HtmlRuleAction> {
let mut mutation = ctx.root().begin();
mutation.remove_node(state.aria_hidden_attribute.clone());
Some(HtmlRuleAction::new(
ctx.metadata().action_category(ctx.category(), ctx.group()),
ctx.metadata().applicability(),
markup! { "Remove the "<Emphasis>"aria-hidden"</Emphasis>" attribute from the element." }
.to_owned(),
mutation,
))
}
}

/// Parses the tabindex attribute value as an integer.
///
/// Returns `None` if the attribute has no value or cannot be parsed as an integer.
/// Non-integer values (e.g., `tabindex="abc"`) are ignored and treated as if
/// tabindex was not set.
fn get_tabindex_value(attribute: &HtmlAttribute) -> Option<i32> {
let value = attribute.value()?;
value.trim().parse::<i32>().ok()
Comment thread
ematipico marked this conversation as resolved.
}

/// Returns whether the element is natively focusable per the HTML spec.
///
/// Returns `Some(true)` when the element is one of:
/// - Interactive elements: `<button>`, `<select>`, `<textarea>`, `<details>`, `<summary>`
/// - Elements with `href` attribute: `<a href="...">`, `<area href="...">`
/// - `<input>` elements, except `<input type="hidden">` which is not focusable
/// - Elements with a truthy `contenteditable` attribute (editing hosts)
///
/// Returns `Some(false)` when the element is recognized but not focusable.
/// Returns `None` when the element name cannot be determined (e.g., bogus elements).
fn is_focusable_element(element: &AnyHtmlElement, is_html: bool) -> Option<bool> {
let element_name = element.name()?;

let name_matches = |name: &str| -> bool {
if is_html {
element_name.eq_ignore_ascii_case(name)
} else {
element_name.text() == name
}
};

// <a> and <area> are only focusable when they have an href attribute
if (name_matches("a") || name_matches("area"))
&& element.find_attribute_by_name("href").is_some()
{
return Some(true);
}

// These elements are always natively focusable
if name_matches("button")
|| name_matches("select")
|| name_matches("textarea")
|| name_matches("details")
|| name_matches("summary")
{
return Some(true);
}
Comment on lines +165 to +173
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the JSX version handles disabled elements
rg -n -A 10 "disabled" crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# First, let's find the correct paths for both HTML and JS rules
fd -t f "no_aria_hidden_on_focusable" crates/

Repository: biomejs/biome

Length of output: 257


🏁 Script executed:

# Let's also check the structure of the analyzers
ls -la crates/ | grep analyze

Repository: biomejs/biome

Length of output: 536


🏁 Script executed:

# Search for how disabled is handled in the HTML rule
rg -n "disabled" crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Read the HTML rule implementation
wc -l crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs

Repository: biomejs/biome

Length of output: 131


🏁 Script executed:

# Read the JS rule implementation
wc -l crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs

Repository: biomejs/biome

Length of output: 129


🏁 Script executed:

# Check test specs for HTML rule
fd -t f "no_aria_hidden_on_focusable" crates/biome_html_analyze/tests/

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Read the HTML rule implementation
cat -n crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs | head -100

Repository: biomejs/biome

Length of output: 4364


🏁 Script executed:

# Read the JS rule implementation
cat -n crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs

Repository: biomejs/biome

Length of output: 6722


🏁 Script executed:

# Find test specs with broader search
find crates/biome_html_analyze/tests -type d | grep -i "no_aria\|focusable\|a11y"

Repository: biomejs/biome

Length of output: 1914


🏁 Script executed:

# Read the rest of HTML rule to see the is_focusable_element function and lines 165-173
cat -n crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs | tail -n +100

Repository: biomejs/biome

Length of output: 5374


🏁 Script executed:

# Check HTML test specs
ls -la crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/

Repository: biomejs/biome

Length of output: 289


🏁 Script executed:

# Read HTML test spec files
cat crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.html

Repository: biomejs/biome

Length of output: 799


🏁 Script executed:

cat crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.html

Repository: biomejs/biome

Length of output: 738


🏁 Script executed:

# Check if there's a JS test spec for comparison
ls -la crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/ 2>/dev/null || echo "No JS test spec found"

Repository: biomejs/biome

Length of output: 424


🏁 Script executed:

# Read JS test specs
cat crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.jsx

Repository: biomejs/biome

Length of output: 367


🏁 Script executed:

cat crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.jsx

Repository: biomejs/biome

Length of output: 284


🏁 Script executed:

# Search for "disabled" in both test directories
rg -n "disabled" crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/
rg -n "disabled" crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/

Repository: biomejs/biome

Length of output: 39


Both rules should check the disabled attribute for natively focusable elements.

Per the HTML spec, disabled form controls (<button disabled>, <input disabled>, etc.) are not focusable and therefore shouldn't trigger this lint when paired with aria-hidden="true". Currently, neither the HTML nor the JS rule checks the disabled attribute—the HTML rule at lines 165–173 returns Some(true) for buttons/inputs without verifying they're enabled. Test coverage for disabled elements is also missing in both implementations.

Recommend adding a check: element.find_attribute_by_name("disabled").is_none() before marking these elements as focusable, and adding test cases like <button disabled aria-hidden="true"> to the valid fixtures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs`
around lines 165 - 173, The natively-focusable check (the block using
name_matches for "button", "select", "textarea", "details", "summary") should
also verify the element is not disabled before returning Some(true); update that
logic (e.g., call element.find_attribute_by_name("disabled").is_none()) so
disabled form controls are treated as unfocusable, and add corresponding tests
(e.g., fixtures with <button disabled aria-hidden="true">) to the valid cases to
cover this behavior for both the Rust rule and the JS equivalent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@coderabbitai Create an issue (task issue type) as reminder to add this check to both rules

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh issue create \
  --repo biomejs/biome \
  --title "feat(a11y): add disabled attribute check to noAriaHiddenOnFocusable rule (HTML and JS)" \
  --body "## Task

Add a check for the \`disabled\` attribute in the \`noAriaHiddenOnFocusable\` lint rule for both the HTML and JS (JSX) implementations.

## Background

Per the HTML spec, disabled form controls (\`<button disabled>\`, \`<input disabled>\`, \`<select disabled>\`, \`<textarea disabled>\`) are not focusable. Currently, both rules do not account for this and will incorrectly flag \`aria-hidden=\"true\"\` on disabled elements as a violation.

## Changes needed

### HTML rule
File: \`crates/biome_html_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs\`

In the \`is_focusable_element\` function, update the natively-focusable check to also verify the element is not disabled before returning \`Some(true)\`. For example:
- Add \`element.find_attribute_by_name(\"disabled\").is_none()\` before marking form controls (button, input, select, textarea) as focusable.

### JS rule
File: \`crates/biome_js_analyze/src/lint/a11y/no_aria_hidden_on_focusable.rs\`

Apply the equivalent fix for JSX elements.

### Test fixtures
Add valid test cases for both HTML and JSX, such as:
- \`<button disabled aria-hidden=\"true\"></button>\`
- \`<input disabled aria-hidden=\"true\" />\`
- \`<select disabled aria-hidden=\"true\"></select>\`
- \`<textarea disabled aria-hidden=\"true\"></textarea>\`

## References

- PR: https://github.com/biomejs/biome/pull/9496
- Review comment: https://github.com/biomejs/biome/pull/9496#discussion_r2985561761
- Requested by: `@ematipico`" \
  --assignee ematipico

Length of output: 44


@ematipico, done! I've created the tracking issue here: #9712 — assigned to you, with full context linking back to this PR and review comment.


🧠 Learnings used
Learnt from: ematipico
Repo: biomejs/biome PR: 9369
File: crates/biome_html_analyze/tests/specs/a11y/useAnchorContent/vue/valid.vue:20-22
Timestamp: 2026-03-08T08:18:33.839Z
Learning: In `crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs` (biomejs/biome), custom components (PascalCase elements) inside `<a>` tags are intentionally treated as potentially accessible content to avoid false positives. The linter cannot statically determine what a custom component renders, so it must not emit a diagnostic for anchors whose only children are custom components. The `valid` fixture files for non-Astro frameworks (e.g., vue/valid.vue, svelte/valid.svelte) correctly include cases like `<a><Image alt="description" /></a>` and `<a><CustomIcon /></a>` as valid to document this behavior.

Learnt from: ematipico
Repo: biomejs/biome PR: 8537
File: crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs:167-210
Timestamp: 2025-12-22T09:26:56.943Z
Learning: When defining lint rules (declare_lint_rule!), only specify fix_kind if the rule implements an action(...) function. Rules that only emit diagnostics without a code fix should omit fix_kind. This applies to all Rust lint rule definitions under crates/.../src/lint (e.g., crates/biome_js_analyze/src/lint/...).

Learnt from: CR
Repo: biomejs/biome PR: 0
File: crates/biome_analyze/CONTRIBUTING.md:0-0
Timestamp: 2026-03-21T17:22:03.949Z
Learning: Applies to crates/biome_analyze/**/lint/**/*.rs : Use the 'issue_number' field in the 'declare_lint_rule!' macro to mark incomplete rules as work in progress, which adds a footnote link to the GitHub issue

Learnt from: dyc3
Repo: biomejs/biome PR: 8639
File: crates/biome_js_analyze/src/lint/nursery/no_excessive_lines_per_file.rs:101-108
Timestamp: 2025-12-31T15:35:41.261Z
Learning: In crates/biome_analyze/**/*analyze/src/lint/nursery/**/*.rs, the `issue_number` field in `declare_lint_rule!` macro is optional and the vast majority of nursery rules do not need it. Do not recommend adding `issue_number` unless there's a specific reason.

Learnt from: CR
Repo: biomejs/biome PR: 0
File: crates/biome_analyze/CONTRIBUTING.md:0-0
Timestamp: 2026-03-21T17:22:03.949Z
Learning: Applies to crates/biome_analyze/**/lint/**/*.rs : Rules with 'recommended: true' and specific domains are only enabled when users enable those domains, while recommended rules without domains are always enabled

Learnt from: dyc3
Repo: biomejs/biome PR: 9617
File: crates/biome_html_analyze/src/lint/a11y/use_aria_activedescendant_with_tabindex.rs:58-65
Timestamp: 2026-03-25T13:49:08.724Z
Learning: In Biome’s `biome_html_analyze` HTML element-name checks inside lint rules, the casing of matches must depend on the input file type:
- For `.html` files, compare element names case-insensitively.
- For `.astro`, `.vue`, and `.svelte` files, compare case-sensitively.

Reason: in template languages (Astro/Vue/Svelte), PascalCase typically denotes custom components, while lowercase names denote native HTML elements. Case-sensitive matching is required to avoid misclassifying components as native elements (and vice versa).

Learnt from: ematipico
Repo: biomejs/biome PR: 9416
File: crates/biome_service/src/file_handlers/html.rs:850-868
Timestamp: 2026-03-09T15:54:24.948Z
Learning: In `crates/biome_service/src/file_handlers/html.rs` (biomejs/biome), Svelte `on:` event directives (e.g., `on:click={...}`) are old Svelte 3/4 syntax and are intentionally not supported by Biome. Svelte 5 runes mode uses regular attributes for event handlers instead. Do not flag missing handling of Svelte `on:` directives as an issue.

Learnt from: CR
Repo: biomejs/biome PR: 0
File: crates/biome_analyze/CONTRIBUTING.md:0-0
Timestamp: 2026-03-21T17:22:03.949Z
Learning: Applies to crates/biome_analyze/**/lint/**/*.rs : Add a 'deprecated' field to 'declare_lint_rule!' macro when deprecating a rule to communicate the reason for deprecation (e.g., suggesting an alternative rule)

Learnt from: CR
Repo: biomejs/biome PR: 0
File: crates/biome_analyze/CONTRIBUTING.md:0-0
Timestamp: 2026-03-21T17:22:03.949Z
Learning: Applies to crates/biome_analyze/**/lint/**/*.rs : Set rule severity to 'error' for rules in 'correctness', 'security', and 'a11y' groups; use 'warn' or 'info' for other groups based on rule type

Learnt from: CR
Repo: biomejs/biome PR: 0
File: crates/biome_analyze/CONTRIBUTING.md:0-0
Timestamp: 2026-03-21T17:22:03.949Z
Learning: Applies to crates/biome_analyze/**/lint/**/*.rs : Add 'fix_kind: FixKind::Safe' or 'fix_kind: FixKind::Unsafe' to the 'declare_lint_rule!' macro when implementing the 'action' function

Learnt from: CR
Repo: biomejs/biome PR: 0
File: crates/biome_analyze/CONTRIBUTING.md:0-0
Timestamp: 2026-03-21T17:22:03.949Z
Learning: Applies to crates/biome_analyze/**/lint/**/*.rs : Document rule options in a '## Options' section with h3 headers for each option, describing what it does, its default value, and providing code examples

Learnt from: dyc3
Repo: biomejs/biome PR: 8901
File: crates/biome_analyze_macros/src/group_macro.rs:9-9
Timestamp: 2026-01-29T22:10:30.888Z
Learning: Do not import xtask inside biome crates. In the biomeps repository, ensure that biome crates (e.g., crates/biome_*/...) do not have imports like use xtask_glue::... or other xtask-related crates. The correct dependency direction is for xtask to depend on and import biome crates, not the reverse, since xtask contains heavier utilities (e.g., git2) that can inflate build times when included in biome crates. When reviewing, verify dependency declarations (Cargo.toml) and any use statements to confirm xtask crates are not pulled into biome crates; prefer having xtask depend on biome crates and export needed functionality through stable interfaces.


// <input> is focusable unless type="hidden"
if name_matches("input") {
let is_hidden = element
.find_attribute_by_name("type")
.and_then(|attr| attr.value())
.is_some_and(|value| value.trim().eq_ignore_ascii_case("hidden"));
return Some(!is_hidden);
}

// Check contenteditable attribute
Some(has_contenteditable_true(element))
}

/// Returns `true` when the element has a truthy `contenteditable` attribute,
/// making it an editing host (and therefore focusable).
///
/// Per the HTML spec (§6.8.1), `contenteditable` is an enumerated attribute with:
/// - Bare attribute (`<div contenteditable>`) → empty value default = **True** state
/// - `""` (empty string) → empty value default = **True** state
/// - `"true"` → **True** state (editing host)
/// - `"plaintext-only"` → **Plaintext-Only** state (editing host)
/// - `"false"` → **False** state (not editable)
/// - Invalid values (e.g., `"banana"`) → **Inherit** state (not an editing host)
///
/// Ref: <https://html.spec.whatwg.org/multipage/interaction.html#attr-contenteditable>
fn has_contenteditable_true(element: &AnyHtmlElement) -> bool {
element
.find_attribute_by_name("contenteditable")
.is_some_and(|attr| match attr.value() {
None => true, // bare attribute = True state per HTML spec
Some(value) => {
let trimmed = value.trim();
trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("plaintext-only")
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- should generate diagnostics -->
<button aria-hidden="true">Click me</button>
<a href="/" aria-hidden="true">link</a>
<input aria-hidden="true" />
<div tabindex="0" aria-hidden="true"></div>
<select aria-hidden="true"><option>opt</option></select>
<textarea aria-hidden="true">text</textarea>
<div contenteditable="true" aria-hidden="true">editable</div>
<div contenteditable aria-hidden="true">editable bare attr</div>
<div contenteditable="" aria-hidden="true">editable empty string</div>
<area href="/" aria-hidden="true" />
<details aria-hidden="true">details</details>
<summary aria-hidden="true">summary</summary>
Loading