Skip to content
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

feat: add a11y no-noninteractive-element-to-interactive-role #8167

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions site/content/docs/06-accessibility-warnings.md
Expand Up @@ -277,6 +277,17 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t

---

### `a11y-no-noninteractive-element-to-interactive-role`

[WAI-ARIA](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) roles should not be used to convert a non-interactive element to an interactive element. Interactive ARIA roles include `button`, `link`, `checkbox`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `option`, `radio`, `searchbox`, `switch` and `textbox`.

```sv
<!-- A11y: Non-interactive element <h3> cannot have interactive role 'searchbox' -->
<h3 role="searchbox">Button</h3>
```

---

### `a11y-no-noninteractive-tabindex`

Tab key navigation should be limited to elements on the page that can be interacted with.
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/compile/compiler_warnings.ts
Expand Up @@ -119,6 +119,10 @@ export default {
code: 'a11y-no-interactive-element-to-noninteractive-role',
message: `A11y: <${element}> cannot have role '${role}'`
}),
a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({
code: 'a11y-no-noninteractive-element-to-interactive-role',
message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'`
}),
a11y_role_has_required_aria_props: (role: string, props: string[]) => ({
code: 'a11y-role-has-required-aria-props',
message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}`
Expand Down
11 changes: 7 additions & 4 deletions src/compiler/compile/nodes/Element.ts
Expand Up @@ -24,14 +24,13 @@ import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element } from '../utils/a11y';
import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role } from '../utils/a11y';

const aria_attributes = 'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(' ');
const aria_attribute_set = new Set(aria_attributes);

const aria_roles = roles.keys();
const aria_role_set = new Set(aria_roles);
const aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract));

const a11y_required_attributes = {
a: ['href'],
Expand Down Expand Up @@ -567,7 +566,7 @@ export default class Element extends Node {

if (typeof value === 'string') {
value.split(regex_any_repeated_whitespaces).forEach((current_role: ARIARoleDefinitionKey) => {
if (current_role && aria_role_abstract_set.has(current_role)) {
if (current_role && is_abstract_role(current_role)) {
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(current_role));
} else if (current_role && !aria_role_set.has(current_role)) {
const match = fuzzymatch(current_role, aria_roles);
Expand Down Expand Up @@ -607,8 +606,12 @@ export default class Element extends Node {
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) {
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name));
}
});

// no-noninteractive-element-to-interactive-role
if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role)) {
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_to_interactive_role(current_role, this.name));
}
});
}
}

Expand Down
61 changes: 53 additions & 8 deletions src/compiler/compile/utils/a11y.ts
Expand Up @@ -7,7 +7,9 @@ import {
import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query';
import Attribute from '../nodes/Attribute';

const non_abstract_roles = [...roles_map.keys()].filter((name) => !roles_map.get(name).abstract);
const aria_roles = roles_map.keys();
const abstract_roles = new Set(aria_roles.filter(role => roles_map.get(role).abstract));
const non_abstract_roles = aria_roles.filter((name) => !abstract_roles.has(name));

const non_interactive_roles = new Set(
non_abstract_roles
Expand Down Expand Up @@ -40,6 +42,10 @@ export function is_interactive_roles(role: ARIARoleDefinitionKey) {
return interactive_roles.has(role);
}

export function is_abstract_role(role: ARIARoleDefinitionKey) {
return abstract_roles.has(role);
}

const presentation_roles = new Set(['presentation', 'none']);

export function is_presentation_role(role: ARIARoleDefinitionKey) {
Expand All @@ -65,7 +71,7 @@ export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Ma
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];

elementRoles.entries().forEach(([schema, roles]) => {
if ([...roles].every((role) => non_interactive_roles.has(role))) {
if ([...roles].every((role) => role !== 'generic' && non_interactive_roles.has(role))) {
non_interactive_element_role_schemas.push(schema);
}
});
Expand All @@ -82,6 +88,10 @@ const interactive_ax_objects = new Set(
[...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget')
);

const non_interactive_ax_objects = new Set(
[...AXObjects.keys()].filter((name) => ['windows', 'structure'].includes(AXObjects.get(name).type))
);

const interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = [];

elementAXObjects.entries().forEach(([schema, ax_object]) => {
Expand All @@ -90,6 +100,14 @@ elementAXObjects.entries().forEach(([schema, ax_object]) => {
}
});

const non_interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = [];

elementAXObjects.entries().forEach(([schema, ax_object]) => {
if ([...ax_object].every((role) => non_interactive_ax_objects.has(role))) {
non_interactive_element_ax_object_schemas.push(schema);
}
});

function match_schema(
schema: ARIARoleRelationConcept,
tag_name: string,
Expand All @@ -110,35 +128,62 @@ function match_schema(
});
}

export function is_interactive_element(
export enum ElementInteractivity {
Interactive = 'interactive',
NonInteractive = 'non-interactive',
Static = 'static',
}

export function element_interactivity(
tag_name: string,
attribute_map: Map<string, Attribute>
): boolean {
): ElementInteractivity {
if (
interactive_element_role_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return true;
return ElementInteractivity.Interactive;
}

if (
tag_name !== 'header' &&
non_interactive_element_role_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return false;
return ElementInteractivity.NonInteractive;
}

if (
interactive_element_ax_object_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return true;
return ElementInteractivity.Interactive;
}

return false;
if (
non_interactive_element_ax_object_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return ElementInteractivity.NonInteractive;
}

return ElementInteractivity.Static;
}

export function is_interactive_element(tag_name: string, attribute_map: Map<string, Attribute>): boolean {
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive;
}

export function is_non_interactive_element(tag_name: string, attribute_map: Map<string, Attribute>): boolean {
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive;
}

export function is_static_element(tag_name: string, attribute_map: Map<string, Attribute>): boolean {
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static;
}

export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name: string, attribute_map: Map<string, Attribute>) {
Expand Down
Expand Up @@ -71,36 +71,34 @@
<div role="widget" />
<div role="window" />

<!-- HTML elements with an inherent, non-interactive role, assigned an interactive role. -->
<main role="button" />
<area role="button" alt="x" />
<article role="button" />
<article role="button" />
<dd role="button" />
<dfn role="button" />
<dt role="button" />
<fieldset role="button" />
<figure role="button" />
<form role="button" />
<frame role="button" />
<h1 role="button">title</h1>
<h2 role="button">title</h2>
<h3 role="button">title</h3>
<h4 role="button">title</h4>
<h5 role="button">title</h5>
<h6 role="button">title</h6>
<hr role="button" />
<img role="button" alt="x" />
<li role="button" />
<li role="presentation" />
<nav role="button" />
<ol role="button" />
<table role="button" />
<tbody role="button" />
<td role="button" />
<tfoot role="button" />
<thead role="button" />
<ul role="button" />
<!-- VALID: div elements assigned an interactive role. -->
<div role="button" />
<div role="checkbox" aria-checked={true} />
<div role="columnheader" />
<div role="combobox" aria-controls={[]} aria-expanded={true} />
<div role="grid" />
<div role="gridcell" />
<div role="link" />
<div role="listbox" />
<div role="menu" />
<div role="menubar" />
<div role="menuitem" />
<div role="menuitemcheckbox" aria-checked />
<div role="menuitemradio" aria-checked />
<div role="option" aria-selected />
<div role="progressbar" />
<div role="radio" aria-checked />
<div role="radiogroup" />
<div role="row" />
<div role="rowheader" />
<div role="scrollbar" aria-controls={[]} aria-valuenow={0} />
<div role="searchbox" />
<div role="slider" aria-valuenow={0} />
<div role="spinbutton" />
<div role="switch" aria-checked />
<div role="tab" />
<div role="textbox" />
<div role="treeitem" aria-selected={true} />

<!-- HTML elements attributed with a non-interactive role -->
<div role="alert" />
Expand Down
Expand Up @@ -635,76 +635,76 @@
"line": 72
}
},
{
{
"code": "a11y-no-interactive-element-to-noninteractive-role",
"end": {
"column": 28,
"line": 145
"line": 143
},
"message": "A11y: <menuitem> cannot have role 'listitem'",
"start": {
"column": 0,
"line": 145
"line": 143
}
},
{
"code": "a11y-no-interactive-element-to-noninteractive-role",
"end": {
"column": 38,
"line": 146
"line": 144
},
"message": "A11y: <option> cannot have role 'listitem'",
"start": {
"column": 0,
"line": 146
"line": 144
}
},
{
"code": "a11y-no-interactive-element-to-noninteractive-role",
"end": {
"column": 38,
"line": 147
"line": 145
},
"message": "A11y: <select> cannot have role 'listitem'",
"start": {
"column": 0,
"line": 147
"line": 145
}
},
{
"code": "a11y-no-interactive-element-to-noninteractive-role",
"end": {
"column": 27,
"line": 148
"line": 146
},
"message": "A11y: <summary> cannot have role 'listitem'",
"start": {
"column": 0,
"line": 148
"line": 146
}
},
{
"code": "a11y-no-interactive-element-to-noninteractive-role",
"end": {
"column": 40,
"line": 149
"line": 147
},
"message": "A11y: <textarea> cannot have role 'listitem'",
"start": {
"column": 0,
"line": 149
"line": 147
}
},
{
"code": "a11y-no-interactive-element-to-noninteractive-role",
"end": {
"column": 22,
"line": 150
"line": 148
},
"message": "A11y: <tr> cannot have role 'listitem'",
"start": {
"column": 0,
"line": 150
"line": 148
}
}
]