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-static-element-interactions compiler rule #8251

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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>