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 +
+
+ +