no-redundant-role

pull/3725/head
Tan Li Hau 6 years ago
parent 3a85d6761a
commit 34e831b88d

16
package-lock.json generated

@ -281,6 +281,16 @@
"integrity": "sha1-7L0W+JSbFXGDcRsb2jNPN4QBhas=", "integrity": "sha1-7L0W+JSbFXGDcRsb2jNPN4QBhas=",
"dev": true "dev": true
}, },
"aria-query": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz",
"integrity": "sha1-ZbP8wcoRVajJrmTW7uKX8V1RM8w=",
"dev": true,
"requires": {
"ast-types-flow": "0.0.7",
"commander": "^2.11.0"
}
},
"array-equal": { "array-equal": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz",
@ -312,6 +322,12 @@
"integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
"dev": true "dev": true
}, },
"ast-types-flow": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
"integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=",
"dev": true
},
"astral-regex": { "astral-regex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz",

@ -62,6 +62,7 @@
"@typescript-eslint/parser": "^2.1.0", "@typescript-eslint/parser": "^2.1.0",
"acorn": "^7.0.0", "acorn": "^7.0.0",
"agadoo": "^1.1.0", "agadoo": "^1.1.0",
"aria-query": "^3.0.0",
"c8": "^5.0.1", "c8": "^5.0.1",
"code-red": "0.0.17", "code-red": "0.0.17",
"codecov": "^3.5.0", "codecov": "^3.5.0",

@ -1,6 +1,7 @@
import Attribute from '../../nodes/Attribute'; import Attribute from '../../nodes/Attribute';
import fuzzymatch from '../../../utils/fuzzymatch'; import fuzzymatch from '../../../utils/fuzzymatch';
import { array_to_string } from './utils'; import { array_to_string } from './utils';
import { aria } from 'aria-query';
const validators = [ const validators = [
no_auto_focus, no_auto_focus,
@ -8,7 +9,6 @@ const validators = [
invalid_aria_attribute, invalid_aria_attribute,
no_aria_hidden, no_aria_hidden,
no_misplaced_role, no_misplaced_role,
no_unknown_role,
no_access_key, no_access_key,
no_misplaced_scope, no_misplaced_scope,
tabindex_no_positive, tabindex_no_positive,
@ -48,322 +48,12 @@ function unsupported_aria_element(attribute: Attribute, name: string) {
} }
} }
// https://github.com/A11yance/aria-query/blob/master/src/ariaPropsMap.js for (const aria_key of aria.keys()) {
const aria_attribute_maps = new Map([ if (aria.get(aria_key).values) {
['aria-details', { type: 'idlist' }], aria.get(aria_key).values = new Set(aria.get(aria_key).values.map(String));
[ }
'aria-activedescendant', }
{ const aria_attributes = [...aria.keys()];
type: 'id',
},
],
[
'aria-atomic',
{
type: 'boolean',
},
],
[
'aria-autocomplete',
{
type: 'token',
values: new Set(['inline', 'list', 'both', 'none']),
},
],
[
'aria-busy',
{
type: 'boolean',
},
],
[
'aria-checked',
{
type: 'tristate',
},
],
[
'aria-colcount',
{
type: 'integer',
},
],
[
'aria-colindex',
{
type: 'integer',
},
],
[
'aria-colspan',
{
type: 'integer',
},
],
[
'aria-controls',
{
type: 'idlist',
},
],
[
'aria-current',
{
type: 'token',
values: new Set([
'page',
'step',
'location',
'date',
'time',
'true',
'false',
]),
},
],
[
'aria-describedby',
{
type: 'idlist',
},
],
[
'aria-disabled',
{
type: 'boolean',
},
],
[
'aria-dropeffect',
{
type: 'tokenlist',
values: new Set(['copy', 'move', 'link', 'execute', 'popup', 'none']),
},
],
[
'aria-errormessage',
{
type: 'string',
},
],
[
'aria-expanded',
{
type: 'boolean',
allowundefined: true,
},
],
[
'aria-flowto',
{
type: 'idlist',
},
],
[
'aria-grabbed',
{
type: 'boolean',
allowundefined: true,
},
],
[
'aria-haspopup',
{
type: 'token',
values: new Set([
'false',
'true',
'menu',
'listbox',
'tree',
'grid',
'dialog',
]),
},
],
[
'aria-hidden',
{
type: 'boolean',
},
],
[
'aria-invalid',
{
type: 'token',
values: new Set(['grammar', 'false', 'spelling', 'true']),
},
],
[
'aria-keyshortcuts',
{
type: 'string',
},
],
[
'aria-label',
{
type: 'string',
},
],
[
'aria-labelledby',
{
type: 'idlist',
},
],
[
'aria-level',
{
type: 'integer',
},
],
[
'aria-live',
{
type: 'token',
values: new Set(['off', 'polite', 'assertive']),
},
],
[
'aria-modal',
{
type: 'boolean',
},
],
[
'aria-multiline',
{
type: 'boolean',
},
],
[
'aria-multiselectable',
{
type: 'boolean',
},
],
[
'aria-orientation',
{
type: 'token',
values: new Set(['vertical', 'horizontal']),
},
],
[
'aria-owns',
{
type: 'idlist',
},
],
[
'aria-placeholder',
{
type: 'string',
},
],
[
'aria-posinset',
{
type: 'integer',
},
],
[
'aria-pressed',
{
type: 'tristate',
},
],
[
'aria-readonly',
{
type: 'boolean',
},
],
[
'aria-relevant',
{
type: 'tokenlist',
values: new Set(['additions', 'removals', 'text', 'all']),
},
],
[
'aria-required',
{
type: 'boolean',
},
],
[
'aria-roledescription',
{
type: 'string',
},
],
[
'aria-rowcount',
{
type: 'integer',
},
],
[
'aria-rowindex',
{
type: 'integer',
},
],
[
'aria-rowspan',
{
type: 'integer',
},
],
[
'aria-selected',
{
type: 'boolean',
allowundefined: true,
},
],
[
'aria-setsize',
{
type: 'integer',
},
],
[
'aria-sort',
{
type: 'token',
values: new Set(['ascending', 'descending', 'none', 'other']),
},
],
[
'aria-valuemax',
{
type: 'number',
},
],
[
'aria-valuemin',
{
type: 'number',
},
],
[
'aria-valuenow',
{
type: 'number',
},
],
[
'aria-valuetext',
{
type: 'string',
},
],
]);
const aria_attributes = [...aria_attribute_maps.keys()];
const aria_attribute_set = new Set(aria_attributes); const aria_attribute_set = new Set(aria_attributes);
function invalid_aria_attribute(attribute: Attribute, name: string) { function invalid_aria_attribute(attribute: Attribute, name: string) {
if (name.startsWith('aria-')) { if (name.startsWith('aria-')) {
@ -379,10 +69,9 @@ function invalid_aria_attribute(attribute: Attribute, name: string) {
} else { } else {
const value = attribute.get_static_value(); const value = attribute.get_static_value();
if (value !== undefined) { if (value !== undefined) {
const { const { type: permitted_type, values: permitted_values } = aria.get(
type: permitted_type, name
values: permitted_values, );
} = aria_attribute_maps.get(name);
if (!validate_attribute(value, permitted_type, permitted_values)) { if (!validate_attribute(value, permitted_type, permitted_values)) {
attribute.parent.component.warn(attribute, { attribute.parent.component.warn(attribute, {
code: `a11y-invalid-aria-attribute-value`, code: `a11y-invalid-aria-attribute-value`,
@ -485,28 +174,6 @@ function no_misplaced_role(attribute: Attribute, name: string) {
} }
} }
const aria_roles = 'alert alertdialog application article banner button cell checkbox columnheader combobox command complementary composite contentinfo definition dialog directory document feed figure form grid gridcell group heading img input landmark link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option presentation progressbar radio radiogroup range region roletype row rowgroup rowheader scrollbar search searchbox section sectionhead select separator slider spinbutton status structure switch tab table tablist tabpanel term textbox timer toolbar tooltip tree treegrid treeitem widget window'.split(
' '
);
const aria_role_set = new Set(aria_roles);
function no_unknown_role(attribute: Attribute, name: string) {
if (name === 'role') {
const value = attribute.get_static_value();
// @ts-ignore
if (value && !aria_role_set.has(value)) {
// @ts-ignore
const match = fuzzymatch(value, aria_roles);
let message = `A11y: Unknown role '${value}'`;
if (match) message += ` (did you mean '${match}'?)`;
attribute.parent.component.warn(attribute, {
code: `a11y-unknown-role`,
message,
});
}
}
}
function no_access_key(attribute: Attribute, name: string) { function no_access_key(attribute: Attribute, name: string) {
// no-access-key // no-access-key
if (name === 'accesskey') { if (name === 'accesskey') {

@ -1,9 +1,12 @@
import Element from '../../nodes/Element'; import Element from '../../nodes/Element';
import Attribute from '../../nodes/Attribute'; import Attribute from '../../nodes/Attribute';
import EventHandler from '../../nodes/EventHandler'; import EventHandler from '../../nodes/EventHandler';
import fuzzymatch from '../../../utils/fuzzymatch';
import emojiRegex from 'emoji-regex'; import emojiRegex from 'emoji-regex';
import Text from '../../nodes/Text'; import Text from '../../nodes/Text';
import { array_to_string } from './utils'; import { array_to_string, is_hidden_from_screen_reader } from './utils';
import { roles } from 'aria-query';
import get_implicit_role from './implicit_role';
export default function validateA11y(element: Element) { export default function validateA11y(element: Element) {
const attribute_map = new Map(); const attribute_map = new Map();
@ -23,6 +26,7 @@ export default function validateA11y(element: Element) {
no_missing_handlers(element, handler_map); no_missing_handlers(element, handler_map);
img_redundant_alt(element, attribute_map); img_redundant_alt(element, attribute_map);
accessible_emoji(element, attribute_map); accessible_emoji(element, attribute_map);
no_unknown_role(element, attribute_map);
} }
const a11y_distracting_elements = new Set(['blink', 'marquee']); const a11y_distracting_elements = new Set(['blink', 'marquee']);
@ -199,7 +203,10 @@ function img_redundant_alt(
) { ) {
if (element.name === 'img') { if (element.name === 'img') {
const alt_attribute = attribute_map.get('alt'); const alt_attribute = attribute_map.get('alt');
if (alt_attribute && !attribute_map.has('aria-hidden')) { if (
alt_attribute &&
!is_hidden_from_screen_reader(element.name, attribute_map)
) {
for (const chunk of alt_attribute.chunks) { for (const chunk of alt_attribute.chunks) {
if (contain_text(chunk, a11y_redundant_alt)) { if (contain_text(chunk, a11y_redundant_alt)) {
element.component.warn(alt_attribute, { element.component.warn(alt_attribute, {
@ -214,13 +221,22 @@ function img_redundant_alt(
} }
} }
function accessible_emoji(element: Element, attribute_map: Map<string, Attribute>) { function accessible_emoji(
const has_emoji = element.children.some(child => contain_text(child, emojiRegex())); element: Element,
attribute_map: Map<string, Attribute>
) {
const has_emoji = element.children.some(child =>
contain_text(child, emojiRegex())
);
if (has_emoji) { if (has_emoji) {
const is_span = element.name === 'span'; const is_span = element.name === 'span';
const has_label = attribute_map.has('aria-labelledby') ||attribute_map.has('aria-label'); const has_label =
attribute_map.has('aria-labelledby') || attribute_map.has('aria-label');
const role = attribute_map.get('role'); const role = attribute_map.get('role');
const role_value = role && role.chunks[0].type === 'Text' ? (role.chunks[0] as Text).data : null; const role_value =
role && role.chunks[0].type === 'Text'
? (role.chunks[0] as Text).data
: null;
if (!has_label || role_value !== 'img' || !is_span) { if (!has_label || role_value !== 'img' || !is_span) {
element.component.warn(element, { element.component.warn(element, {
code: `a11y-accessible-emoji`, code: `a11y-accessible-emoji`,
@ -246,3 +262,45 @@ function contain_text(node, regex: RegExp) {
return false; return false;
} }
} }
const aria_role_set = new Set(roles.keys());
const aria_roles = [...aria_role_set];
const role_exceptions = new Map([['nav', 'navigation']]);
function no_unknown_role(
element: Element,
attribute_map: Map<string, Attribute>
) {
if (!attribute_map.has('role')) {
return;
}
const role_attribute = attribute_map.get('role');
const value = role_attribute.get_static_value();
// @ts-ignore
if (value && !aria_role_set.has(value)) {
// @ts-ignore
const match = fuzzymatch(value, aria_roles);
let message = `A11y: Unknown role '${value}'`;
if (match) message += ` (did you mean '${match}'?)`;
element.component.warn(role_attribute, {
code: `a11y-unknown-role`,
message,
});
}
const implicit_role = get_implicit_role(element.name, attribute_map);
if (implicit_role && implicit_role === value) {
if (
!(
role_exceptions.has(element.name) &&
role_exceptions.get(element.name) === value
)
) {
element.component.warn(role_attribute, {
code: `a11y-redundant-role`,
message: `The element '${element.name}' has an implicit role of '${implicit_role}'. Defining this explicitly is redundant and should be avoided.`,
});
}
}
}

@ -0,0 +1,130 @@
import Attribute from '../../nodes/Attribute';
function a(attribute_map: Map<string, Attribute>) {
return attribute_map.has('href') ? 'link' : '';
}
function area(attribute_map: Map<string, Attribute>) {
return attribute_map.has('href') ? 'link' : '';
}
function img(attribute_map: Map<string, Attribute>) {
if (
attribute_map.has('alt') &&
attribute_map.get('alt').get_static_value() === ''
) {
return '';
}
return 'img';
}
function input(attribute_map: Map<string, Attribute>) {
if (attribute_map.has('type')) {
const value = attribute_map.get('type').get_static_value() || '';
switch (value.toUpperCase()) {
case 'BUTTON':
case 'IMAGE':
case 'RESET':
case 'SUBMIT':
return 'button';
case 'CHECKBOX':
return 'checkbox';
case 'RADIO':
return 'radio';
case 'RANGE':
return 'slider';
case 'EMAIL':
case 'PASSWORD':
case 'SEARCH': // with [list] selector it's combobox
case 'TEL': // with [list] selector it's combobox
case 'URL': // with [list] selector it's combobox
default:
return 'textbox';
}
}
return 'textbox';
}
function link(attribute_map: Map<string, Attribute>) {
return attribute_map.has('href') ? 'link' : '';
}
function menu(attribute_map: Map<string, Attribute>) {
if (attribute_map.has('type')) {
const value = attribute_map.get('type').get_static_value();
return value && value.toUpperCase() === 'TOOLBAR' ? 'toolbar' : '';
}
return '';
}
function menuitem(attribute_map: Map<string, Attribute>) {
if (attribute_map.has('type')) {
const value = attribute_map.get('type').get_static_value() || '';
switch (value.toUpperCase()) {
case 'COMMAND':
return 'menuitem';
case 'CHECKBOX':
return 'menuitemcheckbox';
case 'RADIO':
return 'menuitemradio';
default:
return '';
}
}
return '';
}
const implicit_role = new Map<
string,
string | ((attribute_map: Map<string, Attribute>) => string)
>([
['a', a],
['area', area],
['article', 'article'],
['aside', 'complementary'],
['body', 'document'],
['button', 'button'],
['datalist', 'listbox'],
['details', 'group'],
['dialog', 'dialog'],
['dl', 'list'],
['form', 'form'],
['h1', 'heading'],
['h2', 'heading'],
['h3', 'heading'],
['h4', 'heading'],
['h5', 'heading'],
['h6', 'heading'],
['hr', 'separator'],
['img', img],
['input', input],
['li', 'listitem'],
['link', link],
['menu', menu],
['menuitem', menuitem],
['meter', 'progressbar'],
['nav', 'navigation'],
['ol', 'list'],
['option', 'option'],
['output', 'status'],
['progress', 'progressbar'],
['section', 'region'],
['select', 'listbox'],
['tbody', 'rowgroup'],
['textarea', 'textbox'],
['tfoot', 'rowgroup'],
['thead', 'rowgroup'],
['ul', 'list'],
]);
export default function get_implicit_role(
name: string,
attribute_map: Map<string, Attribute>
): string {
if (implicit_role.has(name)) {
const value = implicit_role.get(name);
if (typeof value === 'string') {
return value;
}
return value(attribute_map);
}
return '';
}

@ -1,5 +1,14 @@
import Attribute from '../../nodes/Attribute';
export function array_to_string(values): string { export function array_to_string(values): string {
return values.length > 1 return values.length > 1
? `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}` ? `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`
: values[0]; : values[0];
} }
export function is_hidden_from_screen_reader(name: string, attribute_map: Map<string, Attribute>) {
if (name === 'input' && attribute_map.get('type').get_static_value() === 'hidden') {
return true;
}
return attribute_map.has('aria-hidden');
}

@ -0,0 +1,13 @@
<!-- valid -->
<div />
<button role="main" />
<MyComponent role="button" />
<button role={`${foo}button`} />
<nav role="navigation" />
<!-- invalid -->
<button role="button" />
<body role="document" />
<script>
let MyComponent, foo;
</script>

@ -0,0 +1,32 @@
[
{
"code": "a11y-redundant-role",
"end": {
"character": 173,
"column": 21,
"line": 8
},
"message": "The element 'button' has an implicit role of 'button'. Defining this explicitly is redundant and should be avoided.",
"pos": 160,
"start": {
"character": 160,
"column": 8,
"line": 8
}
},
{
"code": "a11y-redundant-role",
"end": {
"character": 198,
"column": 21,
"line": 9
},
"message": "The element 'body' has an implicit role of 'document'. Defining this explicitly is redundant and should be avoided.",
"pos": 183,
"start": {
"character": 183,
"column": 6,
"line": 9
}
}
]
Loading…
Cancel
Save