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 interactive-supports-focus #8392

Merged
merged 8 commits into from
Mar 20, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions site/content/docs/06-accessibility-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ Enforce that attributes important for accessibility have a valid value. For exam

---

### `a11y-interactive-supports-focus`

Enforce that elements with an interactive role and interactive handlers (mouse or key press) must be focusable or tabbable.

```sv
<!-- A11y: Elements with the 'button' interactive role must have a tabindex value. -->
<div role="button" on:keypress={() => {}} />
```

---

### `a11y-label-has-associated-control`

Enforce that a label tag has a text label and an associated control.
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/compile/compiler_warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export default {
code: 'a11y-img-redundant-alt',
message: 'A11y: Screenreaders already announce <img> elements as an image.'
},
a11y_interactive_supports_focus: (role: string) => ({
code: 'a11y-interactive-supports-focus',
message: `A11y: Elements with the '${role}' interactive role must have a tabindex value.`
}),
a11y_label_has_associated_control: {
code: 'a11y-label-has-associated-control',
message: 'A11y: A form label must be associated with a control.'
Expand Down
44 changes: 43 additions & 1 deletion 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 } from '../utils/a11y';
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';

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 @@ -75,6 +75,33 @@ const a11y_labelable = new Set([
'textarea'
]);

const a11y_interactive_handlers = new Set([
// Keyboard events
'keypress',
'keydown',
'keyup',

// Click events
'click',
'contextmenu',
'dblclick',
'drag',
'dragend',
'dragenter',
'dragexit',
'dragleave',
'dragover',
'dragstart',
'drop',
'mousedown',
'mouseenter',
'mouseleave',
'mousemove',
'mouseout',
'mouseover',
'mouseup'
]);

const a11y_nested_implicit_semantics = new Map([
['header', 'banner'],
['footer', 'contentinfo']
Expand Down Expand Up @@ -603,6 +630,21 @@ export default class Element extends Node {
}
}

// interactive-supports-focus
if (
!has_disabled_attribute(attribute_map) &&
!is_hidden_from_screen_reader(this.name, attribute_map) &&
!is_presentation_role(current_role) &&
is_interactive_roles(current_role) &&
is_static_element(this.name, attribute_map) &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[memo]

This element doesn't throw the warning because the <span> is not static element.

<script>
  const role = "button";
</script>

<span on:click={() => {}} {role}>Submit</span>

!attribute_map.get('tabindex')
) {
const has_interactive_handlers = handlers.some((handler) => a11y_interactive_handlers.has(handler.name));
if (has_interactive_handlers) {
component.warn(this, compiler_warnings.a11y_interactive_supports_focus(current_role));
}
}

// no-interactive-element-to-noninteractive-role
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) {
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name));
Expand Down
18 changes: 18 additions & 0 deletions src/compiler/compile/utils/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Ma
return aria_hidden_value === true || aria_hidden_value === 'true';
}

export function has_disabled_attribute(attribute_map: Map<string, Attribute>) {
const disabled_attr = attribute_map.get('disabled');
const disabled_attr_value = disabled_attr && disabled_attr.get_static_value();
if (disabled_attr_value) {
return true;
}

const aria_disabled_attr = attribute_map.get('aria-disabled');
if (aria_disabled_attr) {
const aria_disabled_attr_value = aria_disabled_attr.get_static_value();
if (aria_disabled_attr_value === true) {
return true;
}
}

return false;
}

const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];

elementRoles.entries().forEach(([schema, roles]) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!-- VALID -->
<div aria-hidden role="button" on:keypress={() => {}} />
<div aria-disabled role="button" on:keypress={() => {}} />
<div disabled role="button" on:keypress={() => {}} />
<div role="presentation" on:keypress={() => {}} />
<button on:click={() => {}} />
<div role="menuitem" tabindex="0" on:click={() => {}} on:keypress={() => {}} />
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}} />

<!-- INVALID -->
<div role="button" on:keypress={() => {}} />
<span role="menuitem" on:keydown={() => {}} />
<div role="button" on:keyup={() => {}} />
<span role="menuitem" on:click={() => {}} on:keypress={() => {}} />
<div role="button" on:contextmenu={() => {}} />
<span role="menuitem" on:dblclick={() => {}} />
<div role="button" on:drag={() => {}} />
<span role="menuitem" on:dragend={() => {}} />
<div role="button" on:dragenter={() => {}} />
<span role="menuitem" on:dragexit={() => {}} />
<div role="button" on:dragleave={() => {}} />
<span role="menuitem" on:dragover={() => {}} />
<div role="button" on:dragstart={() => {}} />
<span role="menuitem" on:drop={() => {}} />
<div role="button" on:mousedown={() => {}} />
<span role="menuitem" on:mouseenter={() => {}} />
<div role="button" on:mouseleave={() => {}} />
<span role="menuitem" on:mousemove={() => {}} />
<div role="button" on:mouseout={() => {}} />
<span role="menuitem" on:mouseover={() => {}} />
<div role="button" on:mouseup={() => {}} />

Loading