-
-
Notifications
You must be signed in to change notification settings - Fork 964
feat(html): port noAriaHiddenOnFocusable a11y rule to HTML #9496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c183dca
08974d0
183537b
4994131
2e26411
309f915
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ``` |
| 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() | ||
| } | ||
|
|
||
| /// 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.rsRepository: 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 analyzeRepository: 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.rsRepository: 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.rsRepository: 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.rsRepository: 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 -100Repository: 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.rsRepository: 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 +100Repository: 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.htmlRepository: biomejs/biome Length of output: 799 🏁 Script executed: cat crates/biome_html_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/valid.htmlRepository: 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.jsxRepository: biomejs/biome Length of output: 367 🏁 Script executed: cat crates/biome_js_analyze/tests/specs/a11y/noAriaHiddenOnFocusable/invalid.jsxRepository: 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 Per the HTML spec, disabled form controls ( Recommend adding a check: 🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: Length of output: 44
🧠 Learnings used |
||
|
|
||
| // <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> |
Uh oh!
There was an error while loading. Please reload this page.