Skip to content

Commit

Permalink
add no-static-element-interactions compiler rule
Browse files Browse the repository at this point in the history
  • Loading branch information
timmcca-be committed Apr 13, 2023
1 parent c9ccd6e commit 38305ac
Show file tree
Hide file tree
Showing 14 changed files with 277 additions and 61 deletions.
4 changes: 4 additions & 0 deletions src/compiler/compile/compiler_warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`
Expand Down
30 changes: 26 additions & 4 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -738,17 +738,18 @@ 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);
}
}

// 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)));
Expand All @@ -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() {
Expand Down
70 changes: 63 additions & 7 deletions src/compiler/compile/utils/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
);
})
Expand All @@ -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) {
Expand All @@ -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();

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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} />
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,76 @@
"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
},
"end": {
"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
},
"end": {
"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
},
"end": {
"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",
Expand Down Expand Up @@ -63,24 +102,50 @@
"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
},
"end": {
"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
},
"end": {
"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)
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
Loading

0 comments on commit 38305ac

Please sign in to comment.