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', code: 'a11y-no-redundant-roles',
message: `A11y: Redundant role '${role}'` 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) => ({ a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({
code: 'a11y-no-interactive-element-to-noninteractive-role', code: 'a11y-no-interactive-element-to-noninteractive-role',
message: `A11y: <${element}> cannot have role '${role}'` message: `A11y: <${element}> cannot have role '${role}'`

@ -25,7 +25,7 @@ import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings'; import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors'; import compiler_errors from '../compiler_errors';
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; 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_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_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 // 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'); const tab_index = attribute_map.get('tabindex');
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) { if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) {
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex); component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
@ -747,8 +749,7 @@ export default class Element extends Node {
} }
// role-supports-aria-props // role-supports-aria-props
const role = attribute_map.get('role'); const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
if (typeof role_value === 'string' && roles.has(role_value)) { if (typeof role_value === 'string' && roles.has(role_value)) {
const { props } = roles.get(role_value); const { props } = roles.get(role_value);
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props))); 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() { validate_special_cases() {

@ -19,7 +19,8 @@ const non_interactive_roles = new Set(
// 'toolbar' does not descend from widget, but it does support // 'toolbar' does not descend from widget, but it does support
// aria-activedescendant, thus in practice we treat it as a widget. // 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. // 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')) !role.superClass.some((classes) => classes.includes('widget'))
); );
}) })
@ -31,7 +32,11 @@ const non_interactive_roles = new Set(
); );
const 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) { export function is_non_interactive_roles(role: ARIARoleDefinitionKey) {
@ -52,7 +57,10 @@ export function is_presentation_role(role: ARIARoleDefinitionKey) {
return presentation_roles.has(role); 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') { if (tag_name === 'input') {
const type = attribute_map.get('type')?.get_static_value(); 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; 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()) { for (const [schema, ax_object] of elementAXObjects.entries()) {
if (schema.name === tag_name && (!schema.attributes || schema.attributes.every( if (
(attr) => attribute_map.has(attr.name) && attribute_map.get(attr.name).get_static_value() === attr.value 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) { for (const name of ax_object) {
const roles = AXObjectRoles.get(name); const roles = AXObjectRoles.get(name);
if (roles) { if (roles) {
@ -223,3 +241,41 @@ export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name:
} }
return false; 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> </script>
<!-- should warn --> <!-- should warn -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} /> <div on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden="false" /> <div on:click={noop} aria-hidden="false" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<section on:click={noop} /> <section on:click={noop} />
<main on:click={noop} /> <main on:click={noop} />
<article on:click={noop} /> <article on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<header on:click={noop} /> <header on:click={noop} />
<footer on:click={noop} /> <footer on:click={noop} />
<!-- should not warn --> <!-- should not warn -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="foo" /> <div class="foo" />
<a href="http://x.y.z" on:click={noop}>foo</a> <a href="http://x.y.z" on:click={noop}>foo</a>
@ -28,24 +33,37 @@
<input type="button" on:click={noop} /> <input type="button" on:click={noop} />
<input type={dynamicTypeValue} on:click={noop} /> <input type={dynamicTypeValue} on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} {...props} /> <div on:click={noop} {...props} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keydown={noop} /> <div on:click={noop} on:keydown={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keyup={noop} /> <div on:click={noop} on:keyup={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keypress={noop} /> <div on:click={noop} on:keypress={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} on:keydown={noop} on:keyup={noop} /> <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} /> <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} /> <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} /> <div on:click={noop} on:keydown={noop} on:keyup={noop} on:keypress={noop} />
<input on:click={noop} type="hidden" /> <input on:click={noop} type="hidden" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden /> <div on:click={noop} aria-hidden />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden="true" /> <div on:click={noop} aria-hidden="true" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} aria-hidden="false" on:keydown={noop} /> <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} aria-hidden={dynamicAriaHiddenValue} />
<div on:click={noop} role="presentation" /> <div on:click={noop} role="presentation" />
<div on:click={noop} role="none" /> <div on:click={noop} role="none" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click={noop} role={dynamicRole} /> <div on:click={noop} role={dynamicRole} />
<svelte:element this={Math.random() ? 'button' : 'div'} on:click={noop} /> <svelte:element this={Math.random() ? 'button' : 'div'} on:click={noop} />

@ -3,6 +3,7 @@
"code": "a11y-click-events-have-key-events", "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.", "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": { "start": {
<<<<<<< HEAD
"line": 12, "line": 12,
"column": 0 "column": 0
}, },
@ -10,11 +11,24 @@
"line": 12, "line": 12,
"column": 23 "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", "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.", "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": { "start": {
<<<<<<< HEAD
"line": 13, "line": 13,
"column": 0 "column": 0
}, },
@ -22,11 +36,24 @@
"line": 13, "line": 13,
"column": 43 "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", "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.", "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": { "start": {
<<<<<<< HEAD
"line": 15, "line": 15,
"column": 0 "column": 0
}, },
@ -34,6 +61,18 @@
"line": 15, "line": 15,
"column": 27 "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", "code": "a11y-click-events-have-key-events",
@ -63,6 +102,7 @@
"code": "a11y-click-events-have-key-events", "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.", "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": { "start": {
<<<<<<< HEAD
"line": 18, "line": 18,
"column": 0 "column": 0
}, },
@ -70,11 +110,24 @@
"line": 18, "line": 18,
"column": 26 "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", "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.", "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": { "start": {
<<<<<<< HEAD
"line": 19, "line": 19,
"column": 0 "column": 0
}, },
@ -82,5 +135,17 @@
"line": 19, "line": 19,
"column": 26 "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> </script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseover={() => void 0} /> <div on:mouseover={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseover={() => void 0} on:focus={() => void 0} /> <div on:mouseover={() => void 0} on:focus={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseover={() => void 0} {...otherProps} /> <div on:mouseover={() => void 0} {...otherProps} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseout={() => void 0} /> <div on:mouseout={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseout={() => void 0} on:blur={() => void 0} /> <div on:mouseout={() => void 0} on:blur={() => void 0} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:mouseout={() => void 0} {...otherProps} /> <div on:mouseout={() => void 0} {...otherProps} />

@ -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> <Component>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div> <div slot='foo' on:click>hi!</div>
</Component> </Component>

@ -3,5 +3,6 @@
</script> </script>
<Component> <Component>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div> <div slot='foo' on:click>hi!</div>
</Component> </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.", "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": { "start": {
"column": 1, "column": 1,
"line": 6 "line": 7
}, },
"end": { "end": {
"column": 35, "column": 35,

@ -4,5 +4,6 @@
<Component> <Component>
<!-- svelte-ignore unrelated-warning --> <!-- svelte-ignore unrelated-warning -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div> <div slot='foo' on:click>hi!</div>
</Component> </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.", "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": { "start": {
"column": 1, "column": 1,
"line": 7 "line": 8
}, },
"end": { "end": {
"column": 35, "column": 35,

Loading…
Cancel
Save