diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts
index bfcd779fc7..86ec98cb8b 100644
--- a/src/compiler/compile/compiler_warnings.ts
+++ b/src/compiler/compile/compiler_warnings.ts
@@ -76,6 +76,10 @@ export default {
code: 'a11y-unknown-role',
message: `A11y: Unknown role '${role}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '')
}),
+ a11y_no_redundant_roles: (role: string | boolean) => ({
+ code: 'a11y-no-redundant-roles',
+ message: `A11y: Redundant role '${role}'`
+ }),
a11y_accesskey: {
code: 'a11y-accesskey',
message: 'A11y: Avoid using accesskey'
diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts
index 837a0296d0..bf81599bf6 100644
--- a/src/compiler/compile/nodes/Element.ts
+++ b/src/compiler/compile/nodes/Element.ts
@@ -70,6 +70,46 @@ const a11y_labelable = new Set([
'textarea'
]);
+const a11y_nested_implicit_semantics = new Map([
+ ['header', 'banner'],
+ ['footer', 'contentinfo']
+]);
+
+const a11y_implicit_semantics = new Map([
+ ['a', 'link'],
+ ['aside', 'complementary'],
+ ['body', 'document'],
+ ['datalist', 'listbox'],
+ ['dd', 'definition'],
+ ['dfn', 'term'],
+ ['details', 'group'],
+ ['dt', 'term'],
+ ['fieldset', 'group'],
+ ['form', 'form'],
+ ['h1', 'heading'],
+ ['h2', 'heading'],
+ ['h3', 'heading'],
+ ['h4', 'heading'],
+ ['h5', 'heading'],
+ ['h6', 'heading'],
+ ['hr', 'separator'],
+ ['li', 'listitem'],
+ ['menu', 'list'],
+ ['nav', 'navigation'],
+ ['ol', 'list'],
+ ['optgroup', 'group'],
+ ['output', 'status'],
+ ['progress', 'progressbar'],
+ ['section', 'region'],
+ ['summary', 'button'],
+ ['tbody', 'rowgroup'],
+ ['textarea', 'textbox'],
+ ['tfoot', 'rowgroup'],
+ ['thead', 'rowgroup'],
+ ['tr', 'row'],
+ ['ul', 'list']
+]);
+
const invisible_elements = new Set(['meta', 'html', 'script', 'style']);
const valid_modifiers = new Set([
@@ -98,6 +138,23 @@ const react_attributes = new Map([
const attributes_to_compact_whitespace = ['class', 'style'];
+function is_parent(parent: INode, elements: string[]) {
+ let check = false;
+
+ while (parent) {
+ const parent_name = (parent as Element).name;
+ if (elements.includes(parent_name)) {
+ check = true;
+ break;
+ }
+ if (parent.type === 'Element') {
+ break;
+ }
+ parent = parent.parent;
+ }
+ return check;
+}
+
function get_namespace(parent: Element, element: Element, explicit_namespace: string) {
const parent_element = parent.find_nearest(/^Element/);
@@ -351,6 +408,22 @@ export default class Element extends Node {
const match = fuzzymatch(value, aria_roles);
component.warn(attribute, compiler_warnings.a11y_unknown_role(value, match));
}
+
+ // no-redundant-roles
+ const has_redundant_role = value === a11y_implicit_semantics.get(this.name);
+
+ if (this.name === value || has_redundant_role) {
+ component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(value));
+ }
+
+ // Footers and headers are special cases, and should not have redundant roles unless they are the children of sections or articles.
+ const is_parent_section_or_article = is_parent(this.parent, ['section', 'article']);
+ if (!is_parent_section_or_article) {
+ const has_nested_redundant_role = value === a11y_nested_implicit_semantics.get(this.name);
+ if (has_nested_redundant_role) {
+ component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(value));
+ }
+ }
}
// no-access-key
diff --git a/test/validator/samples/a11y-no-redundant-roles/input.svelte b/test/validator/samples/a11y-no-redundant-roles/input.svelte
new file mode 100644
index 0000000000..05525effb6
--- /dev/null
+++ b/test/validator/samples/a11y-no-redundant-roles/input.svelte
@@ -0,0 +1,44 @@
+a link
+
+
+