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
10 changes: 10 additions & 0 deletions .changeset/html-no-redundant-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@biomejs/biome": minor
---

Added the HTML lint rule [`noRedundantRoles`](https://biomejs.dev/linter/rules/no-redundant-roles/). This rule enforces that explicit `role` attributes are not the same as the implicit/default role of an HTML element. It supports HTML, Vue, Svelte, and Astro files.

```html
<!-- Invalid: role="button" is redundant on <button> -->
<button role="button"></button>
```
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_html_analyze/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ name = "html_analyzer"
[dependencies]
biome_analyze = { workspace = true }
biome_analyze_macros = { workspace = true }
biome_aria = { workspace = true }
biome_aria_metadata = { workspace = true }
biome_console = { workspace = true }
biome_deserialize = { workspace = true }
Expand Down
130 changes: 130 additions & 0 deletions crates/biome_html_analyze/src/lint/a11y/no_redundant_roles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use biome_analyze::{
Ast, FixKind, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_aria::AriaRoles;
use biome_aria_metadata::AriaRole;
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_html_syntax::{AnyHtmlElement, HtmlAttribute, HtmlFileSource};
use biome_rowan::{AstNode, BatchMutationExt, Text};
use biome_rule_options::no_redundant_roles::NoRedundantRolesOptions;

use crate::HtmlRuleAction;

declare_lint_rule! {
/// Enforce explicit `role` property is not the same as implicit/default role property on an element.
///
/// :::note
/// In `.html` files, all elements are treated as native HTML elements.
///
/// In component-based frameworks (Vue, Svelte, Astro), only native HTML element names are checked.
/// PascalCase names like `<Button>` and kebab-case names like `<my-button>` are assumed to be
/// custom components and are ignored.
/// :::
///
/// ## Examples
///
/// ### Invalid
///
/// ```html,expect_diagnostic
/// <article role="article"></article>
/// ```
///
/// ```html,expect_diagnostic
/// <button role="button"></button>
/// ```
///
/// ```html,expect_diagnostic
/// <h1 role="heading" aria-level="1">title</h1>
/// ```
///
/// ### Valid
///
/// ```html
/// <article role="presentation"></article>
/// ```
///
/// ```html
/// <span></span>
/// ```
///
pub NoRedundantRoles {
version: "next",
name: "noRedundantRoles",
language: "html",
sources: &[RuleSource::EslintJsxA11y("no-redundant-roles").same(), RuleSource::HtmlEslint("no-redundant-role").same()],
recommended: true,
severity: Severity::Error,
fix_kind: FixKind::Unsafe,
}
}

impl Rule for NoRedundantRoles {
type Query = Ast<AnyHtmlElement>;
type State = RuleState;
type Signals = Option<Self::State>;
type Options = NoRedundantRolesOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();

let source_type = ctx.source_type::<HtmlFileSource>();
if !source_type.is_html() {
let element_name = node.name()?;
let name_text = element_name.text();
if name_text.chars().next().is_some_and(|c| c.is_uppercase())
|| name_text.contains('-')
{
return None;
}
}

let role_attribute = node.find_attribute_by_name("role")?;
let role_attribute_value = role_attribute.initializer()?.value().ok()?.string_value()?;
let trimmed = role_attribute_value.trim();
let explicit_role = AriaRole::from_roles(trimmed)?;

if AriaRoles.get_implicit_role(node)? == explicit_role {
let has_multiple_roles = trimmed.split_ascii_whitespace().nth(1).is_some();
return Some(RuleState {
redundant_attribute: role_attribute,
role_attribute_value,
has_multiple_roles,
});
}
None
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let element_name = ctx.query().name()?;
let element_name = element_name.text();
let role_attribute = state.role_attribute_value.to_string();
Some(RuleDiagnostic::new(
rule_category!(),
state.redundant_attribute.range(),
markup! {
"Using the role attribute '"{role_attribute}"' on the '"{element_name}"' element is redundant, because it is implied by its semantics."
},
))
}

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

pub struct RuleState {
redundant_attribute: HtmlAttribute,
role_attribute_value: Text,
has_multiple_roles: bool,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
// Astro frontmatter
---

<!-- Native elements with redundant roles should still trigger -->
<article role="article"></article>
<button role="button"></button>
<h1 role="heading" aria-level="1">title</h1>
<nav role="navigation"></nav>
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
source: crates/biome_html_analyze/tests/spec_tests.rs
expression: invalid.astro
---
# Input
```html
---
// Astro frontmatter
---

<!-- Native elements with redundant roles should still trigger -->
<article role="article"></article>
<button role="button"></button>
<h1 role="heading" aria-level="1">title</h1>
<nav role="navigation"></nav>

```

# Diagnostics
```
invalid.astro:6:10 lint/a11y/noRedundantRoles FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Using the role attribute 'article' on the 'article' element is redundant, because it is implied by its semantics.

5 │ <!-- Native elements with redundant roles should still trigger -->
> 6 │ <article role="article"></article>
│ ^^^^^^^^^^^^^^
7 │ <button role="button"></button>
8 │ <h1 role="heading" aria-level="1">title</h1>

i Unsafe fix: Remove the role attribute.

6 │ <article·role="article"></article>
│ --------------

```

```
invalid.astro:7:9 lint/a11y/noRedundantRoles FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Using the role attribute 'button' on the 'button' element is redundant, because it is implied by its semantics.

5 │ <!-- Native elements with redundant roles should still trigger -->
6 │ <article role="article"></article>
> 7 │ <button role="button"></button>
│ ^^^^^^^^^^^^^
8 │ <h1 role="heading" aria-level="1">title</h1>
9 │ <nav role="navigation"></nav>

i Unsafe fix: Remove the role attribute.

7 │ <button·role="button"></button>
│ -------------

```

```
invalid.astro:8:5 lint/a11y/noRedundantRoles FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Using the role attribute 'heading' on the 'h1' element is redundant, because it is implied by its semantics.

6 │ <article role="article"></article>
7 │ <button role="button"></button>
> 8 │ <h1 role="heading" aria-level="1">title</h1>
│ ^^^^^^^^^^^^^^
9 │ <nav role="navigation"></nav>
10 │

i Unsafe fix: Remove the role attribute.

8 │ <h1·role="heading"·aria-level="1">title</h1>
│ ---------------

```

```
invalid.astro:9:6 lint/a11y/noRedundantRoles FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Using the role attribute 'navigation' on the 'nav' element is redundant, because it is implied by its semantics.

7 │ <button role="button"></button>
8 │ <h1 role="heading" aria-level="1">title</h1>
> 9 │ <nav role="navigation"></nav>
│ ^^^^^^^^^^^^^^^^^
10 │

i Unsafe fix: Remove the role attribute.

9 │ <nav·role="navigation"></nav>
│ -----------------

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
// Astro frontmatter
---

<!-- Custom components should not trigger the rule -->
<Button role="button"></Button>
<Nav role="navigation"></Nav>
<Article role="article"></Article>
<Dialog role="dialog"></Dialog>
<Form role="form"></Form>
<Table role="table"></Table>

<!-- Kebab-case custom elements should not trigger the rule -->
<my-button role="button"></my-button>
<my-nav role="navigation"></my-nav>
<my-article role="article"></my-article>

<!-- Native elements with non-redundant roles -->
<article role="presentation"></article>
<span></span>
<div></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
source: crates/biome_html_analyze/tests/spec_tests.rs
expression: valid.astro
---
# Input
```html
---
// Astro frontmatter
---

<!-- Custom components should not trigger the rule -->
<Button role="button"></Button>
<Nav role="navigation"></Nav>
<Article role="article"></Article>
<Dialog role="dialog"></Dialog>
<Form role="form"></Form>
<Table role="table"></Table>

<!-- Kebab-case custom elements should not trigger the rule -->
<my-button role="button"></my-button>
<my-nav role="navigation"></my-nav>
<my-article role="article"></my-article>

<!-- Native elements with non-redundant roles -->
<article role="presentation"></article>
<span></span>
<div></div>

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<article role="article"></article>
<button role="button"></button>
<h1 role="heading" aria-level="1">title</h1>
<dialog role="dialog"></dialog>
<input type="checkbox" role="checkbox" />
<figure role="figure"></figure>
<form role="form"></form>
<fieldset role="group"></fieldset>
<img src="foo" alt="bar" role="img" />
<img alt="" role="presentation" />
<a href="#" role="link"></a>
<ol role="list"></ol>
<ul role="list"></ul>
<select name="name" role="combobox"></select>
<select name="name" multiple size="4" role="listbox"></select>
<li role="listitem"></li>
<nav role="navigation"></nav>
<tr role="row"></tr>
<tbody role="rowgroup"></tbody>
<tfoot role="rowgroup"></tfoot>
<thead role="rowgroup"></thead>
<input type="search" role="searchbox" />
<table role="table"></table>
<textarea role="textbox"></textarea>
<input type="text" role="textbox" />
<button role="button presentation"></button>
<nav role="navigation link"></nav>
Loading