add no-static-element-interactions compiler rule

pull/8251/head
Tim McCabe 4 years ago
parent c9ccd6e20f
commit 38305ac7ca

@ -115,6 +115,10 @@ export default {
code: 'a11y-no-redundant-roles',
message: `A11y: Redundant role '${role}'`
}),
a11y_no_static_element_interactions: (element: string, handlers: string[]) => ({
code: 'a11y-no-static-element-interactions',
message: `A11y: <${element}> with ${handlers.join(', ')} ${handlers.length === 1 ? 'handler' : 'handlers'} must have an ARIA role`
}),
a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({
code: 'a11y-no-interactive-element-to-noninteractive-role',
message: `A11y: <${element}> cannot have role '${role}'`

@ -25,7 +25,7 @@ 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_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute } from '../utils/a11y';
import { is_non_interactive_element, is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute, is_interactive_handler } 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);
@ -738,8 +738,10 @@ export default class Element extends Node {
}
}
const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey;
// no-noninteractive-tabindex
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) {
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) {
const tab_index = attribute_map.get('tabindex');
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) {
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
@ -747,8 +749,7 @@ export default class Element extends Node {
}
// role-supports-aria-props
const role = attribute_map.get('role');
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
if (typeof role_value === 'string' && roles.has(role_value)) {
const { props } = roles.get(role_value);
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props)));
@ -762,6 +763,27 @@ export default class Element extends Node {
}
});
}
// no-static-element-interactions
// TODO: investigate footer
// TODO: investigate dynamic roles
if (
!is_hidden_from_screen_reader(this.name, attribute_map) &&
!is_presentation_role(role) &&
!is_interactive_element(this.name, attribute_map) &&
!is_interactive_roles(role) &&
!is_non_interactive_element(this.name, attribute_map) &&
!is_non_interactive_roles(role) &&
!is_abstract_role(role)
) {
const interactive_handlers = handlers.map((handler) => handler.name).filter(is_interactive_handler);
if (interactive_handlers.length > 0) {
component.warn(
this,
compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers)
);
}
}
}
validate_special_cases() {

@ -19,7 +19,8 @@ const non_interactive_roles = new Set(
// 'toolbar' does not descend from widget, but it does support
// aria-activedescendant, thus in practice we treat it as a widget.
// focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable.
!['toolbar', 'tabpanel'].includes(name) &&
// 'generic' is meant to have no semantic meaning.
!['toolbar', 'tabpanel', 'generic'].includes(name) &&
!role.superClass.some((classes) => classes.includes('widget'))
);
})
@ -31,7 +32,11 @@ const non_interactive_roles = new Set(
);
const interactive_roles = new Set(
non_abstract_roles.filter((name) => !non_interactive_roles.has(name))
non_abstract_roles.filter((name) =>
!non_interactive_roles.has(name) &&
// 'generic' is meant to have no semantic meaning.
name !== 'generic'
)
);
export function is_non_interactive_roles(role: ARIARoleDefinitionKey) {
@ -52,7 +57,10 @@ export function is_presentation_role(role: ARIARoleDefinitionKey) {
return presentation_roles.has(role);
}
export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Map<string, Attribute>) {
export function is_hidden_from_screen_reader(
tag_name: string,
attribute_map: Map<string, Attribute>
) {
if (tag_name === 'input') {
const type = attribute_map.get('type')?.get_static_value();
@ -204,11 +212,21 @@ export function is_static_element(tag_name: string, attribute_map: Map<string, A
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>) {
export function is_semantic_role_element(
role: ARIARoleDefinitionKey,
tag_name: string,
attribute_map: Map<string, Attribute>
) {
for (const [schema, ax_object] of elementAXObjects.entries()) {
if (schema.name === tag_name && (!schema.attributes || schema.attributes.every(
(attr) => attribute_map.has(attr.name) && attribute_map.get(attr.name).get_static_value() === attr.value
))) {
if (
schema.name === tag_name &&
(!schema.attributes ||
schema.attributes.every(
(attr) =>
attribute_map.has(attr.name) &&
attribute_map.get(attr.name).get_static_value() === attr.value
))
) {
for (const name of ax_object) {
const roles = AXObjectRoles.get(name);
if (roles) {
@ -223,3 +241,41 @@ export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name:
}
return false;
}
const interactive_handlers = new Set([
// Focus
'focus',
'focusin',
'focusout',
'blur',
// Keyboard
'keydown',
'keypress',
'keyup',
// Mouse
'auxclick',
'click',
'contextmenu',
'dblclick',
'drag',
'dragend',
'dragenter',
'dragexit',
'dragleave',
'dragover',
'dragstart',
'drop',
'mousedown',
'mouseenter',
'mouseleave',
'mousemove',
'mouseout',
'mouseover',
'mouseup'
]);
export function is_interactive_handler(handler: string) {
return interactive_handlers.has(handler);
}

@ -9,16 +9,21 @@
</script>
<!-- should warn -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden="false" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<section on:click={noop} />
<main on:click={noop} />
<article on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<header on:click={noop} />
<footer on:click={noop} />
<!-- should not warn -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="foo" />
<a href="http://x.y.z" on:click={noop}>foo</a>
@ -28,24 +33,37 @@
<input type="button" on:click={noop} />
<input type={dynamicTypeValue} on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} {...props} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keydown={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keyup={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keypress={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keydown={noop} on:keyup={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keyup={noop} on:keypress={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keypress={noop} on:keydown={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keydown={noop} on:keyup={noop} on:keypress={noop} />
<input on:click={noop} type="hidden" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden="true" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden="false" on:keydown={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden={dynamicAriaHiddenValue} />
<div on:click={noop} role="presentation" />
<div on:click={noop} role="none" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} role={dynamicRole} />
<svelte:element this={Math.random() ? 'button' : 'div'} on:click={noop} />

@ -3,6 +3,7 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
<<<<<<< HEAD
"line": 12,
"column": 0
},
@ -10,11 +11,24 @@
"line": 12,
"column": 23
}
=======
"line": 13,
"column": 0,
"character": 249
},
"end": {
"line": 13,
"column": 23,
"character": 272
},
"pos": 249
>>>>>>> 752aa35ed (add no-static-element-interactions compiler rule)
},
{
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
<<<<<<< HEAD
"line": 13,
"column": 0
},
@ -22,11 +36,24 @@
"line": 13,
"column": 43
}
=======
"line": 15,
"column": 0,
"character": 332
},
"end": {
"line": 15,
"column": 43,
"character": 375
},
"pos": 332
>>>>>>> 752aa35ed (add no-static-element-interactions compiler rule)
},
{
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
<<<<<<< HEAD
"line": 15,
"column": 0
},
@ -34,6 +61,18 @@
"line": 15,
"column": 27
}
=======
"line": 18,
"column": 0,
"character": 436
},
"end": {
"line": 18,
"column": 27,
"character": 463
},
"pos": 436
>>>>>>> 752aa35ed (add no-static-element-interactions compiler rule)
},
{
"code": "a11y-click-events-have-key-events",
@ -63,6 +102,7 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
<<<<<<< HEAD
"line": 18,
"column": 0
},
@ -70,11 +110,24 @@
"line": 18,
"column": 26
}
=======
"line": 22,
"column": 0,
"character": 576
},
"end": {
"line": 22,
"column": 26,
"character": 602
},
"pos": 576
>>>>>>> 752aa35ed (add no-static-element-interactions compiler rule)
},
{
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
<<<<<<< HEAD
"line": 19,
"column": 0
},
@ -82,5 +135,17 @@
"line": 19,
"column": 26
}
=======
"line": 23,
"column": 0,
"character": 603
},
"end": {
"line": 23,
"column": 26,
"character": 629
},
"pos": 603
>>>>>>> 752aa35ed (add no-static-element-interactions compiler rule)
}
]

@ -7,9 +7,15 @@
};
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseover={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseover={() => void 0} on:focus={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseover={() => void 0} {...otherProps} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseout={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseout={() => void 0} on:blur={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseout={() => void 0} {...otherProps} />

@ -1,50 +1,50 @@
[
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 35,
"line": 10
},
"message": "A11y: on:mouseover must be accompanied by on:focus",
"start": {
"column": 0,
"line": 10
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 51,
"line": 12
},
"message": "A11y: on:mouseover must be accompanied by on:focus",
"start": {
"column": 0,
"line": 12
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 34,
"line": 13
},
"message": "A11y: on:mouseout must be accompanied by on:blur",
"start": {
"column": 0,
"line": 13
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 50,
"line": 15
},
"message": "A11y: on:mouseout must be accompanied by on:blur",
"start": {
"column": 0,
"line": 15
}
}
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 35,
"line": 10
},
"message": "A11y: on:mouseover must be accompanied by on:focus",
"start": {
"column": 0,
"line": 10
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 51,
"line": 12
},
"message": "A11y: on:mouseover must be accompanied by on:focus",
"start": {
"column": 0,
"line": 12
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 34,
"line": 13
},
"message": "A11y: on:mouseout must be accompanied by on:blur",
"start": {
"column": 0,
"line": 13
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 50,
"line": 15
},
"message": "A11y: on:mouseout must be accompanied by on:blur",
"start": {
"column": 0,
"line": 15
}
}
]

@ -0,0 +1,10 @@
<!-- valid -->
<button on:click={() => {}} />
<div on:keydown={() => {}} role="button" />
<input type="text" on:click={() => {}} />
<div on:copy={() => {}} />
<a href="/foo" on:click={() => {}}>link</a>
<!-- invalid -->
<div on:keydown={() => {}} />
<!-- svelte-ignore a11y-missing-attribute -->
<a on:mousedown={() => {}} on:mouseup={() => {}} on:copy={() => {}}>link</a>

@ -0,0 +1,32 @@
[
{
"code": "a11y-no-static-element-interactions",
"end": {
"character": 249,
"column": 29,
"line": 8
},
"message": "A11y: <div> with keydown handler must have an ARIA role",
"pos": 220,
"start": {
"character": 220,
"column": 0,
"line": 8
}
},
{
"code": "a11y-no-static-element-interactions",
"end": {
"character": 372,
"column": 76,
"line": 10
},
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
"pos": 296,
"start": {
"character": 296,
"column": 0,
"line": 10
}
}
]

@ -4,5 +4,6 @@
<Component>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div>
</Component>

@ -3,5 +3,6 @@
</script>
<Component>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div>
</Component>

@ -4,7 +4,7 @@
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"column": 1,
"line": 6
"line": 7
},
"end": {
"column": 35,

@ -4,5 +4,6 @@
<Component>
<!-- svelte-ignore unrelated-warning -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div>
</Component>

@ -4,7 +4,7 @@
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"column": 1,
"line": 7
"line": 8
},
"end": {
"column": 35,

Loading…
Cancel
Save