diff --git a/package.json b/package.json index c4f1269139..ac742f1d44 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "code-red": "0.0.17", "codecov": "^3.5.0", "css-tree": "1.0.0-alpha22", + "emoji-regex": "^7.0.3", "eslint": "^6.3.0", "eslint-plugin-import": "^2.18.2", "eslint-plugin-svelte3": "^2.7.3", diff --git a/src/compiler/compile/nodes/Attribute.ts b/src/compiler/compile/nodes/Attribute.ts index 9d2a5c580e..067715d2e2 100644 --- a/src/compiler/compile/nodes/Attribute.ts +++ b/src/compiler/compile/nodes/Attribute.ts @@ -106,16 +106,16 @@ export default class Attribute extends Node { return expression; } - + get_static_value() { - if (this.is_spread || this.dependencies.size > 0) return null; - - return this.is_true - ? true - : this.chunks[0] - ? // method should be called only when `is_static = true` - (this.chunks[0] as Text).data - : ''; + if (this.is_spread || this.dependencies.size > 0) return undefined; + if (this.is_true) return true; + if (this.is_static) return this.chunks[0] ? (this.chunks[0] as Text).data: ''; + if (this.chunks[0]) { + const expression = (this.chunks[0] as Expression).node; + return evaluate_value(expression); + } + return undefined; } should_cache() { @@ -127,3 +127,25 @@ export default class Attribute extends Node { : true; } } + +function evaluate_value(node) { + switch (node.type) { + case 'Literal': + return node.value; + case 'UnaryExpression': + switch (node.operator) { + case '~': + return ~evaluate_value(node.argument); + case '!': + return !evaluate_value(node.argument); + case '+': + return +evaluate_value(node.argument); + case '-': + return -evaluate_value(node.argument); + } + break; + case 'TemplateLiteral': + return node.quasis[0].value.cooked; + } + return undefined; +} diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index af9ff2ef2b..c45e3a1e65 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -15,7 +15,7 @@ import list from '../../utils/list'; import Let from './Let'; import TemplateScope from './shared/TemplateScope'; import { INode } from './interfaces'; -import validateA11y from '../utils/validate-a11y/Element'; +import validate_a11y from '../utils/validate-a11y/Element'; 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)$/; @@ -180,7 +180,7 @@ export default class Element extends Node { } validate() { - validateA11y(this); + validate_a11y(this); this.validate_attributes(); this.validate_bindings(); diff --git a/src/compiler/compile/nodes/Text.ts b/src/compiler/compile/nodes/Text.ts index bfd28a5073..48695693a6 100644 --- a/src/compiler/compile/nodes/Text.ts +++ b/src/compiler/compile/nodes/Text.ts @@ -2,6 +2,7 @@ import Node from './shared/Node'; import Component from '../Component'; import TemplateScope from './shared/TemplateScope'; import { INode } from './interfaces'; +import validate_a11y from '../utils/validate-a11y/Text'; export default class Text extends Node { type: 'Text'; @@ -12,5 +13,7 @@ export default class Text extends Node { super(component, parent, scope, info); this.data = info.data; this.synthetic = info.synthetic || false; + + validate_a11y(this); } } diff --git a/src/compiler/compile/utils/validate-a11y/Attribute.ts b/src/compiler/compile/utils/validate-a11y/Attribute.ts index 0dea07711a..9a8bb7815f 100644 --- a/src/compiler/compile/utils/validate-a11y/Attribute.ts +++ b/src/compiler/compile/utils/validate-a11y/Attribute.ts @@ -1,10 +1,11 @@ import Attribute from '../../nodes/Attribute'; import fuzzymatch from '../../../utils/fuzzymatch'; +import { array_to_string } from './utils'; const validators = [ no_auto_focus, unsupported_aria_element, - unknown_aria_attribute, + invalid_aria_attribute, no_aria_hidden, no_misplaced_role, no_unknown_role, @@ -47,26 +48,422 @@ function unsupported_aria_element(attribute: Attribute, name: string) { } } -const aria_attributes = 'activedescendant atomic autocomplete busy checked colindex controls current describedby 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 rowindex selected setsize sort valuemax valuemin valuenow valuetext'.split( - ' ' -); +// https://github.com/A11yance/aria-query/blob/master/src/ariaPropsMap.js +const aria_attribute_maps = new Map([ + ['aria-details', { type: 'idlist' }], + [ + 'aria-activedescendant', + { + 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); -function unknown_aria_attribute(attribute: Attribute, name: string) { +function invalid_aria_attribute(attribute: Attribute, name: string) { if (name.startsWith('aria-')) { - const type = name.slice(5); - if (!aria_attribute_set.has(type)) { - const match = fuzzymatch(type, aria_attributes); - let message = `A11y: Unknown aria attribute 'aria-${type}'`; + if (!aria_attribute_set.has(name)) { + const match = fuzzymatch(name, aria_attributes); + let message = `A11y: Unknown aria attribute '${name}'`; if (match) message += ` (did you mean '${match}'?)`; attribute.parent.component.warn(attribute, { code: `a11y-unknown-aria-attribute`, message, }); + } else { + const value = attribute.get_static_value(); + if (value !== undefined) { + const { + type: permitted_type, + values: permitted_values, + } = aria_attribute_maps.get(name); + if (!validate_attribute(value, permitted_type, permitted_values)) { + attribute.parent.component.warn(attribute, { + code: `a11y-invalid-aria-attribute-value`, + message: validate_attribute_error_message( + name, + permitted_type, + permitted_values + ), + }); + } + } } } } +function validate_attribute(value, expected_types, permitted_values) { + switch (expected_types) { + case 'boolean': + return ( + typeof value === 'boolean' || value === 'true' || value === 'false' + ); + case 'string': + case 'id': + return typeof value === 'string'; + case 'tristate': + return ( + validate_attribute(value, 'boolean', undefined) || value === 'mixed' + ); + case 'integer': + case 'number': + return ( + typeof value === 'number' || + (typeof value === 'string' && isNaN(Number(value)) === false) + ); + case 'token': + return permitted_values.has( + typeof value === 'string' ? value.toLowerCase() : String(value) + ); + case 'idlist': + return ( + typeof value === 'string' && + value + .split(' ') + .every(token => validate_attribute(token, 'id', undefined)) + ); + case 'tokenlist': + return ( + typeof value === 'string' && + value + .split(' ') + .every(token => permitted_values.has(token.toLowerCase())) + ); + default: + return false; + } +} +function validate_attribute_error_message(name, type, permitted_values) { + switch (type) { + case 'tristate': + return `The value for ${name} must be a boolean or the string "mixed".`; + case 'token': + return `The value for ${name} must be a single token from the following: ${array_to_string( + Array.from(permitted_values) + )}.`; + case 'tokenlist': + return `The value for ${name} must be a list of one or more tokens from the following: ${array_to_string( + Array.from(permitted_values) + )}.`; + case 'idlist': + return `The value for ${name} must be a list of strings that represent DOM element IDs (idlist)`; + case 'id': + return `The value for ${name} must be a string that represents a DOM element ID`; + case 'boolean': + case 'string': + case 'integer': + case 'number': + default: + return `The value for ${name} must be a ${type}.`; + } +} + function no_aria_hidden(attribute: Attribute, name: string) { if (name === 'aria-hidden' && /^h[1-6]$/.test(attribute.parent.name)) { attribute.parent.component.warn(attribute, { diff --git a/src/compiler/compile/utils/validate-a11y/Element.ts b/src/compiler/compile/utils/validate-a11y/Element.ts index d7bb5a93d9..04512058ff 100644 --- a/src/compiler/compile/utils/validate-a11y/Element.ts +++ b/src/compiler/compile/utils/validate-a11y/Element.ts @@ -1,6 +1,9 @@ import Element from '../../nodes/Element'; import Attribute from '../../nodes/Attribute'; import EventHandler from '../../nodes/EventHandler'; +import emojiRegex from 'emoji-regex'; +import Text from '../../nodes/Text'; +import { array_to_string } from './utils'; export default function validateA11y(element: Element) { const attribute_map = new Map(); @@ -19,6 +22,7 @@ export default function validateA11y(element: Element) { required_content(element); no_missing_handlers(element, handler_map); img_redundant_alt(element, attribute_map); + accessible_emoji(element, attribute_map); } const a11y_distracting_elements = new Set(['blink', 'marquee']); @@ -138,11 +142,7 @@ function no_missing_attribute( function should_have_attribute(node, attributes: string[], name = node.name) { const article = /^[aeiou]/.test(attributes[0]) ? 'an' : 'a'; - const sequence = - attributes.length > 1 - ? attributes.slice(0, -1).join(', ') + - ` or ${attributes[attributes.length - 1]}` - : attributes[0]; + const sequence = array_to_string(attributes); node.component.warn(node, { code: `a11y-missing-attribute`, @@ -214,6 +214,22 @@ function img_redundant_alt( } } +function accessible_emoji(element: Element, attribute_map: Map) { + const has_emoji = element.children.some(child => contain_text(child, emojiRegex())); + if (has_emoji) { + const is_span = element.name === 'span'; + const has_label = attribute_map.has('aria-labelledby') ||attribute_map.has('aria-label'); + const role = attribute_map.get('role'); + const role_value = role && role.chunks[0].type === 'Text' ? (role.chunks[0] as Text).data : null; + if (!has_label || role_value !== 'img' || !is_span) { + element.component.warn(element, { + code: `a11y-accessible-emoji`, + message: `A11y: Emojis should be wrapped in , have role="img", and have an accessible description with aria-label or aria-labelledby.`, + }); + } + } +} + function contain_text(node, regex: RegExp) { switch (node.type) { case 'Text': diff --git a/src/compiler/compile/utils/validate-a11y/Text.ts b/src/compiler/compile/utils/validate-a11y/Text.ts new file mode 100644 index 0000000000..f18996319f --- /dev/null +++ b/src/compiler/compile/utils/validate-a11y/Text.ts @@ -0,0 +1,17 @@ +import Text from '../../nodes/Text'; +import emojiRegex from 'emoji-regex'; + +export default function validateA11y(text: Text) { + if (text.parent.type === 'Fragment') { + accessible_emoji(text); + } +} + +function accessible_emoji(text: Text) { + if (emojiRegex().test(text.data)) { + text.component.warn(text, { + code: `a11y-accessible-emoji`, + message: `A11y: Emojis should be wrapped in , have role="img", and have an accessible description with aria-label or aria-labelledby.`, + }); + } +} diff --git a/src/compiler/compile/utils/validate-a11y/utils.ts b/src/compiler/compile/utils/validate-a11y/utils.ts new file mode 100644 index 0000000000..c63cbe1c00 --- /dev/null +++ b/src/compiler/compile/utils/validate-a11y/utils.ts @@ -0,0 +1,5 @@ +export function array_to_string(values): string { + return values.length > 1 + ? `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}` + : values[0]; +} diff --git a/test/validator/samples/a11y-accessible-emoji/input.svelte b/test/validator/samples/a11y-accessible-emoji/input.svelte new file mode 100644 index 0000000000..5db27fbdd8 --- /dev/null +++ b/test/validator/samples/a11y-accessible-emoji/input.svelte @@ -0,0 +1,8 @@ + + +🐼 +🐼 + +🐼 +🐼 +🐼 \ No newline at end of file diff --git a/test/validator/samples/a11y-accessible-emoji/warnings.json b/test/validator/samples/a11y-accessible-emoji/warnings.json new file mode 100644 index 0000000000..3177a85f7f --- /dev/null +++ b/test/validator/samples/a11y-accessible-emoji/warnings.json @@ -0,0 +1,47 @@ +[ + { + "code": "a11y-accessible-emoji", + "end": { + "character": 197, + "column": 15, + "line": 6 + }, + "message": "A11y: Emojis should be wrapped in , have role=\"img\", and have an accessible description with aria-label or aria-labelledby.", + "pos": 182, + "start": { + "character": 182, + "column": 0, + "line": 6 + } + }, + { + "code": "a11y-accessible-emoji", + "end": { + "character": 237, + "column": 39, + "line": 7 + }, + "message": "A11y: Emojis should be wrapped in , have role=\"img\", and have an accessible description with aria-label or aria-labelledby.", + "pos": 198, + "start": { + "character": 198, + "column": 0, + "line": 7 + } + }, + { + "code": "a11y-accessible-emoji", + "end": { + "character": 240, + "column": 2, + "line": 8 + }, + "message": "A11y: Emojis should be wrapped in , have role=\"img\", and have an accessible description with aria-label or aria-labelledby.", + "pos": 237, + "start": { + "character": 237, + "column": 39, + "line": 7 + } + } +] diff --git a/test/validator/samples/a11y-aria-props/warnings.json b/test/validator/samples/a11y-aria-props/warnings.json index 73d34fc589..e606ffcb50 100644 --- a/test/validator/samples/a11y-aria-props/warnings.json +++ b/test/validator/samples/a11y-aria-props/warnings.json @@ -1,7 +1,7 @@ [ { "code": "a11y-unknown-aria-attribute", - "message": "A11y: Unknown aria attribute 'aria-labeledby' (did you mean 'labelledby'?)", + "message": "A11y: Unknown aria attribute 'aria-labeledby' (did you mean 'aria-labelledby'?)", "start": { "line": 1, "column": 20, diff --git a/test/validator/samples/a11y-aria-proptypes/input.svelte b/test/validator/samples/a11y-aria-proptypes/input.svelte new file mode 100644 index 0000000000..530f82577b --- /dev/null +++ b/test/validator/samples/a11y-aria-proptypes/input.svelte @@ -0,0 +1,175 @@ + +
+