diff --git a/package-lock.json b/package-lock.json index 0756646df8..7e96398ea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -358,6 +358,15 @@ "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", "dev": true }, + "axobject-query": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.0.2.tgz", + "integrity": "sha512-MCeek8ZH7hKyO1rWUbKNQBbl4l2eY0ntk7OGi+q0RlafrCnfPxC06WZA+uebCfmYp4mNU9jRBP1AhGyf8+W3ww==", + "dev": true, + "requires": { + "ast-types-flow": "0.0.7" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", diff --git a/package.json b/package.json index e44a08039e..77ed87145b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "acorn": "^7.0.0", "agadoo": "^1.1.0", "aria-query": "^3.0.0", + "axobject-query": "^2.0.2", "c8": "^5.0.1", "code-red": "0.0.17", "codecov": "^3.5.0", diff --git a/src/compiler/compile/utils/validate-a11y/Element.ts b/src/compiler/compile/utils/validate-a11y/Element.ts index 322e7d3e89..7773622622 100644 --- a/src/compiler/compile/utils/validate-a11y/Element.ts +++ b/src/compiler/compile/utils/validate-a11y/Element.ts @@ -5,8 +5,9 @@ import fuzzymatch from '../../../utils/fuzzymatch'; import emojiRegex from 'emoji-regex'; import Text from '../../nodes/Text'; import { array_to_string, is_hidden_from_screen_reader } from './utils'; -import { roles } from 'aria-query'; +import { roles, aria } from 'aria-query'; import get_implicit_role from './implicit_role'; +import is_semantic_role_element from './is_semantic_role_element'; export default function validateA11y(element: Element) { const attribute_map = new Map(); @@ -26,7 +27,7 @@ export default function validateA11y(element: Element) { no_missing_handlers(element, handler_map); img_redundant_alt(element, attribute_map); accessible_emoji(element, attribute_map); - no_unknown_role(element, attribute_map); + validate_aria_role(element, attribute_map); } const a11y_distracting_elements = new Set(['blink', 'marquee']); @@ -263,43 +264,83 @@ function contain_text(node, regex: RegExp) { } } -const aria_role_set = new Set(roles.keys()); -const aria_roles = [...aria_role_set]; +const aria_roles = [...roles.keys()]; const role_exceptions = new Map([['nav', 'navigation']]); -function no_unknown_role( +function validate_aria_role( element: Element, attribute_map: Map ) { - if (!attribute_map.has('role')) { + const role_attribute = attribute_map.get('role'); + const explicit_role_name = role_attribute ? role_attribute.get_static_value() : ''; + const implicit_role_name = get_implicit_role(element.name, attribute_map); + + const role_name = explicit_role_name + ? explicit_role_name + : implicit_role_name; + + if (!role_name) { return; } - const role_attribute = attribute_map.get('role'); - const value = role_attribute.get_static_value(); - // @ts-ignore - if (value && !aria_role_set.has(value)) { + if (!roles.has(role_name)) { // @ts-ignore - const match = fuzzymatch(value, aria_roles); - let message = `A11y: Unknown role '${value}'`; + const match = fuzzymatch(role_name, aria_roles); + let message = `A11y: Unknown role '${role_name}'`; if (match) message += ` (did you mean '${match}'?)`; element.component.warn(role_attribute, { code: `a11y-unknown-role`, message, }); + return; + } + + const is_implicit_role = role_name && !explicit_role_name; + const implicit_message = `This role is implicit on the element "${element.name}".`; + + const { + requiredProps: requiredPropKeyValues, + props: propKeyValues, + } = roles.get(role_name); + + const requiredProps = Object.keys(requiredPropKeyValues); + if ( + !is_semantic_role_element(element.name, attribute_map) && + requiredProps.length > 0 + ) { + const hasRequiredProps = requiredProps.every(prop => + attribute_map.has(prop) + ); + + if (hasRequiredProps === false) { + element.component.warn(role_attribute || element, { + code: `a11y-role-has-required-aria-props`, + message: `Elements with the ARIA role "${role_name}" must have the following attributes defined: ${String( + requiredProps + ).toLowerCase()}.${is_implicit_role ? '\n' + implicit_message : ''}`, + }); + } + } + + for (const [attribute_name, attribute] of attribute_map) { + if (aria.has(attribute_name) && !(attribute_name in propKeyValues)) { + element.component.warn(attribute, { + code: `a11y-role-supports-aria-props`, + message: `The attribute ${attribute_name} is not supported by the role "${role_name}".${is_implicit_role ? '\n' + implicit_message : ''}`, + }); + } } - const implicit_role = get_implicit_role(element.name, attribute_map); - if (implicit_role && implicit_role === value) { + if (implicit_role_name && implicit_role_name === explicit_role_name) { if ( !( role_exceptions.has(element.name) && - role_exceptions.get(element.name) === value + role_exceptions.get(element.name) === role_name ) ) { 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.`, + message: `The element '${element.name}' has an implicit role of '${implicit_role_name}'. Defining this explicitly is redundant and should be avoided.`, }); } } diff --git a/src/compiler/compile/utils/validate-a11y/implicit_role.ts b/src/compiler/compile/utils/validate-a11y/implicit_role.ts index 52a7dbde4a..cf1145bfba 100644 --- a/src/compiler/compile/utils/validate-a11y/implicit_role.ts +++ b/src/compiler/compile/utils/validate-a11y/implicit_role.ts @@ -18,8 +18,9 @@ function img(attribute_map: Map) { } function input(attribute_map: Map) { if (attribute_map.has('type')) { - const value = attribute_map.get('type').get_static_value() || ''; - switch (value.toUpperCase()) { + const type = attribute_map.get('type').get_static_value(); + const value = typeof type === 'string' ? type.toUpperCase() : ''; + switch (value) { case 'BUTTON': case 'IMAGE': case 'RESET': diff --git a/src/compiler/compile/utils/validate-a11y/is_semantic_role_element.ts b/src/compiler/compile/utils/validate-a11y/is_semantic_role_element.ts new file mode 100644 index 0000000000..8f05529301 --- /dev/null +++ b/src/compiler/compile/utils/validate-a11y/is_semantic_role_element.ts @@ -0,0 +1,46 @@ +import { AXObjectRoles, elementAXObjects } from 'axobject-query'; +import Attribute from '../../nodes/Attribute'; + +export default ( + name: string, + attribute_map: Map +): boolean => { + if (!attribute_map.has('role')) { + return false; + } + const value = attribute_map.get('role').get_static_value(); + + for (const [concept, ax_objects] of elementAXObjects) { + if ( + // @ts-ignore + concept.name === name && + // @ts-ignore + (concept.attributes + ? // @ts-ignore + concept.attributes.every( + attribute => + attribute_map.has(attribute.name) && + (attribute.value !== undefined + ? (console.log( + attribute_map.get(attribute.name).get_static_value(), + attribute.value + ), + attribute_map.get(attribute.name).get_static_value()) === + attribute.value + : true) + ) + : true) + ) { + for (const ax_object of ax_objects) { + if (AXObjectRoles.has(ax_object)) { + for (const role of AXObjectRoles.get(ax_object)) { + if (role.name === value) { + return true; + } + } + } + } + } + } + return false; +}; diff --git a/test/validator/samples/a11y-role-has-required-aria-props/input.svelte b/test/validator/samples/a11y-role-has-required-aria-props/input.svelte new file mode 100644 index 0000000000..b7e98fa374 --- /dev/null +++ b/test/validator/samples/a11y-role-has-required-aria-props/input.svelte @@ -0,0 +1,43 @@ + + + +
+
+
+
+
+
+ + + + +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ + \ No newline at end of file diff --git a/test/validator/samples/a11y-role-has-required-aria-props/warnings.json b/test/validator/samples/a11y-role-has-required-aria-props/warnings.json new file mode 100644 index 0000000000..1fdfd77007 --- /dev/null +++ b/test/validator/samples/a11y-role-has-required-aria-props/warnings.json @@ -0,0 +1,362 @@ +[ + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 359, + "column": 18, + "line": 14 + }, + "message": "Elements with the ARIA role \"slider\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 346, + "start": { + "character": 346, + "column": 5, + "line": 14 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 381, + "column": 18, + "line": 15 + }, + "message": "Elements with the ARIA role \"slider\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 368, + "start": { + "character": 368, + "column": 5, + "line": 15 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 421, + "column": 18, + "line": 16 + }, + "message": "Elements with the ARIA role \"slider\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 408, + "start": { + "character": 408, + "column": 5, + "line": 16 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 479, + "column": 18, + "line": 17 + }, + "message": "Elements with the ARIA role \"slider\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 466, + "start": { + "character": 466, + "column": 5, + "line": 17 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 537, + "column": 18, + "line": 18 + }, + "message": "Elements with the ARIA role \"slider\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 524, + "start": { + "character": 524, + "column": 5, + "line": 18 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 619, + "column": 22, + "line": 20 + }, + "message": "Elements with the ARIA role \"spinbutton\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 602, + "start": { + "character": 602, + "column": 5, + "line": 20 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 645, + "column": 22, + "line": 21 + }, + "message": "Elements with the ARIA role \"spinbutton\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 628, + "start": { + "character": 628, + "column": 5, + "line": 21 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 689, + "column": 22, + "line": 22 + }, + "message": "Elements with the ARIA role \"spinbutton\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 672, + "start": { + "character": 672, + "column": 5, + "line": 22 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 751, + "column": 22, + "line": 23 + }, + "message": "Elements with the ARIA role \"spinbutton\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 734, + "start": { + "character": 734, + "column": 5, + "line": 23 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 813, + "column": 22, + "line": 24 + }, + "message": "Elements with the ARIA role \"spinbutton\" must have the following attributes defined: aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 796, + "start": { + "character": 796, + "column": 5, + "line": 24 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 891, + "column": 20, + "line": 26 + }, + "message": "Elements with the ARIA role \"checkbox\" must have the following attributes defined: aria-checked.", + "pos": 876, + "start": { + "character": 876, + "column": 5, + "line": 26 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 915, + "column": 20, + "line": 27 + }, + "message": "Elements with the ARIA role \"checkbox\" must have the following attributes defined: aria-checked.", + "pos": 900, + "start": { + "character": 900, + "column": 5, + "line": 27 + } + }, + { + "code": "a11y-unknown-aria-attribute", + "end": { + "character": 959, + "column": 32, + "line": 28 + }, + "message": "A11y: Unknown aria attribute 'aria-chcked' (did you mean 'aria-checked'?)", + "pos": 948, + "start": { + "character": 948, + "column": 21, + "line": 28 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 947, + "column": 20, + "line": 28 + }, + "message": "Elements with the ARIA role \"checkbox\" must have the following attributes defined: aria-checked.", + "pos": 932, + "start": { + "character": 932, + "column": 5, + "line": 28 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 984, + "column": 21, + "line": 29 + }, + "message": "Elements with the ARIA role \"checkbox\" must have the following attributes defined: aria-checked.", + "pos": 969, + "start": { + "character": 969, + "column": 6, + "line": 29 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1066, + "column": 20, + "line": 31 + }, + "message": "Elements with the ARIA role \"combobox\" must have the following attributes defined: aria-controls,aria-expanded.", + "pos": 1051, + "start": { + "character": 1051, + "column": 5, + "line": 31 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1090, + "column": 20, + "line": 32 + }, + "message": "Elements with the ARIA role \"combobox\" must have the following attributes defined: aria-controls,aria-expanded.", + "pos": 1075, + "start": { + "character": 1075, + "column": 5, + "line": 32 + } + }, + { + "code": "a11y-unknown-aria-attribute", + "end": { + "character": 1136, + "column": 33, + "line": 33 + }, + "message": "A11y: Unknown aria attribute 'aria-expandd' (did you mean 'aria-expanded'?)", + "pos": 1124, + "start": { + "character": 1124, + "column": 21, + "line": 33 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1123, + "column": 20, + "line": 33 + }, + "message": "Elements with the ARIA role \"combobox\" must have the following attributes defined: aria-controls,aria-expanded.", + "pos": 1108, + "start": { + "character": 1108, + "column": 5, + "line": 33 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1180, + "column": 21, + "line": 35 + }, + "message": "Elements with the ARIA role \"scrollbar\" must have the following attributes defined: aria-controls,aria-orientation,aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 1164, + "start": { + "character": 1164, + "column": 5, + "line": 35 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1205, + "column": 21, + "line": 36 + }, + "message": "Elements with the ARIA role \"scrollbar\" must have the following attributes defined: aria-controls,aria-orientation,aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 1189, + "start": { + "character": 1189, + "column": 5, + "line": 36 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1248, + "column": 21, + "line": 37 + }, + "message": "Elements with the ARIA role \"scrollbar\" must have the following attributes defined: aria-controls,aria-orientation,aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 1232, + "start": { + "character": 1232, + "column": 5, + "line": 37 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1309, + "column": 21, + "line": 38 + }, + "message": "Elements with the ARIA role \"scrollbar\" must have the following attributes defined: aria-controls,aria-orientation,aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 1293, + "start": { + "character": 1293, + "column": 5, + "line": 38 + } + }, + { + "code": "a11y-role-has-required-aria-props", + "end": { + "character": 1370, + "column": 21, + "line": 39 + }, + "message": "Elements with the ARIA role \"scrollbar\" must have the following attributes defined: aria-controls,aria-orientation,aria-valuemax,aria-valuemin,aria-valuenow.", + "pos": 1354, + "start": { + "character": 1354, + "column": 5, + "line": 39 + } + } +] diff --git a/test/validator/samples/a11y-role-supports-aria-props/input.svelte b/test/validator/samples/a11y-role-supports-aria-props/input.svelte new file mode 100644 index 0000000000..b13a89bc75 --- /dev/null +++ b/test/validator/samples/a11y-role-supports-aria-props/input.svelte @@ -0,0 +1,5 @@ +anchor + +foobar + +