Skip to content

Commit

Permalink
[feat]: Add A11y aria-proptypes check (#6978)
Browse files Browse the repository at this point in the history
* Add aria prop type list

* feat: check aria attribute types

* feat: add proptype tests

* Add documentation

* use aria-query

Co-authored-by: Nurassyl Zekenov <nurassyl@snoonu.com>
Co-authored-by: mka_882@yahoo.com <mka_882@yahoo.com>
Co-authored-by: tanhauhau <lhtan93@gmail.com>
Co-authored-by: David Mosher <davidmosher@gmail.com>
  • Loading branch information
5 people committed Jul 11, 2022
1 parent 4617c0d commit 3990198
Show file tree
Hide file tree
Showing 17 changed files with 445 additions and 2 deletions.
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 " />

0 comments on commit 3990198

Please sign in to comment.