Skip to content

Commit

Permalink
feat: add a11y-no-static-element-interactions compiler rule (#8251)
Browse files Browse the repository at this point in the history
Ref: #820
  • Loading branch information
timmcca-be authored Apr 14, 2023
1 parent 56a6738 commit 9460616
Show file tree
Hide file tree
Showing 14 changed files with 135 additions and 31 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
31 changes: 28 additions & 3 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
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,30 @@ export default class Element extends Node {
}
});
}

const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static;

// no-static-element-interactions
if (
!has_dynamic_role &&
!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((handlerName) => a11y_interactive_handlers.has(handlerName));
if (interactive_handlers.length > 0) {
component.warn(
this,
compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers)
);
}
}
}

validate_special_cases() {
Expand Down
9 changes: 7 additions & 2 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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
</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} />

Expand All @@ -28,24 +32,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" />
<div on:click={noop} role={dynamicRole} />

<!-- svelte-ignore a11y-no-static-element-interactions -->
<svelte:element this={Math.random() ? 'button' : 'div'} on:click={noop} />
Original file line number Diff line number Diff line change
Expand Up @@ -3,83 +3,83 @@
"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": {
"line": 12,
"line": 13,
"column": 0
},
"end": {
"line": 12,
"line": 13,
"column": 23
}
},
{
"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": {
"line": 13,
"line": 15,
"column": 0
},
"end": {
"line": 13,
"line": 15,
"column": 43
}
},
{
"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": {
"line": 15,
"line": 18,
"column": 0
},
"end": {
"line": 15,
"line": 18,
"column": 27
}
},
{
"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": {
"line": 16,
"line": 19,
"column": 0
},
"end": {
"line": 16,
"line": 19,
"column": 24
}
},
{
"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": {
"line": 17,
"line": 20,
"column": 0
},
"end": {
"line": 17,
"line": 20,
"column": 27
}
},
{
"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": {
"line": 18,
"line": 22,
"column": 0
},
"end": {
"line": 18,
"line": 22,
"column": 26
}
},
{
"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": {
"line": 19,
"line": 23,
"column": 0
},
"end": {
"line": 19,
"line": 23,
"column": 26
}
}
Expand Down
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} />
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,48 @@
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 35,
"line": 10
"line": 11
},
"message": "A11y: on:mouseover must be accompanied by on:focus",
"start": {
"column": 0,
"line": 10
"line": 11
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 51,
"line": 12
"line": 15
},
"message": "A11y: on:mouseover must be accompanied by on:focus",
"start": {
"column": 0,
"line": 12
"line": 15
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 34,
"line": 13
"line": 17
},
"message": "A11y: on:mouseout must be accompanied by on:blur",
"start": {
"column": 0,
"line": 13
"line": 17
}
},
{
"code": "a11y-mouse-events-have-key-events",
"end": {
"column": 50,
"line": 15
"line": 21
},
"message": "A11y: on:mouseout must be accompanied by on:blur",
"start": {
"column": 0,
"line": 15
"line": 21
}
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script>
const dynamicRole = "button";
</script>

<!-- valid -->
<button on:click={() => {}} />
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div on:keydown={() => {}} role="button" />
<input type="text" on:click={() => {}} />
<div on:copy={() => {}} />
<a href="/foo" on:click={() => {}}>link</a>
<div role={dynamicRole} on:click={() => {}} />
<footer on:keydown={() => {}} />

<!-- invalid -->
<div on:keydown={() => {}} />
<!-- svelte-ignore a11y-missing-attribute -->
<a on:mousedown={() => {}} on:mouseup={() => {}} on:copy={() => {}}>link</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[
{
"code": "a11y-no-static-element-interactions",
"end": {
"column": 29,
"line": 16
},
"message": "A11y: <div> with keydown handler must have an ARIA role",
"start": {
"column": 0,
"line": 16
}
},
{
"code": "a11y-no-static-element-interactions",
"end": {
"column": 76,
"line": 18
},
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
"start": {
"column": 0,
"line": 18
}
}
]
1 change: 1 addition & 0 deletions test/validator/samples/slot-warning-ignore/input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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>
1 change: 1 addition & 0 deletions test/validator/samples/slot-warning/input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
</script>

<Component>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div>
</Component>
4 changes: 2 additions & 2 deletions test/validator/samples/slot-warning/warnings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"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,
"line": 6
"line": 7
}
}
]
1 change: 1 addition & 0 deletions test/validator/samples/slot-warning2/input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

<Component>
<!-- svelte-ignore unrelated-warning -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div slot='foo' on:click>hi!</div>
</Component>
Loading

0 comments on commit 9460616

Please sign in to comment.