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 aria-proptypes check #6978

Merged
merged 5 commits into from Jul 11, 2022
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
12 changes: 12 additions & 0 deletions site/content/docs/05-accessibility-warnings.md
Expand Up @@ -98,6 +98,18 @@ Enforce img alt attribute does not contain the word image, picture, or photo. Sc

---

### `a11y-incorrect-aria-attribute-type`

Enforce that only the correct type of value is used for aria attributes. For example, `aria-hidden`
should only receive a boolean.

```sv
<!-- A11y: The value of 'aria-hidden' must be exactly one of true or false -->
<div aria-hidden="yes"/>
```

---

### `a11y-invalid-attribute`

Enforce that attributes important for accessibility have a valid value. For example, `href` should not be empty, `'#'`, or `javascript:`.
Expand Down
33 changes: 32 additions & 1 deletion src/compiler/compile/compiler_warnings.ts
@@ -1,12 +1,14 @@
// All compiler warnings should be listed and accessed from here

import { ARIAPropertyDefinition } from 'aria-query';

/**
* @internal
*/
export default {
custom_element_no_tag: {
code: 'custom-element-no-tag',
message: 'No custom element \'tag\' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. <svelte:options tag="my-thing"/>. To hide this warning, use <svelte:options tag={null}/>'
message: 'No custom element \'tag\' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. <svelte:options tag="my-thing"/>. To hide this warning, use <svelte:options tag={null}/>'
},
unused_export_let: (component: string, property: string) => ({
code: 'unused-export-let',
Expand Down Expand Up @@ -60,6 +62,35 @@ export default {
code: 'a11y-aria-attributes',
message: `A11y: <${name}> should not have aria-* attributes`
}),
a11y_incorrect_attribute_type: (schema: ARIAPropertyDefinition, attribute: string) => {
let message;
switch (schema.type) {
case 'boolean':
message = `The value of '${attribute}' must be exactly one of true or false`;
break;
case 'id':
message = `The value of '${attribute}' must be a string that represents a DOM element ID`;
break;
case 'idlist':
message = `The value of '${attribute}' must be a space-separated list of strings that represent DOM element IDs`;
break;
case 'tristate':
message = `The value of '${attribute}' must be exactly one of true, false, or mixed`;
break;
case 'token':
message = `The value of '${attribute}' must be exactly one of ${(schema.values || []).join(', ')}`;
break;
case 'tokenlist':
message = `The value of '${attribute}' must be a space-separated list of one or more of ${(schema.values || []).join(', ')}`;
break;
default:
message = `The value of '${attribute}' must be of type ${schema.type}`;
}
return {
code: 'a11y-incorrect-aria-attribute-type',
message: `A11y: ${message}`
};
},
a11y_unknown_aria_attribute: (attribute: string, suggestion?: string) => ({
code: 'a11y-unknown-aria-attribute',
message: `A11y: Unknown aria attribute 'aria-${attribute}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '')
Expand Down
40 changes: 39 additions & 1 deletion src/compiler/compile/nodes/Element.ts
Expand Up @@ -23,7 +23,7 @@ import { string_literal } from '../utils/stringify';
import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';
import { ARIARoleDefintionKey, roles } from 'aria-query';
import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';

const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|svg|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/;

Expand Down Expand Up @@ -177,6 +177,32 @@ function get_namespace(parent: Element, element: Element, explicit_namespace: st
return parent_element.namespace;
}

function is_valid_aria_attribute_value(schema: ARIAPropertyDefinition, value: string | boolean): boolean {
switch (schema.type) {
case 'boolean':
return typeof value === 'boolean';
case 'string':
case 'id':
return typeof value === 'string';
case 'tristate':
return typeof value === 'boolean' || value === 'mixed';
case 'integer':
case 'number':
return typeof value !== 'boolean' && isNaN(Number(value)) === false;
case 'token': // single token
return (schema.values || [])
.indexOf(typeof value === 'string' ? value.toLowerCase() : value) > -1;
case 'idlist': // if list of ids, split each
return typeof value === 'string'
&& value.split(' ').every((id) => typeof id === 'string');
case 'tokenlist': // if list of tokens, split each
return typeof value === 'string'
&& value.split(' ').every((token) => (schema.values || []).indexOf(token.toLowerCase()) > -1);
default:
return false;
}
}

export default class Element extends Node {
type: 'Element';
name: string;
Expand Down Expand Up @@ -431,6 +457,18 @@ export default class Element extends Node {
if (name === 'aria-hidden' && /^h[1-6]$/.test(this.name)) {
component.warn(attribute, compiler_warnings.a11y_hidden(this.name));
}

// aria-proptypes
let value = attribute.get_static_value();
if (value === 'true') value = true;
if (value === 'false') value = false;

if (value !== null && value !== undefined && aria.has(name as ARIAProperty)) {
const schema = aria.get(name as ARIAProperty);
if (!is_valid_aria_attribute_value(schema, value)) {
component.warn(attribute, compiler_warnings.a11y_incorrect_attribute_type(schema, name));
}
}
}

// aria-role
Expand Down
@@ -0,0 +1,8 @@
<script>
const abc = 'abc';
</script>

<button aria-disabled="yes"/>
<button aria-disabled="no"/>
<button aria-disabled={1234}/>
<button aria-disabled={`${abc}`}/>
32 changes: 32 additions & 0 deletions test/validator/samples/a11y-aria-proptypes-boolean/warnings.json
@@ -0,0 +1,32 @@
[
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-disabled' must be exactly one of true or false",
"start": {
"line": 5,
"column": 8,
"character": 51
},
"end": {
"line": 5,
"column": 27,
"character": 70
},
"pos": 51
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-disabled' must be exactly one of true or false",
"start": {
"line": 6,
"column": 8,
"character": 81
},
"end": {
"line": 6,
"column": 26,
"character": 99
},
"pos": 81
}
]
@@ -0,0 +1,7 @@
<div aria-level="yes" />
<div aria-level="no" />
<div aria-level={`abc`} />
<div aria-level={true} />
<div aria-level />
<div aria-level={"false"} />
<div aria-level={!"false"} />
47 changes: 47 additions & 0 deletions test/validator/samples/a11y-aria-proptypes-integer/warnings.json
@@ -0,0 +1,47 @@
[
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-level' must be of type integer",
"start": {
"line": 1,
"column": 5,
"character": 5
},
"end": {
"line": 1,
"column": 21,
"character": 21
},
"pos": 5
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-level' must be of type integer",
"start": {
"line": 2,
"column": 5,
"character": 30
},
"end": {
"line": 2,
"column": 20,
"character": 45
},
"pos": 30
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-level' must be of type integer",
"start": {
"line": 5,
"column": 5,
"character": 107
},
"end": {
"line": 5,
"column": 15,
"character": 117
},
"pos": 107
}
]
@@ -0,0 +1,7 @@
<div aria-valuemax="yes" />
<div aria-valuemax="no" />
<div aria-valuemax={`abc`} />
<div aria-valuemax={true} />
<div aria-valuemax />
<div aria-valuemax={'false'} />
<div aria-valuemax={!'false'} />
47 changes: 47 additions & 0 deletions test/validator/samples/a11y-aria-proptypes-number/warnings.json
@@ -0,0 +1,47 @@
[
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-valuemax' must be of type number",
"start": {
"line": 1,
"column": 5,
"character": 5
},
"end": {
"line": 1,
"column": 24,
"character": 24
},
"pos": 5
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-valuemax' must be of type number",
"start": {
"line": 2,
"column": 5,
"character": 33
},
"end": {
"line": 2,
"column": 23,
"character": 51
},
"pos": 33
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-valuemax' must be of type number",
"start": {
"line": 5,
"column": 5,
"character": 119
},
"end": {
"line": 5,
"column": 18,
"character": 132
},
"pos": 119
}
]
@@ -0,0 +1,5 @@
<div aria-label />
<div aria-label={true} />
<div aria-label={false} />
<div aria-label={1234} />
<div aria-label={!true} />
17 changes: 17 additions & 0 deletions test/validator/samples/a11y-aria-proptypes-string/warnings.json
@@ -0,0 +1,17 @@
[
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-label' must be of type string",
"start": {
"line": 1,
"column": 5,
"character": 5
},
"end": {
"line": 1,
"column": 15,
"character": 15
},
"pos": 5
}
]
6 changes: 6 additions & 0 deletions test/validator/samples/a11y-aria-proptypes-token/input.svelte
@@ -0,0 +1,6 @@
<div aria-sort="" />
<div aria-sort="descnding" />
<div aria-sort />
<div aria-sort={true} />
<div aria-sort={"false"} />
<div aria-sort="ascending descending" />
62 changes: 62 additions & 0 deletions test/validator/samples/a11y-aria-proptypes-token/warnings.json
@@ -0,0 +1,62 @@
[
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-sort' must be exactly one of ascending, descending, none, other",
"start": {
"line": 1,
"column": 5,
"character": 5
},
"end": {
"line": 1,
"column": 17,
"character": 17
},
"pos": 5
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-sort' must be exactly one of ascending, descending, none, other",
"start": {
"line": 2,
"column": 5,
"character": 26
},
"end": {
"line": 2,
"column": 26,
"character": 47
},
"pos": 26
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-sort' must be exactly one of ascending, descending, none, other",
"start": {
"line": 3,
"column": 5,
"character": 56
},
"end": {
"line": 3,
"column": 14,
"character": 65
},
"pos": 56
},
{
"code": "a11y-incorrect-aria-attribute-type",
"message": "A11y: The value of 'aria-sort' must be exactly one of ascending, descending, none, other",
"start": {
"line": 6,
"column": 5,
"character": 127
},
"end": {
"line": 6,
"column": 37,
"character": 159
},
"pos": 127
}
]
@@ -0,0 +1,7 @@
<div aria-relevant="" />
<div aria-relevant="foobar" />
<div aria-relevant />
<div aria-relevant={true} />
<div aria-relevant={"false"} />
<div aria-relevant="additions removalss" />
<div aria-relevant="additions removalss " />